开放问答
JDK 21 虚拟线程下 CompletableFuture.supplyAsync 默认 ForkJoinPool 成隐性瓶颈:怎么系统性排查与替换?
该问题的正文、回答与评论。
提问者 林景行-341 · 状态 open
虚拟线程CompletableFutureForkJoinPoolJDK21高并发性能调优
背景
我负责一个结算服务(Spring Boot 3.2 + JDK 21,spring.threads.virtual.enabled=true + HikariCP 5.1 + MySQL 8.0),单机 8C16G,入口 QPS 约 1200。核心逻辑:一次结算请求要并行调用 4 个下游(账户余额、费率计算、风控评分、日志归档),代码里用的是 CompletableFuture.supplyAsync(() -> xxxClient.call(...))——没有传 executor 参数。上线后日常 P99 稳定在 45ms,一切正常。
现象
最近一次全链路压测(目标 3000 QPS,持续 10 分钟)时,P99 从 45ms 飙升到 2800ms,但 CPU 利用率只有 35%,HikariCP 连接池 active 数峰值仅 23/50,MySQL 侧 Threads_running 正常。JFR 抓到的热点栈里反复出现 ForkJoinPool$WorkQueue 上的 park,而虚拟线程的 VirtualThread park 几乎没有——说明阻塞全部堆在 commonPool 的 7 个(CPU-1)carrier 上,4 个下游调用被串行化到 7 个线程上排队。
已尝试的排查与结果
- 用
-Djdk.tracePinnedThreads=full启动,日志里没有 pinning 告警,排除 synchronized 钉住问题。 jstack抓到的 ForkJoinPool worker 线程栈:7 个线程全部在SunPark,确认是排队等待下游 I/O 而非 CPU 计算。- 把 4 个
supplyAsync全部改成supplyAsync(..., virtualExecutor)(Executors.newVirtualThreadPerTaskExecutor())后重跑,P99 回落到 62ms,验证了瓶颈就在 commonPool。 - 但全量 grep 后发现代码里还有 37 处
CompletableFuture.supplyAsync/runAsync没传 executor(散落在 12 个类里,部分是 3 年前老代码),不可能一次性改完。
期望得到的答案形态
- 有没有一种运行时拦截或 AOP 方案,能在不改 37 处调用的前提下,把
supplyAsync无参重载默认路由到虚拟线程池?(比如自定义ForkJoinPool的ForkJoinTask拦截、ByteBuddy 字节码增强、或 Spring 的AsyncConfigurer钩子?) - 如果必须逐处修改,有没有一个最小侵入的封装模式(比如一个
ParallelExecutor工具类 + 内部统一用虚拟线程),让 37 处改动只是把supplyAsync(...)换成ParallelExecutor.supply(...),不改业务逻辑? - 你们生产环境里,JDK 21 虚拟线程 +
ForkJoinPool.commonPool的混合场景是怎么治理的?有没有踩过"改了 supplyAsync 但thenCompose/thenCombine链路里某一步又落回 commonPool"的坑?
评论
暂无评论。