垃圾回收算法
垃圾回收概述
垃圾回收(Garbage Collection, GC)是 JVM 自动内存管理的核心机制。GC 需要解决三个基本问题:
- 哪些内存需要回收? — 对象存活判定
- 什么时候回收? — GC 触发时机
- 如何回收? — 垃圾回收算法
对象存活判定
引用计数法
给对象添加一个引用计数器,每被引用一次加 1,引用失效减 1,为 0 时回收。
致命缺陷:无法解决循环引用。
Object a = new Object();
Object b = new Object();
a.field = b;
b.field = a;
a = null;
b = null;
// 两个对象互相引用,计数器不为 0,但实际已不可达
JVM 不使用引用计数法,而是使用可达性分析。
可达性分析算法
从 GC Roots 出发,沿引用链向下搜索,搜索走过的路径称为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连时,证明对象不可达,可回收。
GC Roots ──→ A ──→ B ──→ D
│
└──→ C ──→ E
F ──→ G (不可达,可回收)
GC Roots
可作为 GC Roots 的对象包括:
| 来源 | 说明 |
|---|---|
| 虚拟机栈中引用的对象 | 各栈帧的局部变量表中的引用 |
| 方法区中类静态属性引用的对象 | static 修饰的变量 |
| 方法区中常量引用的对象 | final static 修饰的变量 |
| 本地方法栈中 JNI 引用的对象 | native 方法引用的对象 |
| JVM 内部引用 | 基本类型对应的 Class 对象、常驻异常、类加载器等 |
| 同步锁持有的对象 | synchronized 锁住的对象 |
| JMXBean、JVMTI 中注册的回调 | 运行时管理接口引用 |
四种引用类型
JDK 1.2 之后将引用分为四种:
| 引用类型 | 特点 | 回收时机 | 用途 |
|---|---|---|---|
| 强引用(Strong) | Object obj = new Object() |
永不回收(只要引用存在) | 普通对象引用 |
| 软引用(Soft) | SoftReference<T> |
内存不足时回收 | 缓存 |
| 弱引用(Weak) | WeakReference<T> |
下次 GC 时回收 | WeakHashMap、ThreadLocal |
| 虚引用(Phantom) | PhantomReference<T> |
随时回收,仅用于跟踪 GC | 跟踪 GC、管理堆外内存 |
// 软引用示例 - 适合做缓存
SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = cache.get(); // 可能返回 null(如果已被 GC 回收)
// 弱引用示例 - 适合做临时映射
WeakReference<Key> ref = new WeakReference<>(key);
// 虚引用示例 - 配合 ReferenceQueue 使用
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantom = new PhantomReference<>(obj, queue);
// 对象被回收后,虚引用入队,可在此清理堆外内存
基础垃圾回收算法
标记-清除算法(Mark-Sweep)
最基础的收集算法,分两个阶段:
- 标记:从 GC Roots 出发,标记所有可达对象
- 清除:遍历堆,回收未标记的对象
标记前: [A] [B] [C] [D] [E] [F]
↑ ↑ ↑
可达 可达 可达
标记后: [A✓] [B] [C✓] [D] [E✓] [F]
清除后: [ ] [C✓] [ ] [E✓] [ ] ← 产生内存碎片
- 优点:实现简单,不需要移动对象
- 缺点:
- 产生大量不连续的内存碎片
- 分配大对象时可能触发另一次 GC
- 标记和清除效率都不高
复制算法(Copying)
将内存分为两块等大小区域,每次只使用其中一块:
- 将存活对象复制到另一块区域
- 清除当前区域所有对象
- 交换两块区域的角色
From 区: [A✓] [B] [C✓] [D] [E✓] [F]
↓ 复制
To 区: [A] [C] [E] [ ] ← 连续空间,无碎片
- 优点:实现简单,运行高效,无内存碎片
- 缺点:可用内存缩小为一半,空间利用率低
优化:新生代中 98% 的对象都是”朝生夕死”的,不需要 1:1 划分。HotSpot 默认 Eden:S0:S1 = 8:1:1,只浪费 10% 的空间。
标记-整理算法(Mark-Compact)
结合标记-清除和复制算法的优点:
- 标记:与标记-清除相同
- 整理:将所有存活对象向一端移动,然后清理边界以外的内存
标记后: [A✓] [B] [C✓] [D] [E✓] [F]
整理后: [A] [C] [E] [ ] ← 连续空间,无碎片
- 优点:无内存碎片,不需要牺牲额外空间
- 缺点:移动对象需要更新引用,效率较低(尤其是存活对象多时)
算法对比
| 算法 | 标记-清除 | 复制 | 标记-整理 |
|---|---|---|---|
| 速度 | 中等 | 最快 | 最慢 |
| 空间开销 | 少(有碎片) | 大(2 倍空间) | 少(无碎片) |
| 移动对象 | 否 | 是 | 是 |
| 适用场景 | 老年代(CMS) | 新生代 | 老年代 |
分代收集理论
理论基础
分代收集基于两个假说:
- 弱分代假说:绝大多数对象都是朝生夕死的
- 强分代假说:熬过越多次 GC 的对象越难被回收
基于这两个假说,JVM 将堆分为新生代和老年代,对不同代采用不同的收集算法:
- 新生代:对象存活率低,适合复制算法(只需复制少量存活对象)
- 老年代:对象存活率高,适合标记-清除或标记-整理算法
跨代引用
跨代引用(老年代引用新生代,或反过来)是个问题。如果 Minor GC 只扫描新生代,如何确保不误回收被老年代引用的新生代对象?
解决方案:记忆集(Remembered Set)
记忆集用于记录从非收集区域指向收集区域的指针。在新生代 GC 时,只需扫描记忆集而非整个老年代。
HotSpot 使用卡表(Card Table)实现记忆集:将老年代划分为固定大小的卡页(512 字节),每个卡页对应卡表中一个字节。当老年代中某个卡页的引用字段被修改时,卡表对应位置被标记为”脏”。
卡表标记: [0] [0] [1] [0] [0] [1] [0] [0]
↑ ↑
脏页 脏页
(有跨代引用) (有跨代引用)
写屏障(Write Barrier)用于维护卡表,在引用赋值时更新卡表状态。
安全点与安全区域
安全点(Safepoint)
GC 需要确保在一致性状态下进行,即所有线程的引用关系不再变化。JVM 通过安全点(Safepoint)实现:
- 安全点位置:方法调用、循环跳转、异常跳转等
- 到达方式:
- 抢先式中断(已废弃):GC 时中断所有线程,不在安全点的恢复到安全点
- 主动式中断(HotSpot 采用):设置一个标志位,线程在安全点主动检查并挂起
# 安全点相关参数
-XX:+PrintSafepointStatistics # 打印安全点统计
-XX:PrintSafepointStatisticsCount=1 # 每次安全点都打印
安全区域(Safe Region)
安全点解决了”执行中”线程的问题,但对于阻塞或睡眠的线程(如 Thread.sleep()、等待 IO),它们无法主动到达安全点。
安全区域是安全点的扩展:在安全区域内,引用关系不会变化。线程进入安全区域时标记自己,离开时检查 GC 是否完成,若未完成则等待。
增量更新与原始快照(SATB)
在并发标记阶段,收集线程和用户线程同时运行,可能产生对象消失问题:
黑色对象(已标记)新引用了白色对象(未标记),同时灰色对象(未完全扫描)删除了对白色对象的引用。白色对象被错误回收。
两种解决方案:
增量更新(Incremental Update)
CMS 使用。当黑色对象插入对白色对象的新引用时,记录这个引用。GC 结束后,以这些记录的引用为根重新扫描。
本质:关注引用的新增。
原始快照(SATB, Snapshot At The Beginning)
G1 使用。当灰色对象删除对白色对象的引用时,记录被删除的引用。GC 结束后,以这些记录的引用为根重新扫描。
本质:关注引用的删除,保证 GC 开始时的对象图一致性。
增量更新 SATB
记录新增引用 记录删除引用
(A → C 新增) (B → C 删除)
A(黑) ──新增──→ C(白) A(黑) ──→ C(白)
B(灰) ──删除──↗ B(灰) ──×──→ C
小结
本章介绍了垃圾回收的核心算法和理论基础。理解可达性分析和 GC Roots 是判断对象是否可回收的关键;理解三种基础算法的优缺点是后续学习具体收集器的基础;理解分代理论、记忆集和安全点有助于理解 GC 的实现机制。下一章将详细介绍各种垃圾收集器。
评论