Skip to content

操作系统:进程与线程 ​

S2-009建议学进阶约 60 分钟JDK 21计算机基础 · 操作系统

1. 本节定位 ​

本阶段此前的课程讲的是"程序怎么写";从本节起讲**"程序跑在哪里、由谁调度"**:

text
进程:操作系统分配资源的最小单位(内存、文件句柄)
线程:操作系统调度的最小单位(CPU 时间片)

★ 记住这句话就够了 ✓ ——后面所有关于并发的知识都从它展开 ✓

2. 学习目标 ​

  • 能说清进程与线程的四个本质区别(资源、开销、隔离、通信)★★★;
  • 知道创建进程比创建线程贵多少(含实测:391 倍)★★★;
  • 理解上下文切换的成本,并知道"线程越多越快"何时失效 ★★★;
  • 知道 CPU 密集与 IO 密集任务的线程数怎么配 ★★★;
  • 知道"进程崩溃不影响别人"与"线程共享一切"的后果 ★★。

3. 前置知识检查 ​

4. 为什么需要这个知识 ​

先看一个选型错误 ✗:

text
某服务为了"提高吞吐",每个请求 fork 一个子进程处理。
压测结果:QPS 从 800 掉到 40 ✗✗
原因:每次请求都要付一次"创建进程"的代价 —— 而本节的实测是每次约 100 ms ✗

★ 反过来也有选型错误 ✗:

text
某计算任务用 1000 个线程做矩阵运算,结果比 8 个线程还慢 ✗
原因:线程太多导致上下文切换与缓存失效(下文有实测)

这两个错误的根源是同一个:不知道"创建"和"切换"要花多少代价 ✓

5. 核心概念 ​

5.1 四个本质区别 ★★★ ​

维度进程线程
资源 ★★★分配资源的最小单位(独立地址空间、文件句柄表)调度的最小单位(共享进程的地址空间)
创建开销 ★★★大(复制地址空间、建页表)小(只分配栈与控制块)
隔离性 ★★强(一个崩了不影响别人)✓无(一个崩了全体遭殃)✗
通信 ★★要跨进程(管道、共享内存、Socket)✗直接读写共享变量(但要加锁)✓

★ 一句话:

进程是"独立的房子",线程是"同一间屋子里的若干人" ✓

房子之间要通信得走门(IPC);屋里的人说话直接但容易抢东西 ✓✓

5.2 创建开销:391 倍 ★★★ ​

本节的实测(ProcessThreadLesson,创建 50 个):

text
创建 50 个进程(cmd /c exit):5089 ms(平均 101.78 ms/个)
创建 50 个线程:                13 ms(平均   0.26 ms/个)
→ 进程比线程慢约 391 倍 ✗✗

★ 差的时间花在哪 ✓:

新进程要做的事线程不需要
复制/建立地址空间与页表 ★★共享现成的地址空间 ✓
加载运行时与库复用已加载的 ✓
建文件句柄表、信号处理表复用进程的 ✓

★ 这条实测直接解释了两件事 ✓✓:

现象原因
Web 服务器用线程而不是进程 ★★每请求 100 ms 的开销无法接受 ✗
有"线程池"这种东西 ★★★连创建线程都要 0.26 ms,高频场景下也要省 ✓

★ 算一笔账 ✓:

text
0.26 ms/线程 × 每秒 1000 请求 × 每请求 1 个线程
= 每秒 260 ms 花在"创建线程"上 → 单核的 26% 白扔 ✗✗
而线程池复用之后,这笔开销几乎归零 ✓

5.3 上下文切换的成本 ★★★ ​

实测(同样 2000 万次累加):

text
单线程累加 20000000 次:        16 ms
双线程交替(每 4096 次让出):  47 ms
→ 同样工作量,多线程版本慢 2.9 倍 ✗

★ 注意:这个任务"并行化"之后反而更慢了 ✗ ——原因有三层:

