一年前我把线上服务的线程模型从线程池换成了虚拟线程,QPS从800涨到8500。今天我把这一年的实战经验和踩过的坑,全部告诉你。
大家好,我是卷毛。
去年Java 21正式发布虚拟线程后,我第一时间在两个项目上做了试点。一年下来,有真香的体验,也有血泪的教训。这篇文章,不吹不黑,只讲真实经历。
一、为什么要用虚拟线程?
先说结论:如果你的服务是I/O密集型的,虚拟线程能让你性能起飞。
传统线程池的问题:
java
// 传统线程池 ------ 200个线程就是天花板
ExecutorService pool = Executors.newFixedThreadPool(200);
// 每个线程占用约1MB栈空间
// 200个线程 = 200MB内存
// 而且线程数一多,上下文切换的开销就很大
// 数据库连接池通常也就50-100个连接
// 200个线程抢100个连接,大部分时间在等待
虚拟线程的本质区别:
java
// 虚拟线程 ------ 可以轻松创建几万个
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
// 同时提交10000个任务,毫无压力
IntStream.range(0, 10_000).forEach(i -> {
executor.submit(() -> {
// 每个任务都在自己的虚拟线程中运行
// I/O阻塞时自动让出载体线程
return fetchUserFromDB(i);
});
});
}
我的服务实测数据
makefile
# 测试环境:4核8G,MySQL 8.0,Redis 7.0
# 接口:用户信息聚合查询(查DB + 查Redis + 调外部API)
# 线程池模式(200线程)
QPS: 812
平均响应时间: 245ms
P99响应时间: 890ms
CPU使用率: 45%
内存使用: 1.2GB
# 虚拟线程模式
QPS: 8,530 (10.5倍提升)
平均响应时间: 118ms
P99响应时间: 320ms
CPU使用率: 62%
内存使用: 1.5GB
为什么QPS能涨10倍? 因为虚拟线程在I/O阻塞时会自动让出载体线程给其他虚拟线程。200个平台线程可以承载几万个虚拟线程,I/O等待时间不再被浪费。
二、迁移实战:3步完成改造
Step 1:替换线程池
java
// ❌ 改造前
@Bean
public ExecutorService orderExecutor() {
return new ThreadPoolExecutor(
20, 200, 60L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000),
new ThreadFactoryBuilder().setNameFormat("order-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
}
// ✅ 改造后
@Bean
public ExecutorService orderExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
Step 2:Spring Boot配置开启虚拟线程
yaml
# application.yml
spring:
threads:
virtual:
enabled: true # Tomcat使用虚拟线程处理请求
一行配置,Tomcat的请求处理线程就自动切换到虚拟线程了。
Step 3:处理synchronized代码块
这是最大的坑。虚拟线程在synchronized代码块中会pin住载体线程,无法让出。Java 21中这是一个已知问题,Java 24+已经修复了大部分pinning场景。
java
// ❌ 有pinning风险(Java 21)
public synchronized User getUser(Long id) {
// synchronized + I/O操作 = 载体线程被钉住
return userDao.findById(id); // 数据库查询阻塞时无法让出
}
// ✅ 改用ReentrantLock
private final Lock lock = new ReentrantLock();
public User getUser(Long id) {
lock.lock();
try {
return userDao.findById(id);
} finally {
lock.unlock();
}
}
检测pinning :启动时加JVM参数-Djdk.tracePinnedThreads=full,会打印所有pinning事件。
三、一年踩过的5个坑
坑1:数据库连接池被打满
虚拟线程下,并发量暴增,但数据库连接池还是原来的大小。
java
// 问题:10000个虚拟线程同时查DB,连接池只有50个
// 结果:大量虚拟线程在等待连接,反而更慢
// 解决方案:合理设置连接池大小 + 使用Adaptive Lupos连接池
// HikariCP推荐配置
HikariConfig config = new HikariConfig();
config.setMaximumPoolSize(50); // 不要盲目调大,DB扛不住
config.setMinimumIdle(10);
config.setConnectionTimeout(30000);
// 或者使用支持虚拟线程的连接池
// 如: AgroalDataSource(支持虚拟线程感知)
坑2:ThreadLocal内存爆炸
java
// ❌ 每个虚拟线程都有自己的ThreadLocal副本
// 10万个虚拟线程 × 每个ThreadLocal存1KB = 100MB!
// ✅ 改用ScopedValue(Java 21+)
private static final ScopedValue<UserContext> CONTEXT = ScopedValue.newInstance();
ScopedValue.where(CONTEXT, userContext).run(() -> {
userService.process();
});
// scope结束后自动清理,不会累积
坑3:synchronized + I/O 导致性能下降
java
// ❌ 这个方法在高并发下会pin住载体线程
public synchronized void writeToCache(String key, String value) {
redisClient.set(key, value); // I/O操作在synchronized块内
}
// ✅ 用ReentrantLock替代
private final ReentrantLock cacheLock = new ReentrantLock();
public void writeToCache(String key, String value) {
cacheLock.lock();
try {
redisClient.set(key, value);
} finally {
cacheLock.unlock();
}
}
坑4:第三方库的隐式synchronized
java
// 有些第三方库内部用了synchronized
// 比如: SimpleDateFormat, 某些JDBC驱动
// 解决: 查看库的文档是否支持虚拟线程
// 或升级到支持虚拟线程的版本
// 日期格式化 ------ 用DateTimeFormatter替代SimpleDateFormat
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
// DateTimeFormatter是线程安全的,不需要synchronized
坑5:虚拟线程不是银弹
java
// ❌ CPU密集型任务用虚拟线程,反而更慢
// 虚拟线程的优势在于I/O阻塞时让出载体线程
// CPU密集型任务不会阻塞,虚拟线程没有优势
// 反而多了调度开销
// ✅ CPU密集型任务继续用平台线程
ExecutorService cpuPool = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
);
// ✅ I/O密集型任务用虚拟线程
ExecutorService ioPool = Executors.newVirtualThreadPerTaskExecutor();
四、最佳实践总结
该用虚拟线程的场景
java
// 1. HTTP请求处理(Spring Boot已内置支持)
// 2. 数据库查询
// 3. 调用外部API
// 4. 消息队列消费
// 5. 文件I/O操作
// 典型模式:聚合多个I/O调用
public UserDetailDTO getUserDetail(Long userId) {
try (var scope = StructuredTaskScope.open()) {
var userTask = scope.fork(() -> userService.findById(userId)); // DB查询
var orderTask = scope.fork(() -> orderService.findByUserId(userId)); // DB查询
var logTask = scope.fork(() -> logService.findByUserId(userId)); // DB查询
var tagTask = scope.fork(() -> tagService.findByUserId(userId)); // Redis查询
scope.join();
return UserDetailDTO.builder()
.user(userTask.get())
.orders(orderTask.get())
.logs(logTask.get())
.tags(tagTask.get())
.build();
}
// 4个查询并行执行,总耗时 ≈ 最慢的那个查询
// 而不是串行执行的4个查询时间之和
}
不该用虚拟线程的场景
java
// 1. CPU密集型计算
// 2. 需要限制并发的场景(如爬虫限流)
// 3. 依赖ThreadLocal累积的场景
// 4. 大量使用synchronized的老代码(未重构)
迁移检查清单
- 排查所有synchronized + I/O的组合
- 评估ThreadLocal的使用量和内存占用
- 检查数据库连接池配置是否合理
- 确认第三方库是否兼容虚拟线程
- 添加pinning检测监控
- 压测对比迁移前后的性能指标
五、性能监控
上线后用这几个指标持续监控:
java
// 查看虚拟线程数量
ManagementFactory.getThreadMXBean().dumpAllThreads(true, true);
// 监控pinning事件(开发环境)
// JVM参数: -Djdk.tracePinnedThreads=short
// 使用Micrometer监控
@Bean
public MeterRegistry meterRegistry() {
return new SimpleMeterRegistry();
}
// 监控线程数、QPS、响应时间
// 推荐配合Prometheus + Grafana看板
写在最后
虚拟线程是我这一年用得最爽的Java新特性,没有之一。但我也见过不少团队盲目上线翻车的------不是虚拟线程的问题,是没有做好迁移评估。
虚拟线程很强大,但它不是银弹。理解原理,评估场景,做好迁移,才能真正享受10倍吞吐量的红利。
📌 我是卷毛,9年Java开发,专注Java技术实战分享。
虚拟线程系列还会继续更新,包括结构化并发实战 和虚拟线程排障指南。
关注《卷毛的技术笔记》,第一时间收到更新通知! 🔥
你在用虚拟线程了吗?踩过什么坑?评论区聊聊 👇