
周五晚上十点告警群突然弹出线上服务 CPU 使用率 100% 的红色告警。我第一反应不是去翻代码而是先打开终端一系列命令之后问题在十分钟内定位到了具体行。这个过程里最让我感慨的不是工具用得有多熟练而是很多同事在遇到同类问题时第一步就选错了方向——他们往往直接冲进代码仓库找逻辑漏洞或者一遍遍复现本地环境甚至有人先怀疑中间件、数据库绕了一大圈才回到应用自身。这个“第一步走错”的代价轻则多花几个小时重则在排查期间故障持续影响用户最后还被要求写一份根本没说清根因的报告。这篇文章就围绕“Java 线上 CPU 100%”这个高频故障把完整的排查思路、工具链、常见场景和避坑经验捋一遍。无论你是刚上手 Java 排查的新人还是已经被线上问题磨过几轮的老兵我希望你读完后能形成一套肌肉记忆般的处理流程遇到 CPU 飙高时知道先做什么、后做什么、为什么要这么做。1. 为什么“先找代码”是最要命的错误开局1.1 故障排查的第一性原则保护现场很多人听说线上 CPU 100%脑子里冒出的第一个念头是“哪段代码写了个死循环”于是立刻打开 IDE开始在业务代码里漫无目的地搜索。这个方向错就错在你把排查建立在了“猜测”之上而不是“事实”之上。线上故障处理有一个和刑侦高度相似的逻辑——保护现场。CPU 飙高时操作系统和 JVM 内部留下了大量第一手证据哪个进程占用了 CPU、进程里哪个线程在疯狂消耗 CPU、这个线程当时正在执行哪段代码、线程处于什么状态、JVM 有没有频繁 GC。这些信息只存在于运行中的系统里只要应用一重启或者热门代码被热更新覆盖证据就永久消失了。所以正确的第一步不是看代码而是用命令把现场“拍下来”。这个现场包括进程 CPU 占比、线程级 CPU 占比、线程栈、GC 状态等。等你把这些事实收集齐全再回头去代码里找问题会精准很多。1.2 凭感觉改代码和过早重启的两大坑在 CPU 100% 这种高压力场景下人的判断力很容易被紧张情绪干扰然后就容易掉进两个经典的坑。第一个坑是“凭感觉改代码”。我看到过不止一次有人看到 CPU 高就怀疑是“某个定时任务并发执行导致冲突”或者“循环里多查了一次数据库”于是在没有证据的情况下改了几行代码发上去之后 CPU 确实降了——不是因为改对了而是因为重启带来的副作用后面没过多久又复发了。这种靠“瞎猫碰死耗子”式的排查在线上是极度危险的因为你根本没有建立“原因到结果”的闭环问题只是暂时隐藏了没有根除。第二个坑是“过早重启”。有些团队的应急手册写的是“CPU 高就重启”这个策略放在“抢恢复”层面可以理解但它应该是最后手段而不是第一手段。因为一旦重启线程栈、堆现场、GC 历史全部丢失你剩下的只有一堆监控图上几根不明不白的曲线。换句话说你恢复了服务但没有找到病根下次故障还会以相似甚至更隐蔽的方式卷土重来。每次都靠重启续命团队的技术债会越欠越多。2. 正确的排查路径从全局到线程再到代码2.1 第一步用 top 确认 CPU 消耗的真实分布我建议的排查路径是严格的“从全局到局部”。先做第一层判断CPU 到底是哪个进程消耗的这听起来像废话但线上机器上往往跑着多个 Java 进程还有其他中间件进程、监控 Agent、日志采集脚本。你不先确认这一点后面很容易查错对象。用命令 top然后按大写 P 让进程按 CPU 使用率降序排列。重点看两列%CPU 和 RES。前者是 CPU 占用后者是常驻内存两个指标结合起来能帮你快速判断这个进程是不是“重量级消耗者”。如果排在最前面的根本不是你的 Java 进程而是别的什么程序那问题就根本不在 JVM 这一层。top -d 1 -c-d 1 表示每秒刷新一次。输出里找到那个 CPU 占用接近 100% 的 Java 进程记下它的 PID。比如我的场景里是 12345。到这里我们完成了第一层定位花了不到十秒。2.2 第二步用 top -Hp 把问题定位到具体线程确认了是某个 Java 进程在抢 CPU下一步就要进入这个进程内部看线程。因为 Java 应用是多线程模型一个进程里有业务线程、GC 线程、编译线程、定时任务线程等你还需要再精确一层到底是哪个线程在消耗 CPU。top -Hp 12345-H 表示按线程维度展示-p 指定进程号。这条命令会列出该进程下的所有原生线程同样按 P 按 CPU 排序。此时你会看到类似这样的输出PID USER PR NI VIRT RES SHR S %CPU %MEM TIME COMMAND 23456 app 20 0 5.1g 1.2g ... R 98.7 5.0 15:23.21 java这个 23456 是原生线程 ID也就是操作系统视角里的线程号。需要记下来。注意这里 TIME 那一列也很关键它表示线程累计消耗的 CPU 时间如果这一列数值很高说明这个线程已经长时间霸占 CPU不是瞬间抖动。到这一步我们已经从“进程”推进到了“线程”。但 23456 这串数字对 Java 层来说还不够直观因为 JVM 的线程栈里用的是十六进制的线程 ID我们还需要做一次转换这个放到后面工具细节里讲。2.3 第三步用 jstack 抓取线程栈锁定代码位置现在我们拿到了原生线程 ID下一步就是抓线程栈把这个线程正在执行的 Java 代码拍下来。jstack 12345 jstack.logjstack 是 JDK 自带的线程转储工具会输出进程内所有线程的栈信息。接着把刚才记下的线程号 23456 转成十六进制printf %x\n 23456假设输出是 5ba0然后在 jstack.log 里搜索“nid0x5ba0”grep -A 30 nid0x5ba0 jstack.loggrep -A 30 表示把命中的那一行和后面 30 行一起打印出来。这 30 行的内容就是这个线程当时的完整调用栈。栈顶到栈底从当前执行的方法一路回溯到线程入口绝大多数情况下问题代码在这一步就暴露了。整个过程看起来简单但为什么说“大部分人第一步就走错了”因为很多人会跳过进程确认直接 jstack然后在几十上百个线程里来回翻浪费时间更常见的是不转十六进制直接搜十进制线程号搜不到就怀疑工具出了问题。这些细节恰恰是实战中拉开差距的地方。3. 实战中工具链的配合与参数细节3.1 线程 ID 的进制转换与 nid 对照刚才提到需要把十进制线程 ID 转成十六进制来匹配 jstack 输出里的 nid。这里再展开说一说因为这是一个非常容易踩坑的点。jstack 输出的线程信息里每一行开头都有类似这样的格式http-nio-8080-exec-10 #30 daemon prio5 os_prio0 cpu12.34ms elapsed567.89s tid0x00007f3a34a81000 nid0x5ba0 runnable [0x00007f3a2f9fe000]其中 nid 就是 native thread id 的缩写它是操作系统原生线程号但在 jstack 里以十六进制形式呈现。而我们在 top -Hp 里看到的 23456 是十进制。所以必须先 printf %x\n 23456 转成十六进制再去匹配。这个转换可以用任何你觉得顺手的方式不过 Windows 上可能没有 printf这时候可以用计算器或者 Python 一行命令python3 -c print(hex(23456))还有一种偷懒但可靠的办法在 grep 的时候用大小写不敏感匹配并且带上前缀 0x这样避免因为大小写问题漏掉目标线程。3.2 jstack 输出解读与线程状态分析拿到线程栈后不要急着只看 RUNNABLE 的字样。线程栈的解读有很多细节配合状态字能帮你快速构建判断。RUNNABLE表示线程正在运行或可运行。如果它对应的 CPU 占用很高栈顶往往就是热点代码所在。BLOCKED表示线程在等待一把锁。如果大量线程都卡在 BLOCKED并且都指向同一个锁对象那基本就是锁竞争此时 CPU 高可能是由锁的争抢和唤醒开销叠加引起的。WAITING 或 TIMED_WAITING通常是在 wait/notify 或者 sleep/join这类状态本来常见但如果大量堆积也可能说明线程池配置或业务设计存在问题。注意一个容易误判的点线程状态是 RUNNABLE 不等于它正在执行计算它也可能是在执行某个 native 调用比如文件 IO 或网络 IOJava 层面不会显示阻塞状态。所以判断热点还是要以栈顶的 Java 方法为准。关于抓栈次数我的习惯是连续抓三到五次每次间隔三到五秒。因为线程栈是瞬态快照某些场景下次 CPU 高的线程会“跳动”只抓一次可能恰好抓到不具代表性的瞬间。多次快照之间互相印证可以看到热点是否稳定集中在同一个方法上这对后续判断至关重要。3.3 GC 视角jstat 与 jmap 的必要补充线程栈能解决“哪个线程在忙”但 CPU 100% 还有一类常见原因与业务代码无关——GC 线程在疯狂回收内存。我用 jstat 查看 JVM 的 GC 情况jstat -gcutil 12345 1000 10这个命令的输出里有几个指标需要重点关注YGC 和 YGCT年轻代 GC 次数和总耗时FGC 和 FGCTFull GC 次数和总耗时S0/S1/E/O/M各个内存区域的使用比例如果发现 FGC 次数飙得非常快同时 FGCT 也持续增长CPU 的消耗很可能主要来自 GC 线程的扫描、复制、回收动作。这个时候再去业务代码里找死循环就跑偏了。正确的方向是把堆转储文件导出来分析到底是哪个对象占据了大量内存。jmap -dump:formatb,fileheap.hprof 12345不过注意jmap 的 dump 动作在部分 JDK 版本上会触发较长停顿属于“有副作用”的命令。使用前要评估线上影响最好在低峰期或者已经决定摘流量之后再执行。如果你用的是比较新的 JDK也可以考虑用 jcmd 代替一部分 jmap 功能jcmd 12345 GC.heap_dump /path/to/heap.hprof3.4 Arthas 的价值把手工流程变成半自动如果你觉得上面的命令链路太繁琐还有一个现成的工具可以大幅简化流程——Arthas。它提供了 thread -n 3 这样的命令可以一键输出 CPU 占用最高的前三个线程及其完整调用栈thread -n 3Arthas 的输出比手工拼 top、jstack、grep 更加友好它会直接帮你把线程 ID 和 Java 栈对应起来。对于排查 CPU 100%这基本是神器级的存在。但我依然建议你先理解手工命令的底层逻辑。因为 Arthas attach 到线上 JVM 本身有开销在一些极端受限环境里未必装得上、连得上而且如果你不理解 nid 和线程栈的对应关系Arthas 输出的那一大坨信息你可能也无从下手。4. CPU 100% 的常见元凶与识别特征4.1 死循环与空转从栈顶来看最常见的热点形式是某些方法被无限循环执行。比如一个 while(true) 循环里没有合适的退出条件或者循环条件判断依赖了某个被并发修改的变量导致本应退出的循环永远退不出去再比如高频的字符串处理循环里反复做正则匹配、格式化、拼接数据量大时 CPU 消耗会指数级上升。这类问题的特征在 CPU 排查图上非常清晰CPU 占用呈一条近乎直线的水平高线持续时间长不像 GC 那样会有波峰波谷。在线程栈上它通常表现为同一个栈在多次快照中反复出现且栈顶方法完全相同。我处理过的一个例子是一个消息消费任务代码里对消息体做 content-type 判断时分支条件写成了“按值等于空字符串则继续处理”结果某次消息体字段为 null反而导致循环一直重试最终整条消费线程满载。4.2 GC 压力下的 CPU 抢占如果说死循环是“业务线程自体发烧”那 GC 高压就是“整个 JVM 的免疫系统超负荷运转”。当堆内存接近上限时GC 线程会频繁执行垃圾回收尤其是 Full GC需要扫描整个老年代、整理存活对象这个过程非常消耗 CPU。它的典型特征是在 top 输出里你看到占用 CPU 高的线程名通常是“GC Thread”或者包含“GC”关键字的线程用 jstat 能看到 FGC 数量快速增长整体 CPU 曲线呈现高频锯齿状且伴随明显的内存使用率攀升。CPU 100% 和 GC 频繁经常同时出现它们互为因果内存不足导致 GC 频繁GC 频繁又挤占业务线程的 CPU业务线程处理能力下降积压更多请求产生更多垃圾GC 压力进一步增大形成一个恶性循环。碰到这种状况优先看堆而不是看代码。jmap -histo 看一下对象直方图确认是否有某个业务对象数量异常庞大jmap -histo:live 12345 | head -50或者在有 dump 文件后用 MAT、JProfiler 分析引用链找到谁在持有大对象。查清楚之前不要贸然加堆内存因为如果是内存泄漏加多少堆都不够。4.3 锁竞争与线程反复切换还有一种 CPU 高的情况业务线程本身没做多少计算但整个系统就是很“卡”。这通常和锁竞争有关。当一个锁被大量线程争抢线程在获取不到锁时会被挂起、再唤醒、再竞争。这个“上下文切换”和“线程调度”的过程会消耗大量 CPU而且线程数量越多调度开销越夸张。线程栈上的特征是大量线程处于 BLOCKED 状态它们都指向同一个锁对象或者同一个同步块。用 jstack 能看到很多线程的栈底或者栈中都有 synchronized 相关的字样并且都卡在同一个 monitor 上。之前排查过一个案例某个对象池连接未归还导致所有请求线程都阻塞在等待连接的方法上最后 CPU 被线程调度器拖垮。这种问题靠单纯调大线程池参数治标不治本必须从锁的持有粒度、等待策略、池化对象生命周期这几个方向入手。4.4 容易被忽略的隐蔽性能陷阱还有一些场景单看 CPU 高和线程栈不一定能第一眼看到业务代码的“明显错误”但它们确实是线上 CPU 100% 的常见推手。正则表达式回溯是一个典型。某些正则表达式在面对特定输入时会退化成灾难性的回溯复杂度达到 O(2^n)导致一个线程长时间吞 CPU。我在生产环境碰到过一个邮箱格式校验规则写得比较复杂某天一个超长特殊字符串触发后匹配线程直接卡死CPU 直接打满。判断特征是栈顶是 java.util.regex 相关的类。另一个隐蔽场景是序列化和反序列化。比如使用 Java 原生序列化处理大对象或者 JSON 序列化框架处理循环引用、深嵌套对象都可能在高并发下把 CPU 消耗推上去。还有一个常见坑是日志框架尤其是 debug 日志写到磁盘在高吞吐场景下磁盘 IO 和字符串拼接也会抢走大量 CPU。这类问题不像死循环那么直观排查时要结合调用栈和业务场景综合判断。如果你在栈里看到 java.util.regex、com.fasterxml.jackson.databind、甚至是 java.lang.StringBuilder 等高频出现就要往这些方向多想一步。5. 常见问题与排查技巧实录5.1 现场信息收集速查清单每次 CPU 100% 排查我都会先在终端里按顺序执行这几条命令把现场信息固定下来top -d 1 -c top -Hp pid jstack pid jstack_$(date %s).log jstat -gcutil pid 1000 10 free -h前两条确认进程和线程级别 CPU 消耗第三条保存线程栈第四条看 GC 状态最后一条确认宿主机内存是否有压力。这些命令加起来不到半分钟但收集到的信息基本覆盖了 90% 的 CPU 故障类型。强调一点所有输出我都建议重定向到带时间戳的文件里而不仅仅是打印在屏幕上。因为排查过程往往要持续几分钟甚至更久这些原始数据是你后续分析的核心素材也是复盘报告里最有力的证据。5.2 CPU 高时 jstack 可能“卡住”怎么办一个真实的线上坑当 JVM 处在大规模 GC 或者线程非常繁忙的状态时jstack 本身可能执行得很慢甚至长时间不返回。原因是 jstack 需要获取 JVM 的线程锁来遍历线程栈而这时候 JVM 内部可能本身就处于争抢状态。我当时碰到过一次jstack 挂了两分钟没输出一度以为机器卡死了。后来总结出的对策是如果服务器上安装了 JDK而不是只有 JRE优先使用 jcmd Thread.print 替代 jstackjcmd 在部分场景下更轻量。如果 jstack 已经卡住不要反复执行避免加重负载等一段时间观察是否恢复。对于极端情况可以临时使用 gdb 等底层工具去 attach 进程读取栈信息但这对操作者的要求很高不建议新手轻易尝试。核心原则是先保证服务不继续恶化再考虑是否继续在故障现场做深度抓取。5.3 确认根因之后处置顺序与善后建议定位到问题代码后处置逻辑也要有优先级。如果是死循环这类纯业务代码问题且影响面可控可以优先通过配置中心、开关等机制把执行路径关掉然后再评估恢复方案。如果是 GC 问题并且堆里有大量未释放的对象要么临时扩容容灾要么看能否通过调整堆参数缓解最终还是要解决内存占用问题。线上修复完成之后我还会做两件事。第一件把排查过程中所有的命令输出、线程栈、GC 日志归档到故障文档里形成一次完整的时间线和证据链方便后续复盘。第二件根据根因设计一个回归测试用例——比如死循环场景就构造类似输入做单元测试正则场景就把触发问题的报文放到自动化用例里。没有回归用例的修复我觉得都只能算“半成品修复”。5.4 一个完整的实战时间线最后给一个真实的时间线作为参考方便你对照理解。21:00 告警某服务 CPU 持续 95% 以上。21:02 top 确认 Java 进程 PID 5986 占用 380% CPU多核。21:03 top -Hp 5986 发现线程 PID 6021 单核占满。21:04 printf %x\n 6021 得到 0x1785。21:04 jstack 5986grep nid0x1785看到栈顶是某第三方 SDK 的加解密工具类内部有 while 循环重试逻辑。21:05 结合日志发现是外部系统返回了异常报文SDK 里对待特殊报文的处理逻辑有缺陷进入死循环。21:20 通过配置中心关闭相关入口CPU 回落。第二天补充异常报文处理逻辑发布修复版本加入回归测试。整个过程真正“找”问题的时间非常短大部分时间都花在了“确认事实”和“安全处置”上。这就是为什么我一直强调CPU 100% 这类问题会者不难难的是第一步就走对路。根据我的经验线上故障排查最忌讳的就是被“CPU 100%”这个惊悚的数字带着情绪走。冷静下来按“进程—线程—栈—代码”这个链路一步步推进绝大多数问题都能在一个小时内有结论。希望这篇文章能帮你在下次遇到告警时少走几步弯路一梭子就把问题钉死在证据链上。