原因说明
① 上下文切换本身有成本 ★★保存/恢复寄存器、切换栈、刷新 TLB 与缓存
② 缓存局部性被破坏 ★★两个线程用不同数据,缓存互相挤占(《操作系统:内存管理与局部性原理》)
③ 伪共享(False Sharing) ★★★两个变量在同一缓存行里,一方改了另一方就失效 ✗✗

★ 第三条最隐蔽 ✓:

即使两个线程操作的是"不同的变量",只要它们在同一个缓存行(通常 64 字节)里,就会互相把对方的缓存打失效 ✓✓

解决:给变量之间填充空白字节(padding),或让每个线程用独立的对象 ✓

★ 所以"并行一定更快"是错的 ✗✗ ——这是本节最该带走的一条 ✓

5.4 线程数与并行度 ★★★ ​

实测(16 核机器,CPU 密集任务,总量固定;下表取"正式测量"一轮,数值与"甜点"档位随运行波动):

text
线程数        耗时 ms
1          351
2          156
4           74
8           48
16          37
32          41

★ 两条规律 ✓:

观察解释
从 1 到 8 线程:耗时降到约 1/7 ✓真的在并行 ——但加速比已经低于线程数(8 线程只有约 7 倍)✓
到 16 线程附近收益停住;再往上(32)多数运行里不再更快、甚至回落 ⚠️实际可用并行度有限(内存带宽/缓存/超线程),超配换不来收益 ✗

★★ 为什么"甜点"通常在"核数"附近,而不是越多越好 ✓ ——因为"实际可用并行度"往往不超过"核数" ✓:

因素影响
超线程(Hyper-Threading) ★★"逻辑核"里可能只有一半是物理核 ——同物理核上的两个线程会抢执行单元 ✗
内存带宽 ★算得再快,取不到数据也没用 ✓
缓存容量线程越多,各自的缓存越少 ✓
后台任务操作系统、其他进程也在抢 CPU ✓

★ 本机实测印证:甜点在 16 线程附近(约 9.5 倍);多次运行里 32 线程有时持平、有时回落 —— 超过"实际可用并行度"后,换不来收益、还可能倒扣 ✗

★ 一条可以直接用的配置原则 ✓✓:

任务类型线程数公式理由
CPU 密集 ★★核数(或核数 + 1)多配没用,只会增加切换开销 ✗
IO 密集 ★★核数 × (1 + 等待时间/计算时间)线程大部分时间在等,让出去给别的线程 ✓
混合型分开两个线程池避免慢的 IO 任务占满池子,饿死快任务 ✓✓

★ 最后一条是实战经验 ✓:"给不同类型的任务用不同的线程池" ✓ —— 否则一个慢接口会把线程池占满,导致所有接口都超时 ✗✗

5.5 隔离性:为什么还需要多进程 ★★ ​

实测:

text
子进程以退出码 3 结束,父进程(本进程)依然存活 ✓
线程 [Thread-115] 抛出:线程里的异常(被捕获打印,不影响其他线程)

★ 关键差别 ✓:

场景进程线程
一个实例崩溃 ★★其他进程毫无影响 ✓✓共享状态可能被破坏 ✗
一个实例 OOM ★★只有它死 ✓整个 JVM 死 ✗✗
需要高密度隔离成本高 ✗成本低 ✓

★ 所以选型是这样的 ✓:

场景选择理由
Web 服务处理请求 ★★线程低开销 + 要共享缓存/连接池 ✓
浏览器标签页 ★★进程一个页面崩了不能拖垮全部 ✓
Flink/Spark 的任务 ★★进程(或容器)任务之间不可信,要隔离 ✓
CI 里跑测试容器/进程环境隔离 ✓

★ 一句话:"要共享就同进程,要隔离就不同进程" ✓✓

6. 递进示例 ​

6.1 最小示例:创建一个线程 ​

