Java虚拟线程实战:从线程池痛点到性能优化全流程
真实业务场景
我们团队负责的用户运营平台,有个核心功能是批量用户权益发放:每次大促前需要给百万级的用户发放优惠券、积分等权益,每个用户的发放需要调用3个下游接口(用户中心校验、权益中心发放、通知中心推送),都是IO密集型操作。 之前我们用的是固定大小的线程池(200个线程),处理10万用户发放任务时,总耗时约120秒,CPU利用率长期徘徊在30%左右,瓶颈非常明显:
- 线程池很容易被打满,大促时任务排队时间超过10秒,导致用户投诉;
- 线程上下文切换开销大,200个线程的上下文切换占用了15%左右的CPU;
- 每个平台线程默认占用1MB栈内存,200个线程就占用了200MB内存,JVM堆外内存压力很大。 我们尝试过调整线程池参数、使用缓存、批量调用下游接口,但IO阻塞的问题始终无法解决:只要下游接口响应变慢,线程池就会快速打满,导致后续任务全部排队。
原理分析:虚拟线程是什么
虚拟线程(Virtual Thread)是JDK 19引入的预览特性,在JDK 21中正式成为标准特性,是java.lang.Thread的一个轻量级实现,核心目标是解决传统平台线程(Platform Thread)在IO密集型场景下的性能瓶颈。
和传统线程的核心区别
| 维度 | 平台线程(传统线程) | 虚拟线程 |
|---|---|---|
| 映射关系 | 1:1映射到操作系统线程 | M:N映射到载体线程(平台线程) |
| 创建成本 | 高(默认栈1MB,需OS调度) | 极低(初始栈几百字节,JVM调度) |
| 最大数量 | 受OS限制,通常几千个 | 可创建数百万个,无OS限制 |
| 阻塞开销 | 阻塞OS线程,上下文切换成本高 | 卸载虚拟线程,释放载体线程,无额外开销 |
调度原理
虚拟线程的运行依赖载体线程(Carrier Thread,本质是平台线程):
- 虚拟线程运行Java代码时,会挂载到某个载体线程上,复用载体线程的CPU时间片;
- 当虚拟线程执行到阻塞操作(比如
Thread.sleep()、IO读写、等待锁)时,会从载体线程上卸载,载体线程不会被阻塞,可以去运行其他虚拟线程; - 阻塞操作完成后,虚拟线程会重新挂载到某个可用的载体线程上继续运行。 JVM默认会创建和CPU核心数相同的载体线程,所以即使创建100万个虚拟线程,也只会占用和CPU核心数相同的OS线程,不会增加OS的调度压力。
代码改造:从线程池到虚拟线程
我们的批量发放代码改造非常简单,几乎没有侵入性:
原线程池实现
// 初始化固定大小线程池 ExecutorService pool = Executors.newFixedThreadPool(200); // 提交10万发放任务 for (User user : userList) { pool.submit(() -> { try { sendBenefit(user); } catch (Exception e) { log.error("发放失败,用户:{}", user.getId(), e); } }); } // 关闭线程池,等待所有任务完成 pool.shutdown(); pool.awaitTermination(1, TimeUnit.HOURS);虚拟线程实现
// 创建虚拟线程Executor,每个任务对应一个虚拟线程 try (ExecutorService virtualPool = Executors.newVirtualThreadPerTaskExecutor()) { for (User user : userList) { virtualPool.submit(() -> { try { sendBenefit(user); } catch (Exception e) { log.error("发放失败,用户:{}", user.getId(), e); } }); } } // try-with-resources会自动关闭Executor,等待所有任务完成sendBenefit方法中的阻塞操作(比如调用下游HTTP接口)无需任何修改,虚拟线程会自动处理阻塞卸载:
private void sendBenefit(User user) { // 1. 调用用户中心校验接口(阻塞IO) HttpResponse userResp = httpClient.send(buildUserCheckRequest(user.getId()), HttpResponse.BodyHandlers.ofString()); // 2. 调用权益中心发放接口(阻塞IO) HttpResponse benefitResp = httpClient.send(buildBenefitRequest(user.getId()), HttpResponse.BodyHandlers.ofString()); // 3. 调用通知中心推送接口(阻塞IO) HttpResponse notifyResp = httpClient.send(buildNotifyRequest(user.getId()), HttpResponse.BodyHandlers.ofString()); }踩坑细节:我们踩过的3个坑
虚拟线程用起来很简单,但稍不注意就会踩坑,我们上线前踩了3个比较典型的坑:
坑1:synchronized导致载体线程被pin
我们的老代码里有个UserBenefitService类,用了synchronized修饰整个发放方法,保证发放幂等:
// 错误写法:synchronized修饰方法,长期持有锁 public synchronized void sendBenefit(User user) { // 校验+发放+通知,整个过程约200ms }改成虚拟线程后,性能反而比线程池下降了30%,排查后发现:synchronized在持有锁期间会pin(固定)当前的载体线程,也就是载体线程被阻塞,无法运行其他虚拟线程,等于把虚拟线程又变成了平台线程,完全失去了优势。修复方法:改用ReentrantLock,并且缩小锁的范围,只锁幂等校验的部分:
private final ReentrantLock lock = new ReentrantLock(); public void sendBenefit(User user) { // 只锁幂等校验的部分,耗时<10ms lock.lock(); try { if (benefitSent(user.getId())) { return; } saveBenefitSentRecord(user.getId()); } finally { lock.unlock(); } // 后续IO操作无锁,虚拟线程可以正常卸载 // ... }坑2:ThreadLocal上下文丢失
我们的链路追踪组件用了ThreadLocal存储 traceId,在线程池场景下,由于线程复用,我们之前已经做了ThreadLocal的清理,但改成虚拟线程后,发现链路追踪的traceId经常串。 排查后发现:虚拟线程的ThreadLocal是每个虚拟线程独立的,但是载体线程的ThreadLocal是所有挂载到这个载体线程的虚拟线程共享的。我们的链路追踪组件用的是比较老的版本,把traceId存在了载体线程的ThreadLocal里,导致不同虚拟线程的traceId互相覆盖。修复方法:升级链路追踪组件到支持虚拟线程的版本(比如SkyWalking 9.0+,Pinpoint 2.5+),新版本会把traceId存在虚拟线程的ThreadLocal里,避免串用。
坑3:CPU密集型任务用虚拟线程反而更慢
我们曾经尝试用虚拟线程处理用户头像压缩的任务(CPU密集型,每个任务占用CPU约500ms),结果发现性能和线程池差不多,甚至稍微差一点。 原因是:虚拟线程适合IO密集型任务,遇到CPU密集型任务时,虚拟线程会一直占用载体线程,无法卸载,JVM还要额外做虚拟线程的调度,反而增加了开销。CPU密集型任务还是应该用传统的线程池,线程数和CPU核心数持平即可。
性能数据对比
我们上线前做了压测,10万用户发放任务的对比数据如下:
| 指标 | 传统线程池(200线程) | 虚拟线程 |
|---|---|---|
| 总耗时 | 120秒 | 45秒 |
| CPU利用率 | 30% | 75% |
| 峰值线程数 | 200 | 8(载体线程数=CPU核心数) |
| 堆外内存占用 | 200MB(线程栈) | 约12MB(虚拟线程栈按需分配) |
| 任务排队时间 | 峰值10秒 | 无排队 |
上线后运行了3个大促,稳定性很好,再也没有出现过线程池打满的问题,下游接口响应变慢时,系统自动扩容虚拟线程处理,完全不会影响其他任务。
适用场景和注意事项
- 适用场景:IO密集型任务,比如批量接口调用、消息队列消费、文件IO、网络请求等;
- 不适用场景:CPU密集型任务、需要长期持有锁的任务;
- 版本要求:JDK 21+可以直接使用,JDK 17/18需要开启预览特性(
--enable-preview),更低版本不支持; - 线程类型:虚拟线程默认是守护线程,主线程退出时会被强制终止,所以需要用
try-with-resources或者awaitTermination等待所有任务完成; - 监控:可以用JDK自带的
jcmd <pid> Thread.dump_virtual_threads命令导出虚拟线程的堆栈,排查问题。
