凌晨三点,手机突然响起的 PagerDuty 报警音,绝对是每一位后端开发的噩梦。
“生产环境 CPU 飙升到 100%,服务响应超时,LB 正在剔除节点!”
这时候,你的第一反应是什么?重启?回滚?还是扩容?
讲真,重启能解决 80% 的问题,但解决不了那剩下的 20% 致命顽疾。如果不知道根因,重启只是把“定时炸弹”的倒计时重置了而已。作为架构师,我们不能只做“重启工程师”,必须具备在战火中快速定位并拆弹的能力。
接下来聊聊这个老生常谈但又极具技术含量的硬核话题:从 CPU 100% 到 JVM 调优的完整排查实战。我们将抛弃老旧的命令行排查方式,直接上大杀器 Arthas,结合 Java 21 的新特性,深入底层逻辑,甚至最后我会教你一种“邪道”架构,利用 CPU 100% 来换取极致性能。
一、 案发现场:CPU 为什么会炸?
CPU 飙升通常只有两种核心原因:
- 业务逻辑死循环/高计算:代码写得烂,线程一直在空转或者做无意义的繁重计算。
- JVM 内部系统风暴:通常是 GC 线程在疯狂回收内存(Stop-The-World),或者 JIT 编译器在疯狂编译。
我们要做的,就是从成千上万个线程中,揪出那个“作恶”的线程 ID。
场景复现:一个看似人畜无害的代码
为了演示,我们先来看一个在 Java 21 环境下容易被忽视的 CPU 杀手代码。这是一个模拟处理大量订单数据的场景。
示例代码 1:隐蔽的 Stream 并行流滥用
package com.howell.cpu; import java.util.ArrayList; import java.util.List; import java.util.Random; import java.util.concurrent.CompletableFuture; import java.util.stream.IntStream; /** * 场景:模拟高并发下的 CPU 密集型计算,导致 ForkJoinPool 饱和 */ public class CpuSpikeDemo { public static void main(String[] args) { // 模拟生成 100 万个订单数据 List<Integer> orders = IntStream.range(0, 1000000) .boxed() .toList(); // 开启 50 个并发任务,模拟 Web 容器的线程池 for (int i = 0; i < 50; i++) { CompletableFuture.runAsync(() -> { processOrders(orders); }); } // 保持主线程存活 try { Thread.sleep(Long.MAX_VALUE); } catch (InterruptedException e) {} } private static void processOrders(List<Integer> orders) { // 错误示范:在并发线程中嵌套使用 parallelStream // 这会导致 CommonPool 线程争抢极其严重,CPU 上下文切换频繁 long count = orders.parallelStream() .map(CpuSpikeDemo::heavyCalculation) .count(); System.out.println("Processed: " + count); } private static int heavyCalculation(int num) { // 模拟复杂的业务计算,消耗 CPU double result = 0; for (int i = 0; i < 1000; i++) { result += Math.tan(Math.atan(Math.tan(Math.atan(num + i)))); } return (int) result; } }运行结果说明:这段代码运行后,你会发现宿主机的 CPU 瞬间被打满。虽然processOrders看起来只是处理数据,但parallelStream默认使用 JVM 全局的ForkJoinPool.commonPool()。当外部 Web 容器线程池(如 Tomcat)和内部并行流同时竞争 CPU 资源时,会导致严重的上下文切换和计算资源耗尽。
逻辑图解:
二、 传统排查 vs Arthas 降维打击
在没有 Arthas 之前,老鸟们是这样排查的:
top找到高 CPU 的 PID。top -Hp <PID>找到高 CPU 的线程 TID。printf "%x\n" <TID>将 TID 转为 16 进制。jstack <PID> | grep <16进制TID> -A 20查看堆栈。
这套连招虽然经典,但在容器化环境、微服务架构下,简直慢得像蜗牛。现在,我们用 Arthas。
实战:Arthas 一键定位
假设我们已经 Attach 到了运行的 JVM 进程。
第一步:Dashboard 纵览全局
输入dashboard命令。
- 观察点:看
Thread面板,如果发现某些线程的 CPU 占用率持续在 90% 以上,且状态是RUNNABLE,那就是它了。 - GC 面板:如果 CPU 高但 GC 次数(Count)剧增,说明是 GC 问题,不是业务逻辑问题。
第二步:找出最忙的线程
直接输入:
thread -n 3这行命令会直接把 CPU 消耗最高的 3 个线程的堆栈打印出来。
示例代码 2:正则表达式回溯导致的 CPU 爆满(ReDoS)
很多时候 CPU 飙升是因为写了极差的正则。
package com.howell.cpu; import java.util.regex.Pattern; public class RegexReDosDemo { public static void main(String[] args) { // 一个典型的恶性正则:(x+)+y // 当输入大量 x 但最后没有 y 时,会发生灾难性的回溯 String badRegex = "(x+)+y"; String payload = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"; // 长度适中即可触发 Pattern pattern = Pattern.compile(badRegex); // 模拟请求处理 while (true) { pattern.matcher(payload).matches(); } } }运行结果说明:Arthas 的thread -n 1会直接定位到java.util.regex.Pattern$Loop.match方法。你会清晰地看到线程卡在正则表达式的匹配逻辑中。
排查逻辑图:
三、 终极武器:火焰图(Flame Graph)
有时候,thread命令看到的只是瞬时状态。如果 CPU 是因为大量短小的函数调用累积起来的(比如高频序列化、大量的 String 操作),光看堆栈很难发现热点。
这时候,必须上火焰图。
在 Arthas 中生成火焰图极其简单:
# 开始采样 profiler start # 等待 30 秒... 模拟真实业务运行 # 停止并生成 HTML profiler stop --format html生成的 HTML 就像一张火焰。
- Y 轴:表示调用栈的深度(栈越深,火焰越高)。
- X 轴:表示抽样数(宽度越宽,表示占用的 CPU 时间越长)。
- 颜色:通常无特殊含义,仅作区分,但在某些版本中红色越深代表越热。
核心技巧:找“平顶山”。如果火焰图中有一条很宽的平顶,说明该方法在采样期间一直处于执行状态,它就是性能瓶颈。
示例代码 3:频繁创建对象导致的 GC 飙升(G1 GC 场景)
package com.howell.cpu; import java.util.ArrayList; import java.util.List; /** * 场景:内存分配速率过快,导致 G1 GC 线程并发标记消耗大量 CPU */ public class AllocationSpike { // 1KB 的数据块 private static final int BLOCK_SIZE = 1024; public static void main(String[] args) { while (true) { allocate(); } } private static void allocate() { List<byte[]> list = new ArrayList<>(); // 疯狂分配短生命周期对象 for (int i = 0; i < 10000; i++) { list.add(new byte[BLOCK_SIZE]); } // 方法结束,list 变为垃圾,触发 Young GC } }运行结果说明:在这个场景下,thread -n 3可能会显示G1 Young RemSet Sampling或者G1 Conc#0等 GC 线程占用 CPU 最高。此时生成的火焰图,底座会非常宽,且主要集中在G1CollectedHeap相关的 C++ 调用上(如果开启了 native 采样),或者在 Java 层的内存分配入口。
四、 JVM 参数调优:从 G1 到 ZGC
当你发现代码逻辑没问题,但 CPU 依然因为 GC 居高不下时,就需要进行 JVM 调优了。
在 Java 8⁄11 时代,我们还在纠结 G1 的MaxGCPauseMillis。但在 Java 21 时代,ZGC (Generational ZGC)是架构师的首选。
为什么要切 ZGC?
G1 在堆内存较大(>8GB)或对象分配速率极高时,Mixed GC 会导致显著的 CPU 飙升和 STW。而 ZGC 利用读屏障(Load Barrier)和染色指针,将 STW 控制在微秒级,虽然吞吐量略有损耗,但 CPU 曲线会平滑很多。
示例代码 4:Java 21 虚拟线程调度开销
注意,Java 21 引入了虚拟线程(Virtual Threads)。虽然它轻量,但如果使用不当(如在 synchronized 块中执行阻塞操作),会导致 Carrier Threads(载体线程)被钉住(Pinned),进而导致 CPU 飙升。
package com.howell.cpu; import java.util.concurrent.Executors; public class VirtualThreadPinning { public static void main(String[] args) { // 使用虚拟线程池 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 100; i++) { executor.submit(() -> { // 这是一个陷阱:在 synchronized 块中 sleep // 会导致虚拟线程无法卸载,钉死 Carrier 线程 synchronized (VirtualThreadPinning.class) { try { // 模拟阻塞 I/O Thread.sleep(1000); longWork(); } catch (InterruptedException e) {} } }); } } } private static void longWork() { // 模拟计算 long start = System.currentTimeMillis(); while(System.currentTimeMillis() - start < 200) {} } }运行结果说明:在 Arthas 中,你会发现 ForkJoinPool 的 Worker 线程一直处于忙碌状态。调优建议:
- 将
synchronized替换为ReentrantLock(虚拟线程对 Lock 友好)。 - 启动参数增加
-Djdk.tracePinnedThreads=full来监控钉住的线程。
调优前后的 JVM 参数对比:
- 旧方案 (JDK 8⁄11 G1):
-Xmx8g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 - 新方案 (JDK 21 ZGC):
-Xmx8g -XX:+UseZGC -XX:+ZGenerational
架构演进图:
五、 架构师思维:邪道架构与防御性编程
作为架构师,我们不仅要会排查,还要懂设计。有时候,CPU 100% 并不是 Bug,而是 Feature。
1. 邪道架构:LMAX Disruptor 模式
在低延迟交易系统(HFT)中,为了极致的快,我们不希望线程 Sleep,因为系统调度的开销太大了(微秒级)。我们会故意让线程Busy Spin(忙等待)。
示例代码 5:故意写一个 CPU 100% 的 WaitStrategy
package com.howell.cpu; import java.util.concurrent.atomic.AtomicBoolean; /** * 架构师思维:用 CPU 换延迟 */ public class BusySpinWait { private static volatile boolean signal = false; public static void main(String[] args) { new Thread(() -> { // 消费者:不 Sleep,死循环检查,CPU 单核 100% // 优点:响应延迟最低(纳秒级) while (!signal) { Thread.onSpinWait(); // Java 9+ 提示 CPU 这是一个自旋循环 } System.out.println("Signal received!"); }).start(); try { Thread.sleep(2000); } catch (InterruptedException e) {} signal = true; } }运行结果说明:这会让一个 CPU 核心跑满。但在高频交易领域,这是最佳实践。关键在于你要知道你在做什么,并利用Thread Affinity(线程亲和性)将该线程绑定到特定 CPU 核,避免干扰其他业务。
2. 防御性编程:熔断与限流
如果 CPU 飙升是因为流量突增,单纯改代码没用。必须在架构层引入 Sentinel 或 Resilience4j。
示例代码 6:Resilience4j 防止 CPU 过载
package com.howell.cpu; import io.github.resilience4j.ratelimiter.RateLimiter; import io.github.resilience4j.ratelimiter.RateLimiterConfig; import io.github.resilience4j.ratelimiter.RateLimiterRegistry; import java.time.Duration; public class RateLimitDefense { public static void main(String[] args) { // 限制每秒只能处理 10 个请求 RateLimiterConfig config = RateLimiterConfig.custom() .limitRefreshPeriod(Duration.ofSeconds(1)) .limitForPeriod(10) .timeoutDuration(Duration.ofMillis(25)) .build(); RateLimiterRegistry registry = RateLimiterRegistry.of(config); RateLimiter limiter = registry.rateLimiter("cpuSaver"); for (int i = 0; i < 20; i++) { // 超过阈值的请求直接拒绝,保护 CPU 不被压垮 boolean permission = limiter.acquirePermission(); if (permission) { System.out.println("Processing request " + i); // 执行业务逻辑 } else { System.out.println("Dropped request " + i + " (CPU Protection)"); } } } }六、 避坑指南与总结
在排查 CPU 问题时,有几个常见的思维误区:
- 误区一:看到 CPU 高就加机器。
- 真相:如果是死循环或锁竞争,加机器只会让更多机器一起卡死。
- 误区二:盲目调整线程池大小。
- 真相:对于 CPU 密集型任务,线程数 = CPU 核数 + 1 是最佳实践。开 1000 个线程只会让 CPU 把时间都浪费在上下文切换上。
- 误区三:忽略序列化开销。
- 真相:很多时候,CPU 满载是因为用了性能差的 JSON 库(如某些场景下的 Jackson 配置不当或早期的 Fastjson)。
总结 Takeaway 📝
遇到 CPU 100%,请按以下步骤操作:
- 别慌,保留现场,不要立刻重启(除非服务已全挂)。
- Arthas 启动:
dashboard看概览,thread -n 3抓现行。 - 火焰图分析:
profiler start运行 30 秒,看谁是那个宽底座的“平顶山”。 - 审视代码:死循环?正则回溯?还是 Stream 并行流滥用?
- 架构升级:升级 Java 21 使用 ZGC,引入限流熔断保护脆弱的计算资源。
最后送大家一句话:优秀的架构师,不是写出最复杂的代码,而是能用最简单的工具,在最混乱的现场,找到那个唯一的真相。🚀