java
Thread thread = new Thread(() -> System.out.println("在新线程里跑"));
thread.start();          // ★ start() 才会真正启动;run() 只是普通方法调用
thread.join();           // 等它结束

6.2 业务示例:线程池(生产环境的标准做法) ​

java
// ✗ 不要为每个任务 new Thread:创建 0.26 ms,调度与回收更贵
new Thread(task).start();

// ✓ 用线程池:复用线程,限制并发度
ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
pool.submit(task);

★ 但 Executors.newFixedThreadPool 有个坑 ✗:

它的任务队列是"无界"的 ——任务堆积会 OOM ✗✗

生产环境应该直接用 ThreadPoolExecutor,指定有界队列与拒绝策略 ✓(《线程池与 ThreadPoolExecutor》 展开)

6.3 反例:每请求一个进程 ​

见第 4 节 ✓

7. 完整可运行代码与实测 ​

代码:labs/cs-basics/src/main/java/cn/kb/learn/cs/os/ProcessThreadLesson.java

运行:

powershell
cd labs
.\mvnw.cmd -pl cs-basics compile
java "-Dstdout.encoding=UTF-8" -cp cs-basics/target/classes cn.kb.learn.cs.os.ProcessThreadLesson

实测输出(第 3 小节):

text
===== 3. 线程数与耗时的关系(CPU 密集任务)=====
  本机可用处理器:16(任务总量固定,只改线程数)
  线程数        耗时 ms          相对最快
  1          355            0.15
  2          225            0.24
  4          106            0.51
  8          76             0.71
  16         77             0.70
  32         54             1.00
  (以上是第一轮:主要用来预热 JIT,数值会偏慢,不参与正式统计)

  线程数        耗时 ms          相对单线程
  1          351            1.00
  2          156            2.25
  4          74             4.74
  8          48             7.31
  16         37             9.49
  32         41             8.56

  ★ 读法:**耗时最小的那一档(16 线程,37 ms,比单线程快 9.49 倍)就是本机这个任务的「并行度甜点」** ✓
  ★ 收益远低于线性:32 线程也只有 8.56 倍 —— **再加线程换不来同等加速** ⚠️
  ★ 本次运行已出现「不降反升」:超过甜点后反而更慢 —— 切换与内存带宽开始倒扣 ✗
  ★ 工程结论:**CPU 密集任务按「核数」配线程;IO 密集任务可以多配几倍** ✓
    (因为 IO 线程大部分时间在等待,让出 CPU 给别人用)

注:以上为一次真实运行的原样输出;耗时、"甜点"档位与最后两条判读语句都会随机器与本次运行波动。

★ 三条可落地的结论 ✓:

#结论依据
①绝不为每个请求创建进程100 ms/个 ✗
②高频任务用线程池,不要 new Thread0.26 ms/个也是成本 ✓
③CPU 密集任务按核数配线程,超过收益递减甜点在 16 线程附近,32 线程不再更快 ✓

★ 一个"反直觉"的提醒 ✓:

本节的第 2 个实验证明:并行化一个"本来就很快"的任务,会因为它更慢 ✗

所以"要不要并行"要先算账:并行收益(核心数)vs 并行成本(切换 + 缓存失效 + 同步) ✓✓

8. Debug 观察点 ​

  1. 把线程数扫描的循环次数调小:观察"并行收益"变得不明显 ✓;
  2. 把上下文切换实验的 Thread.yield() 去掉:观察双线程版的耗时下降 ★(说明成本来自切换)✓;
  3. 在任务管理器/top 里观察线程数:一个 Java 进程默认就有几十个线程 ✓;
  4. 把进程创建的 cmd /c exit 换成 java -version:观察每个进程的耗时涨到几百毫秒 ⚠️;
  5. 用 jstack 看线程名:观察 JVM 自己起的线程(GC、JIT、引用处理) ★。

9. 常见错误 ​

