真实业务场景我们团队负责的用户运营平台有个核心功能是批量用户权益发放每次大促前需要给百万级的用户发放优惠券、积分等权益每个用户的发放需要调用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个比较典型的坑坑1synchronized导致载体线程被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操作无锁虚拟线程可以正常卸载 // ... }坑2ThreadLocal上下文丢失我们的链路追踪组件用了ThreadLocal存储 traceId在线程池场景下由于线程复用我们之前已经做了ThreadLocal的清理但改成虚拟线程后发现链路追踪的traceId经常串。 排查后发现虚拟线程的ThreadLocal是每个虚拟线程独立的但是载体线程的ThreadLocal是所有挂载到这个载体线程的虚拟线程共享的。我们的链路追踪组件用的是比较老的版本把traceId存在了载体线程的ThreadLocal里导致不同虚拟线程的traceId互相覆盖。修复方法升级链路追踪组件到支持虚拟线程的版本比如SkyWalking 9.0Pinpoint 2.5新版本会把traceId存在虚拟线程的ThreadLocal里避免串用。坑3CPU密集型任务用虚拟线程反而更慢我们曾经尝试用虚拟线程处理用户头像压缩的任务CPU密集型每个任务占用CPU约500ms结果发现性能和线程池差不多甚至稍微差一点。 原因是虚拟线程适合IO密集型任务遇到CPU密集型任务时虚拟线程会一直占用载体线程无法卸载JVM还要额外做虚拟线程的调度反而增加了开销。CPU密集型任务还是应该用传统的线程池线程数和CPU核心数持平即可。性能数据对比我们上线前做了压测10万用户发放任务的对比数据如下指标传统线程池200线程虚拟线程总耗时120秒45秒CPU利用率30%75%峰值线程数2008载体线程数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命令导出虚拟线程的堆栈排查问题。