一年前我把线上服务的线程模型从线程池换成了虚拟线程QPS从800涨到8500。今天我把这一年的实战经验和踩过的坑全部告诉你。大家好我是卷毛。去年Java 21正式发布虚拟线程后我第一时间在两个项目上做了试点。一年下来有真香的体验也有血泪的教训。这篇文章不吹不黑只讲真实经历。一、为什么要用虚拟线程先说结论如果你的服务是I/O密集型的虚拟线程能让你性能起飞。传统线程池的问题// 传统线程池 —— 200个线程就是天花板ExecutorServicepoolExecutors.newFixedThreadPool(200);// 每个线程占用约1MB栈空间// 200个线程 200MB内存// 而且线程数一多上下文切换的开销就很大// 数据库连接池通常也就50-100个连接// 200个线程抢100个连接大部分时间在等待虚拟线程的本质区别// 虚拟线程 —— 可以轻松创建几万个try(varexecutorExecutors.newVirtualThreadPerTaskExecutor()){// 同时提交10000个任务毫无压力IntStream.range(0,10_000).forEach(i-{executor.submit(()-{// 每个任务都在自己的虚拟线程中运行// I/O阻塞时自动让出载体线程returnfetchUserFromDB(i);});});}我的服务实测数据# 测试环境4核8GMySQL 8.0Redis 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替换线程池// ❌ 改造前BeanpublicExecutorServiceorderExecutor(){returnnewThreadPoolExecutor(20,200,60L,TimeUnit.SECONDS,newLinkedBlockingQueue(1000),newThreadFactoryBuilder().setNameFormat(order-pool-%d).build(),newThreadPoolExecutor.CallerRunsPolicy());}// ✅ 改造后BeanpublicExecutorServiceorderExecutor(){returnExecutors.newVirtualThreadPerTaskExecutor();}Step 2Spring Boot配置开启虚拟线程# application.ymlspring:threads:virtual:enabled:true# Tomcat使用虚拟线程处理请求一行配置Tomcat的请求处理线程就自动切换到虚拟线程了。Step 3处理synchronized代码块这是最大的坑。虚拟线程在synchronized代码块中会pin住载体线程无法让出。Java 21中这是一个已知问题Java 24已经修复了大部分pinning场景。// ❌ 有pinning风险Java 21publicsynchronizedUsergetUser(Longid){// synchronized I/O操作 载体线程被钉住returnuserDao.findById(id);// 数据库查询阻塞时无法让出}// ✅ 改用ReentrantLockprivatefinalLocklocknewReentrantLock();publicUsergetUser(Longid){lock.lock();try{returnuserDao.findById(id);}finally{lock.unlock();}}检测pinning启动时加JVM参数-Djdk.tracePinnedThreadsfull会打印所有pinning事件。三、一年踩过的5个坑坑1数据库连接池被打满虚拟线程下并发量暴增但数据库连接池还是原来的大小。// 问题10000个虚拟线程同时查DB连接池只有50个// 结果大量虚拟线程在等待连接反而更慢// 解决方案合理设置连接池大小 使用Adaptive Lupos连接池// HikariCP推荐配置HikariConfigconfignewHikariConfig();config.setMaximumPoolSize(50);// 不要盲目调大DB扛不住config.setMinimumIdle(10);config.setConnectionTimeout(30000);// 或者使用支持虚拟线程的连接池// 如: AgroalDataSource支持虚拟线程感知坑2ThreadLocal内存爆炸// ❌ 每个虚拟线程都有自己的ThreadLocal副本// 10万个虚拟线程 × 每个ThreadLocal存1KB 100MB// ✅ 改用ScopedValueJava 21privatestaticfinalScopedValueUserContextCONTEXTScopedValue.newInstance();ScopedValue.where(CONTEXT,userContext).run(()-{userService.process();});// scope结束后自动清理不会累积坑3synchronized I/O 导致性能下降// ❌ 这个方法在高并发下会pin住载体线程publicsynchronizedvoidwriteToCache(Stringkey,Stringvalue){redisClient.set(key,value);// I/O操作在synchronized块内}// ✅ 用ReentrantLock替代privatefinalReentrantLockcacheLocknewReentrantLock();publicvoidwriteToCache(Stringkey,Stringvalue){cacheLock.lock();try{redisClient.set(key,value);}finally{cacheLock.unlock();}}坑4第三方库的隐式synchronized// 有些第三方库内部用了synchronized// 比如: SimpleDateFormat, 某些JDBC驱动// 解决: 查看库的文档是否支持虚拟线程// 或升级到支持虚拟线程的版本// 日期格式化 —— 用DateTimeFormatter替代SimpleDateFormatprivatestaticfinalDateTimeFormatterFORMATTERDateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss);// DateTimeFormatter是线程安全的不需要synchronized坑5虚拟线程不是银弹// ❌ CPU密集型任务用虚拟线程反而更慢// 虚拟线程的优势在于I/O阻塞时让出载体线程// CPU密集型任务不会阻塞虚拟线程没有优势// 反而多了调度开销// ✅ CPU密集型任务继续用平台线程ExecutorServicecpuPoolExecutors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());// ✅ I/O密集型任务用虚拟线程ExecutorServiceioPoolExecutors.newVirtualThreadPerTaskExecutor();四、最佳实践总结该用虚拟线程的场景// 1. HTTP请求处理Spring Boot已内置支持// 2. 数据库查询// 3. 调用外部API// 4. 消息队列消费// 5. 文件I/O操作// 典型模式聚合多个I/O调用publicUserDetailDTOgetUserDetail(LonguserId){try(varscopeStructuredTaskScope.open()){varuserTaskscope.fork(()-userService.findById(userId));// DB查询varorderTaskscope.fork(()-orderService.findByUserId(userId));// DB查询varlogTaskscope.fork(()-logService.findByUserId(userId));// DB查询vartagTaskscope.fork(()-tagService.findByUserId(userId));// Redis查询scope.join();returnUserDetailDTO.builder().user(userTask.get()).orders(orderTask.get()).logs(logTask.get()).tags(tagTask.get()).build();}// 4个查询并行执行总耗时 ≈ 最慢的那个查询// 而不是串行执行的4个查询时间之和}不该用虚拟线程的场景// 1. CPU密集型计算// 2. 需要限制并发的场景如爬虫限流// 3. 依赖ThreadLocal累积的场景// 4. 大量使用synchronized的老代码未重构迁移检查清单排查所有synchronized I/O的组合评估ThreadLocal的使用量和内存占用检查数据库连接池配置是否合理确认第三方库是否兼容虚拟线程添加pinning检测监控压测对比迁移前后的性能指标五、性能监控上线后用这几个指标持续监控// 查看虚拟线程数量ManagementFactory.getThreadMXBean().dumpAllThreads(true,true);// 监控pinning事件开发环境// JVM参数: -Djdk.tracePinnedThreadsshort// 使用Micrometer监控BeanpublicMeterRegistrymeterRegistry(){returnnewSimpleMeterRegistry();}// 监控线程数、QPS、响应时间// 推荐配合Prometheus Grafana看板写在最后虚拟线程是我这一年用得最爽的Java新特性没有之一。但我也见过不少团队盲目上线翻车的——不是虚拟线程的问题是没有做好迁移评估。虚拟线程很强大但它不是银弹。理解原理评估场景做好迁移才能真正享受10倍吞吐量的红利。我是卷毛9年Java开发专注Java技术实战分享。虚拟线程系列还会继续更新包括结构化并发实战和虚拟线程排障指南。关注《卷毛的技术笔记》第一时间收到更新通知 你在用虚拟线程了吗踩过什么坑评论区聊聊
虚拟线程用了一年,我真香了——10倍吞吐量是真实存在的
发布时间:2026/7/8 12:36:51
一年前我把线上服务的线程模型从线程池换成了虚拟线程QPS从800涨到8500。今天我把这一年的实战经验和踩过的坑全部告诉你。大家好我是卷毛。去年Java 21正式发布虚拟线程后我第一时间在两个项目上做了试点。一年下来有真香的体验也有血泪的教训。这篇文章不吹不黑只讲真实经历。一、为什么要用虚拟线程先说结论如果你的服务是I/O密集型的虚拟线程能让你性能起飞。传统线程池的问题// 传统线程池 —— 200个线程就是天花板ExecutorServicepoolExecutors.newFixedThreadPool(200);// 每个线程占用约1MB栈空间// 200个线程 200MB内存// 而且线程数一多上下文切换的开销就很大// 数据库连接池通常也就50-100个连接// 200个线程抢100个连接大部分时间在等待虚拟线程的本质区别// 虚拟线程 —— 可以轻松创建几万个try(varexecutorExecutors.newVirtualThreadPerTaskExecutor()){// 同时提交10000个任务毫无压力IntStream.range(0,10_000).forEach(i-{executor.submit(()-{// 每个任务都在自己的虚拟线程中运行// I/O阻塞时自动让出载体线程returnfetchUserFromDB(i);});});}我的服务实测数据# 测试环境4核8GMySQL 8.0Redis 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替换线程池// ❌ 改造前BeanpublicExecutorServiceorderExecutor(){returnnewThreadPoolExecutor(20,200,60L,TimeUnit.SECONDS,newLinkedBlockingQueue(1000),newThreadFactoryBuilder().setNameFormat(order-pool-%d).build(),newThreadPoolExecutor.CallerRunsPolicy());}// ✅ 改造后BeanpublicExecutorServiceorderExecutor(){returnExecutors.newVirtualThreadPerTaskExecutor();}Step 2Spring Boot配置开启虚拟线程# application.ymlspring:threads:virtual:enabled:true# Tomcat使用虚拟线程处理请求一行配置Tomcat的请求处理线程就自动切换到虚拟线程了。Step 3处理synchronized代码块这是最大的坑。虚拟线程在synchronized代码块中会pin住载体线程无法让出。Java 21中这是一个已知问题Java 24已经修复了大部分pinning场景。// ❌ 有pinning风险Java 21publicsynchronizedUsergetUser(Longid){// synchronized I/O操作 载体线程被钉住returnuserDao.findById(id);// 数据库查询阻塞时无法让出}// ✅ 改用ReentrantLockprivatefinalLocklocknewReentrantLock();publicUsergetUser(Longid){lock.lock();try{returnuserDao.findById(id);}finally{lock.unlock();}}检测pinning启动时加JVM参数-Djdk.tracePinnedThreadsfull会打印所有pinning事件。三、一年踩过的5个坑坑1数据库连接池被打满虚拟线程下并发量暴增但数据库连接池还是原来的大小。// 问题10000个虚拟线程同时查DB连接池只有50个// 结果大量虚拟线程在等待连接反而更慢// 解决方案合理设置连接池大小 使用Adaptive Lupos连接池// HikariCP推荐配置HikariConfigconfignewHikariConfig();config.setMaximumPoolSize(50);// 不要盲目调大DB扛不住config.setMinimumIdle(10);config.setConnectionTimeout(30000);// 或者使用支持虚拟线程的连接池// 如: AgroalDataSource支持虚拟线程感知坑2ThreadLocal内存爆炸// ❌ 每个虚拟线程都有自己的ThreadLocal副本// 10万个虚拟线程 × 每个ThreadLocal存1KB 100MB// ✅ 改用ScopedValueJava 21privatestaticfinalScopedValueUserContextCONTEXTScopedValue.newInstance();ScopedValue.where(CONTEXT,userContext).run(()-{userService.process();});// scope结束后自动清理不会累积坑3synchronized I/O 导致性能下降// ❌ 这个方法在高并发下会pin住载体线程publicsynchronizedvoidwriteToCache(Stringkey,Stringvalue){redisClient.set(key,value);// I/O操作在synchronized块内}// ✅ 用ReentrantLock替代privatefinalReentrantLockcacheLocknewReentrantLock();publicvoidwriteToCache(Stringkey,Stringvalue){cacheLock.lock();try{redisClient.set(key,value);}finally{cacheLock.unlock();}}坑4第三方库的隐式synchronized// 有些第三方库内部用了synchronized// 比如: SimpleDateFormat, 某些JDBC驱动// 解决: 查看库的文档是否支持虚拟线程// 或升级到支持虚拟线程的版本// 日期格式化 —— 用DateTimeFormatter替代SimpleDateFormatprivatestaticfinalDateTimeFormatterFORMATTERDateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss);// DateTimeFormatter是线程安全的不需要synchronized坑5虚拟线程不是银弹// ❌ CPU密集型任务用虚拟线程反而更慢// 虚拟线程的优势在于I/O阻塞时让出载体线程// CPU密集型任务不会阻塞虚拟线程没有优势// 反而多了调度开销// ✅ CPU密集型任务继续用平台线程ExecutorServicecpuPoolExecutors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());// ✅ I/O密集型任务用虚拟线程ExecutorServiceioPoolExecutors.newVirtualThreadPerTaskExecutor();四、最佳实践总结该用虚拟线程的场景// 1. HTTP请求处理Spring Boot已内置支持// 2. 数据库查询// 3. 调用外部API// 4. 消息队列消费// 5. 文件I/O操作// 典型模式聚合多个I/O调用publicUserDetailDTOgetUserDetail(LonguserId){try(varscopeStructuredTaskScope.open()){varuserTaskscope.fork(()-userService.findById(userId));// DB查询varorderTaskscope.fork(()-orderService.findByUserId(userId));// DB查询varlogTaskscope.fork(()-logService.findByUserId(userId));// DB查询vartagTaskscope.fork(()-tagService.findByUserId(userId));// Redis查询scope.join();returnUserDetailDTO.builder().user(userTask.get()).orders(orderTask.get()).logs(logTask.get()).tags(tagTask.get()).build();}// 4个查询并行执行总耗时 ≈ 最慢的那个查询// 而不是串行执行的4个查询时间之和}不该用虚拟线程的场景// 1. CPU密集型计算// 2. 需要限制并发的场景如爬虫限流// 3. 依赖ThreadLocal累积的场景// 4. 大量使用synchronized的老代码未重构迁移检查清单排查所有synchronized I/O的组合评估ThreadLocal的使用量和内存占用检查数据库连接池配置是否合理确认第三方库是否兼容虚拟线程添加pinning检测监控压测对比迁移前后的性能指标五、性能监控上线后用这几个指标持续监控// 查看虚拟线程数量ManagementFactory.getThreadMXBean().dumpAllThreads(true,true);// 监控pinning事件开发环境// JVM参数: -Djdk.tracePinnedThreadsshort// 使用Micrometer监控BeanpublicMeterRegistrymeterRegistry(){returnnewSimpleMeterRegistry();}// 监控线程数、QPS、响应时间// 推荐配合Prometheus Grafana看板写在最后虚拟线程是我这一年用得最爽的Java新特性没有之一。但我也见过不少团队盲目上线翻车的——不是虚拟线程的问题是没有做好迁移评估。虚拟线程很强大但它不是银弹。理解原理评估场景做好迁移才能真正享受10倍吞吐量的红利。我是卷毛9年Java开发专注Java技术实战分享。虚拟线程系列还会继续更新包括结构化并发实战和虚拟线程排障指南。关注《卷毛的技术笔记》第一时间收到更新通知 你在用虚拟线程了吗踩过什么坑评论区聊聊