第 8 篇讲线程池时,我们花了很大篇幅回答一个问题:"到底该开多少线程?" 答案是一套要权衡 CPU 核数、IO 等待占比、还得靠压测反复迭代的经验公式。第 13 篇讲异步编排时,我们又用 CompletableFuture 把一段本来很直白的业务逻辑,拆成了一串 thenCompose、thenCombine 的链式回调------为的是在等 IO 的时候别把宝贵的线程占着。
这两件事其实都是在为同一个约束买单:平台线程太贵了,所以不能多开;不能多开,就必须靠"异步 + 回调"把线程榨干。 而 JDK 21 转正的虚拟线程 (Virtual Threads,Project Loom,JEP 444)直接把这个约束本身拆掉了------如果线程便宜到可以随手创建几百万个,那"该开多少线程"就不再是个需要精心调参的问题,"为了不阻塞线程而写成回调"也不再必要,同步阻塞的写法可以重新变成又好读又高吞吐的写法。
这是并发领域这几年最大的一次变化,用它给整个系列收尾正合适。这一篇按这条线索展开:先算清楚平台线程贵在哪、贵出了什么后果;再看虚拟线程靠"挂载/卸载"这一招怎么解决问题;接着讲实际用起来必须知道的几条规则(尤其是"不要池化"和"什么情况下会 pinning");然后介绍配套的结构化并发 和 ScopedValue;最后把 14 篇串成一张全景地图收官。
目录
- [平台线程贵在哪:thread-per-request 的天花板](#平台线程贵在哪:thread-per-request 的天花板)
- 虚拟线程是什么:挂载、卸载与载体线程
- 怎么创建和使用虚拟线程
- Pinning:虚拟线程被钉在载体线程上
- [使用规则:不要池化,慎用 ThreadLocal](#使用规则:不要池化,慎用 ThreadLocal)
- 结构化并发:让并发任务有明确的作用域
- ScopedValue:虚拟线程时代的上下文传递
- [虚拟线程 vs 线程池 vs 响应式:怎么选](#虚拟线程 vs 线程池 vs 响应式:怎么选)
- [全系列回望:14 篇串成一张地图](#全系列回望:14 篇串成一张地图)
一、平台线程贵在哪:thread-per-request 的天花板
先明确一个术语。JDK 21 之后,我们过去说的"线程"有了正式名字叫平台线程 (Platform Thread)------new Thread() 创建出来的那种,它是操作系统内核线程的一层薄包装,一个 Java 平台线程对应一个 OS 线程,一对一。
它贵在两处,都是硬成本:
- 内存贵。 每个平台线程要预留一块栈空间 ,64 位 HotSpot 上默认约 1MB (
-Xss可调,但调小会有StackOverflowError风险)。这块栈是在创建线程时就向操作系统申请的虚拟内存,随线程一直占着。开 1 万个线程,光栈就要占掉近 10GB 虚拟内存------这在多数机器上已经不现实了。 - 切换贵。 平台线程的调度由操作系统内核负责,一次上下文切换要陷入内核态、保存/恢复寄存器现场、还可能连带 TLB 和 CPU 缓存失效(第 1 篇算过这笔账,微秒级的直接开销 + 更隐蔽的缓存失效成本)。线程数远超核数时,CPU 大量时间花在"搬现场"而不是干活。
这两条成本合起来,给后端最主流的编程模型判了个上限。这个模型叫 thread-per-request (一请求一线程):Tomcat 收到一个 HTTP 请求,从线程池里取一个线程,这个线程从头到尾负责这次请求------查数据库、调下游 RPC、组装结果、返回响应。它的最大优点是代码好写好读好调试 :一次请求的完整逻辑就是一段顺序执行的代码,栈追踪是完整的,try-catch 是正常工作的,断点打下去就是你想看的那个上下文。
问题在于,后端服务的绝大多数时间根本不在"算",而在**"等"**------等数据库返回、等下游接口、等 Redis。假设一次秒杀下单请求耗时 200ms,其中 190ms 都在等 IO,只有 10ms 在真正计算。那么:
- 这个线程 95% 的时间是阻塞状态,什么活也没干,但它的 1MB 栈和 OS 线程资源全程被占着。
- 想支撑 1 万 QPS,按 200ms 的响应时间算,需要约 2000 个线程同时在跑(
并发数 = QPS × 响应时间)。2000 个平台线程已经是相当吃力的规模了:约 2GB 栈内存,加上远超核数的调度压力。 - 而这 2000 个线程真正需要的 CPU,其实只有
2000 × 5% = 100个线程的量。硬件明明还有余力,但线程数先撞了墙。
面对这个墙,过去只有两条路:
- 路一:加线程池 + 调参。 这就是第 8 篇的内容------线程池复用线程摊掉创建成本,靠
核数 × (1 + 等待/计算)这类公式估个起点再压测调优。但它只是把成本摊薄,没有消除"一个阻塞的请求就占死一个 OS 线程"这个根本约束,天花板还在那儿。 - 路二:改成异步/响应式。 这就是第 13 篇的
CompletableFuture,以及更彻底的 Reactor / RxJava / Netty 那一套。核心思路是遇到 IO 就不阻塞,把回调注册上去然后把线程还回去 ,等 IO 完成再由某个线程接着跑后续逻辑。这条路确实能用很少的线程扛住极高的并发,代价是:代码从"一段顺序逻辑"变成"一堆碎片化的回调" ,栈追踪断裂(异常堆栈里看不到完整的业务调用链)、调试困难、ThreadLocal失效(一次请求会横跨多个线程)、心智负担陡增。业界管这个代价叫"异步的传染性"------一处改异步,调用链上所有人都得跟着改成异步。
虚拟线程走的是第三条路:不改编程模型,而是把线程本身变便宜。 让你继续写 thread-per-request 那种好读的同步阻塞代码,但阻塞的时候不再占用 OS 线程。
二、虚拟线程是什么:挂载、卸载与载体线程
虚拟线程是由 JVM 而不是操作系统调度的线程。 它同样是 java.lang.Thread 的实例(这点很关键------所有接受 Thread 的现有 API 都能直接用),但它不再一对一绑定 OS 线程。
它的运行需要三个角色配合:
- 虚拟线程(Virtual Thread) :你的任务代码所在的那个"线程",非常轻量。它的栈不是操作系统栈,而是 JVM 在堆上管理的一段结构(叫 continuation/stack chunk),初始只有几百字节到几 KB,按需增长。所以创建几十万、上百万个虚拟线程是可行的。
- 载体线程(Carrier Thread):真正干活的平台线程。虚拟线程要执行代码,必须先"骑"到某个载体线程上。
- 调度器(Scheduler) :JVM 内部一个专门的
ForkJoinPool(注意:不是commonPool,是虚拟线程专用的另一个池),负责把虚拟线程分派到载体线程上。默认并行度等于 CPU 核数 (可用系统属性jdk.virtualThreadScheduler.parallelism调整,但通常不需要动)。这里正好复用了第 13 篇讲过的工作窃取调度。
整个机制的核心就是两个动作:
- 挂载(mount):调度器把一个虚拟线程分配到某个载体线程上开始执行,此时虚拟线程的栈帧被搬到载体线程的 OS 栈上跑。
- 卸载(unmount) :当虚拟线程执行到阻塞操作时 (比如
socket.read()等网络响应、Thread.sleep()、等一把ReentrantLock、BlockingQueue.take()),JVM 不会让载体线程跟着一起阻塞,而是把这个虚拟线程的栈保存回堆里 ,然后让它从载体线程上卸载下来。载体线程立刻被释放,去执行下一个就绪的虚拟线程。等 IO 完成后,这个虚拟线程重新变为就绪,被调度器再挂载到(可能是另一个)载体线程上,从上次的位置继续跑。

这一进一出,就是虚拟线程的全部魔法。 回到上一节的账:190ms 的 IO 等待期间,虚拟线程处于卸载状态,只占着堆上那一小块栈数据,不占用任何 OS 线程 ;载体线程在这 190ms 里可以轮流服务成千上万个其他虚拟线程。于是"2000 个并发请求"不再需要 2000 个 OS 线程,几个(等于核数)载体线程就够了。阻塞的成本从"占住一个 1MB 的 OS 线程"降到了"占住堆上几 KB"。
值得点明的是:这件事之所以能对业务代码透明,是因为 JDK 把标准库里几乎所有阻塞点都改造了一遍。 java.net 的 Socket、java.nio.channels、Thread.sleep()、java.util.concurrent 里的锁和队列......这些原本会阻塞 OS 线程的地方,现在都能识别"当前跑在虚拟线程上",进而触发卸载而不是真阻塞。你写的还是 jdbcTemplate.query(...) 这样的同步调用,卸载发生在你看不见的地方。
版本提示 :虚拟线程在 JDK 19(JEP 425) 首次以预览特性出现,JDK 20(JEP 436)二次预览,JDK 21(JEP 444)正式转正 。所以生产使用请以 JDK 21 及以上 为准,19/20 上需要
--enable-preview且 API 可能变动。
三、怎么创建和使用虚拟线程
API 设计得非常克制,主要就三种用法。
用法一:Thread.ofVirtual() 直接起一个。
java
// 起一个虚拟线程并立即执行
Thread vt = Thread.ofVirtual().name("seckill-worker").start(() -> {
deductStock(productId); // 里面该阻塞就阻塞,不用改成异步
});
vt.join(); // 和平台线程一样 join
// 对比:平台线程的写法(Thread.ofPlatform() 是 new Thread() 的新式等价写法)
Thread pt = Thread.ofPlatform().start(() -> deductStock(productId));
用法二:Executors.newVirtualThreadPerTaskExecutor()(最推荐)。 名字已经说明了一切------每个任务一个新的虚拟线程 ,不是池。它实现了 ExecutorService,所以第 8、13 篇讲的 submit()、Future、CompletableFuture 那套东西全部照旧可用。
java
// 注意用 try-with-resources:ExecutorService 在 JDK 19+ 实现了 AutoCloseable,
// close() 会等所有任务跑完(相当于 shutdown() + awaitTermination())
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Long userId : userIds) { // 假设有 10 万个用户请求
executor.submit(() -> {
checkRiskControl(userId); // 阻塞:调风控接口
deductStock(productId); // 阻塞:查数据库
createOrder(userId, productId); // 阻塞:写数据库
});
}
} // 这里会等到 10 万个任务全部完成
这段代码会创建 10 万个虚拟线程。换成 Executors.newFixedThreadPool(10_000) 试试,多半直接 OOM 或者把机器拖垮;但 10 万个虚拟线程只是堆上多了几十 MB,跑在几个载体线程上。 这就是"线程便宜了以后"代码能有多直白的最好例子------没有回调、没有 thenCompose,就是最朴素的顺序逻辑。
用法三:让 Web 框架整体切过来。 实际项目里更常见的做法不是自己起虚拟线程,而是把容器的请求处理线程换成虚拟线程,一行配置的事。Spring Boot 3.2+(需要 JDK 21):
properties
spring.threads.virtual.enabled=true
打开之后,Tomcat 会用"每个请求一个虚拟线程"来处理,你所有的 Controller / Service 代码一行不用改,就获得了远超原来的并发能力。这正是虚拟线程最大的实用价值:它不要求你重写业务代码。
关于虚拟线程的几个"固定属性",用之前知道一下免得踩坑:
- 虚拟线程永远是守护线程(daemon) ,
setDaemon(false)会抛UnsupportedOperationException。也就是说它不会阻止 JVM 退出,需要等它跑完就得自己join()或者用上面 try-with-resources 的写法。 - 优先级固定为
NORM_PRIORITY,setPriority()调了也没效果(不报错但无作用)。 - 不支持
stop()/suspend()/resume()(这些方法本来也早已废弃)。中断(interrupt())是正常支持的,第 1 篇讲的协作式中断那套照旧。 - 虚拟线程没有线程名的默认值 (
Thread.ofVirtual().start()出来的名字是空字符串),排查问题时建议像上面那样显式.name(...)。
四、Pinning:虚拟线程被钉在载体线程上
前面说"遇到阻塞就卸载",但有几种情况卸载不了 ------虚拟线程会被钉住(pinned)在载体线程上,此时它阻塞就意味着那个宝贵的载体线程也跟着一起阻塞。这是虚拟线程唯一需要你主动留心的性能陷阱。
在 JDK 21 上,导致 pinning 的主要有两种情况:
- 在
synchronized块/方法里执行阻塞操作。 这是最常见、也最容易中招的一种。 - 执行本机方法(native method)或 FFM 外部函数调用时。 本机栈帧无法被 JVM 搬到堆上保存,所以只能钉着。
第一种为什么值得警惕?因为 synchronized 在存量代码里随处可见,包括很多第三方库的内部实现:
java
// ✗ JDK 21 上的隐患:synchronized 块里做阻塞 IO,会把载体线程一起钉住阻塞
public synchronized void deductStock(Long productId) {
stockMapper.updateStock(productId); // 阻塞的数据库操作,此时无法卸载
}
// ✓ JDK 21 上的写法:换成 ReentrantLock,等锁和锁内阻塞都能正常卸载
private final ReentrantLock lock = new ReentrantLock();
public void deductStock(Long productId) {
lock.lock();
try {
stockMapper.updateStock(productId); // 阻塞时可以正常卸载载体线程
} finally {
lock.unlock();
}
}
为什么 ReentrantLock 行而 synchronized 不行? 因为 ReentrantLock 是第 5、6 篇讲的那套纯 Java 实现 (AQS + LockSupport.park()),JDK 能够识别并配合虚拟线程做卸载;而 synchronized 是由 JVM 在虚拟机层面实现的(第 3 篇讲的 monitorenter/monitorexit + 对象头 Mark Word),JDK 21 时它的 monitor 状态与载体线程的栈绑在一起,没法把虚拟线程摘下来。
要注意的是,pinning 本身并不必然出问题 :如果 synchronized 块里只是几个内存操作,很快就出来了,钉一小会儿没有影响。真正的问题是"钉住 + 长时间阻塞"的组合------载体线程总数默认只有核数个,如果几个载体线程都被钉在等 IO 上,整个应用的虚拟线程就都推不动了,这是虚拟线程场景下最典型的"明明用了虚拟线程但吞吐上不去"的原因。
版本提示(重要) :这个限制在 JDK 24(JEP 491,Synchronize Virtual Threads without Pinning) 中已经解决------虚拟线程在
synchronized内阻塞时也能正常卸载了,因此 JDK 24 及以上不再需要为了虚拟线程而把synchronized改写成ReentrantLock。但本机方法/FFM 调用中的 pinning 依然存在 。如果你在 JDK 21~23 上使用虚拟线程,上面那条改写建议仍然适用;诊断上可以用-Djdk.tracePinnedThreads=full(JDK 21 可用)打印被钉住的栈,或者用 JFR 的jdk.VirtualThreadPinned事件观测。
五、使用规则:不要池化,慎用 ThreadLocal
虚拟线程虽然对业务代码基本透明,但有几条"心智模型上的转变"必须建立起来,否则很容易写出"用了虚拟线程但没得到好处"的代码。
规则一:不要池化虚拟线程。 这是最重要的一条,也是最容易犯的错------毕竟我们被"线程池"训练了二十年。
java
// ✗ 错得很典型:把虚拟线程当稀缺资源池化,等于自己给自己重新加上了并发上限
ExecutorService wrong = Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());
// ✓ 正确:每个任务一个虚拟线程,用完就丢
ExecutorService right = Executors.newVirtualThreadPerTaskExecutor();
线程池存在的唯一理由,是平台线程创建昂贵、必须复用摊薄成本。虚拟线程创建极其廉价(就是分配一个小对象),复用它没有任何收益,反而把"最多只能同时处理 200 个任务"这个限制又请回来了。 记住这句话:虚拟线程不是稀缺资源,而是一次性的、用完即弃的任务载体------需要多少就创建多少。
那么"限流"怎么办?答案是:限流应该由专门表达配额的工具来做,而不是靠线程数量隐式实现。 用第 11 篇的 Semaphore 显式表达"数据库最多扛 20 个并发",语义清晰得多:
java
private final Semaphore dbPermits = new Semaphore(20); // 显式表达资源配额
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Long userId : userIds) {
executor.submit(() -> {
dbPermits.acquire(); // 拿不到就在虚拟线程上等(会正常卸载,几乎零成本)
try {
deductStock(productId);
} finally {
dbPermits.release();
}
});
}
}
这个写法在虚拟线程下代价极低:等许可的虚拟线程会卸载,不占载体线程。过去"线程池大小"这一个参数同时承担了"复用线程"和"限制并发"两个职责,虚拟线程把它们拆开了------复用不再需要,限流交给 Semaphore。
规则二:慎用 ThreadLocal,尤其别指望"线程少所以复用缓存"那套。 第 12 篇讲的 ThreadLocal 在虚拟线程上功能是正常的(每个虚拟线程有自己独立的一份),但有两个新问题:
- 数量级变了。 过去 200 个线程各存一份缓存,一共 200 份;现在可能有 100 万个虚拟线程,如果每个都往
ThreadLocal里塞一个大对象,内存直接爆掉。虚拟线程时代,ThreadLocal里绝对不能放重对象。 - "复用线程做缓存"的套路失效了。 有些代码利用线程池线程长期存活的特性,把
SimpleDateFormat、缓冲区之类的重对象缓存在ThreadLocal里复用。虚拟线程一个任务一个、用完即弃,这种缓存每次都要重建,收益归零反而变成了纯开销。 - 好消息是:第 12 篇那个"忘记
remove()导致内存泄漏"的经典坑,在虚拟线程下反而不那么致命 了------虚拟线程不复用,线程结束时它的threadLocals整个被回收。当然,写代码时该remove()还是要remove(),别指望这一点。
传递请求上下文的更好选择是 ScopedValue,第七节讲。
规则三:虚拟线程只对"阻塞型"任务有意义。 虚拟线程解决的是"线程在等 IO 时被浪费"的问题。如果你的任务是纯 CPU 计算 (图像处理、大数据量排序、加解密),那它压根不会阻塞、不会卸载,虚拟线程一点便宜也占不到------这种场景该用的还是固定大小约等于核数的平台线程池,或者第 13 篇的 Fork/Join。用虚拟线程跑 CPU 密集任务不会出错,但也不会更快,反而多一层调度开销。
一句话分工:CPU 密集用平台线程池 / Fork-Join,IO 密集用虚拟线程。
六、结构化并发:让并发任务有明确的作用域
虚拟线程让"随手起一堆线程"变得可行,但也放大了一个老问题:这些线程之间没有任何父子关系,生命周期是散的。 看第 13 篇那种写法的隐患:
java
// 用 Future 并行查两份数据,看着没问题,实则有几个漏洞
Future<UserInfo> userFuture = pool.submit(() -> queryUser(userId));
Future<StockInfo> stockFuture = pool.submit(() -> queryStock(productId));
UserInfo user = userFuture.get(); // 如果这一步就抛异常......
StockInfo stock = stockFuture.get(); // ......stockFuture 还在后台白跑,没人管它,也没人取消它
漏洞有三个:① 泄漏 ------第一个 get() 抛异常后直接跳出方法,第二个任务成了没人管的"孤儿",继续消耗资源直到自己跑完;② 无法整体取消 ------外层请求超时了,想把这两个子任务一起停掉,得自己记住所有 Future 挨个 cancel();③ 关系不可见------从代码结构上看不出"这两个任务属于同一次请求、是一组的",出问题时线程转储里也是一堆互不相干的线程。
结构化并发(Structured Concurrency)的核心主张是:让并发任务的生命周期,服从代码块的结构。 这个思想和 try-with-resources 保证资源不泄漏是一模一样的------如果任务是在某个代码块里 fork 出去的,那么在离开这个代码块之前,所有子任务必然都已经结束(完成、失败或被取消),不可能有任务偷偷活到块外面去。
JDK 21(JEP 453,预览特性)的写法:
java
// 秒杀下单前的并行校验:查用户风控 + 查库存,两者必须都成功
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Subtask<UserInfo> user = scope.fork(() -> queryUser(userId)); // fork 出子任务
Subtask<StockInfo> stock = scope.fork(() -> queryStock(productId)); // 每个子任务跑在自己的虚拟线程上
scope.join() // 等所有子任务结束
.throwIfFailed(); // 只要有一个失败就抛异常(另一个会被自动取消)
return placeOrder(user.get(), stock.get()); // 到这里两个结果都保证可用
} // 出了这个块,两个子任务一定都结束了,不会有孤儿线程
对比上面 Future 的写法,三个漏洞全被堵上了:ShutdownOnFailure 这个策略的语义是"一个失败,全体取消 "(业界叫 short-circuiting),queryUser 抛异常时 queryStock 会被自动取消,不会白跑;作用域结束时保证所有子任务已终结,不可能泄漏;而且代码结构本身就表达了"这两个任务是一组的"这层关系------JDK 甚至会把这个父子关系反映到线程转储里,排查问题时能看到清晰的任务树。
另一个常用策略是 ShutdownOnSuccess:任意一个成功就取消其余的 ,对应第 13 篇 anyOf 那类"多数据源竞速查询"的场景,但比 anyOf 多了"自动取消输家"这个关键行为。
java
// 本地缓存和远程缓存同时查,谁先返回用谁的,另一个自动取消
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<StockInfo>()) {
scope.fork(() -> queryFromLocalCache(productId));
scope.fork(() -> queryFromRedis(productId));
scope.join();
return scope.result(); // 拿到最先成功的那个结果
}
版本提示(重要) :结构化并发截至 JDK 26 仍是预览特性,且 API 在多个版本间反复调整过 ------JDK 19/20 是孵化器(JEP 428/437),JDK 21 起进入预览(JEP 453),此后经历多轮预览;JDK 25(JEP 505)把 API 重新设计为
StructuredTaskScope.open(...)配合Joiner策略,替代直接new ShutdownOnFailure()的子类式写法,JDK 26 又以 JEP 525 进行第六次预览。因此上面的 JDK 21 代码只用于理解思想,实际编写时必须对照目标 JDK 的文档,并使用--enable-preview;不建议把仍在变化的 API 直接固化到长期生产接口中。 虚拟线程(已转正)和结构化并发(仍在演进)的成熟度差别很大,这一点要分清。
七、ScopedValue:虚拟线程时代的上下文传递
第 12 篇的核心场景是"用 ThreadLocal 传递当前登录用户",上面第五节也说了它在虚拟线程时代的尴尬。JDK 为此配套设计了 ScopedValue(作用域值),思路和结构化并发一脉相承:上下文的有效范围,也应该由代码块的结构决定,而不是"设置了就一直有效直到你记得清理"。
java
// 声明一个作用域值(对比 ThreadLocal 的声明方式)
private static final ScopedValue<UserInfo> CURRENT_USER = ScopedValue.newInstance();
// 绑定 + 在绑定范围内执行,不需要(也没有)set/remove
ScopedValue.where(CURRENT_USER, user).run(() -> {
placeOrder(productId); // 这个调用链里的任何地方都能读到 CURRENT_USER
});
// 出了 run() 的范围,绑定自动失效------没有"忘记 remove 导致泄漏"这回事
// 调用链深处读取
public void deductStock(Long productId) {
UserInfo user = CURRENT_USER.get(); // 直接读,只读不可改
}
和 ThreadLocal 的三个关键差别:
- 不可变。
ScopedValue只能在where(...).run(...)的入口处绑定,作用域内无法被中途修改 (没有set()方法)。这消除了ThreadLocal"调用链上任何一层都能偷偷改掉上下文"的隐患。 - 生命周期由结构界定。 绑定只在
run()的动态作用域内有效,出了作用域自动解绑,从根上消除了"忘记remove()导致内存泄漏"这个第 12 篇讲的经典坑。 - 共享而非复制,对海量虚拟线程友好。 配合结构化并发时,子任务能继承父作用域的绑定,且实现上倾向于共享不可变数据而不是给每个线程复制一份------这对"100 万个虚拟线程"的场景是必要的。
版本提示 :
ScopedValue的路线是 JDK 20 孵化(JEP 429)→ JDK 21 起多轮预览(JEP 446 等)→ JDK 25(JEP 506)正式转正 。它的 API 在预览期间也有过调整(例如绑定入口的写法),实际使用同样请对照你的 JDK 版本文档。在 JDK 21 上做生产项目,传递请求上下文仍然以ThreadLocal为主(虚拟线程上它是可用的),只要守住"别放重对象"这条即可。
八、虚拟线程 vs 线程池 vs 响应式:怎么选
三种模型放在一起对比,边界就清楚了:
| 维度 | 平台线程池 | 虚拟线程 | 响应式(Reactor/Netty) |
|---|---|---|---|
| 编程模型 | 同步阻塞,好读 | 同步阻塞,好读 | 异步回调/流式,心智负担大 |
| 单个"线程"成本 | 约 1MB 栈 + OS 线程 | 堆上几百字节起,按需增长 | 无线程概念,任务是回调 |
| 可支撑的并发量 | 几百到几千 | 几十万到百万 | 极高 |
| 调度者 | 操作系统内核 | JVM(专用 ForkJoinPool) | 事件循环 |
| 栈追踪 / 调试 | 完整清晰 | 完整清晰 | 断裂,难调试 |
ThreadLocal |
正常可用 | 可用但别放重对象 | 基本失效 |
| 适合场景 | CPU 密集型任务 | IO 密集型的业务服务 | 极致吞吐、流处理、背压场景 |
| 改造成本 | --- | 极低(配置级) | 高(全链路改造) |
选型结论:
- IO 密集的普通业务服务(绝大多数 Web/微服务后端) :JDK 21+ 直接上虚拟线程。它给了你"响应式的吞吐 + 同步代码的可读性",且几乎不需要改业务代码。这是虚拟线程真正的主战场。
- CPU 密集型计算 :继续用大小约等于核数的平台线程池 ,或第 13 篇的 Fork/Join。虚拟线程在这里没有收益。
- 已经用响应式且跑得很好的系统 :不必为了虚拟线程而重写 。响应式在背压(backpressure)、流式数据处理、复杂的事件编排上仍有虚拟线程给不了的表达力。但新项目如果只是为了扛并发而考虑响应式,现在应该先考虑虚拟线程------这是虚拟线程带来的最实际的决策变化。
- 一个容易被忽略的收益 :虚拟线程让第 13 篇那种"为了不阻塞线程而写成
CompletableFuture链"的代码,有了退回同步写法的可能 。如果你的thenCompose链纯粹是为了避免阻塞(而不是为了表达任务编排关系),在虚拟线程上它可以还原成朴素的顺序代码,可读性会有质的提升。
九、全系列回望:14 篇串成一张地图
到这儿,14 篇走完了。回头看,整个系列其实是在回答一个问题的四个层次:

