Java 21 虚拟线程实测:QPS 涨了 40% 但 P99 炸了?我踩了 3 个坑

🔑 关键词:Java 21,虚拟线程,性能优化,线程池,压测

📖 摘要:把订单服务从 Java 17 迁到 Java 21 虚拟线程,QPS 从 3200 涨到 4500,但 P99 从 180ms 飙到 450ms。记录 pinning、ThreadLocal 内存和监控失效三个坑,附具体参数和解决方案。

先交代环境:8 核 16G,JDK 21.0.2,wrk2 压 5 分钟

图片

我那个订单查询服务原来跑在 Java 17 + Tomcat 默认线程池上,最大线程数 200。机器是 AWS c6i.2xlarge,8 vCPU,16GB 内存,数据库 PostgreSQL 15 独立实例,HikariCP 连接池最大 20。压测用 wrk2,100 个并发连接,持续 5 分钟,请求体固定 2KB,返回 JSON 大概 8KB。Java 17 下 QPS 稳定在 3200 左右,P99 是 180ms,CPU 利用率 65%,GC 用的是 G1,Young GC 每秒 2-3 次,每次 15ms 左右。

后来看到 Spring Boot 3.2 支持 spring.threads.virtual.enabled=true,我就想试试。升级到 JDK 21.0.2 和 Spring Boot 3.2.4,把那个开关打开,其他啥都没改。第一次压测结果挺唬人:QPS 直接干到 4500,涨了 40%。但再一看 P99,450ms,P999 到了 1.2 秒。我当时就懵了,这玩意儿不是号称高吞吐低延迟吗?

图片

坑一:synchronized 把虚拟线程 pin 住了

图片

我第一反应是数据库连接池不够,把 HikariCP 从 20 调到 50,QPS 没怎么变,P99 还是 300ms 以上。然后开了 JFR 录了 30 秒,用 JDK Mission Control 一看,好家伙,大量虚拟线程状态是 BLOCKED,而且载体线程(carrier thread)也被钉住了。原因是有个老代码用 synchronized 关键字做了一个本地缓存,大概每 10 秒刷新一次。虚拟线程碰到 synchronized 块,如果锁被占着,它不会像 IO 那样让出载体线程,而是直接阻塞载体线程。JDK 21 里这个 pinning 问题比预览版好点,但依然存在。我把那个 synchronized 换成了 ReentrantLock,P99 降到 220ms,还是比 Java 17 差。具体参数:-XX:+UseZGC -Xmx8g -XX:ConcGCThreads=2,ZGC 的 P99 确实比 G1 稳,但也没救回那 40ms。

坑二:ThreadLocal 内存爆炸

图片

第二个坑更隐蔽。虚拟线程可以创建上百万个,每个线程如果都持有一个 ThreadLocal 副本,内存直接起飞。我们那个服务用了 Spring Security,SecurityContextHolder 默认是 MODE_THREADLOCAL,每个请求进来都会把认证信息塞到 ThreadLocal 里。Java 17 下线程池只有 200 个线程,ThreadLocal 副本最多 200 份。换成虚拟线程后,每个请求一个虚拟线程,ThreadLocal 副本数量跟请求数一样多,而且虚拟线程结束后 ThreadLocal 不一定及时回收。我看了下 RSS,从 1.2GB 涨到了 4.5GB。先试了 -Dspring.security.strategy=MODE_INHERITABLETHREADLOCAL,没用。后来改成在 Filter 里手动 SecurityContextHolder.clearContext(),并且把 HikariCP 的 leakDetectionThreshold 设成 5000ms,内存才稳住在 2GB 左右。

坑三:Micrometer 监控对虚拟线程不生效

图片

第三个坑是监控。我们原来用 Micrometer 的 executor 指标看线程池活跃线程数、队列大小。切到虚拟线程后,ThreadPoolTaskExecutor 被替换成了 SimpleAsyncTaskExecutor,Micrometer 根本不认。我一开始还以为是 Grafana 面板坏了。后来查了 Spring Boot 3.2 的文档,得用 jdk.ThreadMetrics 或者自己包装 Thread.ofVirtual().factory()。我干脆写了个 VirtualThreadMetrics 类,用 ThreadMXBean 拿 getThreadCount() 和 getDaemonThreadCount(),每 10 秒打点到 Micrometer。但要注意,虚拟线程的 getThreadCount() 返回的是平台线程数,不是虚拟线程数,得用 Thread.getAllStackTraces().size() 近似,不过这个开销太大,我最后只监控了载体线程数。

图片

我的结论:别无脑开,先看 P99 和内存

虚拟线程不是银弹。如果你的应用是 CPU 密集型,或者代码里大量用 synchronized,或者依赖 ThreadLocal 的框架很多,升级后 QPS 可能涨,但 P99 和内存大概率会崩。我后来只把 IO 最重的那几个接口(比如导出 CSV、调用第三方 API)切成了虚拟线程,其他还是用平台线程池。JDK 21 的虚拟线程在 Socket、FileInputStream 这些地方已经没问题了,但 synchronized 和 native 方法还是雷区。你要是想试,先跑 24 小时稳定性,重点看 P99 和 RSS,别只看 QPS。另外,Spring Boot 3.2 的 spring.threads.virtual.enabled=true 只影响 @Async 和 Tomcat 的请求处理,不影响你自己 new ThreadPoolExecutor 的地方。

🏷️ 标签: