主题
界面
主题
本阶段此前的课程讲的是"程序怎么写";从本节起讲**"程序跑在哪里、由谁调度"**:
进程:操作系统分配资源的最小单位(内存、文件句柄)
线程:操作系统调度的最小单位(CPU 时间片)★ 记住这句话就够了 ✓ ——后面所有关于并发的知识都从它展开 ✓
先看一个选型错误 ✗:
某服务为了"提高吞吐",每个请求 fork 一个子进程处理。
压测结果:QPS 从 800 掉到 40 ✗✗
原因:每次请求都要付一次"创建进程"的代价 —— 而本节的实测是每次约 100 ms ✗★ 反过来也有选型错误 ✗:
某计算任务用 1000 个线程做矩阵运算,结果比 8 个线程还慢 ✗
原因:线程太多导致上下文切换与缓存失效(下文有实测)这两个错误的根源是同一个:不知道"创建"和"切换"要花多少代价 ✓
| 维度 | 进程 | 线程 |
|---|---|---|
| 资源 ★★★ | 分配资源的最小单位(独立地址空间、文件句柄表) | 调度的最小单位(共享进程的地址空间) |
| 创建开销 ★★★ | 大(复制地址空间、建页表) | 小(只分配栈与控制块) |
| 隔离性 ★★ | 强(一个崩了不影响别人)✓ | 无(一个崩了全体遭殃)✗ |
| 通信 ★★ | 要跨进程(管道、共享内存、Socket)✗ | 直接读写共享变量(但要加锁)✓ |
★ 一句话:
进程是"独立的房子",线程是"同一间屋子里的若干人" ✓
房子之间要通信得走门(IPC);屋里的人说话直接但容易抢东西 ✓✓
本节的实测(ProcessThreadLesson,创建 50 个):
创建 50 个进程(cmd /c exit):5089 ms(平均 101.78 ms/个)
创建 50 个线程: 13 ms(平均 0.26 ms/个)
→ 进程比线程慢约 391 倍 ✗✗★ 差的时间花在哪 ✓:
| 新进程要做的事 | 线程不需要 |
|---|---|
| 复制/建立地址空间与页表 ★★ | 共享现成的地址空间 ✓ |
| 加载运行时与库 | 复用已加载的 ✓ |
| 建文件句柄表、信号处理表 | 复用进程的 ✓ |
★ 这条实测直接解释了两件事 ✓✓:
| 现象 | 原因 |
|---|---|
| Web 服务器用线程而不是进程 ★★ | 每请求 100 ms 的开销无法接受 ✗ |
| 有"线程池"这种东西 ★★★ | 连创建线程都要 0.26 ms,高频场景下也要省 ✓ |
★ 算一笔账 ✓:
0.26 ms/线程 × 每秒 1000 请求 × 每请求 1 个线程
= 每秒 260 ms 花在"创建线程"上 → 单核的 26% 白扔 ✗✗
而线程池复用之后,这笔开销几乎归零 ✓实测(同样 2000 万次累加):
单线程累加 20000000 次: 16 ms
双线程交替(每 4096 次让出): 47 ms
→ 同样工作量,多线程版本慢 2.9 倍 ✗★ 注意:这个任务"并行化"之后反而更慢了 ✗ ——原因有三层:
| 原因 | 说明 |
|---|---|
| ① 上下文切换本身有成本 ★★ | 保存/恢复寄存器、切换栈、刷新 TLB 与缓存 |
| ② 缓存局部性被破坏 ★★ | 两个线程用不同数据,缓存互相挤占(《操作系统:内存管理与局部性原理》) |
| ③ 伪共享(False Sharing) ★★★ | 两个变量在同一缓存行里,一方改了另一方就失效 ✗✗ |
★ 第三条最隐蔽 ✓:
即使两个线程操作的是"不同的变量",只要它们在同一个缓存行(通常 64 字节)里,就会互相把对方的缓存打失效 ✓✓
解决:给变量之间填充空白字节(padding),或让每个线程用独立的对象 ✓
★ 所以"并行一定更快"是错的 ✗✗ ——这是本节最该带走的一条 ✓
实测(16 核机器,CPU 密集任务,总量固定;下表取"正式测量"一轮,数值与"甜点"档位随运行波动):
线程数 耗时 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 任务占满池子,饿死快任务 ✓✓ |
★ 最后一条是实战经验 ✓:"给不同类型的任务用不同的线程池" ✓ —— 否则一个慢接口会把线程池占满,导致所有接口都超时 ✗✗
实测:
子进程以退出码 3 结束,父进程(本进程)依然存活 ✓
线程 [Thread-115] 抛出:线程里的异常(被捕获打印,不影响其他线程)★ 关键差别 ✓:
| 场景 | 进程 | 线程 |
|---|---|---|
| 一个实例崩溃 ★★ | 其他进程毫无影响 ✓✓ | 共享状态可能被破坏 ✗ |
| 一个实例 OOM ★★ | 只有它死 ✓ | 整个 JVM 死 ✗✗ |
| 需要高密度隔离 | 成本高 ✗ | 成本低 ✓ |
★ 所以选型是这样的 ✓:
| 场景 | 选择 | 理由 |
|---|---|---|
| Web 服务处理请求 ★★ | 线程 | 低开销 + 要共享缓存/连接池 ✓ |
| 浏览器标签页 ★★ | 进程 | 一个页面崩了不能拖垮全部 ✓ |
| Flink/Spark 的任务 ★★ | 进程(或容器) | 任务之间不可信,要隔离 ✓ |
| CI 里跑测试 | 容器/进程 | 环境隔离 ✓ |
★ 一句话:"要共享就同进程,要隔离就不同进程" ✓✓
Thread thread = new Thread(() -> System.out.println("在新线程里跑"));
thread.start(); // ★ start() 才会真正启动;run() 只是普通方法调用
thread.join(); // 等它结束// ✗ 不要为每个任务 new Thread:创建 0.26 ms,调度与回收更贵
new Thread(task).start();
// ✓ 用线程池:复用线程,限制并发度
ExecutorService pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
pool.submit(task);★ 但 Executors.newFixedThreadPool 有个坑 ✗:
它的任务队列是"无界"的 ——任务堆积会 OOM ✗✗
生产环境应该直接用
ThreadPoolExecutor,指定有界队列与拒绝策略 ✓(《线程池与 ThreadPoolExecutor》 展开)
见第 4 节 ✓
代码:labs/cs-basics/src/main/java/cn/kb/learn/cs/os/ProcessThreadLesson.java
运行:
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 小节):
===== 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 Thread | 0.26 ms/个也是成本 ✓ |
| ③ | CPU 密集任务按核数配线程,超过收益递减 | 甜点在 16 线程附近,32 线程不再更快 ✓ |
★ 一个"反直觉"的提醒 ✓:
本节的第 2 个实验证明:并行化一个"本来就很快"的任务,会因为它更慢 ✗
所以"要不要并行"要先算账:并行收益(核心数)vs 并行成本(切换 + 缓存失效 + 同步) ✓✓
Thread.yield() 去掉:观察双线程版的耗时下降 ★(说明成本来自切换)✓;top 里观察线程数:一个 Java 进程默认就有几十个线程 ✓;cmd /c exit 换成 java -version:观察每个进程的耗时涨到几百毫秒 ⚠️;jstack 看线程名:观察 JVM 自己起的线程(GC、JIT、引用处理) ★。| # | 错误 | 说明 |
|---|---|---|
| 1 | 为每个请求创建进程/线程 ✗✗ | 开销 100 ms / 0.26 ms(5.2、6.2)✗ |
| 2 | 以为"线程越多越快" ✗✗ | 实测超配后收益停住甚至倒扣(5.4)✗ |
| 3 | CPU 密集任务配几百个线程 ✗✗ | 切换与缓存失效让性能倒退 ✓ |
| 4 | 混用同一个线程池跑 IO 与 CPU 任务 ✗✗ | 慢任务拖垮快任务(5.4)✗ |
| 5 | 用 executors.newFixedThreadPool 且不管队列 ✗✗ | 无界队列会 OOM ✓ |
| 6 | 以为线程"崩了不影响别人" ✗✗ | 共享内存被破坏,全体遭殃 ✓ |
| 7 | 调 run() 而不是 start() ✗✗ | 那只是普通方法调用,没有新线程 ✓ |
目标:亲手量出"创建"与"切换"的代价。
步骤:
ProcessThreadLesson,记录进程与线程的创建耗时 ✓;jstack 观察一个空跑的 Java 进程有多少线程 ✓。验收方式:能说出"你这台机器上,CPU 密集任务的甜点线程数是多少"。
扩展任务:写一个"IO 密集"的对照实验:
Thread.sleep(10)(模拟 IO 等待)✓;for (int i = 0; i < 10000; i++) {
new Thread(() -> handleRequest()).start();
}这段代码有什么问题? 5. (编程)写一个程序:分别用 1、2、4、8、16 个线程计算同一个数组的和,记录耗时并画出曲线,说明"甜点"在哪。
newFixedThreadPool 的无界队列是个坑 ✓。《操作系统:调度、同步与死锁》:多个线程同时干活时,怎么不出乱子。
无,可直接开始