背景:最近带团队做服务改造,一个对外接口 QPS 800+,里面要串行调 4 个下游 RPC、一次 DB、一次 Redis,P50 还行但 P99 经常抖到 300ms+。我翻了下现有代码,发现「并发模型」这块其实是一笔糊涂账:有的地方用 ForkJoinPool 共享池跑阻塞 IO,有的地方手搓 ThreadPoolExecutor 每个接口一个池,还有的新同学直接上 CompletableFuture 链式调用,线程池参数全凭感觉。想借这个帖把「Java 生态里线程池 / 虚拟线程 / 事件循环(Reactor、Netty 这类)到底怎么选」摊开聊,因为这个问题我踩了 5 年,每次重构都会重新纠结一遍。
常见分歧 / 几种做法,我观察下来大概分四派:
- 保守派:所有阻塞调用都丢线程池,每个资源一个池,参数按 CPU 核数 * 10 拍脑袋。优点是心智简单、栈深、好调试;缺点是池子一多,线程数爆炸,上下文切换和内存都是成本,而且「每个接口一个池」会让故障互相传染。
- 虚拟线程派(Loom):Java 21 之后直接用 virtual thread,阻塞 IO 直接写同步代码,框架层面挂起。优点是把「写起来像同步、跑起来像异步」这件事统一到一处,对老代码改造成本极低;缺点是对「pinning」敏感——synchronized 块、JNI 调用会卡住载体线程,JDK 版本和依赖库是否兼容要逐个验证,而且池子治理那套经验(拒绝策略、队列长度监控)大部分失效了。
- 事件循环派:Reactor / RxJava / Netty 单线程事件循环,所有 IO 非阻塞。吞吐上限最高、线程数最少;但代码可读性、异常传播、调试栈全是噩梦,团队里只要有一个新同学,维护成本就会指数上升。
- 混合派:网关和纯 IO 边界用事件循环,业务逻辑层用线程池或虚拟线程,中间用「线程上下文透传」桥接。灵活但复杂度最高,桥接层自己就会出 bug。
我的倾向:在「业务逻辑里大量阻塞 IO、团队 5 人以上、JDK ≥ 21」的前提下,我会优先选虚拟线程 + 少量专用池(CPU 密集算子才用平台线程池),原因是它把「写起来像同步、跑起来像异步」这件事的边际成本降到了最低,而且对老代码改造是渐进式的,不用一次性重写。事件循环我留给网关 / 长连接 / 纯转发这类「IO 占比 > 90%」的场景,业务层不轻易碰。线程池我保留给 CPU 密集计算和第三方阻塞 SDK 的隔离。但我承认这个结论强依赖 JDK 版本和依赖库兼容性,如果是 JDK 8 / 11 或者大量 C 库调用,虚拟线程的优势会大打折扣,那时事件循环或成熟线程池反而更稳。
补充:上线 3 个月后的实测数据与三个没写进初稿的坑
数据:QPS 800 场景,从「每接口一个 ThreadPoolExecutor」迁到虚拟线程后
- 常驻平台线程数:220 → 18(carrier 池 = CPU 核数 + 3)
- RSS:3.4G → 2.1G(少了 ~4400 个 1MB 栈)
- P99:310ms → 180ms(主要是「线程池排队等待」被消灭了)
- 故障恢复速度反而变差:以前线程池打满会快速 fail-fast,虚拟线程下「每个请求都起一个虚拟线程」把下游压力放大了一倍,雪崩窗口从 8s 拉到 22s。这是初稿漏掉的关键反例——虚拟线程把「背压」从「线程池拒绝」变成了「下游被打穿」。必须额外加信号量(Semaphore)做应用层背压,否则虚拟线程的「无限并发」是伪吞吐。
坑 1:synchronized pinning 的具体反例
我们踩到的不是业务代码里的 synchronized,而是HikariCP 4.x 内部——HikariPool.getConnection() 的某些路径用了 synchronized 块(JDK 21 早期)。表现:压测到 2000 QPS 时 carrier 线程全部被 pin,jstack 里看到 carrier 线程卡在 HikariPool.java:312 的 monitorenter。解法:升级到 HikariCP 5.x(改成了 ReentrantLock)+ 加一个 -Djdk.tracePinnedThreads=full 的压测开关,把 pinning 直接打到日志里,比事后 jstack 定位快 10 倍。
坑 2:ThreadLocal / MDC 桥接 虚拟线程下「一个 carrier 线程服务成千上万个虚拟线程」,ThreadLocal 的语义从「每线程一个」变成了「每虚拟线程一个」,看起来没问题,实际内存爆炸——10 万个虚拟线程 × 每个 5KB 的 MDC/TraceContext = 500MB 纯上下文内存。我们的解法:TraceContext 用 ScopedValue(JDK 21 预览,JDK 25 正式版)替代 ThreadLocal,MDC 改成显式参数传递,别依赖 MDC 自动继承。
坑 3:桥接层的异常传播
事件循环(网关)→ 虚拟线程(业务)的桥接,如果网关用 Reactor,业务用虚拟线程,异常在 Mono.fromCallable 里抛出时,堆栈会断成两段——Reactor 侧只看到 onError,虚拟线程侧的堆栈被吞。必须用 Thread.currentThread().getStackTrace() 手动拼,或者用 Micrometer 的 ContextPropagator 统一透传,不然 on-call 排障时一半的堆栈是空的。
修正后的结论(比初稿更保守):
- JDK 21+ 且依赖库已适配(HikariCP 5+、连接池无 pinning):虚拟线程 + Semaphore 背压是业务层首选。
- JDK 21+ 但依赖库未适配:先留在线程池,逐库替换 pinning 热点后再迁,别一次性重写。
- JDK 8/11:老老实实线程池 + 事件循环网关,虚拟线程不是银弹。
- 事件循环:只用于 IO 占比 > 90% 的边界层(网关、长连接、纯转发),业务层不碰。
- 背压:无论选哪一派,应用层必须有信号量或令牌桶,不要依赖「线程池拒绝」或「下游 5xx」做背压——前者在虚拟线程下失效,后者雪崩太快。
想听大家聊聊几个具体问题:
- 你们生产环境里,虚拟线程和平台线程池是怎么划界的?有没有「看着能用,压测一上量就崩」的坑?
- synchronized 导致的 pinning 你们是怎么治理的?是改 ReentrantLock、还是直接禁用虚拟线程?
- 事件循环和线程池混用的桥接层(线程上下文透传、异常传播),你们是用框架现成的还是自己封的?踩过什么坑?
- 如果是 JDK 8 / 11 的老系统,你们会怎么选?是硬上虚拟线程(升级 JDK)还是老老实实事件循环?
- (新增) 你们怎么把「虚拟线程的无限并发」变成「可控的背压」?Semaphore 阈值怎么定?
欢迎把具体场景(QPS、下游数量、JDK 版本、团队规模)一起带上,我按场景对比会更准。