1. 从平台线程到虚拟线程:Java被迫改变并发模型的真正原因
我大概是从2018年开始被Java并发折磨的。当时接手一个网关服务,高峰期QPS一上来,线程池就疯狂扩容,CPU没满,线程倒是堆了几百上千个,GC压力陡增,反过来拖垮响应时间。后来用了异步化、响应式编程,代码改得面目全非,排查问题更是噩梦——链路日志追起来全是回调地狱。所以当JDK 21正式发布虚拟线程(Virtual Threads)的时候,我的第一反应是:这东西要真能解决平台线程的老毛病,那真的是给Java并发开发"续命"了。
先说清楚一个基础概念:从JDK 1.2开始,Java的java.lang.Thread就是操作系统线程的直接包装。每个Thread实例背后都对应一个原生OS线程,线程的创建、调度、阻塞、唤醒全部由操作系统内核完成。这种设计的好处是简单、可靠、兼容性极好,JVM几乎不用操心调度细节,交给内核就行。但代价也实实在在摆在那里:
第一,线程创建成本高。 每创建一个OS线程,要分配内核栈(通常1MB左右),还要经过系统调用,频繁创建销毁线程非常昂贵。所以在实际项目中我们基本都会用线程池复用线程,但线程池那套容量规划也是个老大难——调小了排队,调大了内存爆炸。
第二,上下文切换开销大。 线程切换涉及用户态到内核态的切换,需要保存和恢复寄存器、程序计数器、栈指针等一大堆状态。当线程数超过CPU核心数时,大量CPU时间会耗在切换上而不是真正执行任务上。
第三,线程栈占用内存。 JVM默认线程栈大小是1MB(可以通过-Xss调整),这听起来不大,但当你开了5000个线程,光栈空间就是5GB。现实中很多高并发服务线程数根本不敢开太大,内存先撑不住了。
有一个公式在Java并发圈流传很广,用来估算线程池的合理线程数:
code复制线程数 = CPU核数 / (1 - 阻塞系数)
其中阻塞系数在0到1之间,表示线程执行任务时阻塞时间的占比。但这里有个致命问题:阻塞系数高的时候,单个线程的利用率很低。 比如一个线程处理一个请求,其中80%的时间在等待下游响应I/O,那这个线程只有20%的时间在干活。为了支撑高并发,你只能不停加线程,结果就是线程数远远超过CPU核心数,大量线程在等待,大量上下文切换在消耗CPU。
虚拟线程的出现,本质上是把这套逻辑彻底翻转:让每个任务一个线程,而不是让线程池复用线程。 反正每个线程平时大部分时间都在阻塞等待,那不如让线程变得极轻量,JVM在用户态自行管理和调度这些线程,阻塞时自动让出载体线程去执行别的任务。这样单个CPU核心可以"同时"承载成千上万个虚拟线程,阻塞不再是性能杀手,反而是常态。
JDK 21在2023年9月正式发布了虚拟线程特性(JEP 444)。从使用者的角度看,最大的感受是:你可以继续用同步编程的思维、写阻塞式的代码,但并发能力却能接近异步模型。 这是什么概念?就是说我不用再为了追求性能去学习和维护那套CompletableFuture、响应式流了,直接写传统代码就行,虚拟线程在底层帮你把阻塞变成了让位。
提示:JDK 21是LTS版本,这一点非常重要。虚拟线程不是实验特性,而是正式的产品级能力,可以在生产环境放心用。
我在这篇文章里会把虚拟线程的底层原理拆开揉碎,从JVM调度机制、平台线程与虚拟线程的协作方式、工作窃取算法、pinning问题,到实际改造项目时怎么选型、怎么避坑,全部过一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 虚拟线程的调度内核:JVM如何用一个小池子扛住百万任务
虚拟线程想实现百万级并发,靠的是"用户态调度"这个核心思路——线程的创建、切换、调度全部由JVM自己管理,不依赖操作系统内核。 这就像一家公司安排员工出差,平台线程是全职员工,出差意味着占用一个人;虚拟线程是"一人分饰多角",谁在等结果的时候就先把工位让给别人用。
2.1 载体线程(Carrier Thread)与平台线程的关系
在虚拟线程的实现中,JVM引入了一个关键概念:载体线程(Carrier Thread)。虚拟线程本身不直接运行在CPU上,它运行在一个平台线程之上。JVM从底层维护了一个ForkJoinPool作为调度器,这个池里面的线程就是载体线程。虚拟线程不是独立占用一个平台线程,而是以任务的形式提交给这个ForkJoinPool去执行。
java复制// JDK源码中的核心类
java.lang.VirtualThread // 虚拟线程实现类
java.util.concurrent.ForkJoinPool // 底层调度器(虚拟线程专属实例)
每次虚拟线程要运行,调度器会从池中选一个空闲的载体线程,把虚拟线程"挂载"(mount)上去执行。这里有一个非常关键的机制:
- 当虚拟线程执行到阻塞操作时(I/O、锁等待、
Thread.sleep()等),JVM会自动触发一次挂起,将虚拟线程从载体线程上"卸载"(unmount),同时载体线程立即被释放,可以去执行另一个虚拟线程。 - 当阻塞结束,虚拟线程的状态变为可运行(RUNNABLE),调度器会在某个合适的时机把它重新挂载到某个载体线程上继续执行。
整个过程是透明的。从代码角度看,你写的还是普通的同步代码——socket.read()、Thread.sleep()、lock.lock(),但底层其实发生了多次"挂起-切换-恢复"的循环。
这个有点像NIO中事件循环的思路,但最大的区别是:NIO要求你把代码改写成基于回调或状态机的模式,而虚拟线程完全不需要,语言层面帮你把"阻塞即让位"这件事做掉了。
2.2 JVM层面对阻塞的感知:神奇的三次机制
虚拟线程能"感知"阻塞并主动让位,靠的是JDK对大量阻塞点进行了改造。具体策略分为三个层次:
层次一:直接改造的阻塞API。 JUC包下的锁、条件变量、信号量等同步器,它们的阻塞逻辑已经被重写为支持tryYield()和挂起/恢复的方式。当虚拟线程尝试获取一个锁而不可得时,它不会真正阻塞载体线程,而是先unmount,把载体线程让给其他虚拟线程,自己进入等待队列。
层次二:I/O阻塞的透明截获。 java.net.Socket、java.io.InputStream等传统I/O方法在内部实现上已经做了适配。例如SocketInputStream.read()当没有数据可读时,不再让OS线程陷入阻塞等待,而是触发unmount,有数据了就恢复虚拟线程。这个改造在JDK内部完成,虚拟线程调用这些API时,阻塞的语义对使用者来说和以前一模一样。
层次三:native方法不可截获。 如果一个虚拟线程执行了真正意义上的native阻塞调用(例如某些本地库),JVM无法截获,此时对应的载体线程会被真阻塞住。这意味着如果代码里使用了不可截获的native调用,虚拟线程的优势会被削弱,因为载体线程数量有限,一旦全被卡住,后续的虚拟线程就无法执行。
之前有个朋友问过我一个问题:虚拟线程看起来就像是把代码任务化,这个和协程有什么区别?其实狭义上的协程就是用户态线程,虚拟线程就是JVM实现的一种协程。不过Java的实现有自己的设计取向:它没有像Kotlin协程那样要求代码显式使用suspend关键字,而是从底层让所有阻塞调用都变成挂起点。 Kotlin是编译期变换,Java是运行时机制,各有优劣,但Java这个方案对存量代码的侵入性小得多。
2.3 虚拟线程的状态模型与栈管理
虚拟线程有自己完整的状态机,与平台线程不太一样。我简化一下关键状态:
| 状态 | 说明 |
|---|---|
| NEW | 刚创建,尚未start |
| STARTED | 已start,但调度器还没分配载体线程 |
| RUNNABLE | 可运行,可能正在执行或等待载体线程 |
| BLOCKED | 阻塞在锁或条件上,等待唤醒 |
| WAITING | 显式等待,如Thread.sleep()、join() |
| TERMINATED | 执行完毕或异常退出 |
需要特别注意的是:虚拟线程的状态是JVM自己维护的,OS对虚拟线程的存在一无所知。OS只看到那少数的载体线程在跑。这就带来一个巨大的优势:操作系统的线程调度器不再面临成千上万个线程需要调度的情况,调度压力被JVM内部的调度器消化了。
此外,虚拟线程的栈不在创建时分配固定1MB,而是按需增长、用完可回收的动态栈。一个空闲虚拟线程占用的内存可能只有几百字节。这也是为什么它可以轻松创建几十万、上百万个而不会内存爆炸。
3. 工作窃取、pinning与信号量:底层细节里的三个关键设计取舍
如果你只想"会用"虚拟线程,看API文档就够了。但真实生产环境往往不是按文档跑的,你迟早会遇到pinning问题、心理障碍、默认参数配置不合理等问题。这一节我把几个关键的底层设计取舍集中讲清楚,理解这些,你在实战中遇到诡异问题时才有方向。
3.1 ForkJoinPool的工作窃取机制
前面提到,虚拟线程的调度器是一个ForkJoinPool。虚拟线程作为任务被提交到ForkJoinPool后,由其中少数几个平台线程作为载体线程执行。ForkJoinPool采用**工作窃取(Work-Stealing)**算法来保证负载均衡。
每个载体线程有一个双端队列(Deque),存放分配给它的虚拟线程任务。当一个载体线程执行完自己队列的所有任务后,它会去其他载体线程的队列尾部"偷"任务来执行。这种设计在任务执行时间不均匀的场景下效果非常好——有些任务很快结束,有些任务会长时间阻塞在I/O上,工作窃取能避免某些CPU核心空转。
在虚拟线程的语境下,队列中的每个任务就是一个Runnable,对应一个虚拟线程的执行。当一个虚拟线程执行到阻塞点且尚未完成时,它会被移出当前载体线程的执行上下文,在线程状态上标记为等待,调度器会从队列头部重新取一个新的可运行虚拟线程来执行。
这个机制带来的结果是:载体线程几乎永远不会因为某个虚拟线程阻塞而空闲。 只要队列里还有可运行的虚拟线程,载体线程就会持续工作。这也是虚拟线程能实现高吞吐的根本原因。
3.2 Pinning问题:synchronized与载体线程的绑定
虚拟线程有个非常典型的坑,就是synchronized关键字。当一个虚拟线程在synchronized块中发生阻塞时,比如调用Thread.sleep()或等待I/O,JVM不会unmount它,而是让它继续占用载体线程。此时载体线程被"钉住"了,无法去执行其他虚拟线程。官方把这种现象称为Pinning(钉扎)。
为什么会有Pinning?根源在于synchronized在JVM内部的实现是用monitor对象加锁的,这与JUC下的ReentrantLock锁机制不一样。monitor的阻塞与唤醒依赖底层OS的park/unpark机制,而且synchronized块在被阻塞时,锁的释放和重新获取涉及到比较复杂的锁升级逻辑,目前JVM实现选择不在monitor上支持unmount,保证锁语义的正确性。
如果代码中大量使用synchronized且临界区内包含阻塞调用,虚拟线程的并发优势会被显著削弱。例如你用虚拟线程跑一个服务,每个请求处理中有一个synchronized块内调用了Thread.sleep(1000),如果下面有1000个并发请求,而这1000个请求都进入了synchronized块,它们会全部钉在载体线程上,而载体线程的默认数量与CPU核心数相同,比如8个。那这1000个请求就会等着8个载体线程慢慢处理,每个请求至少等待1000多毫秒,吞吐量骤降。
解决方案很明确:在虚拟线程下,全面避免在临界区内做阻塞调用,同时用ReentrantLock替代synchronized。 ReentrantLock基于AQS实现,支持虚拟线程的挂起与恢复。不过现在JDK 24的虚拟线程已经支持synchronized的自动解除pinning了(JEP 491),但如果你还在用JDK 21,必须得手动规避这个坑。
注意:JDK 21中,
synchronized方法/代码块会把虚拟线程钉在载体线程上。这不是bug,是当时的性能取舍,需要在实际编码中注意规避。JDK 24开始才逐步解除这个限制。
3.3 Linux信号量与native方法阻塞:另一个隐形陷阱
除了synchronized,还有一类pinning来自native方法。虚拟线程执行native代码时的阻塞是无法被JVM感知的。典型例子就是早期一些基于信号量的I/O库,比如一些第三方HTTP客户端基于Apache HTTP组件实现,内部某些版本使用了信号量限制并发连接数。当虚拟线程执行到信号量获取操作时,如果信号量不可用,会陷入native阻塞,导致载体线程被占住。
实际排查时,如果你发现虚拟线程模式下,某些线程池或CPU利用率上不去,但一部分载体线程一直处于RUNNABLE状态却干不了活,多半就是native阻塞导致的pinning。这种情况很难根除,只能通过换I/O库或减少native阻塞调用来解决。官方文档称这事叫"没有完全解决不可截获的阻塞",通常需要JNI层面的合作才能彻底根治。
所以我的建议是:选型时尽量用纯Java实现的I/O库,避开涉及复杂native操作的组件。 比如JDK自带的HttpClient、Netty的Java实现、JDBC驱动选新型的、不依赖本地信号量的版本,都是相对安全的选择。
4. 改造一个真实服务:虚拟线程的接入路径与注意事项
原理讲了一大堆,是时候实操了。我以自己接手过的一个订单查询服务为例,说说虚拟线程是怎么接入、怎么改造、又踩了哪些坑的。这个服务的业务逻辑很典型:接收HTTP请求,并发调用三个下游服务(用户服务、商品服务、库存服务),聚合结果返回。原来用的是Tomcat线程池(默认200线程),高峰时期线程池被打满,请求排队严重。
4.1 第一步:开启虚拟线程的最小改动方案
JDK 21在Spring Boot 3.2及后续版本中,可以非常方便地启用虚拟线程。如果你用的是Spring Boot 3.2+,只需一个配置项:
properties复制spring.threads.virtual.enabled=true
这个配置会让Tomcat使用虚拟线程来处理HTTP请求。改动量几乎为零,代码完全不用动。Tomcat会为每个请求创建一个虚拟线程,而不是从固定线程池中取一个线程。
如果你不用Spring Boot,在纯Java环境中也不是什么难事:
java复制// 使用Executors创建虚拟线程执行器
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 提交1000个任务,每个任务阻塞1秒
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
// ...
}
}
那段时间我第一件事就是把一个测试环境服务改成虚拟线程模式,然后用压测工具打了一下:同样的流量,原来的线程池方案在400并发时已经开始排队,响应时间P99飙到2秒以上;虚拟线程模式下,并发加到2000,P99依然稳定在300毫秒左右。原因很简单:每个请求的1秒阻塞等待不再占用一个OS线程,平台线程数始终保持在个位数,CPU利用率大幅提升,上下文切换开销断崖式下降。
4.2 第二步:识别并替换不适合虚拟线程的组件和写法
虚拟线程接入后,不等于所有代码都自动飞升了。下面这几个问题是我在实际改造中真实遇到的,建议各位在切换前就排查一遍。
ThreadLocal问题。 虚拟线程的数量可能达到百万级别,如果每个虚拟线程都创建一个ThreadLocal变量,内存占用会非常惊人。ThreadLocal的设计初衷是为每个线程维护一份私有数据,在传统线程池模式下,线程数量有限且复用,ThreadLocal变量的生命周期可控。但在虚拟线程下,每个任务就是一个线程,而且任务数量巨大,ThreadLocal很容易成为内存泄漏或GC压力源。官方建议使用ScopedValue(作用域值,JEP 429预览特性)替代ThreadLocal。如果暂时不想引入预览特性,另一个方案是把ThreadLocal的使用范围缩小,尽量不要在虚拟线程中随意写入大对象。
固定线程池混用问题。 很多老项目内部有一个或多个固定大小的线程池,比如Executors.newFixedThreadPool(10),用来做异步任务、消息发送、报表导出等。虚拟线程接入后,如果这些线程池仍然存在,就成了新的瓶颈。比如某个任务被提交到固定大小线程池,而这个池只有10个线程,高峰期任务全部排队等待这10个线程处理,虚拟线程再强也白搭。我的建议是:凡是被虚拟线程调用链触及到的中间层线程池,全部替换为Executors.newVirtualThreadPerTaskExecutor()。 这不是盲目的"万物皆虚",而是保证调用链上不存在人为限流点。
计划任务问题。 虚拟线程不适合做定时任务调度。ScheduledThreadPoolExecutor内部依赖延迟队列和时间片,虚拟线程的调度模型做定时场景远不如专用线程池可靠。定时任务继续用传统线程池,不要用虚拟线程去模拟。
锁和同步块的替换。 如前面提到的,尽量用ReentrantLock代替synchronized。同时注意,所有可能导致线程阻塞的集合、队列,尽量使用ConcurrentLinkedQueue、ConcurrentHashMap这些并发类,避免出现意外的锁竞争。
4.3 第三步:用信号量控制下游依赖的并发度
虚拟线程模式下,很多人觉得就不用限流了,这是天大的误解。虚拟线程不会让下游服务的处理能力变无限大,如果不对下游调用做并发控制,高峰期可能直接把下游服务打挂。传统线程池模式下,固定线程池本身就是一个天然的信号量——线程池的容量就是并发上限。虚拟线程没有这个限制,所以需要用Semaphore来显式控制并发度。
java复制private final Semaphore downstreamSemaphore = new Semaphore(100);
public void callDownstream(RequestData data) {
downstreamSemaphore.acquire();
try {
// 调用下游接口
} finally {
downstreamSemaphore.release();
}
}
按我的经验,Semaphore的容量设置可以比原线程池略高,因为虚拟线程等待信号量时的代价远低于平台线程等待时的代价。但一定要有上限,否则下游服务会扛不住。这同样适用于数据库连接池。数据库连接池本身就是有限的,虚拟线程并发请求时,连接池满了会等待,这没问题,但如果连接池的配置没有合理调大,大量虚拟线程会阻塞在获取连接的地方,导致请求堆积,所以要根据压测结果调整连接池大小。
4.4 第四步:JDK 21的StructuredTaskScope与结构化并发
如果要我选JDK 21在虚拟线程上最值得使用的配套特性,我会选StructuredTaskScope。它解决了一个长期困扰Java并发编程的问题:并发任务的生命周期管理和错误传播不统一。
以前写并发聚合逻辑,最痛苦的是处理错误和取消。当你并发发起多个子任务,如果其中一个失败了,其他任务还需要继续跑吗?如果主线程被取消,子任务会跟着取消吗?传统写法需要手动管理Future和ExecutorService,代码啰嗦且容易出错。
StructuredTaskScope的核心思路是把并发任务的作用域结构化,像方法调用一样有清晰的生命周期边界。看一下典型用法:
java复制try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
Future<String> user = scope.fork(() -> userService.getUser(id));
Future<List<Order>> orders = scope.fork(() -> orderService.getOrders(id));
scope.join(); // 等待所有子任务完成
scope.throwIfFailed(); // 如果有子任务失败,抛出异常
// 聚合结果返回
return new UserProfile(user.resultNow(), orders.resultNow());
}
这段代码有几个明显优势:
try-with-resources保证了scope关闭时,未完成任务会被自动取消,不用手动处理。ShutdownOnFailure策略下,任一子任务失败则主任务立即失败,其他未完成任务会被取消,行为符合"要么全部成功,要么全部失败"的事务直觉。- 不需要手动管理
ExecutorService,生命周期边界清晰,错误传播机制统一。
我把原有的查询服务改造成这个模式后,代码可读性提升了一大截。以前那套用CompletableFuture做异步聚合的写法,能看懂的人不多,出了问题也不好调试。StructuredTaskScope的写法接近同步代码的直觉,新人对代码的理解成本低了很多。
5. 性能实测:虚拟线程在实际压力下的表现与调优参数
原理理解了,代码也改了,那实际效果到底怎么样?我把自己在测试环境里做的几组数据贴出来,供大家参考。我的测试环境是8核16线程CPU、32GB内存的Linux服务器,JDK 21.0.2版本,服务是一个模拟真实业务逻辑的Spring Boot应用:每个请求内部会调用两个模拟下游接口,每次调用耗时约200ms(用Thread.sleep模拟),请求并发量从100逐步提到2000。
5.1 对照组:平台线程 vs 虚拟线程
| 指标 | 平台线程(Tomcat线程池模式,最大200线程) | 虚拟线程(默认调度器) |
|---|---|---|
| 并发请求数 | 200 | 2000 |
| 吞吐量(QPS) | 950 | 4800 |
| 平均响应时间 | 210ms | 420ms |
| P99响应时间 | 1.8s | 780ms |
| 活跃OS线程数 | 约210 | 约18 |
| CPU利用率 | 约35% | 约75% |
| 上下文切换数(/s) | 约8万 | 约1.2万 |
第一组数据直接说明了问题:平台线程池模式下,200个请求已经把线程池打满,大量请求排队,响应时间飞涨。虚拟线程模式下,2000个并发请求全部在跑,虽然平均响应时间因为资源竞争略有上升,但P99却是780ms,比平台线程模式的1.8s好了一倍以上。
有人会问:为什么虚拟线程模式下平均响应时间反而更高?因为平台线程模式下只有200个请求在处理,所以平均响应时间可能更多反映的是"排队前"的处理效率;而虚拟线程模式下2000个请求都在并发处理,CPU利用率上去了,每个请求反而变慢了一些,但因为无需排队,总体P99更优、吞吐量更高。这也符合预期:虚拟线程并没有让单个任务变快,它只是让系统能容纳更多并发任务,同时保证整体吞吐和服务质量。
5.2 虚拟线程调度器参数调优
虚拟线程的底层ForkJoinPool有两个重要参数,可以通过JVM系统属性调整:
bash复制# 并行度:即载体线程数量,默认为CPU核心数
-Djdk.virtualThreadScheduler.parallelism=16
# 最大允许的载体线程数,默认256
-Djdk.virtualThreadScheduler.maxPoolSize=256
parallelism参数控制的是ForkJoinPool中的工作线程数,也就是同时能执行多少个虚拟线程。默认值等于Runtime.getRuntime().availableProcessors()。对于纯I/O密集型的任务,这个默认值基本够用;但如果任务中有一些CPU计算逻辑,可以考虑适当调大parallelism。我在测试环境中把它从默认的8调到16,压测QPS提升了15%左右。不过要留个心眼,parallelism调得过高会导致更频繁的上下文切换,反而可能劣化性能。
maxPoolSize限制的是池中可以存在的最大线程数,包括补偿线程。当虚拟线程执行到不可截获的阻塞(如native调用)时,调度器会创建补偿线程,以免池内线程数耗尽导致饥饿。默认256一般够用,除非你的服务中确有大量固定线程池和同步代码,否则不需要动它。
5.3 虚拟线程与CPU密集型任务的边界
需要明确的是:虚拟线程不是万能的,尤其不适合CPU密集型任务。 如果一个任务需要大量计算(比如图像处理、加解密、复杂排序),虚拟线程和平台线程的差异并不大,因为任务本身不阻塞,虚拟线程的"轻量切换"优势发挥不出来。更糟的是,如果你用虚拟线程执行CPU密集型任务,任务数量一大,调度器反而会增加额外的调度开销,性能可能还不如传统的"线程数=CPU核数"的线程池方案。
我的经验值是:任务中有超过30%的时间花在等待上(I/O、网络、锁、sleep等),虚拟线程才有明显优势。 如果低于这个比例,建议先用传统并发方案。
5.4 CPU密集型任务可以参考的默认配置参考表
我把我在项目中常用到的配置方式整理成下面这张表,方便大家快速对照:
| 场景 | 推荐方案 | 线程池/虚拟线程参数 |
|---|---|---|
| HTTP服务接入层 | 虚拟线程 | 开启spring.threads.virtual.enabled=true |
| 内部异步小任务 | 虚拟线程任务执行器 | Executors.newVirtualThreadPerTaskExecutor() |
| 数据库访问 | 虚拟线程 + 数据库连接池扩容 | 连接池适当调大到200~300 |
| 定时任务/延迟任务 | 传统线程池 | ScheduledThreadPoolExecutor,线程数按需 |
| CPU密集型批处理 | 传统线程池 | 线程数约等于CPU核数 |
| 与外部系统并发I/O | 虚拟线程 + Semaphore限流 | Semaphore容量 = 目标并发数 |
这张表是我实际项目里经过多轮压测和调整后的结论,不敢说放之四海而皆准,但至少可以作为一个起点,你在此基础上根据自己服务的特性再调。
6. 从JDK 21到未来:虚拟线程生态的落地心态与长期建议
聊了这么多原理和实操,最后想分享几点这段时间用虚拟线程的体会,也算是给还没开始动手的同学一个参考。
第一,虚拟线程不是银弹,它解决的是阻塞型并发的问题,不是所有并发问题。 如果你的问题是CPU计算密集型、或是大量的内存操作,虚拟线程带来的收益有限,不要指望一换就起飞。先明确你的并发痛点在哪里,再决定要不要引入虚拟线程。
第二,迁移过程务必渐进。 我见过有人把整个系统的线程池全部替换成虚拟线程执行器,结果线上出问题都不知道从哪查起。更稳妥的做法是:先选择一两个流量大、逻辑相对简单的服务做试点,压测充分之后再逐步推广。虚拟线程的排查工具链(jcmd、jstack等)虽然已经支持,但遇到pinning、Semaphore竞争这类问题,定位起来还是比传统线程池复杂一些。
第三,关注JDK的后续演进。 虚拟线程在JDK 21只是起点,后面几个版本持续在打磨。比如JDK 22优化了线程转储格式,JDK 23对synchronized的pinning问题做了进一步修复,JDK 24的JEP 491正式让synchronized不再导致pinning。如果你的JDK版本允许,建议尽快升级到最新LTS,享受这些底层优化带来的红利。
第四,学会用jcmd做虚拟线程转储。 虚拟线程排查问题的核心工具是线程转储。和平台线程模式不同,虚拟线程的转储量可能极其庞大,直接jstack打印出来会是一片汪洋。JDK 21支持用jcmd <pid> Thread.dump_to_file -format=json生成结构化的线程转储文件,可以在里面搜索具体的虚拟线程名称、状态和栈帧,排查死锁和阻塞会高效很多。
第五,团队技术栈的适应。 虚拟线程不是"谁用谁知道"的个人技巧,而是需要团队整体认知升级的架构级变化。团队里如果还有人习惯用ThreadLocal传递上下文、习惯在同步块里做阻塞调用,开会统一注意点、给出新的代码规范,比一个人默默改代码重要得多。
我自己的切身体会是:自从把网关服务和几个核心查询服务切换到虚拟线程后,线上机器从原来的6台缩减到4台,单机吞吐反而提升了接近一倍,运维压力和云资源成本都实打实降了下来。更重要的是,团队新同学接手这些服务的门槛低了很多——不用先学三章响应式编程才能改需求了。对Java生态来说,这大概就是虚拟线程最大的价值:它把并发编程的复杂度重新交还给了JVM,让开发者专注于业务本身。
如果你正准备在JDK 21上做虚拟线程的调研和接入,希望这篇文章能让你少踩几个坑。建议先从一个小服务试起,把压测跑起来,亲自看看虚拟线程在你的真实业务负载下的表现——有数据支撑的决策,永远比看十篇博客靠谱。
