监控指标自己把进程打到 CPU 100%:一次 safepoint 风暴的深度复盘
凌晨被告警叫醒,发现某台 consumer 服务器 load5 飙到 8.5。打开
top -H找凶手,CPU 大户竟然是给我们做监控的 micrometer 自己。这次复盘的不是业务 bug、不是慢 SQL、不是慢下游——是 “监控指标” 把自己挂载的进程打到了 60%+ 一个核。让 VM Thread 持续吃 25% CPU 的元凶,是 6 个看着 “人畜无害” 的 Gauge。
本文从告警到根因走完整链路,顺手把国内技术博客很少深入讲的 safepoint 风暴 这个话题讲透。
一、问题现象
1.1 告警
凌晨 10:48,云监控发来一条不起眼的报警:
报警规则: CPU.load
监控指标: (Agent)load.5m
报警条件: 连续满足 5 次,平均值 >= 6
当前值: 8.51 个
持续时间: 5 分钟
实例: consumer-host-A
load5 = 8.5,机器 4 核 —— load 已经超过核数 2 倍,CPU 排队等服务的进程多到溢出。
1.2 现场(top)
SSH 上去,第一个命令永远是 top:
$ top -b -n 1 | head -10
top - 10:37:12 up 649 days, 22:43, load average: 2.87, 3.20, 3.85
Tasks: 264 total, 1 running, 263 sleeping
%Cpu(s): 32.8 us, 3.3 sy, 0.0 ni, 63.9 id, 0.0 wa
MiB Mem : 7727.3 total, 137.8 free, 5925.5 used, 1663.9 buff/cache
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
830757 app 20 0 11.1g 4.3g 11688 S 143.8 57.0 1256:33 java
一个 Java 进程 CPU 143.8%(>100% 表示多核累加),占了 1.4 个核 —— 看上去不算夸张,但 load 已经飙到 8。问题不在单核满载,而在有大量任务在排队等 CPU。
1.3 业务也在恶化
打开 Prometheus 看 XXL-Job 任务的 TP90 对比一周前:
(
avg by (exported_job) (avg_over_time(xxx_xxl_duration_seconds{quantile="0.9"}[1d]))
/
avg by (exported_job) (avg_over_time(xxx_xxl_duration_seconds{quantile="0.9"}[1d] offset 7d))
- 1
) > 0.1
| 任务 | TP90 涨幅 | TP90 绝对值 | 当日执行次数 |
|---|---|---|---|
| taskA | +164% | 0.78 s | 8,625 |
| taskB | +106% | 0.39 s | 29,449 |
| taskC | +70% | 0.97 s | 34,193 |
| taskD | +56% | 0.26 s | 719 |
Grafana 大盘上能直观看到从 5 月 15 日起耗时整体抬升:

