CPU、内存、GC 与线程故障:四条资源链与一条 GC 链的证据闭环
本页源码基线固定 OpenJDK
jdk-17.0.12+7,历史实验运行 Oracle JDK 17.0.12(macOS x86_64,8 核)。故障排查的目标是把监控现象转成可以被推翻的假设,再用最小证据集逐条排除。Linux 的 PID/TID、cgroup 节流与内存记账口径见 Linux/cgroup 单元,各工具的语法与采集边界见 诊断工具单元。预期与实测分开标注;本页数字只证明合成实验,不构成对真实业务 P99 的证明。
一套故障链,四类不同资源
先记录故障窗口(开始/恢复时间、时区)、受影响实例与接口、流量与错误率曲线、最近的发布与配置变化,再区分四类互相独立的账单:用户态 CPU(业务计算、GC 工作、JIT 编译、内核态)、Java 堆与回收后存活集、native 内存(线程栈、代码缓存、直接缓冲、元空间、映射文件)、以及等待(锁、线程池队列、下游 I/O 与容器配额)。四类资源对应四类证据集,混在一起就会把“内存高”修成“堆调大”。
实验基于 labs/jvm-senior 的 JvmSeniorLab,六个模式刻意互相独立:cpu 只制造有界整数计算;heap 固定强持有 32 MiB;direct 保留并触碰 12 MiB 直接缓冲;churn 高分配但只保留一个 256 KiB 数组;blocked 持锁 sleep;deadlock 用屏障构造两个 monitor 的等待环。进程的内存与时长都有上限,没有真实 HTTP 服务,因此可以验证 JVM 侧证据链,不能声称测出了真实业务 P99。GC 日志的逐字段解读、Full GC 触发路径与原因词表见GC 日志单元,G1/ZGC 机制与选型见收集器单元,文件描述符、TIME_WAIT、conntrack 等 Linux/网络口径在 cgroup 单元展开,本页不重复。
lab_jdk=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
lab_build=$(JVM_LAB_JAVA_HOME="$lab_jdk" bash labs/jvm-senior/build.sh)链一:CPU 上涨,热点究竟在哪里
输入为 cpu 20:cpu-burner 在 20 s 内重复整数运算并写 volatile sink,防止结果被完全消除。步骤是先确认进程 CPU 总量,再取线程 CPU 差量与栈,最后判断消耗类型——只看一次栈可能恰好看到线程刚切到辅助方法;只看进程总 CPU,也可能把 GC 工作当成业务热点。
JVM_LAB_JAVA_HOME="$lab_jdk" bash labs/jvm-senior/collect.sh "$lab_build" cpu
# Linux 上在另一终端按脚本打印的 PID 观察线程级 CPU:
top -H -p 12345
pidstat -u -t -p 12345 1 5预期:cpu-burner 获得明显 CPU;两份线程快照都停在计算循环内;GC 停顿与堆占用稳定。以下是本轮 collect 的关键实测结果:
threads-1.txt:"cpu-burner" #14 ... cpu=2576.13ms elapsed=4.12s ... runnable
threads-2.txt:"cpu-burner" #14 ... cpu=4594.65ms elapsed=6.91s ... runnable
两份快照栈一致:at JvmSeniorLab.lambda$main$0(JvmSeniorLab.java:71)
GC.heap_info:garbage-first heap total 67584K, used 21720K
NMT diff:Thread (reserved=23610KB +3080KB)、Code (+180KB);JFR 录制自身记入 Tracing (+15540KB)cpu-burner 的 cpu 字段从 2576.13 ms 涨到 4594.65 ms(窗口约 2.8 s),栈位置不变、堆使用稳定,才支持“业务计算持续占 CPU”的结论;单份快照显示 RUNNABLE 不足以支撑它。线程 CPU 增量除以 wall time 是“核”的使用份额:一个线程 1 s 内运行 0.8 CPU s,约为单核 80%,不能直接与 8 核整体利用率 80% 比较。条件换成 churn 20 后,分配与 GC 工作可能和业务线程共同消耗 CPU,应用热点就不能单独描述整个进程;若编译线程高,看 Compiler.queue、代码缓存与冷启动变化,避免把 JIT 编译时间归因到某个业务函数的稳态成本。容器最多 0.5 CPU 时,单热线程可能已打满配额而宿主平均 CPU 仍很低;CPU 低但延迟高也不排除正在被周期性节流(HotSpot 诊断入口)。
止损应针对证据:异常计算可限流或禁用触发路径;大量分配先限批量与请求体大小;处于冷启动则评估预热和发布节奏。修复后比较相同到达率、数据与窗口下的 CPU/request、错误率、吞吐与尾延迟——减少绝对 CPU 若只是丢了大半请求,不构成性能优化。
链二:堆高,分配压力与保留压力怎样区分
heap 20 由静态 RETAINED 强持有 32 个 1 MiB byte 数组,启动堆 96 MiB 足以容纳;G1 小 region 下这些数组可能走 Humongous 路径,堆占用不严格等于 32 MiB payload。churn 重复产生 256 KiB 数组、只保留当前一个:两者同样在分配数组,回收后存活集合却完全不同。
JVM_LAB_JAVA_HOME="$lab_jdk" bash labs/jvm-senior/collect.sh "$lab_build" heap
"$lab_jdk/bin/java" -Xmx256m -Xlog:gc:file=/tmp/churn-g1.log -cp "$lab_build" JvmSeniorLab churn 15
# 对仍在运行的本地 heap 进程,按实际 PID 采集对象级证据:
lab_out=$(mktemp -d)
"$lab_jdk/bin/jcmd" 12345 GC.class_histogram
"$lab_jdk/bin/jcmd" 12345 GC.heap_dump "$lab_out/heap.hprof"预期:heap 的 [B 字节主要由保留数组贡献,dump 根路径为类静态字段 → ArrayList → elementData → byte[];churn 的分配量很大,但多轮回收后存活量不应按累计分配线性增长。关键实测结果:
heap:应用输出 retainedMiB=32 root=JvmSeniorLab.RETAINED
GC.heap_info:garbage-first heap total 98304K, used 71437K
region size 1024K, 6 young (6144K), 3 survivors (3072K)
NMT diff:Java Heap committed=98304KB(无提交量增量;NMT 包含 Java Heap 类别)
churn:allocatedArrays=469190;1032 次 Pause Young (Normal) mean 1.058ms max 18.177ms
15 s 内分配 469190 × 256 KiB ≈ 114 GiB,回收后稳定在 153M->1M(256M)应用自报保留量是数组 payload,GC.heap_info used 是整个堆的当前使用量,NMT Java Heap reserved/committed 是堆预留/提交量。NMT 包含 Java Heap 类别;committed 无增量仅表示本窗口没有增加堆提交量,不能推出堆内没有新分配、used 没增长,亦不能用 committed 代表 RSS。固定 32 MiB 保留本身不是“泄漏”:应用可能有意缓存,只有生命周期已结束却仍被不必要根持有,或者容量无限增长,才能进一步判为 retention 缺陷;一次 Full GC 后下降只说明当时有可回收对象,不证明未来不再泄漏。churn 说明高分配率可以伴随很低的存活量与毫秒级停顿——分配压力与保留压力是两个问题。从实现看,分配慢路径走 MemAllocator::mem_allocate,TLAB 不够仍可共享分配;G1 分配失败可尝试回收/重试(通用分配路径、G1 分配与恢复)。
故障时间线要能回答:是分配率先上升、晋升先上升,还是回收后存活量先上升?P99 在请求体变大后恶化、JFR 显示序列化中间数组分配、GC 频率上升而基线稳定,优先减少复制与批次;缓存 key 数随流量无限增长、老年代基线逐轮抬高、dump 显示静态 Map 支配对象,优先修生命周期。两者都叫“内存高”,修复完全不同。拿到 dump 后先看类加载器与 dominator tree,再对最大 retained 子图找 path to GC roots,区分强路径与弱/软引用;支配树表示所有到该对象的路径都经过某节点,“最大数组”不一定是根因,真正要修的是让数组长期存活的 owner。
链三:堆不高,直接内存为什么会失败
direct 模式调用 ByteBuffer.allocateDirect 12 次、每次 1 MiB,触碰每个 4 KiB 页并强持有 buffer,使 native 页真正被使用。wrapper 在 Java 堆、数据在 native 内存:堆直方图里 wrapper 很小,不能据此认定直接内存不大。NMT 不保证给出完整独立的 Direct 类别,应同时看 BufferPoolMXBean 的 count/memoryUsed/totalCapacity、OS RSS 与 cgroup 记账(BufferPoolMXBean)。
正常采集与受控失败对照:
JVM_LAB_JAVA_HOME="$lab_jdk" bash labs/jvm-senior/collect.sh "$lab_build" direct
# 预期失败用例:8 MiB 限额下申请第 9 个 1 MiB 缓冲
"$lab_jdk/bin/java" -Xmx96m -XX:MaxDirectMemorySize=8m \
-cp "$lab_build" JvmSeniorLab direct 3预期第二条在请求第 9 个缓冲时抛 direct buffer OOME,因为被强持有的前 8 MiB 无法释放;它是实验的预期失败,不要把非零退出记为实验程序错误。关键实测结果:
正常 collect:应用输出 directMiB=12 touched=true
NMT diff:Other (reserved=12322KB +34KB, committed=12322KB +34KB)
受控失败:OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory
(allocated: 8388608, limit: 8388608)
栈指向 java.nio.Bits.reserveMemory(Bits.java:178)栈里的 Bits.java:178 是运行时构建(Oracle 17.0.12)的行号,与固定 tag 摘录的行号不必一一对应。这份历史记录将 12 MiB 大部分分配解释为 baseline 前的存量;旧 collect 的固定 sleep 不能保证每次都有相同先后。不能由小差量反推分配时点;新的 NMT 有界两时序对照通过握手独立复验,保留原环境与本次环境的区别。直接缓冲也不保证有独立完整分类,“NMT 差量接近零”不等于没有直接缓冲存量。
原码:reserveMemory 在预算不足后进入引用处理重试
原码连续摘录(仅统一公共缩进,截取方法局部):Bits.java,jdk-17.0.12+7,L109–L127。
static void reserveMemory(long size, long cap) {
if (!MEMORY_LIMIT_SET && VM.initLevel() >= 1) {
MAX_MEMORY = VM.maxDirectMemory();
MEMORY_LIMIT_SET = true;
}
// optimist!
if (tryReserveMemory(size, cap)) {
return;
}
final JavaLangRefAccess jlra = SharedSecrets.getJavaLangRefAccess();
boolean interrupted = false;
try {
// Retry allocation until success or there are no more
// references (including Cleaners that might free direct
// buffer memory) to process and allocation still fails.入口先按 VM 初始化状态设置 MAX_MEMORY(来自 VM.maxDirectMemory(),默认取 -XX:MaxDirectMemorySize),再乐观地尝试 tryReserveMemory;失败后取得 JavaLangRefAccess,准备处理包括 Cleaner 在内的引用回收。注释直接写出后续协议:重试直到成功,或没有更多可处理引用且分配仍然失败。因此直接缓冲分配的延迟可能包含清理、GC 请求与退避重试——下一次分配可能先变慢再失败,不能认为“没有异常”就没有压力。
原码:tryReserveMemory 用 CAS 抢占容量预算
原码连续摘录(仅统一公共缩进,截取方法整体):Bits.java,jdk-17.0.12+7,L188–L203。
private static boolean tryReserveMemory(long size, long cap) {
// -XX:MaxDirectMemorySize limits the total capacity rather than the
// actual memory usage, which will differ when buffers are page
// aligned.
long totalCap;
while (cap <= MAX_MEMORY - (totalCap = TOTAL_CAPACITY.get())) {
if (TOTAL_CAPACITY.compareAndSet(totalCap, totalCap + cap)) {
RESERVED_MEMORY.addAndGet(size);
COUNT.incrementAndGet();
return true;
}
}
return false;
}注释本身就是容量口径的答案:-XX:MaxDirectMemorySize 限制的是被该路径记账的 total capacity,而不是真实内存占用,页对齐后二者会有差异;它也不是所有 JNI、Unsafe、mmap 与 native allocator 的总和,更不是整个 RSS 上限。CAS 失败意味着其他线程先取得了预算,需要读取新值重试,不能把失败当成 native malloc 已失败。TOTAL_CAPACITY 在并发下靠循环 + CAS 推进,因此“剩余预算足够”只是快照读数,在高并发申请下仍可能先失败再重试。
修复要考虑池总字节、在途借出上限、关闭责任与超时取消:缓冲仍被强持有时 Cleaner 不会替业务解除引用,频繁手动 GC 也无法回收它;native 内存释放后 allocator/OS 也未必立刻让 RSS 同量下降。
链四:CPU 低,线程为何都不前进
blocked 模式中 owner 持有一个 monitor 并 sleep,waiter 尝试同一 monitor。Thread.sleep 不释放 monitor;Object.wait 则有释放并在返回前重新获得 monitor 的语义,不能把二者当相同等待。deadlock 模式用 barrier 确保 A、B 各持一个锁后竞争对方,建立确定的等待环;工作线程是 daemon,主线程按秒数退出,避免遗留无限挂起进程(Thread.sleep、Object.wait)。
JVM_LAB_JAVA_HOME="$lab_jdk" bash labs/jvm-senior/collect.sh "$lab_build" blocked
JVM_LAB_JAVA_HOME="$lab_jdk" bash labs/jvm-senior/collect.sh "$lab_build" deadlock预期:blocked 两份快照中 waiter BLOCKED、owner TIMED_WAITING 却仍 locked 同一 monitor;deadlock 出现 Java-level deadlock 报告。关键实测结果:
blocked:"lock-waiter" ... BLOCKED (on object monitor)
at JvmSeniorLab.lambda$blocked$2(JvmSeniorLab.java:147)
- waiting to lock <0x00000007fde12778> (a java.lang.Object)
"lock-owner" ... TIMED_WAITING (sleeping)
at JvmSeniorLab.lambda$blocked$1(JvmSeniorLab.java:142)
- locked <0x00000007fde12778> (a java.lang.Object)
deadlock:Found one Java-level deadlock:
"deadlock-A": waiting to lock monitor ... which is held by "deadlock-B"(附反向描述)
栈:at JvmSeniorLab.lambda$deadlockWorker$3(JvmSeniorLab.java:163)两份快照里 waiter 持续 BLOCKED、owner 持续 TIMED_WAITING(sleeping),且 waiting to lock 与 locked 指向同一地址,才支持“owner 持锁 sleep、waiter 持续等待”的结论;单份快照只证明该瞬时关系。一条长等待链不自动等于死锁,只有 A 等 B、B 等 A 这样的环才构成 Java-level deadlock。修复前者要缩小持锁区、把 sleep/I/O 移出锁内,而不是加请求线程;修复后者要统一锁顺序、拆除嵌套或采用有明确超时/回滚的协议。对已经死锁的普通 monitor,interrupt 不会让等待进入 synchronized 的线程自动退出,不能把 interrupt 当通用解锁按钮。
线程池耗尽又不同:全部 worker 在等连接/远端,任务队列上涨,可能没有 monitor 环——等待发生在 park、socket 或连接池。应查 owner 或依赖的真实去向,以及连接池 active/pending、请求超时与取消是否传播。若在固定大小线程池中父任务阻塞等子任务,而子任务也只能排到同一池,可能形成线程饥饿死锁,不保证被死锁检测发现(检测范围见诊断工具单元)。扩大池只能在某些有限负载下缓解,正确修复是拆掉循环依赖或改用非阻塞组合。
链五:GC 停顿恶化与 Full GC 频发
第五条链输入是"停顿/GC"型告警而非单资源上涨。分诊第一层是原因分布:把窗口内 GC 日志按 cause 聚合(或 JFR jdk.GarbageCollection 的 cause 字段),四型走四条完全不同的路——System.gc() 型查显式调用与 RMI 定时任务;Heap Inspection/Heap Dump Initiated GC 型查诊断任务自身(直方图默认诱发 Full GC 的实测见GC 日志单元);G1 Humongous Allocation 型查大对象分配与 region 配置;Allocation Failure/G1 Compaction Pause 型查存活集增长与容量(完整升级梯与源码见GC 日志单元)。
第二层证据按型下钻:jstat -gcutil 差量看 YGC/FGC/CGC 频率趋势;-Xlog:gc* 看单次停顿构成与回收后基线(after==before 的 Full GC 是 retention 信号);分配侧看 JFR 分配采样与 TLAB waste;存活侧才轮到 dump 支配树与根路径。复用本页 churn(分配压力型)与 heap(保留压力型)两个现成模式做教学对照:churn 15 s 分配约 114 GiB 但停顿毫秒级、基线回到 1 MiB;heap 固定保留 32 MiB——"分配高"与"回收后基线高"是两个故障,前者修分配(批次/池化/请求体),后者修生命周期。
止损与验收:Full GC 频发已影响 SLO 时,先按GC 日志单元的应急算例选扩容/回滚止损(保留滚动 JFR 现场),再修因;验收是相同到达率下 FGC 归零或回到基线、Old 回收后占用稳定 N 个发布窗口,且 OOM 情形下"重启后内存下降"不作为修复证据(本页合成事故同款口径)。
合成事故:把证据和止损连起来
假设 4 vCPU、2 GiB 容器、Xmx=1 GiB,发布后响应变慢,堆基线稳定、RSS 与线程数一起增长。这只能说明“Java 堆单独解释不了全部增长”,不能立即判定 ThreadLocal 或直接缓冲泄漏。初始竞争解释有线程池无界扩张、直接缓冲池扩大、元空间/代理类、native 库与文件映射;最小证据集是线程数/栈、NMT 分类差量、direct pool 指标、RSS/smaps 与 cgroup memory.stat。
若 Thread 分类和线程数同涨、多数线程在同一远端 socket 等待,则先限制并发、缩短合适的超时并处理取消,检查调用风暴。线程数从 200 到 1200,即使每线程只实际触碰一部分栈,预留与 native 压力也上升;不能直接按 1000×Xss 宣称 RSS 恰好增加这么多(实测中一份线程栈预留为 1 MiB 级、NMT Thread 增量 3080KB)。若 Thread 稳定而 direct pool 上涨,则回到缓冲借还链。若只有 memory.stat 的 file 上涨,排查日志/文件缓存而不是先 dump Java 堆。
止损与修复要分开记录:限流、熔断、回退版本或扩副本恢复服务,随后再修无界池/取消传播/资源归还。复核采用相同到达率,检查错误率、队列、线程、回收后基线、RSS 与恢复所需时间;重启后内存下降只说明进程资源被释放,不构成已经修复的证据。
高级口述与三轮追问
主问:“CPU 高、内存涨、请求慢,你怎么排?”答:先固定故障窗口与资源口径,区分 CPU 消耗和等待;Java 热点用线程 CPU 差量/JFR/多快照,堆用回收后基线与根路径,native 用 NMT/BufferPool/OS,阻塞用等待图和依赖指标,每个结论至少两类独立证据。
第一轮:“堆 90% 就泄漏?”答:不成立,需看分配、回收后存活、预期缓存容量与根路径。评分点:高占用与生命周期缺陷分开。
第二轮:“direct OOME 为何伴随先等待而不是立即失败?”答:reserveMemory 在预算不足后试图推进引用处理并重试,tryReserveMemory 的 CAS 也可能因并发失败而重试;若强持有不释放仍会失败。评分点:实际失败链与容量口径。
第三轮:“没有 deadlock 报告就没有死锁?”答:远端依赖、初始化等待、线程池饥饿可能不在 monitor 检测范围,要建完整等待依赖图。评分点:检测范围与可复现反例。
架构师变化条件:把恢复能力写进容量设计
稳定到达 500 req/s、平均依赖等待 40 ms 时,按 Little 定律约 20 个并发等待;依赖慢到 2 s,若不设限则变成约 1000 个。线程、连接、在途对象和直接缓冲会一起膨胀,单独优化 GC 不改变这个数量级。候选 A 无界扩池以追求不拒绝,风险是资源级联耗尽;候选 B 对并发和队列设限、用超时取消与降级,代价是明确拒绝一部分请求但保护恢复能力。
若业务要求不能丢,入口可转持久队列并定义幂等、积压上限和恢复吞吐,而不是只把 Java 内存队列放大。条件变为大请求体时,应按字节而非任务数设并发预算。验证需要注入 2 s 下游延迟、观察是否在预算内拒绝/排队、移除故障后是否能排空,以及取消后线程/连接/buffer 是否归还。
闭卷验收
本页 11 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| OpenJDK 17u Bits.java | jdk-17.0.12+7 | reserveMemory(L109–L127)、tryReserveMemory(L188–L203)的容量口径与重试协议 |
| BufferPoolMXBean | Java SE 17 | 直接缓冲 count / memoryUsed / totalCapacity 指标口径 |
| Thread.sleep / Object.wait | Java SE 17 | 等待是否释放 monitor 的语义差异 |
| OpenJDK 17u memAllocator.cpp | jdk-17.0.12+7 | 分配慢路径与失败后的回收/重试入口 |
| OpenJDK 17u g1CollectedHeap.cpp | jdk-17.0.12+7 | G1 分配与恢复路径 |
本地 labs/jvm-senior cpu/heap/direct/blocked/deadlock/churn 采集 | Oracle JDK 17.0.12 | 线程 CPU 差量、堆基线、直接内存与等待图的实测证据 |
访问日期:2026-10-23。源码行号以 jdk-17.0.12+7 标签为准,运行时栈中的行号属于本机 Oracle JDK 17.0.12;实测数字来自 macOS x86_64,更换 JDK、平台或启动参数后需重跑实验。