第一层,地基:并发问题的根源是什么(01-02)。 第 1 篇把三个幽灵------原子性、可见性、有序性 ------现了形,并给了一把贯穿全系列的钥匙:"每个并发工具,都是在治这三者中的某几个"。第 2 篇的 JMM 给出了这三个幽灵背后的规则体系:happens-before 是可见性承诺 而非时间先后,重排序分编译器/指令级/内存系统三层,内存屏障是兑现承诺的手段。这一层是理论根,后面所有工具的行为都能从这里推导出来。
第二层,基础武器:怎么保证原子性和可见性(03-06)。 volatile 只管可见性和有序性、不管原子性;synchronized 三者全管,代价是互斥,靠锁升级 (无锁→偏向→轻量级→重量级)来降低无竞争场景的开销。往轻的方向走是 CAS ------用"事后重试"替代"事前防范"的乐观思路,代价是 ABA 和自旋开销,LongAdder 用分段把热点打散。往重的方向走是 AQS ------state + CLH 队列 + LockSupport.park() 这三大件,是 ReentrantLock、ReadWriteLock、CountDownLatch、Semaphore 共同的骨架,理解了它,第 6、11 篇的一大半就是"填空"。
第三层,工程实践:怎么在真实项目里写对、写好(07-12)。 死锁的四个必要条件指明了预防方向(按序加锁 破坏循环等待最实用),线程安全三策略有明确的优先级------不可变 > 线程封闭 > 同步 (能不共享就不共享,是最省心的并发)。线程池是所有并发任务的入口,"核心线程→队列→最大线程→拒绝"这个顺序是高频易错点。并发容器(ConcurrentHashMap 从分段锁到 CAS + 桶级 synchronized)、阻塞队列(生产者-消费者的标准答案)、同步工具类(协调节奏而非互斥)、ThreadLocal(线程隔离,以及那个弱引用不对称导致的泄漏坑)------这一层是日常写代码真正天天用到的部分。
第四层,异步与未来:怎么把线程用得更值(13-14)。 CompletableFuture 用链式编排替代阻塞等待,Fork/Join 用工作窃取跑分治计算。而这一篇的虚拟线程 ,某种意义上是对前面很多"权衡"的釜底抽薪:线程池大小的调参艺术、为躲避阻塞而付出的回调复杂度,其前提都是"线程昂贵"------这个前提松动之后,很多复杂度就没有存在的必要了。
但要强调的是:虚拟线程没有让前面 13 篇过时,反而让它们更重要。 虚拟线程只解决了"线程贵、不能多开"这一个问题,它完全没有解决共享可变状态的那三个幽灵:
- 100 万个虚拟线程同时
count++,照样超卖------原子性问题原样存在,该用 CAS/锁还得用。 - 虚拟线程之间的可见性,照样归 JMM 和 happens-before 管------第 2 篇的规则一条没变。
- 死锁照样会发生,
synchronized照样是互斥的(而且在 JDK 21 上还多了个 pinning 要提防),ConcurrentHashMap照样是那个ConcurrentHashMap。 - 甚至因为并发度暴涨了几个数量级,原来因为"线程数少、窗口窄"而侥幸没暴露的竞态 bug,现在会被更频繁地撞上。
所以更准确的说法是:虚拟线程改变的是"并发的规模和写法",而不是"并发的难点"。 难点从来都是共享可变状态------这正是第 1 篇开篇就点明、也是整个系列一直在解决的东西。
系列的最后,把整条主线收成三句话:
① 一切并发问题的根源是"共享可变状态",一切并发工具都是在三个方向上做取舍------不共享(不可变、线程封闭、ThreadLocal)、不可变(final、copy-on-write)、协调访问(锁、CAS、原子类)。 遇到新的并发问题先问"这三条哪条走得通",而且优先级就是这个顺序,往往比直接想"该加什么锁"更快找到好方案。
② 判断一个并发工具,就看它治哪个幽灵、代价是什么。 volatile 治可见性和有序性、不治原子性;CAS 治原子性、代价是自旋和 ABA;锁三者全治、代价是互斥和阻塞;ThreadLocal 用"不共享"绕过全部三者、代价是跨线程传值的麻烦。这把钥匙能让你在面对一个陌生的并发类时快速定位它的位置。
③ 并发的"难"是可以被拆解的。 代码不按顺序执行、改了的值别人看不见、一行自增被插队------这些反直觉的现象背后,是多核、缓存一致性、指令重排这些非常讲道理的机制。把机制看清之后,并发就从"玄学"变成了"可以推理的工程",这也是这个系列从第 1 篇开始就想带你抵达的地方。
写完这 14 篇,最想说的其实是:并发领域的知识密度很高,但它的结构 并不复杂------一个根源(共享可变状态)、三个表现(原子性/可见性/有序性)、几套武器(volatile/CAS/AQS 锁)、一层工程封装(线程池/并发容器/同步工具)、一个正在发生的变革(虚拟线程)。把这张地图记住,具体 API 的细节忘了随时可以查;地图丢了,背下来的八股很快就会散成一地碎片。