JVM垃圾收集算法
# JVM垃圾收集算法
# 前言
# 什么是 GC Roots(以 HotSpot 为主)
GC Roots 是一组“起点引用位点”(reference slots),而不是某个对象本身。 从这些“起点”能沿指针走到的对象,都被视为可达。
# ① 线程相关(最核心)
- 每个正在运行的 Java 线程的:
- 栈帧里的本地变量表(形参、局部变量里保存的对象引用)
- 操作数栈中的对象引用(在安全点可见)
- JIT 寄存器里的对象引用(通过 OopMap 在安全点暴露)
注意:只有“当前还在调用栈上的方法”的局部变量算 Roots。已经返回的方法,局部变量就不存在了,自然不算。
# ② 类相关
- 已加载类的静态字段(
static成员里的对象引用) - 运行时常量池里直接持有的对象引用(例如某些常量对象、
String常量等)
# ③ 本地/Native 相关
- JNI 全局引用、JNI 本地引用(在调用期间)
- JVMTI/Agent 持有的对象句柄(受 JVM 记录和管理)
# ④ JVM 内部保留的根
- 活跃的线程对象本身、线程结构里挂的引用(含
ThreadLocalMap的可达链) - 系统/平台类加载器及其 LoaderData 图上的引用
- 代码缓存(CodeCache)里的 Oop 指针、JIT 内联缓存里存下的对象引用
- 挂起的 Finalizer 队列、引用处理队列(Soft/Weak/Final/Phantom 的根扫描入口)
- 其他 HotSpot 维护的内部结构(例如 StringTable 的可达入口等)
# 一、谁还活着?——GC 判活与并发正确性
# 引用计数器法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器加1,当引用失效,计数器减1;任何时候计数器为0的对象就是不可能再被使用的
优点:实现简单、效率高
缺点:它很难解决对象之间相互循环引用的问题
所谓对象之间的相互引用问题,如下面代码所示:除了对象objA 和 objB 相互引用着对 方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算 法无法通知 GC 回收器回收他们。
publicclassReferenceCountingGc{
Object instance = null;
public static void main(String[] args) {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}
2
3
4
5
6
7
8
9
10
11
12
# 可达性分析算法
可达性分析(找活对象)
└─ 并发标记 → 三色模型(白/灰/黑,避免黑→白漏标)
├─ 策略1:增量更新(盯新增引用)
│ └─ 实现:写屏障(post),黑写→白 => 白入灰队列
└─ 策略2:SATB 原始快照(盯删除引用)
└─ 实现:写屏障(pre),记录旧值保持快照完整
屏障机制(读/写两大类)
├─ 写屏障(pre/post)→ 主要保障并发标记正确性(增量更新 / SATB)
└─ 读屏障 → 主要保障并发搬家时的指针修正(Shenandoah/ZGC)
实际收集器 = 策略 + 屏障的组合
CMS:增量更新 + post
G1:SATB + pre(标记) + post(卡表/RS)
Shenandoah/ZGC:SATB(标记) + read(并发搬家)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
将 "GC Root" 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未找到的对象都是垃圾对象
# GC Root 根节点
- 线程栈本地变量、静态变量、本地方法栈变量等等
# 可达性分析算法模型 - 三色标记
这节解释如何保证并发标记正确,不是回收策略本身
“三色标记”源自可达性分析(Tracing)。追踪式 GC 会从 GC Roots 出发遍历对象图以判定存活对象。为在并发或增量场景下正确、直观地描述“哪些对象已访问、哪些待访问”,工程上采用“三色标记”模型:
三色标记(Tri-color marking)是一种抽象模型,用来描述 GC(特别是并发标记) 时如何区分对象的可达性状态。
- 白色:尚未被发现(默认死亡);
- 灰色:已发现但其引用尚未完全扫描;
- 黑色:已发现且其引用已完全扫描。 收集器通过写屏障维持不变式(如“黑不指白”或“灰集合封闭”),避免并发期间的漏标/错标,最终将未被标记(白色)的对象回收。三色模型只是“如何标记”的解释框架,后续“清理/复制/压缩”属于回收与整理阶段的具体策略。
# 三色标记
在并发标记过程中,因为标记过程中应用程序线程还在跑,对象间的引用可能发生变化,多标和漏标的情况就有可能发生 。三色标记算法就是解决这种问题,把GCRoots可达性分析遍历对象过程中遇到的对象,按照 “是否访问过” 这个条件标记成以下三种颜色
/**
* 垃圾收集算法细节之三色标记
* 为了简化例子,代码写法可能不规范,请忽略
*/
public class ThreeColorRemark {
public static void main(String[] args) {
A a = new A();
//开始做并发标记
D d = a.b.d; // 1.读
a.b.d = null; // 2.写
a.d = d; // 3.写
}
}
class A {
B b = new B();
D d = null;
}
class B {
C c = new C();
D d = new D();
}
class C {
}
class D {
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
黑色
表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无需重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象
灰色
表示对象已经被垃圾收集器访问过,但是这个对象上至少存在一个引用还没有被扫描过
白色
表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达
# 多标-浮动垃圾
在并发标记过程中,如果由于方法运行结束导致部分局部变量(gcroot)被销毁,这个gcroot引用的对象之前又被扫描过(被标记为非垃圾对象),那么本轮GC不会回收这部分内存,这部分本应该回收但是没有回收到的内存,被称之为 “浮动垃圾” 。浮动垃圾并不会影响垃圾回收的正确性,只是需要等到下一次垃圾回收中才会被清除。 另外,针对并发标记(还有并发清理)开始后产生的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除,这部分对象期间可能也会变为垃圾,这也算是浮动垃圾的一部分
# 漏标-读写屏障(并发标记阶段)
漏标会导致被引用的对象被当成垃圾误删除
CMS 记新增(增量更新)、G1 记删除(SATB原始快照)
# 三色标记的实现(读屏障与写屏障)
# 写屏障
增量更新实现写屏障

原始快照实现写屏障

# 增量更新(Incremental Update)- CMS垃圾收集器实现 写屏障 的方式
- 增量更新就是当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发标记结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次,这可简化理解为,黑色对象一旦插入了指向白色对象的引用之后,它就变回灰色对象了
- CMS 使用的是“增量更新(Incremental Update)写屏障”:它只关心“新增引用”,尤其是黑对象 → 白对象的新指针。在并发标记期间,如果某对象已经被“涂黑”(表示它以及它的字段都扫描过了,按理不会再次扫描了),此时又新建了一条指向白对象的引用,那么这条边会被写屏障记录下来;Remark 阶段再“回扫这些黑对象”(可理解为把它们临时当成灰去补扫一遍),避免漏标。
sequenceDiagram
participant Root
participant A
participant C
participant GC
rect rgb(230,230,250)
Note over Root: 并发标记开始
Root->>A: 发现 A(入灰)
end
rect rgb(240,255,240)
Note over GC: GC 扫 A(A 变黑)
Note over A: 业务线程写入:A.f = C(黑→白 新增引用)
A->>C: 新引用
Note over A: post-write 屏障:将 C 入灰队列
end
rect rgb(255,250,230)
Note over GC: GC 扫描 C(C 由灰→黑),不漏标
end
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 原始快照(Snapshot At The Beginning,SATB)- G1垃圾收集器实现 写屏障 的方式
原始快照就是当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次,这样就能扫描到白色的对象,将白色对象直接标记为黑色(目的就是让这种对象在本轮gc清理中能存活下来,待下一轮gc的时候重新扫描,这个对象也有可能是浮动垃圾)
SATB 的快照:T0 时刻 从 Roots 可达 的对象集合
核心观念
- 把**并发标记开始那一刻(T0)*从 **GC Roots** 可达的对象集合,视为本轮“**必须保住**”的*逻辑快照。
- 之后对象图会继续变化,但本轮判活始终以 T0 为准。
三个关键规则
- 新增引用可忽略:T0 后新增的边,不改变本轮的活集判定。
- T0 后新分配的对象通过 TAMS/新生代策略被视为本轮天然存活,即使暂时没人引用也不会被本轮回收。
- 删除/覆盖引用必须记录旧值(oldRef):
- 在写入新值之前(pre-write barrier),把即将被移除的旧引用推入 SATB 缓冲;
- 这样即使标记者“来晚了”,在真实堆里已经看不到那条旧边,也能从缓冲中把旧对象补标,不漏掉 T0 应保的对象。
- 允许浮动垃圾:T0 后刚刚变成垃圾的对象,本轮可以不清,留到下一轮;安全优先。
T0(快照): Roots → A(灰) ─→ C(白) // C 在 T0 可达(应保) T1(并发期,业务删边): A.f = D └─ pre-write:记录 oldRef=C → SATB Buffer T2(标记者稍后才扫 A): 真实堆只见 A→D,看不到 A→C └─ 但会从 SATB Buffer 取出 C,令其变灰并扫描 → C 被保住1
2
3
4
5
6
7
8
9
10时间线小图
sequenceDiagram participant Root participant A participant C participant D participant GC Note over Root,A: 🕒 T0 快照:A→C 可达 Root->>A: 灰色(待标记) A->>C: 白色(未标记) Note over A: 并发期间业务执行 A.f = D A->>D: 新引用(可忽略) Note over A: pre-write 记录旧引用 C → SATB Buffer Note over GC: 标记者稍后消费 SATB Buffer 补标 C,保证 T0 可达对象不漏1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16流程分解(以 G1 为例)
- Initial Mark(短暂停顿):从 Roots 起步打下第一批标记。
- Concurrent Mark(并发标记):
- 业务线程继续跑;
- pre-write 记录被移除的旧引用到 SATB Buffer;
- 标记者持续扫灰对象 + 消费 SATB Buffer。
- Remark(短暂停顿):把各线程的 SATB Buffer 彻底清空/合并,补齐尾巴。
- Cleanup:根据标记结果回收垃圾,或进入复制/整理阶段。
# 为什么“T0 可达的对象本轮必须保住”是 SATB 的契约?
- SATB 的定义:本轮 GC 的“活对象”集合,以 T0(并发标记启动/初始标记完成时刻)的可达集为准(“原始快照”)。
- 这样我们就可以忽略所有 T0 之后的新增引用(不需要追踪“新边”);
- 但要防止丢掉 T0 的旧边,所以删除/覆盖引用时必须记录 oldRef(pre-write)。
- 这条契约保证:
- 不提前回收仍可能被程序使用的对象(哪怕 T0→结束这段时间它“变成垃圾”了,也留到下一轮);
- 通过单边(只处理删边)屏障就能保证正确性 → 停顿小、实现简单。
- 如果不这么做,你就要同时追踪“新增引用”和“删除引用”,或者更长的 STW,成本高得多。
- SATB 的定义:本轮 GC 的“活对象”集合,以 T0(并发标记启动/初始标记完成时刻)的可达集为准(“原始快照”)。
# 🔄 与 CMS 的增量更新(Incremental Update)对比
对比项 SATB(G1、ZGC、Shenandoah) 增量更新(CMS) 关注点 删除引用 新增引用 屏障类型 前写屏障(pre-write) 后写屏障(post-write) 维护目标 保住 T0 可达集 及时补标新引用,防漏 允许结果 浮动垃圾 浮动存活对象 SATB = 逻辑上冻结快照,CMS = 实时增量补漏。 前者追求一致性,后者追求新鲜性。
# 读屏障

# 主流收集器算法实现
现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案
- CMS:写屏障 + 增量更新
- G1,Shenandoah:写屏障 + SATB
- ZGC:读屏障 + 颜色指针
为什么G1用SATB?CMS用增量更新?
- SATB相对增量更新效率会高(当然SATB可能造成更多的浮动垃圾),因为不需要在重新标记阶段再次深度扫描被删除引用对象,而CMS对增量引用的根对象会做深度扫描,G1因为很多对象都位于不同的region,CMS就一块老年代区域,重新深度扫描对象的话G1的代价会比CMS高,所以G1选择SATB不深度扫描对象,只是简单标记,等到下一轮GC再深度扫描。
# 记忆集与卡表
在新生代做GCRoots可达性扫描过程中可能会碰到跨代引用的对象,这种如果又去对老年代再去扫描效率太低了。 为此,在新生代可以引入记录集(Remember Set)的数据结构(记录从非收集区到收集区的指针集合),避免把整个老年代加入GCRoots扫描范围。事实上并不只是新生代、 老年代之间才有跨代引用的问题, 所有涉及部分区域收集(Partial GC) 行为的垃圾收集器, 典型的如G1、 ZGC和Shenandoah收集器, 都会面临相同的问题。 垃圾收集场景中,收集器只需通过记忆集判断出某一块非收集区域是否存在指向收集区域的指针即可,无需了解跨代引用指针的全部细节。 hotspot使用一种叫做“卡表”(Cardtable)的方式实现记忆集,也是目前最常用的一种方式。关于卡表与记忆集的关系, 可以类比为Java语言中HashMap与Map的关系。 卡表是使用一个字节数组实现:CARD_TABLE[ ],每个元素对应着其标识的内存区域一块特定大小的内存块,称为“卡页”。 hotSpot使用的卡页(在老年代)是2^9大小,即512字节
卡表的维护
- 卡表变脏上面已经说了,但是需要知道如何让卡表变脏,即发生引用字段赋值时,如何更新卡表对应的标识为1。 Hotspot使用写屏障维护卡表状态
# 小结
可达性分析:GC 的数学题 1.1 对象图与 GC Roots 1.2 标记-清除的本质:求可达集合 1.3 并发带来的难题:对象图在变
三色标记:并发标记的正确性模型 2.1 三色定义与灰队列 2.2 “黑→白漏标”问题的由来 2.3 不变式目标:如何保证“绝不漏标”
屏障:在读/写点上插钩子 3.1 写屏障(pre/post)的语义与代价 3.2 读屏障的语义与代价 3.3 卡表与 Remembered Set(跨区引用记录)
两条经典不变式策略 4.1 增量更新:黑不指白(关注新增引用) - 触发点:post-write - 黑→白发生时把白变灰 - 案例:CMS 4.2 SATB 原始快照:记录旧值(关注删除引用) - 触发点:pre-write - 覆盖/删除引用时记录旧引用 - 案例:G1 / Shenandoah / ZGC(标记)
读屏障为何常与“并发搬家”绑定 5.1 Brooks 转发表 / 着色指针 5.2 Shenandoah/ZGC 的并发重定位流程 5.3 与 SATB 的协同:标记靠写屏障,搬家靠读屏障
收集器速查表:策略—屏障—代价—适用场景
- CMS / G1 / Shenandoah / ZGC 对比表(停顿、吞吐、内存开销)
- 工程实践:如何在博客/面试里“又快又准”地回答
- “三色标记是模型、增量更新/SATB是不变式策略、读/写屏障是实现机制、读屏障更多为并发搬家服务,实际收集器是组合拳。”
# 二、怎么回收?——内存回收与整理策略:三大垃圾收集算法
# 分代收集理论
根据对象存活周期的不同将内存分为几块,一般将java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。例如新生代中每次收集都有大量对象(99%)死去,所以可以选择复制算法,只要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择 “标记-清除” 或 “标记-整理” 算法进行垃圾收集
注意
# 注意:“标记-清除” 和 “标记-整理” 算法都会比复制算法慢十倍以上
复制算法只按存活对象付出复制成本,年轻代典型更快;标记-清除/标记-整理需要遍历整区并可能额外做整理,通常开销更高,但优势是无需等量的空闲对半分区、更适合老年代/分区化收集器。
# 标记复制算法
为了解决效率问题,”复制“ 算法算法出现了。它可以将内存划分为大小相同的两块,每次使用其中的一块。当这一块内存使用完之后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉,这样就使每次的内存回收都是对内存区间的一半进行回收。
# 标记清除算法
算法分为 “标记” 和 “清除” 阶段:标记存活的对象,统一回收未被标记的对象(一般选择这种);也可以反过来;标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
提示
1、效率问题(如果需要标记的对象太多,效率不高)
2、空间问题(标记清除后会产生大量不连续的碎片)
# 标记整理算法
根据老年代特点特出的一种标记算法,标记过程仍然与 “标记-清除” 算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存