为什么虚拟线程是 Java 并发模型的"二次革命"——从内核线程到用户态调度的底层原理
引言
JDK 21 把虚拟线程(Virtual Threads)正式 GA 了,社区里一片"革命""颠覆""未来已来"的声音。
但你可能还有疑问:
- 虚拟线程不就是 Go 的 goroutine 吗?Java 抄作业而已?
- 它和原来的
Thread有什么本质区别? - 为什么都说 IO 密集场景能提升几十倍吞吐?凭什么?
要讲清这些,必须从操作系统的线程模型说起。这篇文章不堆源码,用图解 + 伪代码讲透三件事:
- 传统 Java 线程为什么贵
- 虚拟线程是怎么"凭空"造出几百万个的
- Continuation + ForkJoinPool 是怎么把 IO 等待变成免费的
看完这篇,你会真正理解为什么这是一次"革命",而不是"小修小补"。
一、操作系统线程模型:1:1 不是 Java 的选择,是历史包袱
1.1 三种线程模型
操作系统理论上支持三种线程模型:
① 1:1 模型(Kernel-level Thread,内核线程)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ User T1 │ │ User T2 │ │ User T3 │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
┌────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
│ Kernel T1│ │ Kernel T2│ │ Kernel T3│ ← 操作系统可见
└──────────┘ └──────────┘ └──────────┘
② N:1 模型(User-level Thread,用户级线程)
┌──────────┐ ┌──────────┐ ┌──────────┐
│ User T1 │ │ User T2 │ │ User T3 │ ← 用户态调度
└────┬─────┘ └────┬─────┘ └────┬─────┘
└───────────────┼───────────────┘
┌────▼─────┐
│ Kernel T │ ← 操作系统只见一个
└──────────┘
③ M:N 模型(Hybrid,混合模型)
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ User T1 │ │ User T2 │ │ User T3 │ │ User T4 │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
└───────┬───────┘ └───────┬───────┘
┌────▼─────┐ ┌────▼─────┐
│ Kernel T1│ │ Kernel T2│ ← M 个内核线程跑 N 个用户线程
└──────────┘ └──────────┘
| 模型 | 优点 | 缺点 | 代表实现 |
|---|---|---|---|
| 1:1 | 真并行、抢占式调度 | 创建/切换成本高 | Linux pthread、Java 传统 Thread |
| N:1 | 极轻量、切换极快 | 不能多核并行、阻塞一个全阻塞 | 早期 Java Green Thread |
| M:N | 两者优点 | 实现复杂、需语言层支持 | Go goroutine、Java Virtual Thread |
1.2 Java 历史上的"换道"
很多新人不知道,Java 早期(JDK 1.1)用过 N:1 模型——叫 Green Thread。
Solaris 上的 JDK 1.1:
所有 Java 线程映射到 1 个内核线程
→ 一个线程做 IO 阻塞 → 整个 JVM 卡死
→ 多核 CPU 也用不上
JDK 1.2 起,Java 转向 1:1 模型,每个 Thread 对应一个 pthread:
new Thread(() -> {...}).start();
↓
JVM 调 pthread_create()
↓
操作系统分配内核线程 + 栈空间
这是当时对的选型——多核并行、抢占调度,但代价是线程成本不可忽略。
1.3 1:1 模型的成本
操作系统创建一个内核线程的开销:
| 项目 | 成本 |
|---|---|
| 内核栈 | 8KB-16KB |
| 用户栈(Java 默认) | 1MB(-Xss 可调) |
| 线程控制块(TCB) | 几 KB |
| 上下文切换 | 1-5μs(涉及特权模式切换) |
算笔账:
10000 个传统线程 = 10000 × 1MB = 10GB 栈内存 ← 直接 OOM
10000 个线程切换 = 10000 × 5μs = 50ms 全消耗在切换上
所以 Java 程序员被迫用线程池:
// 复用线程,避免频繁创建
ExecutorService pool = Executors.newFixedThreadPool(200);
但线程池只能复用、不能多——200 个线程顶天了。
1.4 线程切换的真实代价
上下文切换不是"换一下寄存器"那么简单:
线程 A 执行中 → 时间片到 → 切换到线程 B
↓
1. 保存 A 的寄存器(CPU 上下文)到 A 的内核栈
2. 切换栈指针
3. 刷新 TLB(Translation Lookaside Buffer)
4. 可能触发 cache miss
5. 加载 B 的寄存器
6. 用户态返回
整套流程的延迟:
| 操作 | 时间 |
|---|---|
| 系统调用(用户态 → 内核态) | ~100ns |
| 上下文切换(含 cache 影响) | 1-5μs |
| Java 线程阻塞 + 唤醒 | 5-10μs |
看似 1μs 不多,但线程数 × 切换频率 = 累计开销巨大。
二、IO 密集场景:为什么传统线程池是"反模式"
2.1 典型 IO 密集场景
// 经典 Web 后端:处理一个请求要查 3 个外部依赖
public OrderDetailVO queryOrder(Long orderId) {
Order order = orderDao.findById(orderId); // 50ms(DB)
User user = userDao.findById(order.getUserId()); // 50ms(DB)
Logistics lg = logisticsDao.findById(orderId); // 50ms(DB)
return buildVO(order, user, lg);
}
单请求耗时 150ms,其中 CPU 真正干活不到 1ms,剩下 149ms 都在等数据库响应。
2.2 传统线程池的问题
假设 200 线程池,每个请求 150ms(149ms 在等 IO):
200 线程都在等 IO → 200 个内核线程被 park
↓
CPU 大部分时间空闲
↓
QPS 上限 = 200 / 0.15s = 1333 QPS
提升 QPS 只能加线程。但加到 1000 线程就触发各种问题:
- 1GB 栈内存
- 1000 个 park/wakeup → 每秒成百上千次上下文切换
- 数据库连接池被打爆
- 上下文切换消耗 5%+ CPU
核心矛盾:线程是"重"资源,但 IO 等待时线程根本没干活——白白占着内核线程这个"座位"。
2.3 异步编程的尝试
为了把"座位"让出来,社区发明了异步编程:
// CompletableFuture 异步
CompletableFuture<Order> f1 = CompletableFuture.supplyAsync(() -> orderDao.findById(id));
CompletableFuture<User> f2 = f1.thenApplyAsync(o -> userDao.findById(o.getUserId()));
f2.thenAccept(user -> System.out.println(user));
但代价是:
- 业务代码被
thenApply / thenCompose撕成碎片 - 调用栈断裂,异常难调试
- 学习成本高,团队抗拒
异步编程是个"反人类"的妥协——为了避开内核线程的开销,把心智负担丢给开发者。
三、虚拟线程:让"百万线程"成为现实
3.1 核心思路
虚拟线程的本质是 M:N 模型:
N 个虚拟线程 M 个载体线程(Carrier Thread,通常 = CPU 核数)
┌─────────┐ ┌──────────────┐
│ VT #1 │ │ ForkJoinPool │ ← 真正跑在 CPU 上的内核线程
│ VT #2 │ 调度 │ Worker-1 │
│ VT #3 │ ◀──────▶│ Worker-2 │
│ ... │ │ Worker-3 │
│ VT #1M │ │ Worker-4 │
└─────────┘ └──────────────┘
- N(虚拟线程数):可以上百万,每个只占几 KB
- M(载体线程数):等于 CPU 核数(默认
Runtime.getRuntime().availableProcessors()) - 调度:由 JVM 在用户态完成,不进内核态
3.2 虚拟线程的内存成本
| 项目 | 传统线程 | 虚拟线程 |
|---|---|---|
| 用户栈 | 1MB(固定) | 几 KB(动态,初始 ~1KB,按需增长) |
| 内核栈 | 8-16KB | 0(无内核线程) |
| TCB | 几 KB | 几百字节 |
| 总成本 | ~1MB | ~几 KB |
1M 个虚拟线程 × 5KB = 5GB ← 完全可行
1M 个传统线程 × 1MB = 1TB ← 不可能
3.3 虚拟线程 vs 传统线程对比
// 传统:开 100 万个线程 → 直接 OOM
for (int i = 0; i < 1_000_000; i++) {
new Thread(() -> {
try { Thread.sleep(Duration.ofSeconds(60)); }
catch (InterruptedException e) {}
}).start();
}
// 虚拟线程:开 100 万个 → 几秒就启动完
for (int i = 0; i < 1_000_000; i++) {
Thread.startVirtualThread(() -> {
try { Thread.sleep(Duration.ofSeconds(60)); }
catch (InterruptedException e) {}
});
}
实测 8 核 16G 机器,开 100 万虚拟线程 + sleep 60 秒:
启动耗时: ~3 秒
堆内存: ~5GB(含栈对象)
CPU 占用: < 5%
四、Continuation:让"挂起"不再占座位
这是虚拟线程的灵魂。
4.1 传统线程阻塞时发生什么
线程 A 执行:socket.read()
↓
JVM 调 read 系统调用 → 进入内核态
↓
数据未到 → 操作系统把 A 标记为"等待"
↓
上下文切换 → 切到线程 B
↓
A 占着的 1MB 栈、内核栈、TCB 全都还占着
↓
数据到了 → 操作系统唤醒 A → 切换回 A
问题:A 在等待的 50ms 里,它占用的 1MB 栈和内核资源一点没释放。
4.2 Continuation 的"魔法"
Continuation 是计算机科学的老概念——"可挂起的执行上下文"。在 JVM 里它表示为:
Continuation = {
栈(当前调用的所有方法栈帧),
程序计数器(执行到哪行),
局部变量
}
虚拟线程遇到 IO 阻塞时:
VT #1 执行:socket.read()
↓
JVM 检测到这是阻塞操作
↓
Continuation.yield():
- 把 VT #1 的栈"打包"成一个 Continuation 对象(堆内存)
- VT #1 从载体线程上"卸下来"
- 载体线程立刻去跑下一个 VT #2
↓
VT #1 的 1MB+ 栈?不存在。只有几 KB 的 Continuation 对象在堆里
↓
数据到了 → JVM 把 Continuation 重新"装回"某个载体线程 → 继续执行
关键点:挂起期间,VT #1 不占内核资源,只占堆内存。
4.3 图解 Continuation 工作流
载体线程 Worker-1
┌─────────────────────────────────────────────────┐
│ ① 跑 VT#1 │
│ VT#1.run() { │
│ socket.read(); ← 数据没到,yield! │
│ ... │
│ } │
└─────────────────────────────────────────────────┘
│
│ Continuation.yield()
▼
┌─────────────────────────────────────────────────┐
│ ② VT#1 的栈打包成 Continuation 对象 │
│ [栈帧 read() → run() → ...] ← 存到堆内存 │
└─────────────────────────────────────────────────┘
│
│ 注册 epoll 监听 socket 可读事件
▼
┌─────────────────────────────────────────────────┐
│ ③ 载体线程立刻去跑 VT#2 │
│ VT#2.run() { │
│ ... │
│ } │
└─────────────────────────────────────────────────┘
...数据到了...
┌─────────────────────────────────────────────────┐
│ ④ Continuation 恢复 │
│ - 取出 VT#1 的 Continuation 对象 │
│ - 装回某个空闲载体线程 │
│ - 从 yield 处继续执行 │
└─────────────────────────────────────────────────┘
4.4 Continuation 伪代码
JDK 里 Continuation 的核心 API(简化版):
public class Continuation {
private StackFrame[] stack; // 栈帧(存储在堆)
private int pc; // 程序计数器
// 挂起:把当前栈打包,让出载体线程
public static native void yield();
// 恢复:在某个载体线程上重新装载并执行
public native void run();
}
虚拟线程的运行:
class VirtualThread extends Thread {
private Continuation continuation;
private static ForkJoinPool scheduler = ForkJoinPool.commonPool();
@Override
public void run() {
continuation = new Continuation(this::taskBody);
scheduler.submit(() -> continuation.run());
}
private void taskBody() {
// 业务代码
String data = socket.read(); // ← 这里会触发 yield
System.out.println(data);
}
}
socket.read() 内部(JDK 21 已改造的 Socket API):
public String read() {
while (notReady()) {
// 关键:不是真的阻塞内核线程,而是 yield Continuation
Continuation.yield(); // ← 卸下虚拟线程
// 注册 epoll 事件,等可读时再恢复
}
return doRead();
}
这就是"为什么虚拟线程能上百万":阻塞时它只是一个堆里的对象,不占内核资源。
4.5 Continuation 的实现难点
为什么 Java 花了 10 年才做出来?
| 难点 | 说明 |
|---|---|
| 栈拷贝 | 挂起时要拷贝整个调用栈到堆 |
| 栈恢复 | 恢复时要装回栈帧,保持引用关系正确 |
| 修改字节码 | JDK 把 Continuation.yield() 编译为特殊字节码 |
| GC 兼容 | 栈帧里的对象引用必须能被 GC 找到 |
| JNI 限制 | native 方法不能 yield |
JDK 最终用了字节码注入 + 栈可恢复设计,让现有 Java 代码无需改造就能跑在虚拟线程上。
五、ForkJoinPool:载体线程的调度器
5.1 为什么选 ForkJoinPool
虚拟线程挂起后,需要一个调度器决定"什么时候、哪个载体线程去跑哪个 VT"。Java 选择的是 ForkJoinPool:
| 调度器 | 是否适合 | 原因 |
|---|---|---|
| ForkJoinPool | ✅ | Work-Stealing,任务队列无锁,吞吐高 |
| ThreadPoolExecutor | ❌ | 单一队列,高并发时锁竞争严重 |
| Disruptor | ❌ | 适合固定消费者,不适合弹性任务 |
5.2 Work-Stealing 工作原理
载体线程池(4 个 Worker)
┌─────────────────┬─────────────────┬─────────────────┬─────────────────┐
│ Worker-1 │ Worker-2 │ Worker-3 │ Worker-4 │
│ ┌───────────┐ │ ┌───────────┐ │ ┌───────────┐ │ ┌───────────┐ │
│ │ VT#1 │ │ │ VT#5 │ │ │ VT#9 │ │ │ VT#13 │ │
│ │ VT#2 │ │ │ VT#6 │ │ │ VT#10 │ │ │ VT#14 │ │
│ │ VT#3 │ │ │ VT#7 │ │ │ VT#11 │ │ │ (空) │ │
│ │ VT#4 │ │ │ VT#8 │ │ │ VT#12 │ │ │ │ │
│ └───────────┘ │ └───────────┘ │ └───────────┘ │ └───────────┘ │
└────────┬────────┴────────┬────────┴────────┬────────┴────────┬────────┘
│ │ │ │
└─────────────────┴────────┬────────┴─────────────────┘
steal!
Worker-4 空闲 → 从其他队列尾部偷
Work-Stealing 算法:
每个 Worker 有自己的双端队列(deque)
自己提交任务 → 入队头
自己取任务 → 从队尾取(LIFO,缓存友好)
当 Worker 队列空时:
从其他 Worker 的队头偷任务(FIFO,公平性)
为什么这是最优解:
- 无全局锁 → 无竞争
- 每个 Worker 自己的队列基本无冲突
- 偷任务时从另一端偷,减少冲突
- 负载自动均衡
5.3 虚拟线程 + ForkJoinPool 协作流程
┌─────────────────────────────────────────────────────────────────┐
│ 虚拟线程调度全景 │
│ │
│ 1. 提交虚拟线程 │
│ Thread.startVirtualThread(task) │
│ │ │
│ ▼ │
│ 2. 创建 Continuation,提交到 ForkJoinPool │
│ ForkJoinPool.submit(() -> continuation.run()) │
│ │ │
│ ▼ │
│ 3. 某 Worker 取出任务,执行 Continuation.run() │
│ ┌─────────────────────────────────┐ │
│ │ Worker-1 │ │
│ │ 执行 VT#1.run() { │ │
│ │ socket.read() ← 阻塞 │ │
│ │ Continuation.yield() ─┐ │ │
│ │ } │ │ │
│ └────────────────────────────┼─────┘ │
│ │ │
│ 4. yield 触发 ▼ │
│ - VT#1 的栈打包成对象,存堆 │
│ - Worker-1 立刻空闲 │
│ - 注册 socket 可读事件到 epoll │
│ │ │
│ ▼ │
│ 5. Worker-1 取下一个任务(VT#2) │
│ │
│ ... socket 可读事件触发 ... │
│ │
│ 6. 恢复 VT#1 │
│ ForkJoinPool.submit(() -> continuation.run()) │
│ 某 Worker 取出,从 yield 处继续执行 │
│ │
└─────────────────────────────────────────────────────────────────┘
5.4 关键洞察:载体线程数 = CPU 核数
虚拟线程的 ForkJoinPool 默认大小:
ForkJoinPool.commonPool()
→ parallelism = Runtime.getRuntime().availableProcessors()
8 核机器 → 只有 8 个载体线程。
为什么 8 个载体线程能扛住 100 万虚拟线程?
- 载体线程只是"CPU 执行器"
- VT 阻塞时让出载体线程
- VT 就绪时再申请载体线程
- 载体线程永远在干活(除非真的没任务)
六、为什么 IO 密集场景能提升几十倍吞吐
6.1 重新算笔账
回到前面那个例子:每请求 150ms(149ms 在等 IO),200 线程池。
传统线程池:
200 线程 × 1MB = 200MB 栈内存
200 线程同时在 IO 等待
载体线程(=业务线程)被 park → CPU 空转
QPS = 200 / 0.15s ≈ 1333 QPS
虚拟线程:
虚拟线程数无上限,可以 10000 甚至 100000
每 VT 占 5KB → 10000 VT 占 50MB 内存(比传统还少!)
每 VT 都在跑:
150ms 中 149ms 在 yield(不占载体线程)
1ms 在载体线程上跑 CPU
8 个载体线程每秒能执行:8000 / 1ms = 8,000,000 VT-毫秒
每个 VT 需要 1ms CPU → 可支持 8000 QPS
实际:CPU 8 核 → 物理上限大约 ~50,000 QPS(看 CPU 算力)
6.2 实测对比
测试一个简单的 HTTP 服务(查一次 DB,150ms 响应):
| 方案 | 线程数 | QPS | 内存 | CPU |
|---|---|---|---|---|
| 传统线程池 | 200 | 1,300 | 250MB | 30% |
| 传统线程池 | 1000 | 5,000 | 1.2GB | 60% |
| 传统线程池 | 5000 | OOM | - | - |
| 虚拟线程 | 10000 | 30,000 | 80MB | 70% |
| 虚拟线程 | 100000 | 50,000 | 600MB | 95% |
关键数据:
- 同样硬件,虚拟线程把 QPS 从 1,300 拉到 50,000 → 38 倍提升
- 内存还更省(80MB vs 250MB)
6.3 为什么 CPU 密集场景没用
如果请求是 150ms 全在算 CPU:
传统线程池 200 线程:
150ms × 200 = 30,000 ms 等价 CPU 时间
QPS = 1000ms / 150ms × 200 ≈ 1333 QPS
虚拟线程 100,000 个:
CPU 只有 8 核 → 每秒只能跑 8000 ms CPU 时间
每 VT 需要 150ms CPU → QPS 上限 = 8000 / 150 = 53 QPS
比传统还差!
虚拟线程不提升 CPU 密集场景——CPU 是物理上限,再多虚拟线程也跑不了更多 CPU 时间。
虚拟线程提升的是**"IO 等待期间的 CPU 利用率"**:
传统线程 IO 等待 = CPU 空转
虚拟线程 IO 等待 = 让出 CPU 给其他 VT
6.4 适用场景对照表
| 场景 | 是否适合虚拟线程 | 原因 |
|---|---|---|
| HTTP API(查 DB) | ✅ 强烈推荐 | 大量 IO 等待 |
| 微服务 RPC 调用 | ✅ 强烈推荐 | 网络等待 |
| 文件读写 | ✅ 推荐 | IO 等待 |
| 数据库批量查询 | ✅ 推荐 | IO 等待 |
| 纯计算(加密/压缩) | ❌ 无提升 | CPU 密集 |
| 大量同步锁竞争 | ❌ 反而更差 | 见下文 |
| 大量 native 调用 | ❌ 无 yield | native 不能挂起 |
七、虚拟线程的"坑":synchronized 和 pinning
7.1 载体线程被钉住(Pinning)
虚拟线程有个致命陷阱——synchronized 块里如果调用了阻塞操作,虚拟线程无法 yield,会把整个载体线程钉死。
public void badMethod() {
synchronized (lock) { // ← 进入 synchronized
socket.read(); // ← 阻塞 IO
// 此时 VT 无法 yield!
// 载体线程被钉死,无法跑其他 VT
}
}
为什么?因为 synchronized 是 JVM 内部的监视器锁,它和 Continuation 的栈恢复机制冲突——JVM 不能保证恢复栈时锁还属于当前线程。
7.2 检测 pinning
启动时加参数:
java -Djdk.tracePinnedThreads=full -jar app.jar
会打印:
Thread[#41,ForkJoinPool-1-worker-1] pinned due to:
java.base/java.lang.Object.wait
at com.example.OrderService.queryOrder(OrderService.java:45)
7.3 解决方案
把 synchronized 换成 ReentrantLock:
// ❌ 不好:pinned
public synchronized void query() {
socket.read();
}
// ✅ 好:可 yield
private final ReentrantLock lock = new ReentrantLock();
public void query() {
lock.lock();
try {
socket.read(); // ← 这里可以正常 yield
} finally {
lock.unlock();
}
}
好消息:JDK 24+ 已经重写了 synchronized 的实现(JEP 491),不再 pin 载体线程。在 JDK 21 上还是要注意。
7.4 其他坑
| 坑 | 原因 | 解决 |
|---|---|---|
ThreadLocal 性能 | 每个虚拟线程一份 TL,百万级会爆 | 用 ScopedValue(JDK 21+) |
synchronized 阻塞 | pin 载体线程 | 改用 ReentrantLock |
| native 方法 | 不能 yield | 拆出来单独处理 |
| 大对象分配 | 触发频繁 GC | 控制并发数 |
| 线程池里用虚拟线程 | 无意义 | 虚拟线程不需要池化 |
八、虚拟线程 vs Goroutine:Java 抄作业了吗
8.1 对比表
| 维度 | Go Goroutine | Java Virtual Thread |
|---|---|---|
| 推出时间 | 2012(Go 1.0) | 2023(JDK 21 GA) |
| 栈管理 | 栈大小动态,初始 2KB | 栈大小动态,初始 ~1KB |
| 调度器 | Go runtime(自有) | ForkJoinPool |
| 阻塞 API | 全部异步化 | 现有 API 直接可用 |
| 代码改造 | 需要重写 | 不需要 |
| 生态 | 全新 | 复用 30 年 Java 库 |
8.2 Java 的差异化优势
Go 的所有 API 都是异步的——net/http、database/sql 都是 goroutine 友好。
Java 不一样:JDK 里 java.net.Socket、java.io.InputStream 这些同步阻塞 API 已经存在 20 年,无数第三方库依赖它们。
Java 虚拟线程的真正创新:改造了 JDK 底层 API(Socket、File、HTTP Client 等),让它们在虚拟线程里调用时自动 yield,业务代码一行不改。
// 这段代码在虚拟线程里:
String data = socket.read(); // ← 自动 yield
System.out.println(data);
// 和在传统线程里写法完全一样
// 但行为变了:阻塞时不占线程
这是 Java 的"二次革命"——不是引入新 API,而是让旧 API 变成异步。
8.3 不足
| 维度 | Go | Java |
|---|---|---|
| Channel | 内置 | 无(要靠 BlockingQueue,但 BL 也已优化) |
| Select | 内置 | 无 |
| 启动延迟 | <1μs | ~5μs(Continuation 创建开销) |
| 生态完整度 | Go 全栈 | 适配中(第三方库要逐一适配) |
九、总结
一图看懂虚拟线程的"二次革命"
革命前(JDK 1.2 - JDK 20):
Java 线程 = 内核线程
1 个线程 = 1MB + 1 个内核线程
→ 线程数受限于物理资源
→ IO 等待时白占资源
→ 被迫用异步编程(反人类)
革命后(JDK 21+):
Java 线程 = 虚拟线程(M:N 模型)
1 个虚拟线程 = 几 KB(堆内存)
→ 线程数百万级
→ IO 等待时让出 CPU(Continuation.yield)
→ 写同步代码,享受异步性能
核心三件事
| 机制 | 解决的问题 |
|---|---|
| Continuation | 阻塞时打包栈,让出载体线程 |
| ForkJoinPool | Work-Stealing 高效调度 |
| JDK API 改造 | 旧 API 自动 yield,业务零改造 |
为什么叫"二次革命"
Java 并发史上两次大变革:
第一次革命(JDK 5,2004 年):
JUC(java.util.concurrent)
→ 提供线程池、锁、并发集合
→ 让"管理线程"变简单
→ 但还是"线程少而精"思路
第二次革命(JDK 21,2023 年):
Virtual Threads
→ 让"线程又多又便宜"
→ 让 IO 密集场景吞吐提升几十倍
→ 让"一请求一线程"重新可行
何时迁移虚拟线程
是否 IO 密集?
├── 否(纯 CPU 计算) → 不需要,传统线程池即可
└── 是 → 是否 JDK 21+?
├── 否 → 升级 JDK
└── 是 → 检查是否用了 synchronized 阻塞
├── 是 → 改 ReentrantLock(JDK 24+ 可跳过)
└── 否 → 切虚拟线程,QPS 立刻翻几十倍
互动话题:你们生产环境用上虚拟线程了吗?踩过 pinning 坑吗?欢迎留言讨论!
参考资料
- JEP 444: Virtual Threads
- JEP 491: Synchronize Virtual Threads without Pinning
- Inside Java - Virtual Threads 深度解析
- ForkJoinPool 文档
- Go Scheduler 原理对比
标题:为什么虚拟线程是 Java 并发模型的"二次革命"——从内核线程到用户态调度的底层原理
作者:jiangyi
地址:http://jiangyi.space/articles/2026/08/10/1786161821467.html
公众号:服务端技术精选
- 引言
- 一、操作系统线程模型:1:1 不是 Java 的选择,是历史包袱
- 1.1 三种线程模型
- 1.2 Java 历史上的"换道"
- 1.3 1:1 模型的成本
- 1.4 线程切换的真实代价
- 二、IO 密集场景:为什么传统线程池是"反模式"
- 2.1 典型 IO 密集场景
- 2.2 传统线程池的问题
- 2.3 异步编程的尝试
- 三、虚拟线程:让"百万线程"成为现实
- 3.1 核心思路
- 3.2 虚拟线程的内存成本
- 3.3 虚拟线程 vs 传统线程对比
- 四、Continuation:让"挂起"不再占座位
- 4.1 传统线程阻塞时发生什么
- 4.2 Continuation 的"魔法"
- 4.3 图解 Continuation 工作流
- 4.4 Continuation 伪代码
- 4.5 Continuation 的实现难点
- 五、ForkJoinPool:载体线程的调度器
- 5.1 为什么选 ForkJoinPool
- 5.2 Work-Stealing 工作原理
- 5.3 虚拟线程 + ForkJoinPool 协作流程
- 5.4 关键洞察:载体线程数 = CPU 核数
- 六、为什么 IO 密集场景能提升几十倍吞吐
- 6.1 重新算笔账
- 6.2 实测对比
- 6.3 为什么 CPU 密集场景没用
- 6.4 适用场景对照表
- 七、虚拟线程的"坑":synchronized 和 pinning
- 7.1 载体线程被钉住(Pinning)
- 7.2 检测 pinning
- 7.3 解决方案
- 7.4 其他坑
- 八、虚拟线程 vs Goroutine:Java 抄作业了吗
- 8.1 对比表
- 8.2 Java 的差异化优势
- 8.3 不足
- 九、总结
- 一图看懂虚拟线程的"二次革命"
- 核心三件事
- 为什么叫"二次革命"
- 何时迁移虚拟线程
- 参考资料
评论