
那台 8C16G 的机器 CPU 直接顶到 100% 的时候我盯着监控上的红线愣了几秒。后台接口没大流量数据库 QPS 正常Redis 也没有慢查询所有迹象都表明这不是业务峰值该有的样子。真正让我头皮发麻的是 jstack 打印出来的堆栈一堆线程卡在java.util.concurrent.ThreadPoolExecutor相关的调用上RUNNABLE 状态却看不到任何业务代码在执行。这就是我这次要聊的两个 JDK BUG 踩坑实录准确点说是两个被线程池“看似正常”的机制逼出来的 CPU 打满事故。如果你也在用 ThreadPoolExecutor、SynchronousQueue、ForkJoinPool.commonPool 或者嵌套 parallelStream这篇文章值得你花几分钟看完因为这两个坑藏得比想象中深。1. 事故现象CPU 100% 但业务没有高峰1.1 第一现场报警与堆栈第一次报警发生在下午两点多某任务调度平台的 CPU 使用率从 10% 直接拉满持续了大概二十分钟。业务方反馈说任务执行变慢但并没有新增任务投递。我先看了 Prometheus 上的 JVM 线程数指标活跃线程数从平时的二十多个涨到了四五百而且还在继续增长。赶紧连续打了三次 jstack间隔 5 秒一次。对比下来发现大量线程都停在同一个代码块附近pool-3-thread-12 #34 prio5 os_prio0 tid0x00007f3c600fe800 nid0x5c63 runnable [0x00007f3c0bffa000] java.lang.Thread.State: RUNNABLE at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1073) at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134) at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624) at java.lang.Thread.run(Thread.java:748)注意这里线程状态是 RUNNABLE但卡在getTask()里。没有业务堆栈全部在等任务线程数却一直在增加。这就是第一个反常点线程池在空转。1.2 常规排查为什么失灵按惯例CPU 100% 先看死循环、锁竞争、Full GC。但这次 Full GC 没有锁竞争也没有业务线程寥寥无几几乎全是线程池内部的 worker 线程。更诡异的是这些 worker 并不像正常空闲线程那样 WATING 在getTask()的take()或poll()上而是全在 RUNNABLE 状态里忙等。如果你也遇到这种“线程数持续增加 线程池内部 RUNNABLE 忙等 业务无流量”的组合那大概率不是代码写错了而是线程池的底层机制在特定参数组合下被触发了一个不太常见的 JDK 缺陷。下面我拆成两个独立的坑来讲第一个就和 ThreadPoolExecutor 的扩容竞态有关。2. 第一个坑任务提交撞上 Worker 退出窗口JDK BUG 实录2.1 触发器核心线程数为 0 SynchronousQueue 重试型拒绝处理那个调度平台为了“零闲置线程”把核心线程数设成了 0最大线程数设成了 200工作队列用的是SynchronousQueue。理由也很直白任务都是短小轻量的不希望核心线程长期占着资源希望线程能尽快回收。同时为了怕任务提交失败拒绝策略没有用默认的AbortPolicy而是自定义了一个“重试型”策略public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (!executor.isShutdown()) { executor.execute(r); // 重新提交直到成功 } }第一眼看这个策略很常见在任务波动大的场景里不少人这么写。但它和SynchronousQueue组合在一起就是一个雷。2.2 根因拆解线程池扩容逻辑的竞态窗口SynchronousQueue本身不持有任务它只负责把提交线程和消费线程“配对”。线程池提交任务时如果当前工作线程数已经达到核心线程数这里是 0所以核心线程数限制等于没有并且队列offer没有消费者在等待线程池会尝试addWorker创建新线程。问题出在addWorker内部。线程池统计工作线程数用的是AtomicInteger但线程退出要把自己从 workers 集合中移除这个过程不是和addWorker原子化的。具体来说一个 worker 任务执行完进入getTask()获取下一个任务SynchronousQueue里没有任务于是它准备退出。线程池判断当前 workerCount 已经达到池容量准备走退出流程从workers集合中移除自身。就在这个瞬间外部线程提交了一个新任务发现 workerCount 小于最大线程数于是调用addWorker创建新线程。但此时旧 worker 还没有真正从workers集合和 workerCount 中消失新线程创建的时候又遇到了线程池状态变更、workerCount 校验等竞态导致addWorker失败或创建出多余的 worker。这个窗口期在 JDK 8 的某些小版本里被多次报告过核心表现就是任务提交线程和 worker 退出线程撞车时线程池无法正确补偿一个 worker任务只能走拒绝路径。而拒绝路径又是无限重试重试线程重新execute()再次撞上退出窗口继续被拒绝继续重试。于是 CPU 就在这个“提交-拒绝-重试-再拒绝”的循环里被打满了。2.3 实时复现与证据链我后来用一段最小化代码复现了这个问题。核心参数如下ThreadPoolExecutor pool new ThreadPoolExecutor( 0, 200, 60L, TimeUnit.SECONDS, new SynchronousQueue(), new RetryRejectedPolicy() );复现手法是让少数任务在运行时触发退出窗口同时用一个独立线程持续提交任务。跑了大概两分钟后jstack 里就开始出现大量SynchronousQueue.offer阻塞与重试堆栈叠加的现场。最直接的数据是getPoolSize()它会在 50 到 200 之间反复跳动但getCompletedTaskCount()却几乎不涨说明线程创建了很多但任务根本没被执行。你排查的时候不用复现也能确认这个痕迹看线程数是不是“暴涨但执行量不涨”再看 jstack 里有没有成片的SynchronousQueue.transfer和ThreadPoolExecutor.execute重试栈。满足这两条基本可以锁定这个窗口竞态。2.4 修复方案与影响面这个坑的修复方式不是去改拒绝策略而是从一开始就别碰这个参数组合。我当时改了三个地方核心线程数从 0 改为合理值比如 10 到 20避免每次都走“创建-销毁”循环。SynchronousQueue换成LinkedBlockingQueue让任务有缓冲空间不再依赖即时配对。拒绝策略重写允许等待一段时间而不是无限立即重试。参考实现是使用限时循环public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (executor.isShutdown()) { return; } try { if (!executor.getQueue().offer(r, 10, TimeUnit.SECONDS)) { throw new RejectedExecutionException(Task queue is full); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RejectedExecutionException(Interrupted, e); } }这个方案的影响面是任务不再无限重试极端情况下会拒绝任务并抛出明确异常至少不会把 CPU 打满。同时因为核心线程数不为 0即使没有任务也只会保留少量常驻线程资源成本可以忽略。升级 JDK 到修复版本能缓解部分竞态但我仍然建议改参数组合不要赌 JDK 版本。3. 第二个坑commonPool 自旋风暴JDK BUG 实录3.1 触发器parallelStream 嵌套并行流 join第二个事故更隐蔽。一个异步报表服务核心逻辑里用了parallelStream()处理集合为了并发控制又嵌套了一层CompletableFuture最后调用future.join()等待结果。ListReportTask tasks buildTasks(); tasks.parallelStream() .forEach(task - { CompletableFutureResult f CompletableFuture.supplyAsync(() - calc(task)); Result r f.join(); // 阻塞等待子任务 });这段代码看起来正常但它在内部悄然用到了ForkJoinPool.commonPool()。这个池子是 JVM 全局共享的parallelStream默认用它CompletableFuture.supplyAsync没指定线程池时也用它。两个机制共用同一个池再加上任务内部还要再拆分子任务问题就出现了。3.2 根因拆解ForkJoinPool 的 spin 与补偿机制ForkJoinPool的 worker 获取任务时走的是 work-stealing 算法。每个 worker 有一个自己的双端队列初始会从队尾取任务自己的队列空了就去偷别人队头的任务。当线程发现到处都没有任务时它不会立刻阻塞而是进入一个有限次数的自旋等待这个自旋次数在ForkJoinPool里是可配置的默认是nspin内部逻辑如下如果自己队列空且全局任务队列也空先自旋一轮期望在线程 park 前等到新任务。自旋消耗的是 CPU 周期不是睡眠。自旋次数在低并行度、连续空轮询时会被放大。如果工作线程因为join()阻塞而任务队列又没有足够任务线程池的补偿机制会尝试创建新的 worker 来顶替但commonPool的并行度默认是 CPU 核数减一线程数有上限不能无限补偿。当所有 worker 都被阻塞在join()上且被阻塞的线程还在扫描任务队列时就出现了“所有线程都活着、都在自旋、都在偷空队列”的假死状态。我对这次事故打了一次火焰图ForkJoinPool.scan()和ForkJoinPool.awaitWork()占到了整体 CPU 样本的 90% 以上。这不是锁竞争这是纯粹的忙等风暴。回到这段报表代码parallelStream拆分成多个 task每个 task 又通过supplyAsync提交到同一个commonPool然后join()等子任务。如果子任务排队靠后父任务已经占据了一个 worker 并在自旋等待子任务又依赖另一个 worker 来执行而并行度就是 CPU 核数减一线程数根本不够用于是所有线程都在 join 自旋没有线程去执行队列里等待的子任务。表现出来就是 CPU 打满任务时长翻了好几倍。3.3 堆栈识别与复现判断是否踩中这个坑jstack 里会出现两类典型栈ForkJoinPool.commonPool-worker-3 #23 prio5 os_prio0 tid0x00007f6b1802c800 nid0x37 runnable java.lang.Thread.State: RUNNABLE at java.util.concurrent.ForkJoinPool.awaitWork(ForkJoinPool.java:1823) at java.util.concurrent.ForkJoinPool.runWorker(ForkJoinPool.java:1693) at java.util.concurrent.ForkJoinPool$Worker.run(ForkJoinPool.java:604)另一类是业务栈和ForkJoinTask.get()堆栈叠加说明业务任务确实在等子任务但子任务没有线程执行。最小复现也很简单创建一段递归拆分任务并调join()的代码把commonPool并行度强压到 2再提交大量任务很快就能看到 CPU 发烫。复现时记得指定 JVM 参数-Djava.util.concurrent.ForkJoinPool.common.parallelism23.4 修复方案与影响面我当时做的修复是报表服务的并行流全部改为自定义线程池不再使用commonPool。嵌套的CompletableFuture显式传入自定义ExecutorService禁止默认池共享。去掉不必要的parallelStream因为集合量不大单线程更快不值得引入并行调度开销。如果你不想每次都写一个完整的ForkJoinPool可以单独建一个固定线程池给supplyAsyncExecutorService bizPool Executors.newFixedThreadPool(16); CompletableFutureResult f CompletableFuture.supplyAsync(() - calc(task), bizPool);这样的影响面是commonPool不再被业务关键链路污染并行流和异步任务互相隔离CPU 打满的风险直接被拆掉。升级 JDK 也有效果JDK 后续版本调整了awaitWork的自旋策略但靠升级不如靠隔离因为你无法保证依赖库内部不会顺手用一下commonPool。4. 线程池 CPU 飙高的排查方法论4.1 第一轮top jstack 三张快照遇到线程池 CPU 高不要急着猜。先top -Hp pid看哪些线程最耗 CPU拿到线程 nid再转十六进制去 jstack 里匹配。关键点是 jstack 至少要打三张间隔 5 秒。因为线程是动态的单独的瞬间快照可能正好错过自旋高峰期。三张快照里如果都看到同一个方法栈的线程基本可以确定是热点路径如果三张完全不同但都是 RUNNABLE 空转更说明是忙等。还有一个小技巧top -H输出的 nid 是十进制jstack 里是十六进制。先printf %x\n 12345转一下再去 grep别瞪着屏幕看半天。4.2 第二轮ARTHAS 线程诊断如果有条件建议直接用 ARTHAS。Arthas 的thread -n 3可以直接按 CPU 占用排序显示最热的几个线程还能直接抓线程栈。不用做十六进制换算省很多事。我常用的命令thread -n 5它会输出 CPU 占用前五的线程和对应的堆栈。如果看到线程名是ForkJoinPool.commonPool-worker-*并且状态是 RUNNABLE基本覆盖面就很大了。再配合thread -b查锁阻塞能快速区分是锁竞争还是自旋。4.3 第三轮最小化复现与 JDK 版本对照定位到可疑机制后不要直接上生产改代码。先在测试环境做最小化复现。复现时建议做两个变量的隔离固定 JDK 版本分别调整线程池参数和任务提交方式。固定线程池参数分别切换 JDK 小版本看表现是否差异明显。JDK BUG 和普通业务代码 bug 最大的区别就是它往往只在特定参数组合、特定 JDK 小版本下触发。像第一个坑同样的代码在 JDK 8u112 上能复现到 JDK 8u192 上频率明显下降但并不能保证完全消失。所以如果你没有条件复现至少要把 JDK 版本差异记录清楚方便后续升级验证。5. 事后防坑清单与编码规范5.1 线程池参数设计红线经历过这两次事故后我给团队定的线程池参数设计红线是这样的参数建议原因corePoolSize至少大于 0避免线程频繁创建销毁也避免触发退出竞态窗口workQueue优先 LinkedBlockingQueue 或 ArrayBlockingQueue给任务缓冲不依赖即时配对maximumPoolSize与队列容量一起评估防止线程数无限膨胀rejectedExecutionHandler禁止无限立即重试重试必须限时或降级特别是涉及SynchronousQueue的配置要反复确认是否真的需要“零缓冲”。大部分业务场景根本不需要即时配对SynchronousQueue不是给普通异步任务设计的。5.2 拒绝策略禁止无界重试我见过很多团队为了防止任务丢失在拒绝策略里写executor.execute(r)或者while (true)循环提交。这个想法的出发点是好的但代价是如果线程池本身出了异常状态重试就是火上浇油。无限重试不仅不会恢复线程池还会把提交线程的 CPU 全部吃掉。拒绝策略的正确做法是有限重试 降级。一般我会这样设计任务先入队等待最多 5 到 10 秒。还是进不去直接抛RejectedExecutionException由调用方决定补偿还是记录日志。监控里对RejectedExecutionException计数告警而不是默默吞掉。记住拒绝策略不是业务补偿机制它是线程池的最后一道保险。保险丝不应该被当成短路导线用。5.3 并行流与 ForkJoinPool 使用边界parallelStream和CompletableFuture默认池共享commonPool这件事很容易被忽视。在一个高并发服务里任何一环用了并行流都可能把commonPool的线程占满从而影响所有使用默认池的代码。我现在的编码规范是业务代码一律不直接用parallelStream()如果确实要并行自己建专属线程池。CompletableFuture的所有异步操作都显式传入自定义ExecutorService。如果依赖第三方库无法控制它内部使用的线程池那就在压测阶段专门用火焰图看看commonPool的占用情况。另外commonPool的并行度可以通过-Djava.util.concurrent.ForkJoinPool.common.parallelism调整但调大并行度只能缓解症状不能解决任务饥饿和自旋问题核心还是隔离。最后再分享一个实在话这两次事故之后我再也不敢在小版本升级上偷懒。JDK 8 到 JDK 8 的小版本之间java.util.concurrent包下的行为差异比想象中大。线程池这种天天被调用的基础设施出了问题往往不是“崩了”而是“悄悄把 CPU 吃满”。排查的时候先怀疑状态再怀疑代码最后怀疑参数组合这个顺序能省下大半天时间。如果你现在正好也被类似问题折磨建议立刻打三张 jstack先找 RUNNABLE 状态的线程集中在哪是 ThreadPoolExecutor 的 worker还是 ForkJoinPool 的 commonPool worker方向基本就出来了。别问我怎么知道的我光是火焰图就烧掉了半包烟。