GC 探针:可达性、三色标记与 G1 SATB
本目录包含三个独立实验与一个统一诊断入口,分别回答不同问题:
| 实验 | 回答的问题 | 证据类型 |
|---|---|---|
TriColorSatbSim | 为什么“黑对象指向白对象”会漏标?SATB 记录旧值后如何补救? | 确定性模型,不是 JVM 调度 |
GcReachabilityProbe | 强引用、静态引用、弱引用、虚引用在 GC 后分别怎样? | 当前 JVM 的观测输出 |
AllocationPressure + GC 日志 | G1 的并发标记周期有哪些阶段?Remark/Cleanup 是否停顿? | G1 原生日志 |
diagnose.sh(U11) | GC 日志字段怎么读?Full GC 从哪来?诊断命令自身改变什么? | 受控小堆的 G1 日志/jstat/JFR/TLAB 实测 |
环境基线
| 项 | 值 |
|---|---|
| 运行时 | Oracle JDK 17.0.12(HotSpot 64-Bit Server VM 17.0.12+8-LTS-286) |
| 收集器 | G1(-XX:+UseG1GC) |
| 源码基线 | OpenJDK 17u jdk-17.0.12+7 |
| 操作系统 | macOS x86_64 |
| 依赖 | 仅 JDK,无第三方库 |
GC 日志格式、阶段名称和默认参数会随 JDK 版本变化;本目录只对上述基线负责。
运行
cd labs/gc
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home ./run.sh脚本依次编译三个类,并生成:
out/reachability-probe.txtout/gc.logout/tricolor-satb.txtout/allocation-pressure.txtout/g1-marking.log
实验一:三色标记与 SATB 模型
TriColorSatbSim 固定了以下时序:快照时根为 A、B,B → C 是 C 的唯一路径;标记线程先扫描 A,业务线程随后删除 B → C 并新增 A → C,最后才扫描 B。
== without SATB ==
snapshot : A=GRAY B=GRAY C=WHITE
after scan A : A=BLACK B=GRAY C=WHITE
after mutation : A=BLACK B=GRAY C=WHITE satbQueue=[]
after scan B : A=BLACK B=BLACK C=WHITE
sweep : C is collected
== with SATB ==
snapshot : A=GRAY B=GRAY C=WHITE
after scan A : A=BLACK B=GRAY C=WHITE
after mutation : A=BLACK B=GRAY C=WHITE satbQueue=[C:WHITE]
after remark : A=BLACK B=BLACK C=BLACK
sweep : C is kept读法:
- 没有 SATB 时,A 已经变黑,不会再扫描;B 被扫描时旧边已被删除,C 没有机会被染灰,最终被回收。
- SATB 在删除
B → C前记录旧值 C;Remark 阶段从 SATB 队列重新标记 C,因此 C 被保留。 - 这个模型刻意省略了并发线程、缓冲区溢出、新生代对象等细节,只证明“记录旧引用”能关闭这条漏标路径,不证明 G1 的完整实现。
实验二:引用强度与可达性
GcReachabilityProbe 在 64 MB 堆上创建局部强引用、静态强引用、仅弱引用可达的对象和一个虚引用,然后调用 System.gc() 并等待。
java.version=17.0.12 gc=mixed mode, sharing
maxHeap=67108864
after GC #1: local=true static=true weakOnly=false phantomQueued=true
after GC #2: local=false static=false weakOnly=false phantomQueued=false
phantomRef.get()=null读法:
local在第一次 GC 时仍为 true:调用Reference.reachabilityFence(local)后,局部变量在 fence 之前保持强可达。static在第一次 GC 时仍为 true:静态字段是根;把它置空后第二次 GC 才回收。weakOnly两次都是 false:只有弱引用的对象在第一次 GC 就被清除。- 虚引用在第一次 GC 后进入队列;
PhantomReference.get()始终返回 null,不能用它取回对象。 - 第二次
phantomQueued=false是因为第一次已经poll()取走;队列不会重复入队同一个引用。
out/gc.log 里可以看到这两次回收是 Pause Full (System.gc())。这说明该探针演示的是“引用强度与可达性”,不是并发标记;System.gc() 只是建议,JVM 可以不立即执行,也可以用 Full GC 执行。
实验三:G1 并发标记日志
AllocationPressure 持续分配并保留一部分 byte[],用 128 MB 小堆、关闭自适应 IHOP、把 InitiatingHeapOccupancyPercent 设为 5,强制尽早触发并发标记。
java -Xms128m -Xmx128m -XX:+UseG1GC -XX:-G1UseAdaptiveIHOP \
-XX:InitiatingHeapOccupancyPercent=5 \
'-Xlog:gc*,gc+marking=debug:file=out/g1-marking.log:time,uptime,level,tags' \
-cp out AllocationPressureout/g1-marking.log 中的第一个完整周期(省略并发子阶段):
GC(1) Pause Young (Concurrent Start) (G1 Evacuation Pause)
GC(2) Concurrent Mark Cycle
GC(2) Concurrent Mark
GC(2) Pause Remark
GC(2) Pause Cleanup
GC(3) Pause Young (Prepare Mixed) (G1 Evacuation Pause)
GC(4) Pause Young (Mixed) (G1 Evacuation Pause)读法:
Concurrent Start是一次 Young GC,同时启动标记周期;它不是“只做初始标记”的独立停顿。Concurrent Mark与业务线程并发执行;日志里可以穿插 Young GC。Pause Remark是 Stop-The-World:完成标记、引用处理和类卸载等收尾。Pause Cleanup也是 Stop-The-World:决定是否进入空间回收阶段;Remark 与 Cleanup 之间的区域选择计算是并发的。Prepare Mixed和Mixed表示后续 Young GC 开始回收部分 Old 区域。
本次记录中出现了 3 个并发标记周期,没有 Pause Full。这是 128 MB 堆和固定 IHOP 的人工结果,不代表生产环境不会发生 Full GC;换堆大小、分配速率或 JDK 版本都可能改变周期数量和阶段。
失败边界
System.gc()不保证回收数量、完成时间或引用入队数量;不能用一次输出证明“对象一定被回收/一定存活”。- JIT 可能让局部变量比源码作用域更早失效或更晚失效;
reachabilityFence只保证调用之后仍强可达,不保证调用之前不被回收。 - 三色模拟是单线程确定性模型,不覆盖 SATB 缓冲区溢出、并发竞争、新生代对象和标记中止。
- G1 日志阶段名与参数随 JDK 版本变化;
Concurrent Start、Remark、Cleanup的具体行为应结合对应版本文档阅读。 - 本实验不测量延迟、吞吐或 GC 开销,不能据此给出生产调优结论。
U11:GC 日志解剖、Full GC 触发链与观察者效应(diagnose.sh)
diagnose.sh 是 U11 新增的统一诊断入口(白名单 all|gclog|fullgc|observe|tlab;未知模式、参数过多、JDK 工具缺失和非 JDK17 退出 2;macOS 未设 JAVA_HOME 时经 java_home 发现 17,文档化入口仍要求显式 JAVA_HOME)。入口以唯一 mktemp 根目录(build+evidence)编译 GcLogDiagProbe.java,EXIT/INT/TERM trap 定向删除;每次运行的 JVM 参数固定并如实登记(gclog -Xms64m -Xmx64m;fullgc 三配置 -Xms32m -Xmx32m / -Xms16m -Xmx16m / -Xms32m -Xmx32m -XX:G1HeapRegionSize=1m;observe 子进程 -Xms128m -Xmx128m 自限 25 秒;tlab -Xms32m -Xmx32m),全部配 -Xlog:gc... 落到自身临时目录并由探针回读。
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
bash labs/gc/diagnose.sh gclog # 行类型/字段/周期去重断言
bash labs/gc/diagnose.sh fullgc # System.gc / 分配失败升级梯 / Humongous 三链
bash labs/gc/diagnose.sh observe # jstat/jcmd/JFR 序列 + 观察者效应断言
bash labs/gc/diagnose.sh tlab # -Xlog:gc+tlab=debug 统计行fullgc 的两个 OOM 是预期受控失败:探针捕获 OutOfMemoryError 后正常退出(exit 0),终态如 Pause Full (G1 Compaction Pause) x2 → Java heap space。observe 对自己启动的子进程执行 jstat -gcutil/-gc、GC.heap_info、GC.class_histogram(前后各采一次 jstat,断言 FGC 差量>0,即直方图诱发的 Full GC 可观察)、Thread.print(断言子进程 safepoint 日志出现 Safepoint "PrintThreads" 行)与 6 秒 JFR 录制(断言 jdk.GarbageCollection 的 name=G1New/cause="G1 Evacuation Pause" 在场)。断言只压结构性事实;次数、耗时、waste% 一律按观察值打印非契约。U11 运行版本 Oracle JDK 17.0.12+8(x86_64);完整命令、四次真实红绿、负例与限制见实测证据,教学读法见GC 日志与线上故障诊断正文。