几个任务一周内 TP90 翻倍。CPU 高、load 高、业务慢——根因到底是哪一件,还是另有元凶?
二、排查现场
2.1 top -H 看进程内部线程
$ top -H -b -n 1 -p 830757
%Cpu(s): 39.5 us, 9.2 sy
PID USER ... %CPU COMMAND
1129021 app 37.0 java
1127842 app 22.1 java
1129114 app 20.3 java
...
Threads: 3904 total, 6 running
3904 个线程 —— 一个普通 Tomcat 应用线程数也就几百,这个数字明显异常。
记下几个最高 CPU 的 TID(1129021、1127842、1129114),转 hex,准备 jstack:
$ printf '%x\n' 1129021
113a3d
2.2 jstack 抓栈 —— 但抓不准
$ jstack 830757 | grep 'nid=0x113a3d' -A 10
"http-nio-7002-exec-9" #1311 daemon prio=5 nid=0x113a3d waiting on condition
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
...
at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
WAITING?刚才 top 里它明明 37% CPU 啊?
第二次、第三次 jstack 同样:还是 WAITING。
真相是:高 CPU 线程瞬时切换很快,jstack 那一刻它可能正好回
take()阻塞,等下一个任务。要抓住"现行犯",必须短时间内连续多轮采样 + 比对。
2.3 hot-stack.sh:多轮采样的"现行犯"抓捕术
写了个小脚本,并行跑 top -H 和 jstack 多轮,找出多次出现在 top 高 CPU 列表的线程,然后从对应轮的 jstack 里挑一个 RUNNABLE/BLOCKED 的栈:
#!/bin/bash
# hot-stack.sh —— 抓 Java 进程当前 CPU 热点线程的栈
# 用法:./hot-stack.sh <PID> [采样轮数=5] [间隔秒=0.3]
set -u
PID="${1:-}"
ROUNDS="${2:-5}"
SLEEP="${3:-0.3}"
[ -z "$PID" ] && { echo "用法: $0 <PID> [轮数=5] [间隔=0.3s]"; exit 1; }
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
echo "采样 $ROUNDS 轮 × ${SLEEP}s..."
for i in $(seq 1 "$ROUNDS"); do
top -H -b -n 1 -p "$PID" > "$TMP/top.$i" 2>/dev/null &
jstack "$PID" > "$TMP/stack.$i" 2>/dev/null &
wait
sleep "$SLEEP"
done
# 每轮 top 提取 (TID, %CPU)
for i in $(seq 1 "$ROUNDS"); do
awk '/^ *[0-9]+ / && $9+0 > 1.0 {print $1, $9}' "$TMP/top.$i" > "$TMP/cpu.$i"
done
# 累计 TID 出现次数
for i in $(seq 1 "$ROUNDS"); do
awk '{print $1}' "$TMP/cpu.$i" | head -10
done | sort | uniq -c | sort -rn > "$TMP/hot.txt"
[ ! -s "$TMP/hot.txt" ] && { echo "没抓到 CPU > 1% 的线程"; exit 0; }
echo ""
echo "热点 TID($ROUNDS 轮里出现次数排序):"
echo "============================================================"
printf "%-6s %-10s %-10s\n" "命中" "TID" "nid(hex)"
head -10 "$TMP/hot.txt" | while read -r count tid; do
printf "%-6s %-10s 0x%s\n" "$count" "$tid" "$(printf '%x' "$tid")"
done
TOP5=$(awk '{print $2}' "$TMP/hot.txt" | head -5)
extract_stack() {
awk -v nid="nid=0x$1 " '
$0 ~ nid {p=1; print; next}
p && /^$/ {p=0}
p {print}
' "$2"
}
get_cpu_history() {
local tid="$1" hist="" cpu
for i in $(seq 1 "$ROUNDS"); do
cpu=$(awk -v t="$tid" '$1 == t {print $2; exit}' "$TMP/cpu.$i")
[ -z "$cpu" ] && cpu="-" || cpu="${cpu}%"
hist="${hist} ${cpu}"
done
echo "$hist"
}
for TID in $TOP5; do
HEX=$(printf '%x' "$TID")
CPU_HIST=$(get_cpu_history "$TID")
BLOCK=""; FOUND=""
for i in $(seq 1 "$ROUNDS"); do
CUR=$(extract_stack "$HEX" "$TMP/stack.$i")
if [ -n "$CUR" ] && echo "$CUR" | grep -q 'RUNNABLE\|BLOCKED'; then
BLOCK="$CUR"; FOUND="$i"; break
fi
done
if [ -n "$BLOCK" ]; then
echo ""
echo "============================================================"
echo " TID=$TID nid=0x$HEX"
echo " 各轮 CPU:${CPU_HIST}"
echo "============================================================"
echo "$BLOCK"
echo ""
echo "[来自第 $FOUND 轮 dump,状态: 活跃]"
else
LAST=$(extract_stack "$HEX" "$TMP/stack.$ROUNDS")
if [ -n "$LAST" ]; then
NAME=$(echo "$LAST" | head -1 | sed -E 's/^"([^"]+)".*/\1/')
echo "[跳过-非活跃] TID=$TID nid=0x$HEX \"$NAME\" 各轮 CPU:${CPU_HIST}"
fi
fi
done
跑起来:
$ ./hot-stack.sh 830757
采样 5 轮 × 0.3s...
热点 TID(5 轮里出现次数排序):
============================================================
命中 TID nid(hex)
5 1127834 0x11359a
3 1129030 0x113a46
2 1130020 0x113e24
...
============================================================
TID=1129030 nid=0x113a46
各轮 CPU: - - 58.3% 69.2% 69.6%
============================================================
"http-nio-7002-exec-9" #1311 daemon nid=0x113a46 runnable
java.lang.Thread.State: RUNNABLE
at sun.management.ThreadImpl.getThreadInfo1(Native Method)
at sun.management.ThreadImpl.getThreadInfo(ThreadImpl.java:178)
at io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics.getThreadStateCount(JvmThreadMetrics.java:84)
at io.micrometer.core.instrument.binder.jvm.JvmThreadMetrics.lambda$bindTo$0(JvmThreadMetrics.java:74)
at io.micrometer.core.instrument.internal.DefaultGauge.value(DefaultGauge.java:40)
at io.micrometer.prometheus.PrometheusMeterRegistry.lambda$newGauge$3(PrometheusMeterRegistry.java:235)
at io.micrometer.prometheus.MicrometerCollector.collect(MicrometerCollector.java:69)
at io.prometheus.client.exporter.common.TextFormat.write004(TextFormat.java:22)
at org.springframework.boot.actuate.metrics.export.prometheus.PrometheusScrapeEndpoint.scrape(...)
第一只现行犯:
ThreadMXBean.getThreadInfo1 (Native)
← micrometer JvmThreadMetrics.getThreadStateCount
← PrometheusMeterRegistry.newGauge
← TextFormat.write004
← PrometheusScrapeEndpoint.scrape (= /actuator/prometheus)
这条 http-nio 线程正在处理 Prometheus 拉 metrics 的请求,结果它自己跑到 60% CPU。
2.4 另一只大户:VM Thread
[跳过-非活跃] TID=1127834 nid=0x11359a "VM Thread"
各轮 CPU: 20.0% 29.2% 20.8% 23.1% 21.7%
VM Thread 是 JVM 的内部协调线程,5 轮采样每轮都吃 20-29% CPU。
VM Thread 持续高 CPU 通常只有三个原因:
- GC
- safepoint 同步
- JIT 编译
2.5 洗清 GC 嫌疑
jstat -gcutil 看 GC:
$ jstat -gcutil 830757 1000 10
S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
0.00 100.00 26.39 60.79 91.22 86.80 23016 405.429 0 0.000 405.429
0.00 100.00 34.72 60.79 91.22 86.80 23016 405.429 0 0.000 405.429
... 10 秒里 YGC 仅涨 2 次
- YGC 每 5 秒一次,单次 17ms(约 0.3% CPU)—— 解释不了 VM Thread 25%
- Old 区稳定 60.6~60.8% → 无内存泄漏
- FGC = 0
GC 排除。剩下的只能是 safepoint 同步了。
2.6 算一下 Prometheus 抓取频率
$ grep '/actuator/prometheus' logs/access.log | awk '{print $4}' | uniq -c
1 10:52:46
1 10:52:47 ← 注意紧挨着
1 10:52:56
1 10:53:02
1 10:53:06
1 10:53:16
1 10:53:17 ← 又紧挨着
1 10:53:26
...
30 秒内出现 6 次访问。间隔模式 6, 4, 10, 1, 9 重复 —— 平均 5 秒一次,比通常的 15 秒一次高 3 倍。
看抓取方 IP:
$ grep '/actuator/prometheus' logs/access.log | awk '{print $1}' | sort | uniq -c
4567 10.0.1.10
3245 10.0.1.20
两个 Prometheus 实例都在抓(一个 15s 间隔、一个 10s 间隔,叠加起来约 6s 一次)。
三、分析原因:什么是 safepoint 风暴
到这一步,直接元凶已经清楚:
Prometheus 抓
/actuator/prometheus→ 触发 micrometer 的jvm.threads.statesgauge → 内部调ThreadMXBean.getThreadInfo()→ 触发 JVM safepoint → 3900 线程被强制对齐 → VM Thread 持续协调。
但要理解"为什么这事这么贵",得先把 safepoint 讲明白。
3.1 什么是 safepoint:物业断电的比喻
safepoint = JVM 内的"集合检查点" —— 所有 Java 应用线程必须暂停在某个安全位置,让 JVM 自己做一些"全局视角的事"。
形象比喻:一栋大楼里有 3900 个员工在各自工位上干活,物业偶尔需要"全楼断电检修"。
为了不让正在用电梯的人摔下来,物业不能直接拉闸 —— 必须先广播"5 秒后停电",等所有人走到楼梯口(= safepoint)就位,才能停电检修,完事后再广播"恢复"。
把"物业" = VM Thread、“员工” = Java 业务线程、“停电检修” = GC / jstack / 拍线程快照……、“楼梯口” = safepoint。
JIT 编译的代码里到处都插入了 "safepoint 检查"指令 —— 在循环回边(每跑一圈检查一次)、方法返回、安全点 poll 页等位置。
3.2 谁会触发 safepoint
| 谁触发 | 干啥 |
|---|---|
| GC | 标记存活对象、移动对象、修引用 |
jstack / jmap / jcmd |
拍线程栈、拍堆快照 |
ThreadMXBean.getThreadInfo() ⬅️ 本次主角 |
拍所有线程的状态/栈/锁 |
| Deoptimization | JIT 发现编译错了,回退到解释器 |
偏向锁撤销、Object.wait/notify(老 JDK) |
内部状态机切换 |
| 类重定义(Java agent / hotswap) | 改字节码 |
3.3 safepoint 协调全过程
T0: VM Thread: "我要做 GC,请所有线程到 safepoint"
↓ 设置全局 safepoint flag
T1: 线程A: 下次循环回边时看到 flag → 立刻停
线程B: 正在跑 Math.sqrt → 继续跑几条指令到下一个 check
线程C: 正在 epollWait(native)→ 内核态算 safepoint 已就位
...3900 个线程各自到达检查点
T2: VM Thread 轮询:所有线程都到了吗?→ 等齐
T3: VM Thread: 开始干活(这段叫 STW,Stop-The-World,全线程暂停)
T4: VM Thread: 释放 safepoint flag
T5: 所有线程恢复
总 STW = (T2 - T1) 等齐时间 + (T3 - T2) 干活时间。
3.4 为什么线程数多让 safepoint 变贵
回到大楼比喻:
| 维度 | 100 人 | 3900 人 |
|---|---|---|
| 广播"集合" | 0 | 0 |
| 每人到楼梯口 | 几秒 | 几秒 |
| 等齐 = max(每人时间) | 几秒 | 可能几秒,但更可能几十秒(尾延迟) |
| 物业实际干活 | 1 分钟 | 1 分钟(不变) |
线程多 → 尾延迟(tail latency)拖累 max → 等齐时间被拉长。
ThreadMXBean.getThreadInfo() 还多一笔账:它不只要 safepoint,还要遍历所有 3900 个线程的内部数据结构拷贝,本身就是 O(N)。
3.5 micrometer 的 JvmThreadMetrics:6 倍放大器
打开 JvmThreadMetrics 源码(简化版):
public class JvmThreadMetrics implements MeterBinder {
@Override
public void bindTo(MeterRegistry registry) {
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
// 这四个都是 O(1) 的:
Gauge.builder("jvm.threads.live", bean, ThreadMXBean::getThreadCount)
.register(registry);
Gauge.builder("jvm.threads.peak", bean, ThreadMXBean::getPeakThreadCount)
.register(registry);
Gauge.builder("jvm.threads.daemon", bean, ThreadMXBean::getDaemonThreadCount)
.register(registry);
Gauge.builder("jvm.threads.started", bean, ThreadMXBean::getTotalStartedThreadCount)
.register(registry);
// ⚠️ 这一段是 O(N) × 6(Thread.State 有 6 个枚举值):
for (Thread.State state : Thread.State.values()) {
Gauge.builder("jvm.threads.states", bean,
b -> Arrays.stream(b.getThreadInfo(b.getAllThreadIds()))
.filter(info -> info.getThreadState() == state)
.count())
.tags("state", state.name().toLowerCase())
.register(registry);
}
}
}
注意最后那个 for 循环:为每个 Thread.State 注册一个 gauge。Thread.State 有 6 个枚举值(NEW / RUNNABLE / BLOCKED / WAITING / TIMED_WAITING / TERMINATED),所以这里一口气注册了 6 个 gauge。
每个 gauge 在 scrape 时都要跑一次 getThreadInfo(getAllThreadIds()) —— 这是个 O(N) 操作,而且每次都重新拍快照。
也就是说,每次 Prometheus 抓一次,要触发 6 次 safepoint + 6 次全线程快照。
3.6 算账:风暴是怎么形成的
触发 safepoint 的源(多个并发):
· YGC 每 5 秒一次(必要的)
· micrometer scrape 每 6 秒一次 × 6 个 gauge = 1 秒一次
· 偶发的 jstack / jcmd 忽略
每次 safepoint:
· 等齐 3900 线程:几 ms ~ 几十 ms 尾延迟
· 拍快照:O(N) = O(3900)
VM Thread 持续被"喊集合 → 等齐 → 干活 → 释放"占满
→ VM Thread 持续 25% CPU
→ 业务线程频繁被 STW 切碎,延迟抖动
→ load 飙高
可视化:
正常情况(线程数 < 200):
safepoint: | | | | |
VM Thread: 短 短 短 短 短 ── CPU 占用低
本次(3900 线程 + 高频触发):
safepoint: |||| |||| |||| |||| ||||
VM Thread: 长长长 长长长 长长长 长长长 ── CPU 持续 25%
其他线程: ┊STW┊┊STW┊┊STW┊┊STW┊┊STW┊
(被频繁打断,业务延迟抖动)
"风暴"不是一次性爆炸,是持续不断的小爆炸 —— 把 VM Thread 烧热、把业务线程频繁打断。
3.7 完整因果链
1. 业务设计 事件总线让每个事件 Bean 单独建一个 MQ Consumer → 3900 线程
2. 监控设计 jvm.threads.states 是 O(N) × 6 个 gauge
3. 抓取频率 两个 Prometheus 实例叠加 = 6s 一次
4. JVM 行为 每次抓取触发 safepoint,等齐 3900 线程
5. 受害方 VM Thread 25% / 业务线程 STW / Redisson channel PING 超时
哦对,Redisson PING 超时 —— 副产品。Netty EventLoop 在 epoll 时被频繁 STW,导致 pingConnectionInterval 周期内 PING 字节没发出,触发 RedisTimeoutException: Unable to send PING command。这次连 Redis 都"莫名其妙"地超时了。
四、解决方案
4.1 立即止血:减少抓取方
两个 Prometheus 实例其中一个是历史遗留,干掉:
# 删除其中一个 prometheus.yml 的 scrape config
# scrape_configs:
# - job_name: 'consumer'
# static_configs:
# - targets: ['consumer-host-A:7002']
预期效果:抓取频率从 6s 一次降到 10s 一次,safepoint 风暴次数立刻减半。
但这只是"少抓",单次抓取的 60%+ CPU 风暴还在。
4.2 治本:关 jvm.threads.states 指标
Spring Boot + micrometer 提供了精细化的开关:
# application.yml
management:
metrics:
enable:
# 关闭按状态分桶的线程指标(对应 Prometheus 指标 jvm_threads_states_threads)。
# 其内部 ThreadMXBean.getThreadInfo(allIds, 0) 是 O(N) 且触发 safepoint,
# 在大线程数场景下每次 Prometheus scrape 都会让 http-nio 跑到 60%+ CPU、
# 并间接推高 VM Thread。
# 仍保留便宜的 jvm.threads.live / peak / daemon / started 指标(都是 O(1))。
jvm.threads.states: false
关键设计:精确关闭"贵的"那一个,保留便宜的几个 jvm_threads_* 指标。这样你仍然能在 Grafana 上看到线程数总量、峰值、daemon 占比 —— 只是失去了"按 state 分桶"的能力。
⚠️ 不要一刀切关
management.metrics.binders.jvm: false。那会把jvm.memory、jvm.gc等所有 JVM 指标都干掉,监控就瞎了。
4.3 长期:降低线程基数
事件总线让每个事件 Bean 单独建一个 MQ Consumer 的设计是根因之一,不解决迟早还会被别的 O(N) 指标拖死。
中长期方向:
- 评估事件总线是否能让多个事件 Bean 共享同一个 MQ Consumer
- 提升单 Consumer 的并发度配置,降低 Container 数量
- 把
consumeThreadMin/Max从默认 20 调小(如 5),合计就能从 ~1500 降到 ~400
但这是另一个故事了,本文不展开。
五、解决与验证
5.1 上线改造清单
| # | 改造 | 效果 |
|---|---|---|
| 1 | 停掉一个 Prometheus 抓取 | 抓取频率 6s → 10s |
| 2 | 配置 jvm.threads.states: false |
scrape 单次 CPU 风暴归零 |
| 3 | 修复业务侧某个不停止的任务(顺手修) | 减少无效压力 |
5.2 验证命令
部署后这套三件套验证:
# 1. 确认 Prometheus 端点上指标已消失
curl http://127.0.0.1:7002/actuator/prometheus | grep -c '^jvm_threads_states'
# 改前应该是 6 行,改后是 0
# 2. 跑 hot-stack 看 http-nio 是否还吃 60%+
./hot-stack.sh <PID>
# 3. 看 VM Thread 是否同步下来
jstat -gcutil <PID> 1000 10
5.3 实际效果
| 指标 | 改前 | 改后 |
|---|---|---|
| VM Thread CPU | 持续 20~29% | < 5% |
| http-nio scrape CPU 峰值 | 单次 60~70% | 平时 < 5% |
| load5 | 6~8(峰值 8.51) | 1.5~3 |
| 业务 RedisTimeout 频次 | ~80 条/天 | < 5 条/天 |
| TP90 业务任务(taskA) | 0.78 s | 0.31 s |
CPU / 内存 / 负载三连降——这张是云监控的对比图,改造发生在中午,曲线断崖式下来:

