- 文档
- 教程
- 后端
【免费下载链接】JCSprout
👨🎓 Java Core Sprout : basic, concurrent, algorithm
本篇基于 JCSprout 仓库的 docs/jvm/cpu-percent-100.md 实战记录展开,还原一次真实的生产服务器 CPU 负载飙高故障:从
ps、top -Hp、jstack等常规排查手段切入,逐步定位到 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.logjstack输出的每个线程栈中都包含线程的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());这段代码对应了生产事故的两个特征:
- 线程池固定 15 个线程(LongEventMain.java),每个 Disruptor 一个消费者,多个队列叠加后消费线程数远超核心数;
- 构造 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 核心数严重不匹配。策略没有好坏,只有"用对场景"和"用错场景"。
线上优化与根治方案
综合两次对照实验,作者给出了由浅入深的两步走方案:
- 快速止血:先将线上等待策略从
YieldingWaitStrategy换为BlockingWaitStrategy,立刻把 CPU 使用率压下来。该策略带来的延迟代价在现有业务上可以接受,适合作为临时缓解手段; - 根本解决:拆分应用。现状是一个应用同时处理 N 个业务、每个业务使用好几个 Disruptor 队列,所有队列共享一台服务器的 CPU。应将应用按业务拆分为"一个应用处理一种业务类型",分别独立部署——这样既能让 Disruptor 消费线程数回归"小于 CPU 核心数"的适用区间,又实现了故障隔离、互不影响。
顺带发现:线程池配置也存在资源浪费
这次 dump 线程时还发现,老系统竟然创建了800+ 个线程。原因是创建线程池时核心线程数与最大线程数设置成了相同值,导致空闲线程永远得不到回收,白白占用线程栈内存和调度开销。因此在拆分业务的同时,也应当结合业务量调整线程池参数,把线程数降下来,"物尽其用"。
总结:一次 CPU 100% 排查的完整方法论
回顾整个排查过程,可以提炼出一条可复用的实战链路:
| 阶段 | 手段 | 产出 |
|---|---|---|
| 定位热点 | ps→top -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 -Hp与jstack观察 CPU 和线程状态的变化,即可直观感受两种等待策略的巨大差异。同主题的姊妹篇 一次内存溢出排查优化实战 记录了 Disruptor 环形缓冲区引发的另一类线上故障(OOM),两篇文章相互印证,共同构成"Disruptor 使用避坑"的完整案例集。
- 文档
- 教程
- 后端
【免费下载链接】JCSprout
👨🎓 Java Core Sprout : basic, concurrent, algorithm
相关推荐
JCSprout 实战:一次由 Disruptor RingBuffer 引发的线上 OOM 排查与解决全记录
JCSprout 实战:一次由 Disruptor RingBuffer 引发的线上 OOM 排查与解决全记录 导读 本文是 JCSprout 项目对一次真实线
文档教程后端JCSprout 实战:Disruptor 环形队列引发线上 OOM 的排查、定位与根治
JCSprout 实战:Disruptor 环形队列引发线上 OOM 的排查、定位与根治 线上应用反复抛出 OutOfMemoryError 是每位后端开发者都
文档教程后端LMAX Disruptor等待策略终极指南:Blocking、Yielding和BusySpin性能对比与选择技巧
LMAX Disruptor等待策略终极指南:Blocking、Yielding和BusySpin性能对比与选择技巧 LMAX Disruptor是一款高性能的
并发编程
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考