JCSprout 实战:一次生产 CPU 100% 排查优化与 Disruptor 等待策略调优
2026/9/20 16:01:44 网站建设 项目流程
  • 文档
  • 教程
  • 后端

【免费下载链接】JCSprout

👨‍🎓 Java Core Sprout : basic, concurrent, algorithm

项目地址:https://gitcode.com/gh_mirrors/jc/JCSprout
点击查看免费下载

本篇基于 JCSprout 仓库的 docs/jvm/cpu-percent-100.md 实战记录展开,还原一次真实的生产服务器 CPU 负载飙高故障:从pstop -Hpjstack等常规排查手段切入,逐步定位到 Disruptor 环形队列的YieldingWaitStrategy等待策略在消费线程数远超 CPU 核心数时引发的"自旋 + yield"风暴,并通过本地模拟复现与对照实验给出两种优化路径。读完本文,你将掌握一套完整的"高 CPU 问题定位 → 线程栈分析 → 本地复现 → 策略调优 → 架构拆分"排查方法论。

问题背景:年底的运维报警

项目上线后不久,运维突然报警:部分服务器负载非常高。排查现场只运行着一个 Java 应用,没有其他可疑进程,因此问题几乎可以锁定在该 Java 进程内部。值得一提的是,作者此前还刻意提高过某些服务器的负载用于内存分配实验(详见 JVM 内存分配),好在两套环境互不影响,本次报警是真实的生产问题。

这种"Java 进程占满 CPU"的故障在生产环境中非常典型,定位的关键在于:把 CPU 使用率从"进程"粒度下钻到"线程"粒度,再把线程栈快照与代码逻辑对应起来

定位问题:一条完整的线程级排查链路

第一步:ps拿到 Java 进程 PID

先确认负载来源并拿到目标进程号:

ps -ef | grep java

记录下 Java 应用的PID,后续所有操作都围绕这个进程展开。

第二步:top -Hp按 CPU 排序查看线程

top默认显示进程级汇总,必须进入线程视图才能看到"到底是谁在烧 CPU":

top -Hp <pid>

进入交互界面后,按大写P可以将线程按照CPU 使用比例排序。此时可以看到若干个线程的 CPU 使用率高达 100% 左右,热点线程立刻浮出水面。

第三步:jstackdump 线程栈快照

为了看清这些热点线程正在执行什么代码,把线程栈快照落盘:

jstack <pid> > pid.log

jstack输出的每个线程栈中都包含线程的nid(native thread id),这是和top中线程 ID 对应的关键索引。

第四步:线程 ID 转 16 进制,在快照中精确定位

top的热点线程中随机挑选一个,例如pid=194283,将其转换为 16 进制得到2f6eb

因为线程快照中线程 ID 是以 16 进制存放的(形如nid=0x2f6eb),必须做进制转换才能在日志中精确搜索。

grep "0x2f6eb" -A 20 pid.log

搜索结果显示该线程正卡在Disruptor的堆栈上。作者此前就遇到过一起由 Disruptor 队列引发的内存溢出(详见 强如 Disruptor 也发生内存溢出?),因此对这条堆栈非常敏感——没想到同一框架又惹出了新麻烦。

第五步:借助线程分析平台批量归类

手工 grep 只能看单线程,为了直观查看全部线程的状态分布,把快照上传到专门的线程分析平台(如 fastthread.io 这类在线分析工具)。平台会列出所有消耗 CPU 的线程,结果发现几乎全部命中同一条堆栈:

  • 都是Disruptor 队列的消费堆栈;
  • 都在执行java.lang.Thread.yield函数;
  • 线程状态均为RUNNABLE
  • 处于该状态的线程约有30 多个

根因初判:大量线程自旋 + yield 互相竞争

Thread.yield的语义是"让出当前 CPU 时间片,重新参与调度竞争"。如果大量线程同时处于"自旋等待 → yield 让出 → 又抢回 CPU"的循环中,调度器会频繁切换上下文,宏观表现就是 CPU 使用率居高不下。

结合"30 多个线程都在执行 yield"和"堆栈全部指向 Disruptor"这两个事实,初步判断:大量 Disruptor 消费线程执行yield后互相竞争,导致 CPU 使用率升高

源码与配置层面的印证:等待策略是关键

业务使用方式:一个业务两个队列,队列数量爆炸

Review 代码后发现,系统按业务场景解耦:每一个业务场景内部都会使用 2 个 Disruptor 队列。假设当前有 7 个业务类型:

2 个队列 × 7 个业务 = 14 个 Disruptor 队列

每个队列又有一个消费者线程,仅此一项就创建了 14 个消费线程(生产环境数量更多)。而它们都跑在同一台服务器的同一进程里,共享 CPU 资源。

YieldingWaitStrategy:压榨 CPU 的自旋 + yield 策略

进一步查看配置,发现消费等待策略被设置为YieldingWaitStrategy。查询 Disruptor 官方文档可知:

YieldingWaitStrategy是一种充分压榨 CPU 的策略,使用自旋 + yield的方式来提高性能;当消费线程(Event Handler threads)的数量小于 CPU 核心数时推荐使用该策略。

也就是说,该策略的设计初衷是用高 CPU 占用换取低延迟:消费线程没有新事件时不会休眠,而是自旋探测序号并周期性让出 CPU。它成立的前提是"消费线程数 < CPU 核心数"——消费线程可以各占一个核并行自旋;一旦消费线程数远超 CPU 核心数,大量线程在极少的核上反复自旋、让出、再竞争,CPU 使用率自然飙升。

仓库源码佐证:演示工程就是"同款姿势"

JCSprout 仓库中的演示代码 LongEventMain.java 完整复刻了这种高风险用法:

// 消费线程池:固定 15 个线程 ThreadPoolExecutor executor = new ThreadPoolExecutor(15, 15, 1, TimeUnit.MILLISECONDS, queue, namedThreadFactory); // 环形缓冲区大小,必须为 2 的幂 int bufferSize = 8; // 构造 Disruptor,显式指定 YieldingWaitStrategy Disruptor<LongEvent> disruptor = new Disruptor<>(factory, bufferSize, executor, ProducerType.SINGLE, new YieldingWaitStrategy()); // 每个队列挂一个消费者 disruptor.handleEventsWith(new LongEventHandler());

这段代码对应了生产事故的两个特征:

  1. 线程池固定 15 个线程(LongEventMain.java),每个 Disruptor 一个消费者,多个队列叠加后消费线程数远超核心数;
  2. 构造 Disruptor 时显式传入YieldingWaitStrategy(LongEventMain.java),复现了生产上的等待策略配置。

项目通过 pom.xml 引入com.lmax:disruptor:3.3.7依赖,上述 API 均基于该版本。

生产者侧的数据流也值得留意:LongEventProducer.java 展示了标准的发布流程——ringBuffer.next()申请序号 →ringBuffer.get(sequence)获取事件槽位 → 填充数据 →ringBuffer.publish(sequence)发布;而 LongEventHandler.java 的消费者仅打印一条日志。这个"极简消费者"恰好是YieldingWaitStrategy暴露问题的放大器:消费者任务瞬间完成,其余时间全部消耗在自旋等待上。

本地模拟复现:15 个队列验证 CPU 飙升

纸上谈兵不算数,作者在本地复刻了生产环境,验证判断是否成立。

模拟环境搭建

  • 创建15 个 Disruptor 队列(对应生产的"多业务多队列");
  • 每个队列用一个线程池持续往队列里发送100 万条数据
  • 消费程序仅仅打印日志,不处理任何业务。

该模拟与仓库中的 LongEventMain.java 结构完全一致:15 个固定线程的消费线程池(new ThreadPoolExecutor(15, 15, ...))、每队列一个LongEventHandler、生产者通过productExecutor.execute(new Work(producer, l))提交 100 万次发布任务(LongEventMain.java)。

复现现象

跑了一段时间后观察:

  • CPU 使用率确实很高
  • jstackdump 线程后发现与生产现象完全一致:消费线程全部处于RUNNABLE状态,同时都在执行yield

问题在本地成功复现,说明判断方向正确。

对照实验:两种优化方案的量化验证

方案一:等待策略切换为BlockingWaitStrategy

Disruptor 官方文档同样指出,BlockingWaitStrategy是默认的等待策略,它基于锁(Lock+Condition)机制:消费线程没有新事件时会被真正阻塞挂起,而不是自旋,因此对 CPU 使用率不友好程度极低

在保持其它条件完全一致的情况下,仅将等待策略换为BlockingWaitStrategy,重新跑一遍模拟:

  • CPU 使用率明显下降
  • 再次 dump 线程,发现大部分线程已经处于waiting(阻塞等待)状态,而非RUNNABLE自旋。