#错误说明
1为每个请求创建进程/线程 ✗✗开销 100 ms / 0.26 ms(5.2、6.2)✗
2以为"线程越多越快" ✗✗实测超配后收益停住甚至倒扣(5.4)✗
3CPU 密集任务配几百个线程 ✗✗切换与缓存失效让性能倒退 ✓
4混用同一个线程池跑 IO 与 CPU 任务 ✗✗慢任务拖垮快任务(5.4)✗
5用 executors.newFixedThreadPool 且不管队列 ✗✗无界队列会 OOM ✓
6以为线程"崩了不影响别人" ✗✗共享内存被破坏,全体遭殃 ✓
7调 run() 而不是 start() ✗✗那只是普通方法调用,没有新线程 ✓

10. 动手实验 ​

目标:亲手量出"创建"与"切换"的代价。

步骤:

  1. 跑 ProcessThreadLesson,记录进程与线程的创建耗时 ✓;
  2. 把线程创建数量改成 1000,观察总耗时(仍是毫秒级)★;
  3. 把上下文切换实验的让出频率调高(每 16 次让出一次),观察耗时进一步上升 ✓;
  4. 在本机跑一次线程数扫描,找出你这台机器的"甜点" ★★;
  5. 用 jstack 观察一个空跑的 Java 进程有多少线程 ✓。

验收方式:能说出"你这台机器上,CPU 密集任务的甜点线程数是多少"。

扩展任务:写一个"IO 密集"的对照实验:

  • 任务体是 Thread.sleep(10)(模拟 IO 等待)✓;
  • 用 1 / 4 / 16 / 64 个线程跑同样多的任务 ✓;
  • 对照 CPU 密集的曲线 → 说明为什么 IO 密集可以配更多线程 ★★

11. 练习题 ​

  1. 判断:线程和进程的创建开销差不多。(对 / 错)
  2. 填空:______ 是资源分配的最小单位,______ 是调度的最小单位;创建它们相差约 ______ 倍。
  3. 简答:为什么"给一个很快的任务并行化"可能更慢?
  4. (代码阅读)
java
for (int i = 0; i < 10000; i++) {
    new Thread(() -> handleRequest()).start();
}

这段代码有什么问题? 5. (编程)写一个程序:分别用 1、2、4、8、16 个线程计算同一个数组的和,记录耗时并画出曲线,说明"甜点"在哪。

参考答案(要点)
  1. 错。实测差 391 倍 ✓(100 ms vs 0.26 ms)✓★
  2. 进程;线程;391 倍 ✓
  3. 因为并行的收益(多核)可能小于成本(上下文切换 + 缓存失效 + 同步) ✓★
  4. ① 为每个请求创建线程,0.26 ms × 1 万 = 2.6 秒纯浪费 ✗;② 没有并发上限,可能瞬间起 1 万个线程 → OOM 或崩溃 ✗✗; ③ 无法优雅关闭 ✗ ——应该用线程池 ✓
  5. 要点:耗时随线程数下降,到核数附近趋于平缓,超过后可能回升 ✓

12. 掌握标准 ​

  • [ ] 能说清进程与线程的四个本质区别 ★★★;
  • [ ] 知道创建进程比线程贵约 391 倍,并知道为什么 ★★★;
  • [ ] 知道上下文切换的成本,以及"并行可能更慢" ★★★;
  • [ ] 知道 CPU 密集按核数、IO 密集可按倍数配线程 ★★★;
  • [ ] 知道"不同类型任务用不同线程池" ★★;
  • [ ] 知道"进程隔离强、线程共享一切"的后果 ★★;
  • [ ] 知道 newFixedThreadPool 的无界队列是个坑 ✓。

13. 扩展资源 ​

14. 下一步学习 ​

《操作系统:调度、同步与死锁》:多个线程同时干活时,怎么不出乱子。

进度只保存在本机浏览器,可随时清除

个人学习项目:AI 生成内容均经人工复核,第三方资源仅作索引