更细粒度看 Grafana 的核心指标大盘,VM Thread 和 http-nio 都恢复正常:

业务侧 XXL-Job 任务耗时同步下降:

业务 TP90 也跟着回落 —— 因为业务线程不再被 safepoint 频繁切片。
六、举一反三
6.1 hot-stack.sh:现行犯抓捕术
文章第二节给了完整脚本。核心思想:
- 单次 jstack 抓不准(高 CPU 线程瞬时切换很快)
- 多轮采样 + 命中频次排序,反复出现的才是真凶
- 同一线程在多轮 jstack 里,优先选 RUNNABLE / BLOCKED 状态的那一帧栈
- 抓不到活跃栈的(VM Thread 等),输出"非活跃"标记,不打整段栈污染输出
6.2 一句话识别 safepoint 风暴
CPU usr 高 + load 飙、VM Thread 持续吃 CPU、GC 频率正常、业务接口延迟抖动 —— 先怀疑 safepoint 风暴。
特别在以下场景:
- 线程数 > 1000
- 监控接了 micrometer + Prometheus
- 业务无明显瓶颈
6.3 jvm.threads.states 不是唯一的"O(N) 监控指标"
类似坑还有:
| 指标 | 实现 | 大线程数下表现 |
|---|---|---|
jvm.threads.states |
ThreadMXBean.getThreadInfo(allIds) × 6 |
⚠️ 最贵 |
jvm.gc.pause |
GC notification callback | 通常 O(1) |
process.files.open |
平台相关 | Linux 上 O(打开文件数) |
自定义 gauge 里调 Thread.getAllStackTraces() |
O(N) + safepoint | ⚠️ 自定义代码也要警惕 |
写自定义 metric 的原则:metric 计算应该是 O(1),不能 O(N)。需要遍历的,要么缓存(异步更新),要么干脆不暴露。
6.4 排查 SOP 速查表
下次再遇到 load 飙高但 GC 正常时,照着跑:
| 步骤 | 命令 | 目的 |
|---|---|---|
| 1 | top -b -n 1、top -H -p $PID |
找高 CPU 进程 + 高 CPU 线程数总数 |
| 2 | ./hot-stack.sh $PID |
多轮采样抓真活跃栈 |
| 3 | jstat -gcutil $PID 1000 10 |
洗清 GC 嫌疑 |
| 4 | grep '/actuator/prometheus' access.log | awk '{print $4}' | uniq -c |
看 scrape 频率是否异常 |
| 5 | curl /actuator/prometheus | wc -l |
看暴露的 metric 总数(> 5000 也偏多) |
| 6 | 看 hot-stack 栈帧是否有 micrometer / ThreadImpl.getThreadInfo |
命中 → safepoint 风暴 |
七、总结
凌晨那条告警,揭开的真相是:
3900 个线程 + 每 6 秒一次的 ThreadMXBean.getThreadInfo + 6 个 O(N) gauge = 监控自己把进程压在地上摩擦。
这种"监控反噬"在国内很少被深入复盘,因为它太隐蔽:
- 栈里看不到业务代码 → 业务同学一脸懵
- GC 没问题 → SRE 排除一项嫌疑
- CPU 不算特别高 → 看着像"机器配置不够"
- 业务接口也慢 → 像下游有问题
唯一的破解办法是:对 VM Thread 持续高 CPU 这个信号有敏感度。它几乎只能来自三件事 —— GC、safepoint、JIT —— 前后者用 jstat 和 -XX:+PrintCompilation 就能排除,剩下的只有 safepoint。
一句话送给程序员们
别让监控成了进程的"勒索软件" —— 每次抓数据都要 3900 个员工集合,这监控不要也罢。
延伸阅读(本系列其他线上问题排查实战):
- CPU 占用高排查实战:从 top 到火焰图,一套组合拳搞定
- 内存占用高排查实战:从 free 到 MAT,揪出那个吃内存的家伙
- Load 占用高排查实战:从 uptime 到上下文切换,揪出那个拉爆系统负载的家伙
- load 飙到 150 但 CPU 不到 20%:一次 LangFuse 线程泄漏的深度复盘
- 从 MQ 积压追到事件总线:诊断 4K 线程吃光 7G 内存的实战
如果这篇文章帮你下次少跑几个 jstack 命令,那它就值了。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)