GC Roots、可达性与三色标记:从根集合到 SATB 队列
本页可达性等级以 Java SE 17
java.lang.ref文档为准,G1 行为以 OpenJDK 17u 源码(固定jdk-17.0.12+7)和 Oracle JDK 17 调优指南为准,实验运行 Oracle JDK 17.0.12(macOS x86_64)。GC Roots 的具体集合、并发标记实现和日志格式属于虚拟机实现细节,不是 Java 语言规范;规范语义、HotSpot 实现与本机实测分三层表述。
可达性从根集合出发
主流 JVM 使用 tracing GC:从一组 GC Roots 出发遍历对象图,能到达的对象视为存活,不能到达的才可能被回收。Java API 定义“强可达、软可达、弱可达、虚可达、不可达”这些可达性等级,但不规定 HotSpot 必须把哪些位置当作根。
以 HotSpot G1 为例,根扫描覆盖活动线程栈中的引用、类加载器数据、JNI/VM 强根、代码缓存和虚拟机内部根等。G1RootProcessor 把根处理分成 Java 根和 VM 根,再分别扫描线程、类加载器数据图、OopStorage 强根和代码缓存。g1RootProcessor.cpp 这个列表是 HotSpot 实现,不是“Java 语言规定的 GC Roots 清单”。因此回答“GC Roots 包括虚拟机栈、静态变量、常量、JNI 引用”时,应补一句“这是 HotSpot 的常见分类,具体以实现为准”。更严谨的说法是:根是虚拟机认为可直接访问、无需经过堆对象遍历就能到达的引用入口。
局部变量是否成为根还受 JIT 活跃性分析影响。源码里变量仍在作用域内,不代表机器码执行到某一点时它仍被视为存活;反过来,JIT 也可能让变量比源码预期活得更久。Reference.reachabilityFence(x) 可以保证 x 在调用之后仍强可达,常用于把“对象必须活到某个操作结束”的意图显式表达出来。它不启动 GC,也不能替代关闭文件、连接或本地资源。
System.gc() 只是建议 JVM 回收,不保证回收数量、完成时间,也不保证引用一定被清除或入队。实验里用它只是为了制造可观察的回收点,不能把一次输出当作生产环境的回收证明。
引用强度决定“还能不能拿到对象”
Java 提供四种主要引用强度:
| 引用 | 何时清除/入队 | 典型用途 | 注意点 |
|---|---|---|---|
| 强引用 | 只要强可达就不回收 | 普通对象引用 | 静态集合、长生命周期缓存最容易形成 retention |
SoftReference | 内存压力下由 GC 酌情清除 | 对内存敏感的缓存 | 不是“永远不会被回收”,也不是缓存命中率保证 |
WeakReference | 对象只弱可达时清除并入队 | 规范化映射、旁路索引 | 不能阻止回收;引用对象本身也要保持可达 |
PhantomReference | 对象 phantom reachable 后入队 | 回收后的通知、清理编排 | get() 永远返回 null,不能取回对象 |
ReferenceQueue 是通知机制,不是析构函数。GC 判断引用对应的可达性变化后,会清除引用并把引用对象加入队列;程序需要自己 poll()/remove() 处理。注册了队列的引用对象如果自身先变成不可达,就永远不会入队,所以通常要把引用对象保存在一个长期可达的结构里。
虚引用在对象被判定为 phantom reachable 后入队,get() 始终为 null;它适合做“对象已经不可达”的通知,不适合访问对象本身。Cleaner 和 finalization 也不能保证及时执行,资源管理仍应使用显式 close() 或 try-with-resources。
弱引用和软引用都不是缓存的完整策略:缓存还需要容量上限、过期、淘汰、命中率、回源和一致性设计。把对象放进 SoftReference 不等于不会 OOM;把对象放进 WeakHashMap 也不等于自动获得正确的生命周期。
三色标记与不变式
三色标记把对象分成三种状态:白色是尚未访问,可能存活也可能已经不可达;灰色是已经发现、自身可达但子引用还没扫描完;黑色是自身和子引用都已扫描完。标记开始时把根置灰,然后从灰色对象出发扫描其引用,把新的白对象置灰;一个灰色对象的子引用处理完后,它变黑。标记结束时,仍为白色的对象才可能被回收。
经典三色标记(配合增量更新)要维持“黑对象不指向白对象”的不变量。如果黑对象新增一条指向白对象的边,而黑对象不会再被扫描,这个白对象就可能被漏掉。并发标记的难点在于:业务线程会在标记线程遍历对象图的同时修改引用。一个典型的漏标时序:
快照:根 A、B;B → C 是 C 的唯一路径
扫描 A:A 变黑
业务线程:删除 B → C,新增 A → C
扫描 B:B 已经看不到 C
结果:C 仍是白色,被错误回收这里 C 在标记开始时是活的,但在标记结束前失去了从已扫描部分到它的路径。没有并发控制时,标记线程和业务线程的交错会让“快照时存活”的对象被漏掉。
SATB 在前写屏障记录旧值
G1 使用 Snapshot-At-The-Beginning(SATB)。它在标记开始时取得一个“虚拟快照”:在 Initial Mark 时活着的对象,在本轮标记中都被视为存活;标记期间才死亡的对象可能被保留到下一轮,形成浮动垃圾。
SATB 的关键不是“阻止业务线程修改引用”,而是在引用被覆盖前记录旧值。G1 的写屏障入口是 write_ref_field_pre,它在排除特定装饰器后调用 enqueue;enqueue 再检查 SATB 队列是否 active,并读取字段旧值入队。因此当业务线程执行 B.refs.remove(C) 覆盖 B → C 时,旧值 C 被记录到 SATB 队列;Remark 阶段再处理队列,把 C 重新标记。即使随后新增 A → C,C 也不会因为 A 已经变黑而丢失。
两点必须分清:第一,SATB 保守保留快照时存活、但标记期间死亡的对象,这才是浮动垃圾;不能说“标记开始时不可达的对象,后来被业务合法拿到也允许回收”——普通强引用程序无法凭空重新取得完全不可达的旧对象,弱引用取值、引用处理和新对象分配还有额外协议,必须保证当前合法使用的对象不会被误回收。第二,SATB 与增量更新是两类不同策略:增量更新在“黑对象指向白对象”时通过后写屏障把新值染灰,维持经典三色不变量;SATB 用前写屏障记录旧值,保证快照存活集合。G1 选择 SATB 是为了缩短 Remark 的停顿,代价是可能多保留一些本轮已死亡的对象。
SATB 不是免费的:引用字段的每次写入都可能触发屏障和入队,队列会占内存,浮动垃圾会增加下一轮压力。它是在停顿、吞吐和内存之间做的取舍,不是“没有成本的正确性”。
原码:前屏障与后屏障分工不同
原码连续摘录(仅统一公共缩进,截取方法局部):g1BarrierSet.inline.hpp,jdk-17.0.12+7,L61–L77。
inline void G1BarrierSet::write_ref_field_pre(T* field) {
if (HasDecorator<decorators, IS_DEST_UNINITIALIZED>::value ||
HasDecorator<decorators, AS_NO_KEEPALIVE>::value) {
return;
}
enqueue(field);
}
template <DecoratorSet decorators, typename T>
inline void G1BarrierSet::write_ref_field_post(T* field, oop new_val) {
volatile CardValue* byte = _card_table->byte_for(field);
if (*byte != G1CardTable::g1_young_card_val()) {
// Take a slow path for cards in old
write_ref_field_post_slow(byte);
}
}前屏障对未初始化目标或 AS_NO_KEEPALIVE 装饰器直接退出,其余路径调用 enqueue(field)。后屏障按 field 地址取得卡表项,非 young 卡才进入慢路径。前者支撑 SATB 快照保留旧值,后者维护跨区引用相关记录;把两者合称“记一下新引用”会解释错删除旧边与跨区更新。
原码:enqueue 先查 active 再读旧值
原码连续摘录(仅统一公共缩进,截取方法整体):g1BarrierSet.inline.hpp,jdk-17.0.12+7,L48–L58。
template <class T>
inline void G1BarrierSet::enqueue(T* dst) {
G1SATBMarkQueueSet& queue_set = G1BarrierSet::satb_mark_queue_set();
if (!queue_set.is_active()) return;
T heap_oop = RawAccess<MO_RELAXED>::oop_load(dst);
if (!CompressedOops::is_null(heap_oop)) {
SATBMarkQueue& queue = G1ThreadLocalData::satb_mark_queue(Thread::current());
queue_set.enqueue_known_active(queue, CompressedOops::decode_not_null(heap_oop));
}
}is_active() 是第一个闸门:不在并发标记期时,写屏障只做一次状态检查即返回,这正是“SATB 队列只在标记期激活”的实现位置——G1 在并发标记开始时把所有线程队列置为 active,Remark 收尾后置回 inactive。第二个闸门是旧值判空:用 relaxed 语义读取旧 oop,非空才解码并进入线程本地队列;对象要被覆盖成什么新值在这里完全不关心。enqueue_known_active 走线程本地队列的快路径,缓冲区耗尽后的慢路径仍由队列集合处理,不能理解成“入队一定成功且一定等到 Remark 才处理”——队列在并发标记期间也能被消费。
SATB 队列过滤:不是所有旧值都要重标
队列里的条目并不会不加区分地全部重新标记。G1SATBMarkQueueSet 在处理缓冲时用两个判断过滤:requires_marking 检查条目是否位于堆内、是否低于所在 region 的 NTAMS(next top-at-mark-start),discard_entry 在其上再排除已被本轮标记的对象。
原码:requires_marking 与 discard_entry
原码连续摘录(仅统一公共缩进,截取方法整体):g1SATBMarkQueueSet.cpp,jdk-17.0.12+7,L83–L102。
static inline bool requires_marking(const void* entry, G1CollectedHeap* g1h) {
// Includes rejection of NULL pointers.
assert(g1h->is_in_reserved(entry),
"Non-heap pointer in SATB buffer: " PTR_FORMAT, p2i(entry));
HeapRegion* region = g1h->heap_region_containing(entry);
assert(region != NULL, "No region for " PTR_FORMAT, p2i(entry));
if (entry >= region->next_top_at_mark_start()) {
return false;
}
assert(oopDesc::is_oop(cast_to_oop(entry), true /* ignore mark word */),
"Invalid oop in SATB buffer: " PTR_FORMAT, p2i(entry));
return true;
}
static inline bool discard_entry(const void* entry, G1CollectedHeap* g1h) {
return !requires_marking(entry, g1h) || g1h->is_marked_next(cast_to_oop(entry));
}entry >= region->next_top_at_mark_start() 这一行就是“标记开始后新分配对象不显式重标”的依据:NTAMS 之上的对象是标记周期开始后新分配的,按隐式存活规则处理,不需要靠旧值队列重新标记;新生代对象由 Young GC 单独负责,陈旧引用也会被过滤。discard_entry 再加一条“已被本轮标记”的排除,避免重复工作。因此“SATB 记录旧值”不等于“所有旧值都要扫描”,实现会在队列处理阶段筛选,这解释了“为什么队列里不是所有旧值都需要重新标记”。这些是 HotSpot 实现细节,但能防止把 SATB 抽象成“全量记录、全量重标”。
G1 并发标记周期与停顿边界
Oracle JDK 17 的 G1 调优指南把标记周期描述为从 Young-only 阶段过渡到 Space-Reclamation 阶段的一部分,关键阶段包括:
- Concurrent Start:在一次 Young GC 的同时启动标记;日志中通常显示为
Pause Young (Concurrent Start)。 - Concurrent Mark:与业务线程并发执行,可能穿插多次 Young GC。
- Remark:Stop-The-World,最终完成标记、全局引用处理、类卸载、回收完全空区域并清理内部结构。
- Cleanup:Stop-The-World,决定是否进入空间回收阶段;Remark 与 Cleanup 之间的区域选择计算是并发的。
- Prepare Mixed / Mixed:后续 Young GC 开始回收部分 Old 区域。
IHOP 控制何时因非年轻代占用启动并发标记。JDK 17 指南使用“老年代占用百分比”的教学口径;固定 tag 实现应进一步看 G1StaticIHOPControl::get_conc_mark_start_threshold 的 _initial_ihop_percent * _target_occupancy / 100,以及 G1Policy::record_new_heap_size 用当前堆 region 数乘 region 大小更新 target。它不是“当前已使用老年代大小”再乘百分比。自适应还结合标记耗时、老年代分配速率和预留空间预测。小堆实验关闭自适应并设为 5% 只为触发周期,不是生产建议(g1IHOPControl.hpp、g1Policy.cpp)。
日志里要区分两类行:Concurrent ... 与业务线程并发执行,不直接形成 STW 停顿;Pause ... 是 Stop-The-World,应用线程被暂停,Remark 和 Cleanup 都属于这一类。Concurrent Mark Cycle 结束不代表对象已经被搬走;它完成的是标记和区域选择,真正的回收发生在后续 Young/Mixed GC 的 Evacuation 阶段。把“并发标记”与“对象回收”混为一谈,会导致对停顿和内存变化的错误归因。
图解:漏标窗口与 SATB 补救
图左侧是三种颜色与经典三色不变量的关系;右侧把漏标时序拆成四步:建立快照、扫描 A、业务线程改写引用,以及“无 SATB / 有 SATB”两个结局。读图时把注意力放在第 3 步的时间窗:B 尚未扫描、C 仍是白色时旧边被删除;SATB 记录的是旧值 C,而不是新边,所以 Remark 能把它补回来。
可运行实验:三色模型、引用探针与 G1 日志
实验目录:labs/gc。本机运行时为 Oracle JDK 17.0.12(macOS x86_64),源码基线固定 jdk-17.0.12+7。
cd labs/gc
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home ./run.sh三个实验分别产出:
TriColorSatbSim:确定性三色模型,对比无 SATB / 有 SATB 的漏标结果。GcReachabilityProbe:强引用、静态引用、弱引用、虚引用在System.gc()后的观测。AllocationPressure:用 128 MB 小堆和固定低 IHOP 触发 G1 并发标记,记录阶段日志。
预期(前几节推导):无 SATB 时 C 被漏标回收;有 SATB 时旧值入队、Remark 后 C 变黑保留。实测(本次运行):
== 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引用探针的实测输出:
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()=nulllocal 第一次为 true 是 reachabilityFence 保证的结果;static 第一次为 true 因为静态字段是根,置空后第二次才回收;只有弱引用的对象第一次 GC 即被清除;虚引用入队且 get() 恒为 null,第二次 phantomQueued=false 是因为第一次已 poll() 取走。out/gc.log 中对应的回收是 Pause Full (System.gc())——这正说明该探针演示的是引用强度,不是并发标记。
G1 日志(128 MB 堆、-XX:-G1UseAdaptiveIHOP -XX:InitiatingHeapOccupancyPercent=5)中第一个完整周期的实测摘录:
GC(1) Pause Young (Concurrent Start) (G1 Evacuation Pause) 31M->28M(128M) 6.346ms
GC(2) Concurrent Mark Cycle
GC(2) Concurrent Mark 8.621ms
GC(2) Pause Remark 47M->34M(128M) 1.006ms
GC(2) Pause Cleanup 38M->38M(128M) 0.163ms
GC(3) Pause Young (Prepare Mixed) (G1 Evacuation Pause) 58M->31M(128M) 9.060ms
GC(4) Pause Young (Mixed) (G1 Evacuation Pause) 32M->29M(128M) 2.717ms本次记录共 3 个完整并发标记周期(GC(2) 13.304ms、GC(6) 37.585ms、GC(10) 28.966ms)与 8 次 Pause Young,0 次 Pause Full;AllocationPressure 稳定保留 retained=12000 个数组。注意:Concurrent Mark Cycle 行在日志里出现两次(开始与结束各一条,结束行带耗时),数周期要按带耗时的行或周期编号去重,不能用行数直接当周期数。
这些输出证明了什么:没有 SATB 时,快照时可达的 C 会被漏标;记录旧值后,Remark 能把它补回;强引用和静态引用在第一次 GC 后仍可达,只有弱引用的对象第一次 GC 后已被清除,虚引用入队且 get() 为 null;G1 日志确实出现 Concurrent Start → Concurrent Mark → Remark → Cleanup → Prepare Mixed → Mixed 的序列。
这些输出不证明什么:三色模拟不是 JVM 调度,不覆盖并发竞争、SATB 队列溢出、新生代处理和标记中止;System.gc() 不保证回收行为;一次 128 MB、IHOP=5% 的日志不能外推生产环境的周期数量、停顿或吞吐。
诊断时不要把“高占用”直接当成泄漏
SATB 的浮动垃圾会让 Old 区在标记周期结束后仍保留一些本轮已死亡的对象,直到下一轮回收;这不等同于内存泄漏。判断泄漏要看多轮 GC 后的保留集合、heap dump 中的支配树和到 GC Roots 的路径,而不是看单次占用。常见证据链:
- GC 日志:Full GC 频率、Remark/Cleanup 停顿、Old 区回收前后变化、Humongous 分配。
- JFR:GC 事件、对象分配采样、线程和锁事件,用来把分配速率和停顿放在同一时间线。
- Heap dump:对象支配树、到 GC Roots 的最短路径、类加载器数量、静态集合大小。
jcmd/jmap:堆直方图、类直方图、dump 触发;本地内存问题还要看 NMT,而不是只看堆 dump。
如果 dump 显示对象被静态 Map 持有,修复方向是生命周期和有界缓存,而不是“多调几次 GC”。如果对象只被弱引用/软引用持有,却仍看到高占用,要检查引用对象本身是否被长期持有、值是否反向强引用键、以及缓存是否无界。
练习
- 画出“A 黑、B 灰、C 白,删除 B→C 并新增 A→C”的时序,分别标注无 SATB 与有 SATB 时 C 的最终颜色。
- 修改三色模型:如果只新增 A→C、不删除 B→C,C 会被漏标吗?说明“快照存活”与“当前可达”的差别。
- 对照
requires_marking的过滤条件,给一个“旧值入队但不需要重标”的例子,并说明依据哪一行代码。 - 把
AllocationPressure的 IHOP 改成 20,重新运行并比较并发标记周期数量;解释差异,不把结果外推到生产。 - 给一个静态
HashMap持续增长的场景,写出用 heap dump 证明“对象被静态根持有”的步骤。 - 阅读一段 GC 日志,区分
Concurrent Mark、Pause Remark、Pause Cleanup和Pause Full,说明哪些会暂停应用线程。
高级答案与三轮追问
主问:“SATB 记录什么、为什么能防止漏标,代价是什么?”答:前写屏障在字段被覆盖前读取旧值并放入线程本地 SATB 队列;Remark 重新标记这些旧值,使“快照时存活”的对象不会因为边被改掉而漏标。代价是屏障开销与浮动垃圾。
第一轮:“SATB 为什么允许浮动垃圾?”答:目标是不漏掉快照存活集合,而不是精确回收标记期间死亡的对象;把删除旧边的信息记录下来、延后处理,能缩短 Remark。评分点:快照语义 + 停顿/内存取舍。
第二轮:“标记期间新分配的对象怎么处理?”答:requires_marking 按 NTAMS 过滤掉标记开始后分配的对象,它们按隐式存活处理;新生代对象由 Young GC 负责。评分点:指出 NTAMS 过滤与隐式存活,而不是“记录所有旧值”。
第三轮:“内容元数据缓存如何避免 GC 层面的泄漏?”答:先把缓存做成有界——容量、权重、过期、淘汰和回源策略都要明确;不要用无界静态 Map,也不要把 SoftReference 当成容量策略。键使用稳定 ID,值使用不可变快照,更新时整体替换并清理旧版本;监控命中率、淘汰、堆占用和 Old 区增长,出现疑似泄漏时用 heap dump 找到支配树和到 GC Roots 的路径。GC 调参不能替代对象生命周期设计。评分点:边界设计 + 证据闭环。
技术案例:缓存、去重与转码缓冲
以下场景用于分析技术选型与故障边界,可使用实验数据验证,无需填写个人项目经历。
- 内容中心元数据缓存:为缓存设置容量上限、过期和淘汰;不要用无界静态 Map 保存内容版本。更新时替换不可变快照,旧版本由淘汰策略回收,并用命中率、堆占用和 GC 日志验证。
- 消息消费去重:本地去重集合必须有界并带 TTL;弱引用不能替代业务过期时间,也不能保证跨实例去重。跨实例仍依赖数据库唯一键或 Redis 原子操作。
- 图片/转码临时缓冲:大对象和直接缓冲区要显式释放;堆 dump 看不到全部本地内存,需结合 NMT 和池化策略。不要依赖
System.gc()或 finalization 及时归还。 - 线上 Full GC 排查:先把 GC 日志、JFR 和 heap dump 放在同一时间线,区分分配速率、Old 区 retention、Humongous 分配和 Remark 停顿;再用支配树和到 GC Roots 的路径确认持有者。没有证据时不要断言“内存泄漏”或“GC 参数不对”。
架构师变化条件:浮动垃圾能否放得下
纸面输入:标记周期 2 s,每秒 80 MiB 的原存活对象失去最后强引用,堆空闲余量 256 MiB。若这些对象都因 SATB 留到本轮之后,可有约 160 MiB 本轮浮动垃圾。这只是简化上界估计,实际取决于死亡时刻和标记进度;余量还要承受新分配和复制目标空间,不能全部用来容纳浮动垃圾。
条件改为 CPU quota 减半、周期延长到 4 s,同一死亡速率下浮动保留可约 320 MiB,原预算不再成立。候选方案是限制缓存更新/在途对象峰值、增加堆和容器预算并提早标记、为并发 GC 补足 CPU。验证要同时记录周期时长、GC 后存活集、分配/晋升、容器节流和请求时间线。如果多轮回收后仍持续增长,应回到根路径找生命周期问题,不能用浮动垃圾解释一切。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java SE 17 java.lang.ref 包文档 | Java SE 17 | 强/软/弱/虚可达定义、引用入队语义 |
| Java SE 17 PhantomReference | Java SE 17 | get() 恒为 null、phantom reachable 后入队 |
| Java SE 17 Reference.reachabilityFence | Java SE 17 | 强可达保证及其边界 |
| Java SE 17 System.gc | Java SE 17 | best-effort,不保证回收数量/时间/引用入队 |
| Oracle JDK 17 G1 调优指南 | JDK 17 | 标记周期、SATB、Remark/Cleanup、IHOP/自适应 IHOP |
| OpenJDK 17u g1BarrierSet.inline.hpp | jdk-17.0.12+7 | enqueue(L48–L58)、前/后屏障(L61–L77) |
| OpenJDK 17u g1SATBMarkQueueSet.cpp | jdk-17.0.12+7 | requires_marking / discard_entry 的 NTAMS 过滤(L83–L102) |
| OpenJDK 17u g1ConcurrentMark.cpp | jdk-17.0.12+7 | SATB 队列在标记开始时激活、Remark 后停用 |
| OpenJDK 17u g1IHOPControl.hpp / g1Policy.cpp | jdk-17.0.12+7 | IHOP 阈值口径与目标容量更新 |
| OpenJDK 17u g1RootProcessor.cpp | jdk-17.0.12+7 | Java 根、VM 根、代码缓存根的处理入口 |
labs/gc | Oracle JDK 17.0.12 | 三色模型、引用探针与 G1 日志的实测输出 |
访问日期:2026-10-23。源码行号以 jdk-17.0.12+7 标签为准;运行环境为 Oracle JDK 17.0.12(macOS x86_64)。G1 参数、日志阶段名和默认 IHOP 可能随 JDK 版本变化,升级后需重新核对。