并发 14 · 收官:虚拟线程与结构化并发

第 8 篇讲线程池时,我们花了很大篇幅回答一个问题:"到底该开多少线程?" 答案是一套要权衡 CPU 核数、IO 等待占比、还得靠压测反复迭代的经验公式。第 13 篇讲异步编排时,我们又用 CompletableFuture 把一段本来很直白的业务逻辑,拆成了一串 thenComposethenCombine 的链式回调------为的是在等 IO 的时候别把宝贵的线程占着。

这两件事其实都是在为同一个约束买单:平台线程太贵了,所以不能多开;不能多开,就必须靠"异步 + 回调"把线程榨干。 而 JDK 21 转正的虚拟线程 (Virtual Threads,Project Loom,JEP 444)直接把这个约束本身拆掉了------如果线程便宜到可以随手创建几百万个,那"该开多少线程"就不再是个需要精心调参的问题,"为了不阻塞线程而写成回调"也不再必要,同步阻塞的写法可以重新变成又好读又高吞吐的写法

这是并发领域这几年最大的一次变化,用它给整个系列收尾正合适。这一篇按这条线索展开:先算清楚平台线程贵在哪、贵出了什么后果;再看虚拟线程靠"挂载/卸载"这一招怎么解决问题;接着讲实际用起来必须知道的几条规则(尤其是"不要池化"和"什么情况下会 pinning");然后介绍配套的结构化并发ScopedValue;最后把 14 篇串成一张全景地图收官。

目录

  1. [平台线程贵在哪:thread-per-request 的天花板](#平台线程贵在哪:thread-per-request 的天花板)
  2. 虚拟线程是什么:挂载、卸载与载体线程
  3. 怎么创建和使用虚拟线程
  4. Pinning:虚拟线程被钉在载体线程上
  5. [使用规则:不要池化,慎用 ThreadLocal](#使用规则:不要池化,慎用 ThreadLocal)
  6. 结构化并发:让并发任务有明确的作用域
  7. ScopedValue:虚拟线程时代的上下文传递
  8. [虚拟线程 vs 线程池 vs 响应式:怎么选](#虚拟线程 vs 线程池 vs 响应式:怎么选)
  9. [全系列回望: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()、等一把 ReentrantLockBlockingQueue.take()),JVM 不会让载体线程跟着一起阻塞,而是把这个虚拟线程的栈保存回堆里 ,然后让它从载体线程上卸载下来。载体线程立刻被释放,去执行下一个就绪的虚拟线程。等 IO 完成后,这个虚拟线程重新变为就绪,被调度器再挂载到(可能是另一个)载体线程上,从上次的位置继续跑。

这一进一出,就是虚拟线程的全部魔法。 回到上一节的账:190ms 的 IO 等待期间,虚拟线程处于卸载状态,只占着堆上那一小块栈数据,不占用任何 OS 线程 ;载体线程在这 190ms 里可以轮流服务成千上万个其他虚拟线程。于是"2000 个并发请求"不再需要 2000 个 OS 线程,几个(等于核数)载体线程就够了。阻塞的成本从"占住一个 1MB 的 OS 线程"降到了"占住堆上几 KB"。

值得点明的是:这件事之所以能对业务代码透明,是因为 JDK 把标准库里几乎所有阻塞点都改造了一遍。 java.net 的 Socket、java.nio.channelsThread.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()FutureCompletableFuture 那套东西全部照旧可用。

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_PRIORITYsetPriority() 调了也没效果(不报错但无作用)。
  • 不支持 stop()/suspend()/resume() (这些方法本来也早已废弃)。中断(interrupt())是正常支持的,第 1 篇讲的协作式中断那套照旧。
  • 虚拟线程没有线程名的默认值Thread.ofVirtual().start() 出来的名字是空字符串),排查问题时建议像上面那样显式 .name(...)

四、Pinning:虚拟线程被钉在载体线程上

前面说"遇到阻塞就卸载",但有几种情况卸载不了 ------虚拟线程会被钉住(pinned)在载体线程上,此时它阻塞就意味着那个宝贵的载体线程也跟着一起阻塞。这是虚拟线程唯一需要你主动留心的性能陷阱。

JDK 21 上,导致 pinning 的主要有两种情况:

  1. synchronized 块/方法里执行阻塞操作。 这是最常见、也最容易中招的一种。
  2. 执行本机方法(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() 这三大件,是 ReentrantLockReadWriteLockCountDownLatchSemaphore 共同的骨架,理解了它,第 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)、不可变(finalcopy-on-write)、协调访问(锁、CAS、原子类)。 遇到新的并发问题先问"这三条哪条走得通",而且优先级就是这个顺序,往往比直接想"该加什么锁"更快找到好方案。

② 判断一个并发工具,就看它治哪个幽灵、代价是什么。 volatile 治可见性和有序性、不治原子性;CAS 治原子性、代价是自旋和 ABA;锁三者全治、代价是互斥和阻塞;ThreadLocal 用"不共享"绕过全部三者、代价是跨线程传值的麻烦。这把钥匙能让你在面对一个陌生的并发类时快速定位它的位置。

③ 并发的"难"是可以被拆解的。 代码不按顺序执行、改了的值别人看不见、一行自增被插队------这些反直觉的现象背后,是多核、缓存一致性、指令重排这些非常讲道理的机制。把机制看清之后,并发就从"玄学"变成了"可以推理的工程",这也是这个系列从第 1 篇开始就想带你抵达的地方。

写完这 14 篇,最想说的其实是:并发领域的知识密度很高,但它的结构 并不复杂------一个根源(共享可变状态)、三个表现(原子性/可见性/有序性)、几套武器(volatile/CAS/AQS 锁)、一层工程封装(线程池/并发容器/同步工具)、一个正在发生的变革(虚拟线程)。把这张地图记住,具体 API 的细节忘了随时可以查;地图丢了,背下来的八股很快就会散成一地碎片。

相关推荐
聚美智数1 小时前
图片水印-图片剪裁-图片缩放API接口介绍
java·服务器·数据库
xierui1231232 小时前
AI Agent 隐私架构:本地化重点为什么是登录态与执行权限
java·人工智能·网络安全·架构
cfm_29142 小时前
Spring AI Tool 调用架构全解
java·人工智能·spring
sun༒2 小时前
Spring @Scheduled 定时任务详解:Cron表达式、执行顺序、优先级与并行调度
java·后端·spring
一次旅行3 小时前
DeepSeek‑V4‑Flash‑Vision‑Exp 小白入门实战|3种传图方式、完整可跑代码、避坑排障
java·前端·人工智能
野生技术架构师3 小时前
Spring Boot 实现数据脱敏:自定义注解 + Jackson 序列化器
java·spring boot·后端
AI人工智能+电脑小能手3 小时前
大白话说Java设计模式-28-模板方法模式(业务实战篇)
java·设计模式·模板方法模式·spring源码·订单系统·代码复用
奥莱维3 小时前
KNX酒店方案_KNX专用线与高端酒店技术逻辑
java·服务器·前端·数据库
工业一体机老司机3 小时前
Linux工业一体机自启动服务配置:systemd服务单元编写与开机优化实战
java·linux·服务器