线程状态流转与上下文切换开销
问题的提出
Java 面试中,"线程有几个状态"这个问题几乎必问,但大多数人的回答止步于背出 6 个状态名。真正有价值的问题是:这些状态之间到底怎么流转?上下文切换到底有多贵?为什么 JDK 21 虚拟线程号称能解决问题?
今天我们不背答案,而是从操作系统调度器的视角,把线程状态和上下文切换这件事说清楚。
线程的 6 种状态
Java 线程的状态由 Thread.State 枚举定义,共 6 个:
| 状态 | 含义 | 触发条件 |
|---|---|---|
| NEW | 创建未启动 | new Thread() 之后,start() 之前 |
| RUNNABLE | 可运行/运行中 | JVM 层将操作系统的 Ready 和 Running 合并了 |
| BLOCKED | 等待锁阻塞 | 尝试进入 synchronized 块但锁被其他线程持有 |
| WAITING | 无限期等待 | Object.wait()、Thread.join()、LockSupport.park() |
| TIMED_WAITING | 带超时等待 | Thread.sleep(time)、wait(timeout)、parkNanos() |
| TERMINATED | 执行完毕 | run() 方法正常返回或异常退出 |
状态流转全图(文字版)
NEW
│ start()
▼
RUNNABLE ──── 获取锁失败 ────→ BLOCKED
│ │ 获取锁
│ wait()/park()/join() ▼
├─────────────────────────────→ RUNNABLE
│
│ sleep()/wait(timeout)
▼
TIMED_WAITING ──── 超时/notify ──→ RUNNABLE
│
│ run() 完成
▼
TERMINATED一个面试高频陷阱:Thread.yield() 做了什么?它会从 RUNNABLE 回到 RUNNABLE,只是让出当前时间片,不会改变状态。yield() 只是把线程从 CPU 移到等待队列尾部,状态仍然是 RUNNABLE。
从 RUNNABLE→BLOCKED 的详细时序
假设线程 A 和 B 竞争同一个 synchronized 锁:
时间线:
t1: 线程 A 持有锁,正在执行同步块
t2: 线程 B 执行到 synchronized(obj) → JVM 检查锁标记
t3: 在线程 A 的栈帧中,锁标记为 1(被持有)
t4: 线程 B 被标记为 BLOCKED → 进入 _sync_waiter 队列
t5: 线程 A 退出同步块 → 释放锁 → JVM 唤醒 _sync_waiter 队列头部
t6: 线程 B 被操作系统重新调度 → 状态变为 RUNNABLE → 获取锁这个流程中,t4→t6 涉及一次内核态线程切换,成本是 5-10 μs。
上下文切换的成本有多大?
很多人知道上下文切换有成本,但不知道具体多大。用数据说话:
一次上下文切换 ≈ 1-10 μs(微秒)。
看起来很小?我们来算一笔账:
- 一个 CPU 核心每秒可以执行约 10 万-100 万次上下文切换
- 一个高并发 Web 请求(Tomcat 默认 200 线程),一次请求涉及几十上百次切换
- 上下文切换能吃掉 20%-50% 的 CPU 时间
操作系统做上下文切换时的完整流程
[线程 A 运行中] [操作系统内核] [线程 B 运行中]
│ │ │
│---- 时间片用完 / 系统调用 -----→│ │
│ │ 保存 A 的寄存器(通用寄存器、 │
│ │ PC、栈指针、FPU 状态) │
│ │ │
│ │ 保存 A 的 TLB 状态 │
│ │ 刷新 TLB(如果地址空间不同) │
│ │ │
│ │ 调度器选择下一个线程 B │
│ │ │
│ │ 加载 B 的寄存器状态 │
│ │ 加载 B 的 TLB 状态 │
│ │----- 切换到用户态 ------------→│
│ │ │ 继续执行 B其中 TLB 刷新 是最大的开销来源——因为不同线程的地址空间可能不同,TLB 缓存失效后,后续的内存访问都会变慢。Intel 的测量数据表明,TLB miss 会导致 10-100 个 CPU 周期的额外延迟。
用户态切换 vs 内核态切换
不是所有线程切换都一样贵。关键区别在于是否陷入内核:
| 切换类型 | 涉及操作 | 成本 | 是否陷入内核 |
|---|---|---|---|
| synchronized 阻塞 | futex 系统调用,线程挂起/唤醒 | 5-10 μs | ✅ 是 |
LockSupport.park()/unpark() | 同样 futex,但省去锁竞争逻辑 | 1-3 μs | ✅ 是 |
Thread.yield() | 让出 CPU,不阻塞,仍在运行队列 | 0.1-0.5 μs | ❌ 否(用户态) |
| CAS 自旋 | 在用户态循环 | 10-50 ns | ❌ 否(用户态) |
| 虚拟线程切换 | 用户态栈切换 | 1-2 ns | ❌ 否(用户态) |
关键结论:CAS 自旋比 synchronized 阻塞快了 3 个数量级。这就是为什么高并发场景下,CAS 替代锁、自旋替代阻塞 是常用的优化手段。
一个真实的生产案例
2023 年我参与过一个线上 P2 事故:某支付网关接口在晚高峰突然 RT 从 5ms 飙升到 800ms。
排查过程:
# 1. 先看线程数
ps -eLf | grep java | wc -l
# 输出:1200+ (远超 CPU 核心数 16)# 2. 查看上下文切换
cat /proc/{pid}/status | grep ctxt
# voluntary_ctxt_switches: 4539821
# nonvoluntary_ctxt_switches: 8934721
# 非自愿切换占比 66%!说明线程数远超 CPU 核心数# 3. perf 确认 CPU 花在哪
perf stat -e context-switches,cpu-migrations,cycles,instruction -p {pid} -- sleep 5
# context-switches: 284,293 (每秒约 5.7 万次上下文切换)# 4. jstack 看 BLOCKED 线程
jstack -l {pid} | grep -c "BLOCKED"
# 320 个线程处于 BLOCKED 状态根因:线程池配置了 newFixedThreadPool(500),但机器只有 16 核。500 个线程争抢 16 个 CPU,导致大量非自愿上下文切换,CPU 时间基本都花在调度上了。
修复:改成 newFixedThreadPool(16 * 2),增加虚拟线程选项,RT 回到 8ms。
如何量化上下文切换开销?
生产环境可以用这些工具看看你的系统在上下文切换上花了多少时间:
# 查看进程的上下文切换次数
cat /proc/{pid}/status | grep ctxt
# 输出示例
voluntary_ctxt_switches: 12345 # 主动让出 CPU(如等待锁、sleep)
nonvoluntary_ctxt_switches: 6789 # 被动被抢走 CPU(时间片用完)# perf 统计上下文切换频率
perf stat -e context-switches,cpu-migrations,cycles,instructions -p {pid} -- sleep 10# jstack 结合 grep 看 BLOCKED 线程数
jstack -l {pid} | grep -c "BLOCKED"如果 nonvoluntary_ctxt_switches 占总切换比例很高,说明线程数超出了 CPU 核心数太多,需要控制线程池大小。
优化策略
1. 减少锁粒度
// 坏:整个方法加锁
public synchronized void process(int id) { ... }
// 好:分段锁
private final Lock[] locks = new Lock[16];
public void process(int id) {
locks[id % 16].lock();
try { ... } finally { locks[id % 16].unlock(); }
}2. CAS 替代锁
// 锁版本
public synchronized int increment() { return ++count; }
// CAS 版本(无上下文切换)
private final AtomicInteger count = new AtomicInteger(0);
public int increment() { return count.incrementAndGet(); }3. 控制线程数
// CPU 密集型:Ncpu + 1
int cpuCores = Runtime.getRuntime().availableProcessors();
ExecutorService pool = Executors.newFixedThreadPool(cpuCores + 1);
// IO 密集型:Ncpu * 2(但不要超过太多,否则上下文切换开销反超)
ExecutorService ioPool = Executors.newFixedThreadPool(cpuCores * 2);一个常见的误区:IO 密集型不是说线程数越多越好。线程数超过一定阈值后,上下文切换的开销会反超 IO 等待时间。实测数据:16 核机器上,200 线程比 32 线程的吞吐量还低,因为 CPU 时间全花在调度上了。
4. 终极解法:虚拟线程(JDK 21+)
虚拟线程是 JDK 21 的正式特性,它把线程调度的单位从「操作系统线程」降级为「用户态纤程」。
// 传统线程池
ExecutorService pool = Executors.newFixedThreadPool(200);
pool.submit(() -> handleRequest());
// 虚拟线程(JDK 21)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> handleRequest());
}虚拟线程为什么快?
- 虚拟线程的上下文切换发生在 用户态,不需要系统调用
- 切换成本约 1-2 ns,比传统线程的 1-10 μs 低了 3-4 个数量级
- M:N 调度模型:M 个虚拟线程映射到 N 个平台线程(N = CPU 核心数)
- 当虚拟线程执行到阻塞操作(如
Thread.sleep()、BlockingQueue.take())时,只需在用户态挂起当前虚拟线程,平台线程继续执行其他虚拟线程
虚拟线程的内部机制:
[虚拟线程 A] [虚拟线程 B] [虚拟线程 C] ... [虚拟线程 M]
│ │ │ │
└────────────┴────────────┴──────────────────┘
│ M:N 调度
▼
[平台线程 1] [平台线程 2] ... [平台线程 N]
│
▼
[操作系统线程 1] [操作系统线程 2] ... [操作系统线程 N]
│
▼
[CPU 核心]当虚拟线程 A 执行到 Thread.sleep(100) 时:
- JDK 的 Continuation 机制捕获当前栈帧
- 保存到堆内存中
- 平台线程切换到虚拟线程 B 继续执行
- 100ms 后,虚拟线程 A 被调度器重新放到工作队列中
整个过程不需要操作系统内核参与,所以成本极低。
虚拟线程的坑(面试加分项):虚拟线程在以下场景会 pinned(钉死在平台线程上,无法释放):
synchronized同步块(但不是java.util.concurrent.Lock)JNI调用native方法
如果 hot path 上用了 synchronized,虚拟线程的优势会被抵消。JDK 21 在 java.util.concurrent 包中已经全面替换为 ReentrantLock,如果你自己写代码,尽量用 ReentrantLock 替代 synchronized。
适用场景:IO 密集型任务(网络请求、数据库访问、文件读写)效果最明显。CPU 密集型任务(大量计算)不需要虚拟线程,因为瓶颈不在上下文切换。
面试高频追问
Q: Thread.sleep(0) 和 Thread.yield() 有什么区别?
A: sleep(0) 会触发一次线程调度,让当前线程退出 CPU,但状态仍然是 RUNNABLE。yield() 也是让出 CPU,但 sleep(0) 会触发一次完整的调度周期,而 yield() 只是把线程放到就绪队列尾部。实测 sleep(0) 的切换成本比 yield() 高约 30%。
Q: 为什么 JVM 要把 Ready 和 Running 合并成 RUNNABLE?
A: 因为 JVM 层面无法精确感知操作系统的线程调度。一个线程在 Java 层面看到是 RUNNABLE,但实际可能在操作系统的就绪队列中等待 CPU。JVM 选择不暴露这个细节,统一对外表现为 RUNNABLE。
Q: 200 个线程和 2000 个虚拟线程哪个吞吐量高?
A: 如果都是 IO 密集型的,2000 个虚拟线程吞吐量更高,因为虚拟线程的切换成本可以忽略不计。但如果 hot path 上用了 synchronized,2000 个虚拟线程中如果有大量 pinned 场景,反而可能更差。
面试追问:Agent 场景特供
Q: 你做一个 Agent 框架,每个 Agent 实例一个线程,为什么用了虚拟线程后 QPS 翻倍?
A: 传统 Agent 框架(如 LangChain 的 executor 模式)每个 Agent 跑一个平台线程。Agent 在 call_llm() 时发出 HTTP 请求后就阻塞等待——线程挂起、上下文切换、恢复,每次切换 5-10 μs。如果一个 Agent 调用 3 次 LLM,每次 500ms 等待,这 500ms 里线程被挂起两次,平台线程被白白浪费。
换成虚拟线程后,call_llm() 内部的 HttpClient.send() 阻塞时,JVM 直接在用户态挂起当前虚拟线程,平台线程立即去执行其他虚拟线程。500ms 等待零开销,切换成本从 5-10 μs 降到 1-2 ns。
// 传统线程池版本:500 线程,每个线程 500ms 等待 LLM 响应
// 16 核机器上,实际并行度只有 16,其余 484 个线程在抢 CPU 时间片
// 上下文切换吃掉 40% CPU
// 虚拟线程版本:5000 个任务,每个任务执行 3 次 LLM 调用
// 平台线程数 = 16 (CPU 核心),虚拟线程之间零开销切换
ExecutorService vtPool = Executors.newVirtualThreadPerTaskExecutor();
for (int i = 0; i < 5000; i++) {
int query = i;
vtPool.submit(() -> {
String r1 = callLLM("query-" + query); // 阻塞,虚拟线程挂起
String r2 = callLLM("refine-" + r1); // 阻塞,虚拟线程挂起
return callLLM("summarize-" + r2); // 阻塞,虚拟线程挂起
});
}Q: 那 Agent 框架里用 synchronized 会怎样?
A: 虚拟线程遇到 synchronized 块会被 pinned(钉死在平台线程上)。如果 Agent 框架的 ToolRegistry 查找工具用了 synchronized 方法,那么所有并发 Agent 调用 findTool() 时,底层平台线程都被钉住,虚拟线程退化为普通线程。
// 坏:虚拟线程遇到 synchronized 就被 pinned
public synchronized Tool findTool(String name) { ... }
// 好:用 ReentrantLock 替代,虚拟线程不会被 pinned
private final ReentrantLock lock = new ReentrantLock();
public Tool findTool(String name) {
lock.lock();
try { ... } finally { lock.unlock(); }
}JDK 21 的 java.util.concurrent 内部已经全面替换为 ReentrantLock,但你自己写的 synchronized 代码块仍然是坑。
Q: 写一个 LLM 调用超时控制,用不同线程模型有什么差异?
// 传统线程:超时靠线程池 + Future.get(timeout)
// 如果超时,线程仍然在阻塞等待响应,不能立即回收
ExecutorService pool = Executors.newFixedThreadPool(100);
Future<String> future = pool.submit(() -> callLLM(prompt));
try {
return future.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true); // 中断线程
return fallback();
}
// 虚拟线程:超时直接挂起,平台线程立即释放
// 即使超时了,虚拟线程的 Continuation 状态被回收
// 平台线程不受影响,零额外开销
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var future = executor.submit(() -> callLLM(prompt));
return future.get(5, TimeUnit.SECONDS);
}这个区别在面试里很加分:传统线程超时后,线程池里的线程仍然 hold 着,如果你不 cancel(true),线程会一直阻塞到响应回来,但已经没人读结果了。虚拟线程的 Continuation 机制天然支持超时取消,不消耗平台线程。
总结
- Java 线程 6 种状态,重点掌握 NEW → RUNNABLE → BLOCKED/WAITING/TIMED_WAITING 之间的流转条件
- 一次上下文切换约 1-10 μs,高并发场景下能吃掉 20%-50% 的 CPU
- 用户态切换(CAS、自旋)比内核态切换(锁阻塞)快 2-3 个数量级
- 优化方向:减少锁粒度、CAS 替代锁、控制线程数、虚拟线程
- 虚拟线程是 JDK 21 的终极解法,切换成本降低到纳秒级,IO 密集型场景首选
- 注意虚拟线程的 pinned 陷阱:hot path 上用
ReentrantLock替代synchronized - Agent 框架场景下,虚拟线程的收益最大:LLM 调用是天然阻塞点,虚拟线程让 wait 时间零成本
用 cat /proc/pid/status 看看你的线上应用,voluntary_ctxt_switches 和 nonvoluntary_ctxt_switches 的比例是多少?如果非自愿切换占比超过 30%,说明线程池配大了,该调参了。