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,本质是平台线程):

  1. 虚拟线程运行Java代码时,会挂载到某个载体线程上,复用载体线程的CPU时间片;
  2. 当虚拟线程执行到阻塞操作(比如Thread.sleep()、IO读写、等待锁)时,会从载体线程上卸载,载体线程不会被阻塞,可以去运行其他虚拟线程;
  3. 阻塞操作完成后,虚拟线程会重新挂载到某个可用的载体线程上继续运行。 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个大促,稳定性很好,再也没有出现过线程池打满的问题,下游接口响应变慢时,系统自动扩容虚拟线程处理,完全不会影响其他任务。

适用场景和注意事项

  1. 适用场景:IO密集型任务,比如批量接口调用、消息队列消费、文件IO、网络请求等;
  2. 不适用场景:CPU密集型任务、需要长期持有锁的任务;
  3. 版本要求 :JDK 21+可以直接使用,JDK 17/18需要开启预览特性(--enable-preview),更低版本不支持;
  4. 线程类型 :虚拟线程默认是守护线程,主线程退出时会被强制终止,所以需要用try-with-resources或者awaitTermination等待所有任务完成;
  5. 监控 :可以用JDK自带的jcmd <pid> Thread.dump_virtual_threads命令导出虚拟线程的堆栈,排查问题。
相关推荐
唐青枫1 天前
Java Flyway 实战指南:用 SQL 脚本管理数据库版本
java
三品吉他手会点灯1 天前
C语言学习笔记 - 50.流程控制4 - 流程控制为什么非常非常重要
c语言·开发语言·笔记·学习
huangdong_1 天前
电商平台图片URL原图转换技术深度解析:从缩略图到高清原图的完整方案
java·后端·spring
記億揺晃着的那天1 天前
Java 调用外部 Go 程序的实践:ProcessBuilder 在生产环境中的应用
java·golang·processbuilder
JAVA面经实录9171 天前
Java 数据结构与算法 (终极完整学习文档)
java·数据结构·算法
JAVA面经实录9171 天前
操作系统面试题
java·服务器·数据库·计算机网络·面试
一杯奶茶¥1 天前
基于springboot的失物招领管理系统带万字文档 校园失物招领管理系统 失物认领管理系统java springboot vue
java·vue.js·spring boot·java项目
在放️1 天前
Python 爬虫 · 第三方代理接入与合规使用
开发语言·爬虫·python
不能只会打代码1 天前
边缘视频分析平台的架构设计与性能优化——从750ms到190ms的调优之路
java·spring boot·redis·性能优化·边缘计算·物联网竞赛
小刘|1 天前
Spring AI Alibaba 集成和风天气 API 实战
java·服务器·前端