上周三半夜,我被拉进一个紧急群,说订单查询接口的P99飙到了3秒。我一看监控,好家伙,8C16G的机器上跑了200个Tomcat线程,CPU才40%,但请求全堆在队列里。这场景太熟悉了——典型的阻塞式IO把线程池吃干了。我第一反应是上虚拟线程试试,毕竟JDK 21都发布一年多了,Spring Boot 3.2也原生支持。但我没想到,这一试就试出了后面一堆幺蛾子。
先交代测试环境,免得有人说我瞎编。硬件:阿里云ecs.g7.2xlarge,8核16G,ESSD云盘。软件:Ubuntu 22.04,OpenJDK 21.0.2(注意这个版本号,后面要考),Spring Boot 3.2.2,内嵌Tomcat 10.1.18。压测工具是wrk2,运行在另一台同配置的机器上。接口逻辑很简单:查MySQL拿订单,然后调一个外部HTTP接口,最后拼装返回。我写了三个版本:传统平台线程+Tomcat默认线程池、虚拟线程+Tomcat、以及Spring WebFlux。每个版本压测5分钟,预热1分钟,取稳定后的数据。
结果先摆出来:平台线程版本,QPS最高1200,P99 3.2秒,CPU 45%,线程数200。虚拟线程版本,QPS冲到8900,P99 180毫秒,CPU 85%,但内存从1.2G涨到了2.8G。WebFlux版本,QPS 9500,P99 150毫秒,CPU 90%,内存1.5G。看起来虚拟线程完胜平台线程,甚至接近WebFlux?但别急,我掉进了第一个坑:虚拟线程把MySQL连接池打爆了。HikariCP默认最大连接10,结果瞬间创建了几千个虚拟线程去抢连接,全卡在getConnection()上。我改成500,QPS掉到6000,但P99反而降到120毫秒。
第二个坑更隐蔽:synchronized导致的pin。我的代码里有个同步块用来初始化一个本地缓存,里面调了File.exists()。在虚拟线程下,这个同步块会pin住载体线程,导致其他虚拟线程无法调度。我用-Djdk.tracePinnedThreads=full启动,日志里刷了几百行"Thread[#123] pinned"。解决办法是把synchronized换成ReentrantLock,QPS又回到了8500。但注意,JDK 21.0.1之前有个bug,即使没有同步块,某些native调用也会pin,升级到21.0.2才修复。所以如果你还在用21.0.0或21.0.1,赶紧升。
第三个坑是ThreadLocal。我之前用ThreadLocal存用户上下文,每个请求会塞一个对象。平台线程下,200个线程只有200份副本。虚拟线程下,每个请求创建一个虚拟线程,ThreadLocal就变成了每个请求一份,内存直接爆炸。而且虚拟线程的ThreadLocal不会自动清理,除非你显式remove。我改用ScopedValue(JDK 21预览特性)或者干脆把上下文作为参数传递,内存才降回1.8G。这里有个反直觉的点:虚拟线程并不适合所有场景,如果你的代码重度依赖ThreadLocal,迁移成本可能比收益还大。
那虚拟线程到底该不该上生产?我的观点可能跟很多布道师不一样:对于IO密集型且没有synchronized、没有ThreadLocal、连接池足够大的服务,虚拟线程是神器,能让QPS翻几倍。但如果你用的是老代码,里面一堆同步块和ThreadLocal,那还是先重构吧。另外,虚拟线程的调试工具还不太成熟,jstack默认看不到虚拟线程,得用jcmd