这从线程状态层面解释了 CPU 降低的原因:waiting线程不会参与调度竞争,自然不再消耗 CPU 时间片。

方案二:削减队列数量,回归策略适用前提

BlockingWaitStrategy只是"减缓"了症状,作者注意到YieldingWaitStrategy的官方适用前提是"消费线程数 < CPU 核心数",而当前的用法显然违背了这一前提(一个队列一个消费者,队列一多线程数就爆了)。

于是做了第二个对照实验:只保留 1 个 Disruptor 队列,等待策略依然是YieldingWaitStrategy。结果:

  • 跑了一分钟,CPU 使用率一直比较平稳且不高

说明问题根源不只是等待策略本身,更是队列(消费线程)数量与 CPU 核心数严重不匹配。策略没有好坏,只有"用对场景"和"用错场景"。

线上优化与根治方案

综合两次对照实验,作者给出了由浅入深的两步走方案:

  1. 快速止血:先将线上等待策略从YieldingWaitStrategy换为BlockingWaitStrategy,立刻把 CPU 使用率压下来。该策略带来的延迟代价在现有业务上可以接受,适合作为临时缓解手段;
  2. 根本解决拆分应用。现状是一个应用同时处理 N 个业务、每个业务使用好几个 Disruptor 队列,所有队列共享一台服务器的 CPU。应将应用按业务拆分为"一个应用处理一种业务类型",分别独立部署——这样既能让 Disruptor 消费线程数回归"小于 CPU 核心数"的适用区间,又实现了故障隔离、互不影响。

顺带发现:线程池配置也存在资源浪费

这次 dump 线程时还发现,老系统竟然创建了800+ 个线程。原因是创建线程池时核心线程数与最大线程数设置成了相同值,导致空闲线程永远得不到回收,白白占用线程栈内存和调度开销。因此在拆分业务的同时,也应当结合业务量调整线程池参数,把线程数降下来,"物尽其用"。

总结:一次 CPU 100% 排查的完整方法论

回顾整个排查过程,可以提炼出一条可复用的实战链路:

阶段手段产出
定位热点pstop -Hp→ 按P排序拿到高 CPU 线程 ID
抓取现场jstack <pid> > pid.log线程栈快照
精确命中线程 ID 十进制转 16 进制后 grepnid定位到 Disruptor 堆栈
批量归类线程分析平台 / 再次 dump确认 30+ 线程都在yield自旋
根因确认Review 配置:YieldingWaitStrategy+ 多队列多消费者消费线程数远超 CPU 核心数
本地复现15 个队列模拟压测,dump 对比现象与生产一致
对照验证BlockingWaitStrategy;减为单队列CPU 均显著下降
落地优化换策略止血 → 按业务拆分应用 → 收缩线程池根治 CPU 100%

几个关键结论值得记住:

  • jstack中的线程 ID 是 16 进制的,与top输出的十进制线程号必须做进制转换才能对应;
  • Thread.yield本身不是原罪,大量线程同时自旋 + yield 互相竞争才是 CPU 飙升的机制;
  • YieldingWaitStrategy只适合消费线程数小于 CPU 核心数的低延迟场景;默认的BlockingWaitStrategy用阻塞换 CPU,是"求稳"场景的更优解;
  • 中间件用得越多,越要关注资源总量:队列、消费者、线程池的个数乘以业务类型数之后,可能远超预期,这类"数量放大"是隐形的性能炸弹。

如果你想亲手复现文中的实验,可直接运行 JCSprout 仓库中的演示代码 LongEventMain.java(需引入 pom.xml 中的com.lmax:disruptor:3.3.7依赖),配合top -Hpjstack观察 CPU 和线程状态的变化,即可直观感受两种等待策略的巨大差异。同主题的姊妹篇 一次内存溢出排查优化实战 记录了 Disruptor 环形缓冲区引发的另一类线上故障(OOM),两篇文章相互印证,共同构成"Disruptor 使用避坑"的完整案例集。

  • 文档
  • 教程
  • 后端

【免费下载链接】JCSprout

👨‍🎓 Java Core Sprout : basic, concurrent, algorithm

项目地址:https://gitcode.com/gh_mirrors/jc/JCSprout
点击查看免费下载
上一篇:零停机升级:JuiceFS版本无缝过渡实战指南
下一篇:SyncTV部署完全教程:Docker、Helm与二进制安装对比

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询