2. 线程与进程模型深入
在并发编程中,进程与线程是最基础的两个执行单元。理解它们的差异、线程的生命周期、上下文切换的代价,以及多线程 API 的正确用法与常见陷阱,是写出稳定并发程序的前提。本节从模型对比出发,逐步深入到线程状态迁移、切换开销的量化估算,最后给出可运行的 Java 示例与陷阱清单。
一、进程与线程的本质区别
进程是操作系统进行资源分配的基本单位,拥有独立的虚拟地址空间、文件描述符表、信号处理表等;线程是 CPU 调度的基本单位,同一进程内的多个线程共享地址空间和大部分资源,但各自拥有独立的程序计数器、栈和寄存器上下文。
这种“资源共享、执行独立”的设计带来两个直接后果:一是线程间通信成本远低于进程间通信(IPC),因为不需要经过内核拷贝或序列化;二是线程间隔离性弱,一个线程的越界写可能破坏同进程其他线程的数据,而进程崩溃通常不会影响其他进程。
| 对比维度 | 进程 | 线程 |
|---|---|---|
| 资源分配 | 独立地址空间、文件表 | 共享进程资源 |
| 调度单位 | 是 | 是(更轻量) |
| 创建开销 | 较大(复制页表等) | 较小(约 1/10 或更低) |
| 通信方式 | 管道、共享内存、socket | 共享变量、锁、条件变量 |
| 隔离性 | 强 | 弱 |
| 崩溃影响 | 通常不影响其他进程 | 可能导致整个进程退出 |
二、线程生命周期与状态迁移
以 Java 的 Thread.State 为例,线程共有六种状态:NEW、RUNNABLE、BLOCKED、WAITING、TIMED_WAITING、TERMINATED。它们之间的迁移关系是理解并发行为的关键。
需要特别注意:RUNNABLE 在 JVM 层面包含了“就绪”和“运行中”两种子状态,是否真正占用 CPU 由操作系统调度决定。BLOCKED 仅指等待进入 synchronized 临界区,而 WAITING/TIMED_WAITING 是调用了 Object.wait、Thread.join、LockSupport.park 等方法后的等待状态。
三、上下文切换开销的量化理解
上下文切换是内核保存当前线程的寄存器、程序计数器、栈指针等状态,并恢复另一个线程状态的过程。它本身不执行用户业务逻辑,因此是纯开销。一次切换的耗时通常在 1~10 微秒量级,具体取决于 CPU 架构、缓存命中情况和内核实现。
可以用一个简单模型估算切换带来的吞吐损失。假设单次切换耗时 t_switch = 3μs,线程每次执行 t_work = 50μs 后因锁竞争或时间片耗尽发生切换,则有效 CPU 利用率为:
利用率 = t_work / (t_work + t_switch)
= 50 / (50 + 3)
≈ 94.3%
若把 t_work 降到 5μs,利用率变为 5 / 8 ≈ 62.5%,超过三分之一的 CPU 时间花在切换上。这说明:锁粒度越细、临界区越短,并不总是越好;当临界区短到与切换开销可比时,反而应合并操作或使用无锁结构。
vmstat 1 观察 cs 列(每秒上下文切换次数)。四、多线程基本 API 与典型陷阱
下面给出一个可运行的 Java 示例,演示创建线程、共享计数器以及用 synchronized 保护临界区。示例参数为教学假设,非实测数据。
public class CounterDemo {
private int count = 0;
public synchronized void increment() {
count++; // 非原子:读-改-写三步
}
public synchronized int get() {
return count;
}
public static void main(String[] args) throws InterruptedException {
CounterDemo demo = new CounterDemo();
Thread[] threads = new Thread[4];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 100_000; j++) {
demo.increment();
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join(); // 主线程等待所有子线程结束
}
System.out.println("count = " + demo.get());
}
}
若去掉 synchronized,最终结果通常小于 400000,因为 count++ 不是原子操作,多个线程可能读到相同的旧值。这是最经典的竞态条件。
常见陷阱还包括:
- 死锁:两个线程以相反顺序获取两把锁。避免方式是统一加锁顺序,或使用
tryLock超时。 - 活锁:线程不断重试却无法推进,例如两个线程互相礼让。
- 线程泄漏:线程池未关闭或任务阻塞导致线程无法回收。
- 可见性问题:未使用 volatile 或锁时,一个线程的写可能对另一线程不可见。
- 误用 Thread.stop/suspend:已废弃,会破坏对象一致性,应使用中断标志位协作停止。
Thread.stop() 强制终止线程。正确做法是设置 volatile boolean running 标志,线程循环检查该标志并自行退出;阻塞在 wait/sleep 时通过 interrupt() 唤醒并捕获 InterruptedException。五、实践判断:何时用进程,何时用线程
如果任务之间需要强隔离、可能崩溃且不能互相影响,或者需要独立部署与资源限额,优先用进程;如果任务需要频繁共享大量数据、追求低通信延迟和高吞吐,优先用线程。对于 CPU 密集型任务,线程数接近 CPU 核心数即可;对于 I/O 密集型任务,可适当放大线程数,但需结合上下文切换开销与内存占用综合评估。
一个可操作的判断流程是:先估算任务的 CPU 时间占比 p,再根据目标利用率 U 估算所需线程数 N ≈ U / (1 - p)(该公式为简化模型,仅作教学参考)。例如 p = 0.8、目标 U = 0.9,则 N ≈ 4.5,取 5 左右。实际部署时还应通过压测观察 cs、load average 和响应时间,逐步调整。
掌握线程状态迁移、切换开销的量级以及 API 的正确用法,才能在后续的任务调度与并发框架设计中做出合理取舍。