发现如下现象:集群中某POD的CPU 持续 90%+,响应变慢。迅速进行资源隔离,使该POD不在注册中心注册,流量将不进入此POD进行处理。随后,进行细致的排查。
1. 排查过程
1.1 定位高 CPU 进程
java
# 查看整体 CPU 使用情况
top
# 按 CPU 排序(默认即按 CPU 降序)
# 查看具体进程 PID、%CPU、COMMAND
# 查看线程级 CPU 使用(定位到具体线程)
top -H -p <PID>
# 或使用 ps
ps -eo pid,ppid,cmd,%cpu --sort=-%cpu | head
1.2 jstack 抓取线程栈
jstack 是 JDK 自带的一个命令行工具,用于打印指定 Java 进程的线程堆栈信息(即 thread dump),常用于分析 CPU 占用高、死锁、线程阻塞等问题。
java
# 12345即PID
jstack -l 12345 > jstack.log
-l:除了线程堆栈,还会打印关于锁的附加信息(如持有/等待的 monitor 和 synchronizers),建议始终加上。
输出重定向到文件便于后续分析。
注意:需要以运行该 Java 进程的相同用户身份执行(或 root),否则可能无权限。
抓取时机建议
CPU 高时:连续抓取 2~3 次,每次间隔 5~10 秒,对比哪些线程始终处于 RUNNABLE 状态,可能是热点代码。
可手动,也可用如下脚本:
java
for i in {1..3}; do
echo "=== Dump $i at $(date) ===" >> cpu_high_jstack.log
jstack -l 12345 >> cpu_high_jstack.log
sleep 10
done
自动化抓取脚本:
java
#!/bin/bash
PID=$1
DURATION=${2:-30}
INTERVAL=5
for i in $(seq 1 $((DURATION / INTERVAL))); do
echo "[$(date)] Capturing jstack ($i)" >> jstack_auto.log
jstack -l $PID >> jstack_auto.log
sleep $INTERVAL
done
1.3 jstack文件分析
一般需要通过线程PID的十进制,转化为16进制,以此在jstack文件中搜索
java
# 查看进程中哪个线程 CPU 最高
top -H -p 12345
# 假设发现线程 PID(实际是 LWP ID)为 12345(十进制)
# 转换为 16 进制(jstack 中 nid 是 16 进制)
printf "%x\n" 12345 # 输出 3039
# 在 jstack.log 中搜索 nid=0x303e
grep -A 20 "nid=0x303e" jstack.log
关键字段解读(线程状态)
| 状态 | 含义 | 常见场景 |
|---|---|---|
RUNNABLE |
正在运行或可运行(在 CPU 上或就绪队列) | CPU 密集型操作、死循环 |
BLOCKED |
阻塞中,等待获取 monitor 锁 | 多线程竞争 synchronized |
WAITING |
无限期等待其他线程执行特定操作 | Object.wait(), Thread.join(), LockSupport.park() |
TIMED_WAITING |
有超时的等待 | Thread.sleep(), Object.wait(1000) |
TERMINATED |
已终止 | 一般不会出现在 dump 中 |
1.4 典型问题示例
- CPU 占用高
查找多个 RUNNABLE 线程,且堆栈重复出现在相同方法(如 HashMap.get、正则处理、加密计算等)。可能因非线程安全的 HashMap 在并发下形成环形链表,导致死循环。
java
"http-nio-8080-exec-10" #10 prio=5 os_prio=0 tid=0x00007f8a4c00d000 nid=0x3039 runnable [0x00007f8a2d3d2000]
java.lang.Thread.State: RUNNABLE
at java.util.HashMap.get(HashMap.java:569)
at com.example.service.CacheService.getValue(CacheService.java:45)
- 死锁
jstack 会在 dump 末尾自动检测并打印:
典型的循环等待,需修改加锁顺序。
java
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x00007f8a2c00a123 (object 0x000000076b8a1234, a java.lang.Object),
which is held by "Thread-2"
"Thread-2":
waiting to lock monitor 0x00007f8a2c00b456 (object 0x000000076b8a5678, a java.lang.Object),
which is held by "Thread-1"
- 线程池耗尽
大量线程处于 WAITING(如 Tomcat 的 http-nio-xxx 线程全在 park),但请求堆积。
可能原因:下游依赖慢(DB、RPC)、线程池配置过小。
2. 原因分析
在执行top -H -p 12345命令之后,出现如下结果,显示c2 CompilerThread和lettuce-eventEx共同占用CPU超过90%。
java
top - 10:15:32 up 20 days, 3:42, 2 users, load average: 8.75, 7.20, 6.80
Threads: 128 total, 4 running, 124 sleeping, 0 stopped, 0 zombie
%Cpu(s): 92.3 us, 2.1 sy, 0.0 ni, 5.1 id, 0.2 wa, 0.0 hi, 0.3 si, 0.0 st
MiB Mem : 16384.0 total, 2100.5 free, 9876.3 used, 4407.2 buff/cache
MiB Swap: 2048.0 total, 1800.0 free, 248.0 used. 5600.1 avail Mem
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
22345 app 20 0 8567808 3.2g 25664 R 48.2 20.1 5:23.41 C2 CompilerThre
12346 app 20 0 8567808 3.2g 25664 R 45.7 20.1 4:56.12 lettuce-eventEx
12347 app 20 0 8567808 3.2g 25664 S 0.3 20.1 0:12.05 GC Thread#0
12348 app 20 0 8567808 3.2g 25664 S 0.0 20.1 0:05.33 VM Thread
12349 app 20 0 8567808 3.2g 25664 S 0.0 20.1 1:02.77 http-nio-8080-e
2.1 jstack文件分析
在jstack文件中发现出现PID的22345对应的十六进制ox5749处出现BLOCKED,是否证明发生阻塞一直持续CPU占用90%?
这个是难以确定的,jstack 中出现 BLOCKED 状态的线程 通常不会导致 CPU 占用高达 90%,反而更常见于 CPU 使用率较低但系统响应慢 的场景。
BLOCKED 线程本身几乎不消耗 CPU ,因为它们处于"等待锁 "的挂起状态,不会在 CPU 上执行指令。
所以,如果系统 CPU 持续 90%+,而你只看到大量 BLOCKED 线程,那高 CPU 的"真凶"大概率是其他 RUNNABLE 线程。
- BLOCKED 是 Java 线程在 等待 synchronized 锁 时的状态。
- 此时线程被操作系统挂起(进入阻塞队列),不参与
- CPU 调度。 它既不执行代码,也不轮询(不像自旋锁),因此 CPU 占用接近 0%。
高 CPU(90%+)的原因:一般由一个或多个 RUNNABLE 线程引起(如死循环、正则回溯、频繁 GC、加密计算等)。
2.2 C2 CompilerThread
C2 CompilerThread 和 C1 CompilerThread 是 HotSpot JVM 中 JIT(Just-In-Time)编译器的两个核心组件,它们共同负责将 Java 字节码动态编译为高效的本地机器码,以提升运行时性能。二者在优化级别、适用场景和资源消耗上有显著区别。
Java 程序启动时,JVM 默认以解释模式执行字节码(慢)。
当某段代码被频繁执行(称为"热点代码"),JVM 会触发 JIT 编译,将其编译为平台相关的本地机器码(快)。
在 JDK 8 及以后,默认启用 分层编译(Tiered Compilation),即:
解释执行 → C1 编译 → C2 编译,逐级优化。
关键区别:
| 对比项 | C1 Compiler | C2 Compiler |
|---|---|---|
| 别名 | Client Compiler | Server Compiler |
| 优化目标 | 快速启动 + 适度性能 | 最大化吞吐量 |
| 编译速度 | 快 | 慢 |
| 代码质量 | 中等 | 极高 |
| 资源消耗 | 低 CPU / 内存 | 高 CPU / 内存 |
| 适用应用 | 桌面应用、短生命周期程序 | 服务器、长期运行服务 |
| 是否默认启用 | 分层编译下会使用 | 分层编译下会使用 |
| JDK 默认行为 | JDK 8+ 默认开启 分层编译(C1 → C2) |
1. 影响原因1:内存
通常不会直接导致。
若因内存不足引发 频繁 GC → 应用行为异常 → 热点代码突变 → 触发更多 JIT 编译,则可能间接加剧 C2 负载。
编译一个方法可能消耗几 MB 内存,但现代服务器通常有足够 native 内存。
即使堆只有 512MB,只要 CodeCache 和 native 内存充足,C2 仍可正常工作。
先看 GC 日志!如果 GC 正常,则问题在代码/JIT 本身,而非堆大小。
java
jstat -gc 12345
执行结果:
java
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
512.0 512.0 0.0 512.0 2048.0 1980.2 204800.0 85632.4 120832.0 115200.5 14592.0 13824.3 42 2.120 0 0.000 2.120
512.0 512.0 512.0 0.0 2048.0 2010.5 204800.0 86100.1 120832.0 115210.2 14592.0 13825.0 43 2.170 0 0.000 2.170
512.0 512.0 0.0 512.0 2048.0 1995.8 204800.0 86520.7 120832.0 115215.6 14592.0 13825.8 44 2.220 0 0.000 2.220
字段含义详解
| 列名 | 含义 | 说明 |
|---|---|---|
S0C / S1C |
Survivor 0/1 容量 | 当前 Survivor 区大小(KB) |
S0U / S1U |
Survivor 0/1 已使用 | 总有一个为 0(G1 中可能不严格交替) |
EC / EU |
Eden 区容量 / 已使用 | 高频对象分配区,快满时触发 Young GC |
OC / OU |
Old 区容量 / 已使用 | 长期存活对象区域,接近 OC 可能触发 Mixed GC 或 Full GC |
MC / MU |
Metaspace 容量 / 已使用 | 存放类元数据(非堆) |
CCSC / CCSU |
Compressed Class Space 容量 / 使用 | 用于压缩类指针(64 位 JVM) |
YGC |
Young GC 次数 | 自启动以来 Young GC 总次数 |
YGCT |
Young GC 总耗时(秒) | 如 2.120 表示共花了 2.12 秒 |
FGC |
Full GC 次数 | 关键指标!应尽量为 0 |
FGCT |
Full GC 总耗时 | 若 >0 且增长,需警惕 |
GCT |
所有 GC 总耗时 | YGCT + FGCT |
2. 影响原因2:频繁编译
执行此命令,查看哪个线程频繁编译
java
jstat -printcompilation 12345 1000
12345:Java 进程 PID(这里仅为示例)
1000:每隔 1000 毫秒(1 秒)输出一次
-printcompilation:显示 最近被 JIT 编译的方法
java
Compiled Size Type Method
12345 89 4 java/util/HashMap get
12346 156 4 com/example/service/OrderService processOrder
12347 210 4 io/lettuce/core/protocol/CommandHandler channelRead
12348 64 3 java/lang/StringBuilder toString
12349 302 4 com/example/cache/RedisCache getValue
字段含义详解
| 列名 | 含义 | 说明 |
|---|---|---|
| Compiled | 编译任务 ID | 自增编号,唯一标识一次编译 |
| Size | 字节码大小(字节) | 被编译方法的字节码长度 |
| Type | 编译类型 | * 3 = C1 编译(Client) * 4 = C2 编译(Server) |
| Method | 方法全限定名 | 格式:包/类 方法名 |
2.3 lettuce-eventExecutor
Lettuce 是基于 Netty NIO 的 Redis 客户端。
lettuce-eventExecutor 实际是 Netty 的 EventLoop 线程,负责:
处理 Redis 连接的网络 I/O(读/写)
解码 Redis 响应
触发命令回调(CompletableFuture)
心跳、重连、超时处理
关键特性:
默认单线程处理所有连接事件(即使你有多个 Redis 实例,也可能共享同一个 EventLoop)
必须快速完成任务,否则会阻塞整个事件循环 → 导致 CPU 100% + 命令堆积
解决思路:
- 检查 Redis 服务端健康度
- 排查大 Key / 高频请求
- 确保不在回调中阻塞
- 升级 Lettuce/Netty
如果使用 Lettuce / Jedis 操作 Redis 的 Bitmap(位图),会调用:
java
redis.setbit("user:login", 1000, true);
发现如下堆栈:
java
"http-nio-8080-exec-5" #25 prio=5 os_prio=0 tid=... nid=... runnable
java.lang.Thread.State: RUNNABLE
at com.example.service.UserActivityService.recordLogin(UserActivityService.java:45)
at io.lettuce.core.RedisAsyncCommandsImpl.setbit(RedisAsyncCommandsImpl.java:XXX)
...
为什么 setBit 相关线程会是 RUNNABLE 且高 CPU?
正常情况(短暂)
单次 SETBIT 命令很快(Redis 本身 O(1)),线程短暂 RUNNABLE 属正常。
异常情况(持续高 CPU)
以下场景会导致 setBit 调用成为热点,线程长期 RUNNABLE:
场景 1:高频批量 SETBIT 操作
例如:每秒处理 10 万用户登录,每个用户执行一次 SETBIT
结果:
业务线程池(如 Tomcat http-nio-*)持续忙碌
Lettuce 的 eventExecutor 也因大量命令而高 CPU(见你之前的问题)
场景 2:在循环中调用 setBit(无批处理)
java
// 反模式:逐个设置位
for (int day = 0; day < 365; day++) {
redis.setbit("user:" + userId, day, isActive[day]);
}
→ 365 次网络往返 + 365 次命令解析 → CPU 和网络双高
场景 3:操作超大 offset(如 offset = 10亿)
Redis 的 Bitmap 是按需分配内存的,但 offset 极大时:
Redis 需要分配巨大内存(10^9 / 8 / 1024 / 1024 ≈ 120MB)
客户端序列化/反序列化耗时增加
可能触发 Full GC 或网络阻塞
场景 4:同步调用 + 高并发
使用 redis.setbit(...).get()(阻塞等待)在高并发下:
大量线程阻塞在 CompletableFuture.get()
但发起调用的线程仍是 RUNNABLE(直到被 park)
2.3 汇总分析
以上两者,通常不是两个独立问题,而是相互关联的性能风暴。根本原因往往是:Lettuce 高频/异常操作触发大量新代码路径 → JVM 将其识别为热点 → C2 编译器疯狂优化 → 双线程 CPU 飙升。
| 线程 | 角色 | CPU 高的原因 |
|---|---|---|
lettuce-eventExecutor |
Netty 事件循环线程,处理 Redis 网络 I/O、命令编解码、回调 | - 高频 Redis 请求 - 大 Value 传输 - Redis 异常(重连/超时) - 回调中执行耗时操作 |
C2 CompilerThread |
HotSpot 的 Server JIT 编译器,对热点代码做深度优化 | - 新热点方法激增(如 Lettuce 内部类、Codec、回调逻辑) - Deoptimization(逆优化)频繁 → 反复编译 |
二、典型场景分析
场景 1:突发流量 + Redis 批量操作
应用启动或促销活动导致:
每秒数万次 SETBIT / GET / MGET
Lettuce 事件线程持续处理网络事件(RUNNABLE)
这些操作涉及:
Lettuce 内部 CommandHandler, RedisStateMachine, 自定义 RedisCodec
这些方法首次成为热点 → C2 开始编译
结果:Lettuce 线程 + C2 线程同时吃满 CPU
场景 2:操作大 Key 或复杂序列化
例如:GET 一个 10MB 的 JSON 字符串
Lettuce 需要:
从 Socket 读取大量字节
调用你的 StringCodec.decode()(可能含 JSON 解析)
如果 decode() 是新路径 → C2 编译它
结果:Lettuce 线程忙于 I/O + C2 忙于编译 codec
场景 3:Redis 服务端异常 → 事件风暴
Redis 宕机/网络抖动 → Lettuce:
不断触发 exceptionCaught
执行重连逻辑
调用大量超时回调(CompletableFuture.completeExceptionally)
这些异常路径在正常运行时从未执行 → 成为"新热点"
C2 开始编译这些冷路径 → CPU 双高
场景 4:动态生成 Lambda / 匿名类(在回调中)
java
redis.get("key").thenAccept(value -> {
// 每次创建新的 Lambda 类!
process(value);
});
高频调用 → JVM 加载大量 LambdaForm$MH 类
C2 需要编译每个新类的方法
Lettuce 线程忙于触发回调
结果:Metaspace 增长 + C2 + Lettuce 双高 CPU
四、解决方案
- 优化 Redis 使用方式
批量操作:用 Pipeline 合并多个 SETBIT
java
connection.setAutoFlushCommands(false);
for (int offset : offsets) {
commands.setbit(key, offset, 1);
}
connection.flushCommands();
避免大 Key:Bitmap 按天/月分 key(如 user:login:202504)
设置合理超时:防止重试风暴
- 避免在回调中创建动态类
缓存 Lambda 或改用静态方法引用:
java
// 不好
redis.get(k).thenAccept(v -> process(v));
// 好
redis.get(k).thenAccept(MyService::process);
- 简化 Codec 逻辑
自定义 RedisCodec 避免 JSON 解析等重操作
或将解码移出 EventLoop:
java
redis.get(key).thenApplyAsync(this::decodeJson, businessPool);
- JVM 调优(临时缓解)
bash
# 限制 C2 线程数(默认根据核数计算)
-XX:CICompilerCount=2
# 延迟 C2 编译(适合短期任务)
-XX:TieredStopAtLevel=1
- 升级依赖
Lettuce ≥ 6.2.0
Netty ≥ 4.1.80.Final
JDK ≥ 17(JIT 更稳定)
3. 总结
分析过程如上,应迅速止血,隔离问题POD,保证应用可用性。