GC 日志解读与线上故障诊断:从单行字段到证据闭环
本页源码基线固定 OpenJDK
jdk-17.0.12+7,实验运行 Oracle JDK 17.0.12(macOS x86_64,G1 默认收集器),工具行为以 JDK 17 手册与本机实测互证。日志行、jstat 计数、JFR 事件与 TLAB 统计都是观察值不是契约:GC 行为随堆大小、分配压力与版本变化;单次快照不当因果证明(U01 口径在本页全程延续)。GC 机制正文(SATB/三色标记、G1 region 与 ZGC 屏障)见对应单元,本页只承接「怎么读证据、怎么下结论、诊断本身花多少成本」。
为什么需要专门读 GC 日志:从监控曲线到停顿证据
监控面板给的是结论级现象:P99 抖动、堆占用高、Full GC 告警。要把现象转成可被推翻的假设,必须下到原始证据层——GC 日志行、jstat/jcmd 计数、JFR 事件流——它们各自能证明的范围不同:
| 证据层 | 能证明 | 单独不能证明 |
|---|---|---|
-Xlog:gc* 日志行 | 每次收集的类型、原因、堆变化、停顿时长 | 请求端到端延迟、并发阶段外的业务等待 |
| jstat 计数(YGC/FGC/CGC…) | 时间窗口内的收集次数与累计耗时趋势 | 单次停顿的构成与原因 |
| JFR GC 事件流 | 结构化停顿/原因字段,可与执行采样放同一时间线 | 没采到的方法与采样盲区外的事件 |
| heap dump / 直方图 | 存活集合的持有者与支配关系 | 分配速率、收集频率、native 内存 |
常见误操作是只看其中一层就下结论:"日志里有一次 200ms 的 Full GC,所以慢是 GC 导致的"不成立——需要把停顿窗口与请求时间线对齐;反过来 "jstat FGC=0 所以没有长停顿" 也不成立——jstat 不记录 safepoint 等待与并发标记的 CPU 占用。本页逐层给出读法与反例。
一条 Pause 行的字段解剖与行类型分界
本机实测(labs/gc/diagnose.sh gclog,64 MiB 堆、短命数组分配 2 秒)的两种典型行:
[0.047s][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 23M->1M(64M) 1.040ms
[0.038s][gc] GC(0) Pause Full (System.gc()) 1M->1M(32M) 1.489ms逐字段拆开:
| 片段 | 含义 | 易错读法 |
|---|---|---|
[0.047s] | uptime 装饰器:JVM 启动后秒数(还可用 time 换墙钟、加 level,tags) | 与业务请求时间戳直接对齐前先统一时钟基准 |
GC(0) | 收集编号;一次周期的多个阶段共享编号(GC(1) 的 Remark/Cleanup 属于同一周期) | 按行数数"发生了多少次 GC"会数错 |
Pause | 该阶段 Stop-The-World;Concurrent 前缀的行与业务并发,不是停顿 | 把 Concurrent Mark 行当停顿累加 |
Young (Normal) / Full | 收集类型;括号第一组是子阶段(Concurrent Start/Normal/Prepare Mixed/Mixed) | 混淆"子阶段"与"原因" |
(G1 Evacuation Pause) / (System.gc()) | 原因标签:这次收集为什么被触发,词表见下节 | 把原因当类型:G1 Humongous Allocation 出现在 Young 行上,说明触发原因是大对象分配,不是"收集了大对象" |
23M->1M(64M) | 收集前 used → 收集后 used(堆总容量) | 三种口径:这不是 committed/RSS(见内存区域单元);after==before 也是合法输出(见反例 3) |
1.040ms | 该停顿阶段自身耗时 | 不含 safepoint 同步时间与请求排队恢复时间(见收集器单元安全点段) |
并发周期计数要按"带耗时的行"去重。GC(1) Concurrent Mark Cycle(无耗时)是周期开始标记,GC(1) Concurrent Mark Cycle 2.670ms(带耗时)是结束行;数周期数必须数结束行,用行数会把每个周期数两次。周期内穿插的 Young 回收各有自己的编号。16 MiB 堆分配失败实测(下节完整梯子)里 GC(1) 与 GC(3) 两个周期,日志行有 4 条,周期只有 2 个。
最后是因果口径:一条 Pause Young ... 18.177ms 行只证明该停顿发生过,不证明某个请求的 18ms 慢由它导致。对齐方法:请求时间线(网关/应用访问日志)与 GC 停顿窗口相交才算初步证据,再改变条件复验(收紧分配或换配置重跑对照)。
原码:Full GC 的原因词表
原码连续摘录(仅统一公共缩进,截取 switch 中段):gcCause.cpp,jdk-17.0.12+7,L72–L100。
case _allocation_failure:
return "Allocation Failure";
case _tenured_generation_full:
return "Tenured Generation Full";
case _metadata_GC_threshold:
return "Metadata GC Threshold";
case _metadata_GC_clear_soft_refs:
return "Metadata GC Clear Soft References";
case _old_generation_expanded_on_last_scavenge:
return "Old Generation Expanded On Last Scavenge";
case _old_generation_too_full_to_scavenge:
return "Old Generation Too Full To Scavenge";
case _adaptive_size_policy:
return "Ergonomics";
case _g1_inc_collection_pause:
return "G1 Evacuation Pause";
case _g1_compaction_pause:
return "G1 Compaction Pause";
case _g1_humongous_allocation:
return "G1 Humongous Allocation";日志里每个括号原因都来自这张表(完整表还包括 Heap Inspection Initiated GC、Heap Dump Initiated GC、Diagnostic Command、ZGC 的 Allocation Rate 等,见同文件)。读表要点:System.gc()、Heap Inspection、Heap Dump、Diagnostic Command 是外部请求原因——诊断命令自己也会出现在原因词表里,这是"观察者效应"的词表级证据;Allocation Failure、G1 Humongous Allocation、Metadata GC Threshold 是资源压力原因,分别指向堆、大对象/连续空间、元空间三条不同的排查线;G1 Compaction Pause 是 G1 的 Full GC 动作名,进入路径见下节源码。同一个词表也出现在 JFR jdk.GarbageCollection 事件的 cause 字段里——日志与事件流的词表是同一份。
Full GC 触发路径:实测三条原因链
labs/gc/diagnose.sh fullgc 用三个受控小堆 JVM 分别触发三条路径(完整输出见证据文件)。OOM 是本实验的预期受控失败:探针捕获 OutOfMemoryError 后正常退出,非零的意外才叫失败。
路径一:显式请求(System.gc())。32 MiB 堆上一次 System.gc():
GC(0) Pause Full (System.gc()) 1M->1M(32M) 1.489msG1 默认对 System.gc() 做完整压缩停顿;-XX:+ExplicitGCInvokesConcurrent(默认 false,本机 PrintFlagsFinal 核对)可改为并发周期。RMI 定时任务、缓存预热代码里的显式 GC 是生产 Full GC 的常见外部来源,先查原因标签再谈调参。
路径二:分配失败升级梯(G1 Humongous Allocation → … → G1 Compaction Pause ×2 → OOM)。16 MiB 堆逐个保留 1 MiB 数组直到 OOM,日志完整梯子:
GC(0) Pause Young (Concurrent Start) (G1 Humongous Allocation) 7M->7M(16M) 0.767ms
GC(1) Concurrent Mark Cycle
GC(1) Pause Remark 11M->11M(16M) 0.361ms
GC(1) Pause Cleanup 11M->11M(16M) 0.002ms
GC(1) Concurrent Mark Cycle 1.861ms
GC(2) Pause Young (Concurrent Start) (G1 Humongous Allocation) 13M->13M(16M) 0.408ms
GC(4) Pause Young (Normal) (G1 Humongous Allocation) 13M->13M(16M) 0.380ms
GC(5) Pause Full (G1 Compaction Pause) 13M->13M(16M) 1.119ms
GC(6) Pause Full (G1 Compaction Pause) 13M->13M(16M) 0.837ms
→ OutOfMemoryError: Java heap space(保留约 6 MiB 后)读法:原因标签显示 1 MiB 数组在该堆上走了 Humongous 路径(region=1 MiB,1 MiB 数组加头部大于半 region,判定源码见收集器单元);并发标记两轮都没腾出空间后进入 Full GC,而且连续两次。为什么恰好两次?升级梯在源码里:
原码:G1 分配失败的升级梯
原码连续摘录(仅统一公共缩进,截取方法整体):g1CollectedHeap.cpp,jdk-17.0.12+7,L1203–L1242。
HeapWord* G1CollectedHeap::satisfy_failed_allocation(size_t word_size,
bool* succeeded) {
assert_at_safepoint_on_vm_thread();
// Attempts to allocate followed by Full GC.
HeapWord* result =
satisfy_failed_allocation_helper(word_size,
true, /* do_gc */
false, /* maximum_collection */
false, /* expect_null_mutator_alloc_region */
succeeded);
if (result != NULL || !*succeeded) {
return result;
}
// Attempts to allocate followed by Full GC that will collect all soft references.
result = satisfy_failed_allocation_helper(word_size,
true, /* do_gc */
true, /* maximum_collection */
true, /* expect_null_mutator_alloc_region */
succeeded);
if (result != NULL || !*succeeded) {
return result;
}
// Attempts to allocate, no GC
result = satisfy_failed_allocation_helper(word_size,
false, /* do_gc */
false, /* maximum_collection */
true, /* expect_null_mutator_alloc_region */
succeeded);
if (result != NULL) {
return result;
}
assert(!soft_ref_policy()->should_clear_all_soft_refs(),
"Flag should have been handled and cleared prior to this point");注释把梯子写明:第一级"分配后 Full GC",第二级"分配后收集全部软引用的 Full GC"(maximum compaction),第三级只分配不再 GC,全部失败返回 NULL——上层随后抛 OutOfMemoryError。helper 内部(同文件 L1162–L1200)先在 safepoint 试分配、再 expand_and_allocate 扩堆,仍失败才 GCCauseSetter compaction(this, GCCause::_g1_compaction_pause) 发起 Full GC——这就是日志里两个 G1 Compaction Pause 与词表的连接点。对照实测:两次 Full 恰好对应两级"做 GC"的 helper;软引用全清那级对纯强引用负载自然也救不回来。生产上这提示 Full GC 行连续成对出现 + 原因 Allocation Failure/G1 Compaction 时,扩堆或减少保留比调 GC 参数更接近根因。
路径三:大对象专链(G1 Humongous Allocation)。32 MiB 堆、G1HeapRegionSize=1m、逐个保留 4 MiB 数组:4 条 Young 行全部带 (G1 Humongous Allocation) 原因、两次并发周期、两次 G1 Compaction Pause、保留第 5 个数组前 OOM。把它与路径二分开看的意义:同样以 Humongous 原因收场,路径二证明"小堆会把中等数组也变成 Humongous",路径三证明"显式大对象在正常 region 堆上也走这条链"——原因标签回答的是触发来源,对象尺寸是否超阈值是另一个独立判断。
词表里还有两条本票未注入触发的原因,如实标注:Metadata GC Threshold(类加载压力耗尽 Metaspace 预算时触发,排查线是类加载数量与 MaxMetaspaceSize,本票未实测,按词表与类加载单元登记);Heap Dump Initiated GC(heap dump 默认预 GC,源码证据见诊断工具单元)。
晋升失败、担保失败、疏散失败:三个不能混用的术语
社招高频混淆点。三个词分别属于两代收集器模型与 G1,混用会推出错误的修复动作。
晋升失败(promotion failed):分代收集器(Serial/Parallel)年轻代回收时,存活对象要复制晋升到老年代;老年代放不下或碎片过多时,晋升失败的分支被触发。固定 tag 的 Parallel 实现:
原码:Parallel 的 Promotion failed 分支
原码连续摘录(仅统一公共缩进,截取分支整体):psScavenge.cpp,jdk-17.0.12+7,L517–L522。
// Finally, flush the promotion_manager's labs, and deallocate its stacks.
promotion_failure_occurred = PSPromotionManager::post_scavenge(_gc_tracer);
if (promotion_failure_occurred) {
clean_up_failed_promotion();
log_info(gc, promotion)("Promotion failed");
}post_scavenge 汇总各 worker 的晋升结果,失败时 clean_up_failed_promotion 恢复现场并打印 Promotion failed(gc,promotion 标签)。注意同文件后续:失败时不交换 survivor 空间、按失败分支更新统计——晋升失败的年轻回收是一次"白干一部分"的回收。
担保(guarantee):年轻回收开始前,分代收集器要评估"老年代剩余空间是否装得下预计晋升量"。旧资料里的 -XX:-HandlePromotionFailure 属 JDK 6/7 时代开关;本机 JDK 17 PrintFlagsFinal 全量输出中已无该参数(实测核对),现代实现把担保判断内化为自适应策略:
原码:Parallel 的担保判断是填充均值比较
原码连续摘录(仅统一公共缩进,截取方法整体):psAdaptiveSizePolicy.cpp,jdk-17.0.12+7,L175–L186。
bool PSAdaptiveSizePolicy::should_full_GC(size_t old_free_in_bytes) {
// A similar test is done in the scavenge's should_attempt_scavenge(). If
// this is changed, decide if that test should also be changed.
bool result = padded_average_promoted_in_bytes() > (float) old_free_in_bytes;
log_trace(gc, ergo)("%s after scavenge average_promoted " SIZE_FORMAT " padded_average_promoted " SIZE_FORMAT " free in old gen " SIZE_FORMAT,
result ? "Full" : "No full",
(size_t) average_promoted_in_bytes(),
(size_t) padded_average_promoted_in_bytes(),
old_free_in_bytes);
return result;
}判定是"带填充的晋升均值 > 老年代剩余",为真则在年轻回收后再补一次 Full GC(Ergonomics 原因)。面试口径应是:担保失败不是一个 JDK 17 存在的开关或日志词,而是"老年代放不下预计晋升量时年轻回收不再安全"的机制描述;JDK 17 里它的可观察形态是 Ergonomics 原因的 Full GC 与上面的均值比较(trace 日志可见四元组)。
原码:Parallel 分配失败的五级梯
原码连续摘录(仅统一公共缩进,截取方法整体):parallelScavengeHeap.cpp,jdk-17.0.12+7,L443–L485。
HeapWord* ParallelScavengeHeap::failed_mem_allocate(size_t size) {
assert(SafepointSynchronize::is_at_safepoint(), "should be at safepoint");
assert(Thread::current() == (Thread*)VMThread::vm_thread(), "should be in vm thread");
assert(!is_gc_active(), "not reentrant");
assert(!Heap_lock->owned_by_self(), "this thread should not own the Heap_lock");
// We assume that allocation in eden will fail unless we collect.
// First level allocation failure, scavenge and allocate in young gen.
GCCauseSetter gccs(this, GCCause::_allocation_failure);
const bool invoked_full_gc = PSScavenge::invoke();
HeapWord* result = young_gen()->allocate(size);
// Second level allocation failure.
// Mark sweep and allocate in young generation.
if (result == NULL && !invoked_full_gc) {
do_full_collection(false);
result = young_gen()->allocate(size);
}
death_march_check(result, size);
// Third level allocation failure.
// After mark sweep and young generation allocation failure,
// allocate in old generation.
if (result == NULL) {
result = allocate_old_gen_and_record(size);
}
// Fourth level allocation failure. We're running out of memory.
// More complete mark sweep and allocate in young generation.
if (result == NULL) {
do_full_collection(true);
result = young_gen()->allocate(size);
}
// Fifth level allocation failure.
// After more complete mark sweep, allocate in old generation.
if (result == NULL) {
result = allocate_old_gen_and_record(size);
}
return result;注释自带编号:年轻回收 → Full GC(不清软引用)→ 老年代直接分配 → Full GC(清软引用)→ 再试老年代。与 G1 的梯子对照着读:G1 偏向先扩堆再压缩("favor expansion over collection",helper 内先 expand_and_allocate),Parallel 的梯子先回收后兜底老年代——同样以 OOM 收场,两家升级顺序不同,日志/事件原因词也不同(G1 是 G1 Compaction Pause,Parallel 是 Allocation Failure/Ergonomics)。
疏散失败(evacuation failure / to-space exhausted):G1 专属。回收时复制存活对象到目标 region,目标空间不足即失败——对象留在原地、可能伴随 To-space exhausted 字样,失败累积推高后续 Full GC 概率。它不是晋升失败:G1 不做"预计晋升量 vs 老年代剩余"的担保评估,也没有该日志词;判定与修复路径(看本轮存活量、可用 region、并发周期进度,而不是直接加大 Young)见收集器单元疏散失败段。三词对照表:
| 术语 | 收集器模型 | 发生时机 | 日志/事件词 | 修复方向 |
|---|---|---|---|---|
| 晋升失败 | Serial/Parallel 分代 | 年轻回收中晋升放不下 | Promotion failed(gc,promotion) | 降低晋升(Survivor/年龄)、老年代余量、碎片 |
| 担保 | Parallel 自适应 | 年轻回收前后评估 | Ergonomics 原因的 Full GC | 同上;现代无开关可关 |
| 疏散失败 | G1 | 复制存活对象时目标空间不足 | To-space exhausted 字样 | 复制余量、Mixed 节奏、Humongous 治理 |
观察序列:jstat、jcmd 与观察者效应
observe 模式对同一个 128 MiB 堆的受控负载进程执行完整序列(证据)。jstat -gcutil 的实测两行:
S0 S1 E O M CCS YGC YGCT FGC FGCT CGC CGCT GCT
0.00 25.00 12.66 2.41 72.51 17.44 1422 0.330 0 0.000 0 0.000 0.330
0.00 25.00 12.66 2.41 72.51 17.44 1649 0.383 0 0.000 0 0.000 0.383| 列 | 含义 | 读法边界 |
|---|---|---|
| S0/S1 | 两个 survivor 使用率 % | 同一时刻只有一个非零是正常态 |
| E/O | Eden/Old 使用率 % | 瞬时值;E 在两次 Young 之间单调上升是健康形态 |
| M/CCS | Metaspace/压缩类空间使用率 % | 类加载型泄漏看这里配合 VM.classloader_stats |
| YGC/YGCT | 年轻回收次数/累计秒 | 差量除以窗口=频率;累计值可跨窗口比较 |
| FGC/FGCT | Full GC 次数/累计秒 | 本票实测它把诊断命令诱发的 Full GC 也计入(下文) |
| CGC/CGCT | 并发周期次数/累计秒 | G1 并发标记周期计入 CGC(本机以 IHOP=5% 负载实测:日志 1019 个完成周期对应 CGC 1226,量级一致) |
| GCT | YGCT+FGCT+CGCT 总计 | 是 GC 时间账,不是停顿时间全额(并发阶段与业务重叠) |
jstat -gc 给绝对容量口径(S0C/S1C/EC/OC 等 C=capacity、U=used,单位 KiB),百分比列由此换算;两者的"O"都是当前占用,不等于 heap dump 的存活集(dump 前通常先 GC,见诊断工具单元)。jstat 通过 perfdata 共享内存读取计数器,采样本身不请求 safepoint——这与 jcmd 形成对照,也是它适合做高频基线观测的原因(jstat 手册)。
观察者效应是本节的核心实测。序列里执行 jcmd PID GC.class_histogram(默认参数),前后各采一次 jstat:
jstat FGC before class_histogram = 0
jstat FGC after class_histogram = 1
child log: GC(1952) Pause Full (Heap Inspection Initiated GC) 2M->1M(128M) 2.141ms直方图命令本身诱发了一次 Full GC,三处独立证据同时变化:jstat FGC 计数、子进程 GC 日志行、原因词表里的 Heap Inspection Initiated GC。这不是理论推导——直方图源码把 !_all.value() 作为"请求 Full GC"参数(摘录见诊断工具单元),本实验给出运行层复核。推论:对延迟敏感服务执行默认直方图/dump 前,必须把这次额外 Full GC 计入影响评估;对照采样前后口径必须说明是否含诊断诱发。同序列里 jcmd PID Thread.print 之后,子进程 -Xlog:safepoint 记录:
Safepoint "PrintThreads", Time since last: 578957 ns, Reaching safepoint: 94915 ns, At safepoint: 286761 ns, Total: 381676 nsThread.print 本身是一次要在 safepoint 执行的 VM 操作——读线程栈的动作先让所有线程停下来。高频 Thread.print 会把诊断成本叠加到被诊断进程,这正是下一节"总账"的一行。
JFR 的 GC 事件流:把停顿放进同一时间线
同一次录制里(jcmd JFR.start settings=profile),GC 事件是结构化字段而非日志文案(jfr 手册、本机 jfr metadata 核对事件定义):
jdk.GCPhasePause { startTime, duration = 3.00 ms, gcId = 0, name = "GC Pause" }
jdk.GarbageCollection{ duration = 3.00 ms, gcId = 0, name = "G1New",
cause = "G1 Evacuation Pause",
sumOfPauses = 3.00 ms, longestPause = 3.00 ms }
jdk.YoungGarbageCollection { duration, gcId = 0, tenuringThreshold = 15 }三类事件的分工:jdk.GCPhasePause 逐次停顿(含 name 与 duration);jdk.GarbageCollection 每次收集一条,name(G1New/G1Old)给类型、cause 给原因(与 gcCause 词表同源)、sumOfPauses/longestPause 给窗口聚合;jdk.YoungGarbageCollection 携带 tenuringThreshold 等代际细节(另有 jdk.G1GarbageCollection、jdk.GCPhasePauseLevel1..4 逐阶段下钻、jdk.MetaspaceGCThreshold 等)。事件流的价值在对齐:同一录制里的 jdk.ExecutionSample/jdk.ObjectAllocationSample 与 GC 事件共享时间轴,可以把"停顿窗口"与"当时在执行什么"叠在一起看——这是日志做不到的。口径边界:事件 duration 是收集自身耗时,不含请求恢复;没采到某事件不证明没发生(采样与阈值边界见诊断工具单元 JFR 段);cause 字段让"原因词表"在事件流里同样适用——按 cause 聚合 GarbageCollection 事件是快速分型手段(Humongous 驱动 vs Evacuation 驱动 vs 显式请求)。
OOM 分类与线上处置
OutOfMemoryError 的 message 是第一级分诊表。只有前两类与直缓冲是本仓库实测,其余按固定 tag 与官方文档登记、标注未实测:
| message 片段 | 指向 | 处置首步 | 本票证据 |
|---|---|---|---|
Java heap space | 堆容量/存活集不足 | GC 日志看 Full GC 原因链(本页路径二/三),再 dump 找持有者 | 实测:16m/32m 受控保留到 OOM |
GC overhead limit exceeded | GC 占用时间比例过高(回收赶不上分配) | 先降分配压力/查 retention,不是先扩堆;UseGCOverheadLimit 默认 true(本机 PrintFlagsFinal);固定 tag 中 G1 与 Parallel 接入该检查,ZGC 仅在 mem_allocate 透传出参、不实施检查 | 未实测(按源码登记) |
Metaspace | 类元数据超限 | 类加载器数量、动态生成类、MaxMetaspaceSize | 未实测 |
Cannot reserve ... direct buffer memory (allocated: ..., limit: ...) | 直接内存记账超 MaxDirectMemorySize | 缓冲池借还链与 Cleaner 时序 | 页 F 实测(受控失败样例) |
unable to create new native thread | 线程数/ulimit/栈预算 | 线程数曲线、ulimit、Xss 与池上限 | 未实测 |
Requested array size exceeds VM limit | 单个数组超上限 | 改分块流式处理 | 未实测 |
线上处置三步:前(常开小体积滚动 JFR default 档 + GC 日志落盘,把"事故瞬间"的现场成本前置);中(HeapDumpOnOutOfMemoryError 的边界:只对堆类 OOM 触发、dump 本身高开销且默认预 GC、磁盘要先有预算、多实例同时 OOM 会同时 dump——需要限流);后(重启后内存下降只说明进程资源被释放,不构成修复证据;复验口径见故障实战单元)。诊断预算的完整取舍算例见诊断工具单元 K08 段。
诊断成本与观察者偏差的总账
把散在工具页与本章的实测成本合并成一张决策表:
| 动作 | 直接成本(实测/源码) | 偏差来源 | 何时可用 |
|---|---|---|---|
jstat 采样 | perfdata 读取,无 safepoint | 计数器为 JVM 维护口径;百分比是瞬时值 | 高频基线,常开 |
-Xlog:gc* 落盘 | 每行一次格式化+IO;量级与 GC 频率成正比 | 日志自身不改 GC 行为 | 生产常开(量可控) |
jcmd Thread.print | safepoint VM 操作(实测 PrintThreads At safepoint 0.29ms/次,量级观察) | 观察期间全线程停住;高频采集放大停顿 | 按需,连续 3–5 次足够 |
GC.class_histogram | 默认请求 Full GC(实测 FGC 0→1 + Heap Inspection Initiated GC 行) | 采样后的直方图≠采样前存活集 | 维护窗口或低峰 |
GC.heap_dump | 预 GC + 全堆遍历 + 写盘,时长随堆线性 | dump 内容是"预 GC 后"口径;-all 变口径不变成本 | 预算审批后 |
| JFR default 档 | 常开缓冲(U01 实测 Tracing 记账 +15540KB native) | 低频事件为主 | 常开 |
| JFR profile 档 | 采样间隔降低、事件量大增 | 观测成本换分辨率,须在代表负载下评估 | 按需短窗口 |
-Xlog:safepoint | 极低 | 用于区分 reaching/at safepoint,避免停顿误归因 | 常开可接受 |
"偏差"是指观测改变被观测者或读数口径:诊断诱发的 Full GC 会被 jstat 计入、dump 预 GC 会改写存活集、Thread.print 的停顿会计入总停顿预算。诚实结论是"先低成本常开通道 + 高成本动作按预算触发",而不是"工具零开销"。
TLAB 统计:分配侧的浪费账单
U10 移交的采集项:-Xlog:gc+tlab=debug 在每次 GC 时输出各线程 TLAB 汇总(内存区域单元讲机制,本节给读法)。实测两行(tlab 模式,32 MiB 堆短命数组):
GC(0) TLAB totals: thrds: 2 refills: 2 max: 1 slow allocs: 41 max 41 waste: 76.7% gc: 450680B max: 293584B slow: 0B max: 0B
GC(1) TLAB totals: thrds: 1 refills: 1 max: 1 slow allocs: 52 max 52 waste: 50.0% gc: 262128B max: 262128B slow: 0B max: 0B字段读法(对应 threadLocalAllocBuffer.cpp 的统计路径):thrds 参与统计的线程数;refills TLAB 换新次数;slow allocs TLAB 外慢路径分配次数;waste % 浪费占 TLAB 分配的比例;gc 字节数是 TLAB 退役时剩余未用空间(源码 L81 _gc_waste += remaining()——退役的 TLAB 剩余部分做 GC 可解析填充后不可再用于分配);slow 字节是慢路径浪费。应用:单线程小负载的 waste% 高(76.7%)主要反映"线程只用了 TLAB 一部分就到 GC 点",未必是问题;大量低分配线程会让尾部浪费累积放大(每线程一个 TLAB),此时才讨论 MinTLABSize/线程数治理。76.7% 与 50.0% 都是本机单轮观察值,不是健康阈值。
可运行实验:四个受控入口
入口是 labs/gc/diagnose.sh(本机 macOS x86_64、Oracle JDK 17.0.12,显式 JAVA_HOME;白名单 all|gclog|fullgc|observe|tlab,未知模式/参数过多/JDK 非 17 退出 2;每次运行有 alarm 上限与唯一临时目录 trap 清理):
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
bash labs/gc/diagnose.sh gclog # 64m 堆 churn:行类型/字段/周期去重断言
bash labs/gc/diagnose.sh fullgc # 三触发链:System.gc / 16m 分配失败梯 / 32m Humongous
bash labs/gc/diagnose.sh observe # jstat/jcmd/JFR 序列 + 观察者效应断言(FGC 差量)
bash labs/gc/diagnose.sh tlab # -Xlog:gc+tlab=debug 统计行解析断言压在结构性事实上(字段可解析、after≤before≤capacity、升级梯 ≥2 次 Full、FGC 差量>0、TLAB 行在场),计数与耗时一律按观察值打印。这些输出证明什么:三条 Full GC 原因链在受控配置下可复现且日志形态与源码梯子一致;诊断命令的 Full GC 副作用可被三处独立证据同时观察到;TLAB 统计通道真实可用。不证明什么:小堆配置不代表生产堆形态;未覆盖 Metadata/Heap Dump 原因与 Parallel/ZGC 侧运行(摘录按源码推导);生产 Full GC 频率、容量与延迟结论需在真实负载下另行验证。既有 labs/gc/run.sh 三实验(三色/SATB、引用强度、G1 标记日志)原样保留,本轮在沙盒副本复跑通过(仓库资产未改写)。
反例:四个被实测推翻的说法
- "G1 分配失败后只做一次 Full GC 就抛 OOM":红测断言恰好 1 条
G1 Compaction Pause,实测 2 条——升级梯是"普通 Full GC → 清软引用的最大压缩 Full GC → 放弃"两级(源码 L1203–L1242 注释即梯子)。 - "诊断命令只读不写,不影响被观察的 JVM":红测断言
GC.class_histogram后 jstat FGC 不变,实测 0→1 且日志出现Heap Inspection Initiated GC行。 - "每次 GC 后 used 一定显著下降":红测断言所有 Pause 行 after < before,实测高保留负载下
7M->7M、13M->13M六条持平行——GC 无法释放仍被强引用的对象是正常输出;"after==before 的 Full GC"恰是 retention 问题的信号而非日志错误。 - "1 MiB 的数组是小对象,不会触发 Humongous 路径":16 MiB 堆(region=1 MiB)上 1 MiB 数组加头部大于半 region,实测全部以
(G1 Humongous Allocation)原因触发收集。对象"大不大"由堆/region 配置决定,不是绝对字节数。
高级口述与三轮追问
主问:"线上 Full GC 频发,你怎么排?"答:先取原因标签分布(日志按 cause 聚合或 JFR jdk.GarbageCollection.cause)分型:System.gc() 型查显式调用与 RMI;Heap Inspection/Heap Dump 型查诊断任务自身;G1 Humongous Allocation 型查大对象分配与 region 配置;Allocation Failure/G1 Compaction 型查存活集增长与容量。再按型取第二层证据(dump/TLAB/jstat 差量),每型给一个可推翻的假设。
第一轮:"Pause Full 行连续两条是不是日志重放?"答:不是重放,是升级梯两级(第二级清软引用的最大压缩),源码注释明确;两条的 gcId 不同。评分点:梯子顺序 + gcId 区分。
第二轮:"jstat 看到老年代 95%,要立刻 dump 吗?"答:先看趋势与原因分布——若并发周期在正常运转且 Full GC 计数不涨,95% 可能是 IHOP 节奏问题;dump 是高影响动作,先低成本通道。评分点:分诊顺序 + dump 成本意识。
第三轮:"为什么我的 Thread.print 采集让 P99 更差了?"答:Thread.print 是 safepoint VM 操作(实测 PrintThreads safepoint 行),高频采集把诊断成本叠加进业务时间线;改低频、限量、或用 JFR 事件替代。评分点:观察者效应 + 替代通道。
架构师变化条件:Full GC 应急决策算例
假设算例(非实测):4 vCPU/8 GiB 堆的下单服务,发布后每小时 3–5 次 G1 Compaction Pause,单次 1.2–2 s,P99 告警。三案对比——A 扩堆到 12 GiB:成本 50% 内存增幅,掩盖问题不解决 retention,回滚条件是 Full GC 仍出现即失败;B 保持堆、先挂滚动 JFR + 按原因聚合:成本是观测开销与 1–2 个发布周期的诊断窗口,责任在 SRE 与开发共同,可证伪(若 cause 全是 Humongous 则指向批量导出大数组,修分块);C 立即回滚版本:最快止损但丢失现场,若故障与版本无关则空回滚。决策记录必须含:原因分布证据、扩容/回滚的触发阈值(如 P99>800ms 持续 5 分钟)、诊断预算(dump 需磁盘 10 GiB+ 与一次额外 Full GC 的窗口)、恢复后的验收(相同到达率下 FGC=0 且 Old 基线稳定 N 小时)。条件变化:若 OOM 已在发生,顺序反转——先保副本与现场(滚动 JFR 已在),再止损,最后修因;若只有单副本,任何停顿型采集都要先确认容量冗余。这套决策与诊断预算算例同源,把"采集什么"升级为"应急时敢采什么"。
闭卷验收
本页 12 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| OpenJDK 17u gcCause.cpp | jdk-17.0.12+7 | GCCause::to_string 原因词表(L72–L100) |
| OpenJDK 17u g1CollectedHeap.cpp | jdk-17.0.12+7 | satisfy_failed_allocation 升级梯(L1203–L1242) |
| OpenJDK 17u psScavenge.cpp | jdk-17.0.12+7 | Parallel Promotion failed 分支(L517–L522) |
| OpenJDK 17u psAdaptiveSizePolicy.cpp | jdk-17.0.12+7 | 担保判断填充均值比较(L175–L186) |
| OpenJDK 17u parallelScavengeHeap.cpp | jdk-17.0.12+7 | Parallel 分配失败五级梯(L443–L485) |
| OpenJDK 17u threadLocalAllocBuffer.cpp | jdk-17.0.12+7 | TLAB 统计的 _gc_waste 口径 |
| jstat 手册 / jcmd 手册 / jfr 手册 | JDK 17 | 列语义、命令与事件边界 |
| Java 工具页统一日志 / G1 调优指南 / 并行收集器指南 | JDK 17 | -Xlog 标签、阶段语义、GC 开销上限 |
| OutOfMemoryError | Java SE 17 | 异常契约 |
本机 jfr metadata、-Xlog:help、PrintFlagsFinal | Oracle JDK 17.0.12+8 | 事件定义、无 deopt 类标签干扰核对、UseGCOverheadLimit/ExplicitGCInvokesConcurrent 默认值、无 HandlePromotionFailure |
本地 labs/gc/diagnose.sh 四模式与既有 run.sh | Oracle JDK 17.0.12 | 全部实测输出(证据) |
访问日期:2026-10-25。源码行号以 jdk-17.0.12+7 标签为准;实测数字来自本机 Oracle JDK 17.0.12(macOS x86_64)单轮或多轮观察,小堆参数为受控触发失败分支的人工配置;更换 JDK、平台、堆参数后需重跑,结论不构成性能承诺。