【JVM深度解析】第04篇:垃圾回收算法与实现原理
摘要
垃圾回收(Garbage Collection,GC)是 JVM 自动内存管理的核心机制,将开发者从手动管理内存的繁琐中解放出来,但也带来了性能开销。本文深入解析 GC 的完整技术体系:从对象存活性判断(引用计数法 vs 可达性分析法)、四种引用类型(强/软/弱/虚),到三大基础算法(标记-清除、标记-整理、复制),再到分代收集理论与 HotSpot 中的跨代引用处理机制(记忆集/卡表/写屏障)。重点剖析 Stop-The-World 的本质原理与 SafePoint/SafeRegion 机制,最终通过与实际 GC 算法的对应关系,建立从理论到实践的完整映射。
引言
GC 是 Java 最被讨论却最少被理解的话题之一。
开发者们常常满足于"GC 会自动管内存"的粗浅认知,直到线上 Full GC 频繁、服务停顿数秒、应用响应超时的那一刻,才意识到深入理解 GC 的必要性。
但 GC 并不神秘。它本质上就是在回答两个问题:
- 哪些对象是垃圾?(对象存活判断)
- 怎么回收这些垃圾?(回收算法)
理解了这两个问题,GC 的所有行为都变得清晰可预期。
一、判断对象已死:垃圾的认定
1.1 引用计数法(Reference Counting)
原理:为每个对象维护一个引用计数器,每有一个引用指向该对象则加 1,引用失效则减 1,计数为 0 的对象即可回收。
Object A ──→ 计数器: 2 (被 B 和 C 引用) Object B ──→ 计数器: 1 (被 A 引用) Object C ──→ 计数器: 0 ← 可以回收 ✅优点:实现简单,回收即时(计数归零立即回收,无需 GC 扫描)
致命缺点——循环引用:
// 循环引用导致内存泄漏classNode{Nodenext;}Nodea=newNode();Nodeb=newNode();a.next=b;// b 的引用计数 = 1b.next=a;// a 的引用计数 = 1a=null;// 局部变量 a 不再引用,但对象 A 的计数仍为 1(b.next 指着它)b=null;// 局部变量 b 不再引用,但对象 B 的计数仍为 1(a.next 指着它)// A 和 B 的引用计数都为 1,永远不会被回收!// 这就是循环引用导致的内存泄漏循环引用示意: ┌──────────────────┐ ↓ │ 对象 A (计数=1) ──→ 对象 B (计数=1) │ └──→ 对象 A(形成循环) 外部已无引用,但两者计数都不为0,无法回收 ❌现实应用:Python、PHP、Swift 等使用引用计数,但都配备了循环引用检测机制。JVM 不使用引用计数法(主流 JVM 均使用可达性分析)。
1.2 可达性分析法(Reachability Analysis)
原理:从一组称为GC Roots的根对象出发,沿着引用链向下遍历。能被 GC Roots 直接或间接引用到的对象为"可达"(存活),无法到达的对象即为垃圾。
GC Roots(根节点): ├── 当前所有线程的虚拟机栈中的对象引用(局部变量、方法参数) ├── 方法区中静态变量引用的对象 ├── 方法区中常量引用的对象(如字符串常量池中的引用) ├── 本地方法栈中 JNI 引用的对象 ├── JVM 内部引用(基本类型对应的 Class 对象、常驻异常对象如 NPE、OOM) ├── 被 synchronized 持有的对象 └── JMXBean、JVMTI 中的回调注册对象等 GC Root 1 GC Root 2 GC Root 3 │ │ │ ▼ ▼ ▼ 对象 A 对象 C ──→ 对象 E │ │ ▼ ▼ 对象 B 对象 D 对象 F ──→ 对象 G (没有 GC Root 可到达,F 和 G 都是垃圾)可达性分析解决了循环引用问题:
即使 A ↔ B 互相引用,但如果从 GC Roots 出发无法到达 A 和 B, 它们依然被判定为垃圾,可以回收。✅1.3 对象的"两次死亡"机会
即使被可达性分析判定为"不可达",对象还有一次"复活"机会:
第一次标记(不可达) │ ▼ 是否覆盖了 finalize() 方法? │ │ 否 是 │ │ ▼ ▼ 直接回收 被放入 F-Queue JVM 低优先级线程执行 finalize() 如果在 finalize() 中重新建立引用: ───────────────────────────────── Object.SAVE_HOOK = this; // 自救! 第二次标记时检查:仍不可达?→ 回收 如果可达?→ 从回收集合中移除,对象"复活" ✅ ⚠️ 注意:finalize() 每个对象只会被调用一次!⚠️ 建议:
finalize()方法不可预测、难以调试、已被官方标记为弃用(@Deprecated,Java 9+)。尽量不要使用。需要清理资源时,使用try-with-resources或显式的close()方法。
二、四种引用类型
JDK 1.2 引入了四种引用强度,允许开发者精细控制对象的 GC 行为:
引用强度排序:强引用 > 软引用 > 弱引用 > 虚引用 GC 压力 强引用 ─────────────────────────────── 永不回收(除非引用断开) 软引用 ─────────────────────────────── 内存不足时回收 弱引用 ─────────────────────────────── 下次 GC 必定回收 虚引用 ─────────────────────────────── 随时可回收,主要用于跟踪回收动作2.1 强引用(Strong Reference)
// 普通的引用赋值就是强引用Objectobj=newObject();// obj 是强引用// 只要 obj 不为 null,对象永远不会被 GC 回收// 即使 OOM,JVM 也不会回收强引用对象obj=null;// 断开引用,对象才能被 GC2.2 软引用(Soft Reference)
// 内存充足时保留,内存不足时回收,适合做缓存SoftReference<byte[]>softRef=newSoftReference<>(newbyte[1024*1024]);// 访问时需要判空byte[]data=softRef.get();if(data==null){// 已被 GC,需要重新加载data=loadData();softRef=newSoftReference<>(data);}// 典型应用:图片缓存Map<String,SoftReference<Image>>imageCache=newHashMap<>();2.3 弱引用(Weak Reference)
// 无论内存是否充足,下次 GC 必定被回收WeakReference<Object>weakRef=newWeakReference<>(newObject());// GC 后 weakRef.get() 返回 null// 典型应用1:ThreadLocal 的 ThreadLocalMap// key 是 WeakReference<ThreadLocal>,防止 ThreadLocal 对象泄漏// (但 value 是强引用,value 泄漏仍需手动 remove())// 典型应用2:WeakHashMap(key 为弱引用,key 被 GC 后 entry 自动清除)WeakHashMap<Object,String>weakMap=newWeakHashMap<>();2.4 虚引用(Phantom Reference)
// 无法通过虚引用获取对象,仅用于跟踪对象被回收的动作// 必须配合 ReferenceQueue 使用ReferenceQueue<Object>queue=newReferenceQueue<>();PhantomReference<Object>phantomRef=newPhantomReference<>(newObject(),queue);// phantomRef.get() 永远返回 null!// 当对象被 GC 时,PhantomReference 会被加入 queue// 监控线程可以处理 queue 来执行清理操作// 典型应用:NIO 的 DirectByteBuffer 堆外内存回收(JDK9+ Cleaner机制)三、三大基础回收算法
所有 GC 实现都基于以下三种基础算法的组合:
3.1 标记-清除算法(Mark-Sweep)
步骤:
- 标记阶段:从 GC Roots 出发,标记所有可达对象
- 清除阶段:遍历堆内存,未被标记的对象占用的空间置为"空闲"
标记前: ┌──────────────────────────────────────────────────────────┐ │ [存活A] [垃圾B] [存活C] [垃圾D] [垃圾E] [存活F] [垃圾G] │ └──────────────────────────────────────────────────────────┘ 标记后(✓ 表示存活): ┌──────────────────────────────────────────────────────────┐ │ [✓ A ] [垃圾B] [✓ C ] [垃圾D] [垃圾E] [✓ F ] [垃圾G] │ └──────────────────────────────────────────────────────────┘ 清除后: ┌──────────────────────────────────────────────────────────┐ │ [存活A] [空 ] [存活C] [空 ] [空 ] [存活F] [空 ] │ └──────────────────────────────────────────────────────────┘ ↑ 内存碎片!优点:实现简单,不需要移动对象(无需更新引用)
缺点:
- 内存碎片:清除后内存不连续,大对象分配困难
- 效率随碎片增加而降低:分配时需要遍历空闲列表
- 需要 Stop-The-World(标记阶段需要一致的内存快照)
应用场景:CMS 的老年代回收
3.2 标记-整理算法(Mark-Compact)
步骤:
- 标记阶段:同标记-清除,标记存活对象
- 整理阶段:将所有存活对象向一端移动,然后清除边界外的内存
标记前: ┌──────────────────────────────────────────────────────────┐ │ [存活A] [垃圾B] [存活C] [垃圾D] [垃圾E] [存活F] [垃圾G] │ └──────────────────────────────────────────────────────────┘ 整理后(存活对象移动到左侧,消除碎片): ┌──────────────────────────────────────────────────────────┐ │ [存活A] [存活C] [存活F] [────────── 连续空闲内存 ─────────] │ └──────────────────────────────────────────────────────────┘ ↑ 指针碰撞分配区域(高效!)优点:
- 无内存碎片,可用指针碰撞快速分配
- 内存空间利用率高
缺点:
- 移动对象代价高:需要更新所有指向被移动对象的引用
- Stop-The-World 时间更长(需要移动对象并更新引用)
应用场景:Serial Old、Parallel Old 的老年代回收,G1 的 Mixed GC
3.3 复制算法(Copying)
步骤:
- 将内存分为两个等大的半区(From 和 To)
- 每次只使用 From 区
- GC 时:将 From 区的存活对象全部复制到 To 区(紧凑排列)
- 清空 From 区,交换 From/To 角色
GC 前(From 区): ┌──────────────────────────────────┐ ┌──────────────────────────────────┐ │ [存活A][垃圾B][存活C][垃圾D][垃圾E]│ │ 空闲(To 区) │ └──────────────────────────────────┘ └──────────────────────────────────┘ From 区(使用中) To 区 GC 后(复制到 To 区): ┌──────────────────────────────────┐ ┌──────────────────────────────────┐ │ 清空(新的 To 区) │ │ [存活A][存活C] ← 指针碰撞分配区域 │ └──────────────────────────────────┘ └──────────────────────────────────┘ 原 From 区 原 To 区(新 From 区)优点:
- 无内存碎片(复制后紧凑排列)
- 分配效率高(指针碰撞)
- GC 效率高(只遍历存活对象,存活对象少时速度快)
缺点:
- 内存利用率只有 50%(一半空间始终空闲)
- 存活对象多时,复制代价高(不适合老年代)
应用场景:年轻代 GC(存活率低,复制量少)
3.4 三种算法对比
| 对比维度 | 标记-清除 | 标记-整理 | 复制 |
|---|---|---|---|
| 内存碎片 | 有 | 无 | 无 |
| 内存利用率 | 高 | 高 | 50% |
| GC 停顿时间 | 中等 | 较长 | 较短(存活少时) |
| 适用场景 | 老年代(存活多,移动代价高) | 老年代(高吞吐优先) | 年轻代(存活少) |
| 主要用例 | CMS 老年代 | Serial/Parallel Old | 年轻代所有收集器 |
四、分代收集理论
4.1 两个假说支撑分代设计
现代 GC 的分代收集理论基于两个被大量实践验证的假说:
弱分代假说(Weak Generational Hypothesis):
绝大多数对象都是朝生夕灭的——大部分对象在创建后很快就变成垃圾。
强分代假说(Strong Generational Hypothesis):
熬过越多次 GC 的对象,越难以消亡。
基于这两个假说,JVM 将堆分为年轻代(Young Generation)和老年代(Old Generation),针对不同的对象特征使用最适合的算法:
年轻代:对象存活率极低(一次 Minor GC 通常回收 90%+) → 使用复制算法(只需复制少量存活对象,高效) → 频繁 GC(但每次 GC 速度快) 老年代:对象存活率高(都是"熬过来"的长寿对象) → 使用标记-清除 或 标记-整理(不适合复制,存活对象太多) → 较少 GC(但每次 GC 代价较大)4.2 跨代引用问题
分代 GC 面临一个棘手问题:老年代对象可能引用年轻代对象。
Minor GC 只扫描年轻代,但如果老年代引用了年轻代对象,这些对象不应该被回收——可这要怎么知道?
错误做法:每次 Minor GC 都扫描老年代(相当于 Full GC,失去了分代的意义)
正确做法:记忆集(Remembered Set)+ 卡表(Card Table)
卡表(Card Table)原理: 堆内存 ┌────────────────────────────────────────────────────────────────┐ │ 老年代 │ │ [卡页0][卡页1][卡页2][卡页3][卡页4][卡页5][卡页6][卡页7]... │ │ 512B 512B 512B 脏 512B 512B 脏 512B │ └────────────────────────────────────────────────────────────────┘ 卡表(Card Table)= 每个卡页对应一个字节(脏位标记): [ 0 ][ 0 ][ 0 ][ 1 ][ 0 ][ 0 ][ 1 ][ 0 ]... ↑ ↑ 卡页3是"脏的" 卡页6是"脏的" (含有指向年轻代的引用) (含有指向年轻代的引用) Minor GC 时: 只需扫描"脏"卡页(而非整个老年代),找到跨代引用, 将这些跨代引用加入 GC Roots,确保被引用的年轻代对象不被回收。写屏障(Write Barrier):
当对象引用关系发生变化(赋值语句obj.field = newRef)时,JVM 通过写屏障代码将对应的卡页标记为"脏":
// 普通赋值 obj.field = newRef; // JIT 编译后(含写屏障)相当于: obj.field = newRef; if (对象 obj 在老年代 && newRef 在年轻代) { CARD_TABLE[addr(obj) >> 9] = DIRTY; // >> 9 = 除以512(卡页大小) }五、Stop-The-World 与 SafePoint
5.1 为什么需要 Stop-The-World
GC 在标记对象存活状态时,需要一个一致的内存快照。如果标记过程中对象引用关系在不断变化,就可能出现:
危险场景(没有 STW): T=1: GC 开始标记,发现 对象A 可达 T=2: 用户线程执行:唯一引用 A 的变量 = null(A 变成垃圾了) T=3: GC 继续,A 被错误地标记为"存活"(对象游离,即浮动垃圾) 或者反向危险: T=1: GC 已经扫描过 对象B,标记为不可达(垃圾) T=2: 用户线程执行:新建了一个到 B 的引用(B "复活"了) T=3: GC 回收了 B(被错误回收,产生悬空指针!)Stop-The-World(STW)通过暂停所有用户线程来保证内存快照的一致性,避免上述问题。
5.2 SafePoint(安全点)
JVM 不能在任意位置暂停线程,而是让线程运行到**安全点(SafePoint)**后才能停下来。
安全点是代码中的特定位置,在这些位置上:
- 对象引用关系已知(通过 OopMap 记录了哪些位置有对象引用)
- 线程可以安全地被挂起
安全点的设置位置:
- 方法调用点(方法返回之前) - 循环的末尾(循环体回跳之前,防止超长循环不到达安全点) - 异常处理代码 - 可能发生 GC 的位置STW 的执行流程:
GC 触发 │ ▼ JVM 设置标志位(需要 GC) │ ▼ 各线程检查标志位(在安全点检查) │ ▼ 线程到达最近的安全点后挂起 │ ▼ 所有线程都到达安全点(全部停止) │ ▼ GC 执行(此时内存状态固定) │ ▼ GC 完成,唤醒所有线程继续执行5.3 SafeRegion(安全区域)
如果线程处于 Sleep 或 Blocked 状态(如等待 I/O),它可能长时间无法到达安全点。
安全区域(Safe Region):一段代码区域,在这段代码中,引用关系不会发生变化。JVM 可以在安全区域内任意位置暂停线程。
// 线程正在执行 I/O 等待,被标记进入安全区域Thread.sleep(1000);// 在 sleep 期间不会改变对象引用关系// 线程进入安全区域后,可以随时被 GC 暂停六、GC 的性能核心指标
6.1 吞吐量 vs 停顿时间
GC 设计面临根本性的权衡:
高吞吐量 低停顿时间 ────────────────────────────────────────────────────── 用户代码运行时间 / 总运行时间 最大 GC 停顿时间 ████ GC ████████ 用户代码 ████ GC ████ ─ STW 时间 ─ 吞吐量 = 用户运行时间 / (用户时间 + GC时间) 停顿时间 = max(单次GC停顿) 特点:GC 可以耗时较长, 特点:GC 单次停顿要短, 但整体运行用户代码的比例高 即使总 GC 耗时更多 适合:后台批处理、科学计算 适合:交互式应用、在线服务 代表:Parallel GC(JDK8 默认) 代表:ZGC、Shenandoah6.2 GC 的三大评估指标
| 指标 | 定义 | 优化方向 |
|---|---|---|
| 吞吐量 | 用户代码时间 / (用户时间 + GC时间) | 提高,适合批处理场景 |
| 停顿时间 | 单次 GC 导致的最长 STW 时间 | 降低,适合在线服务 |
| 内存占用 | GC 本身需要的额外内存 | 降低,适合内存紧张场景 |
💡GC 调优的核心矛盾:吞吐量与停顿时间往往是对立的——降低停顿时间(增加 GC 频率)会降低吞吐量;提高吞吐量(减少 GC 频率,单次 GC 处理更多)会增加单次停顿时间。选择 GC 收集器和调优参数,本质上是在这两者之间找到适合业务需求的平衡点。
七、GC 算法与收集器的对应关系
| GC 收集器 | 年轻代算法 | 老年代算法 | 特性 |
|---|---|---|---|
| Serial + Serial Old | 复制 | 标记-整理 | 单线程,停顿长,简单稳定 |
| ParNew + CMS | 复制 | 标记-清除 | 并发,低停顿,有碎片 |
| Parallel Scavenge + Parallel Old | 复制 | 标记-整理 | 多线程,高吞吐,JDK8默认 |
| G1 | 复制(Region间) | 标记-整理(Region内) | 可预测停顿,平衡 |
| ZGC | 着色指针+复制 | 着色指针+复制 | 超低停顿(<10ms) |
| Shenandoah | 复制 | 并发整理(Brooks指针) | 并发整理,低停顿 |
八、总结
GC 的本质是两步:判定垃圾(可达性分析)+回收垃圾(三大算法)。
- 可达性分析:从 GC Roots 出发,不可达 = 垃圾,彻底解决了引用计数法的循环引用问题
- 三大算法:标记-清除(简单但碎片)、标记-整理(无碎片但移动开销大)、复制(高效但内存减半)——现代 GC 都是三者的组合
- 分代收集:基于弱分代假说,年轻代用复制算法,老年代用标记-整理/标记-清除,通过卡表+写屏障处理跨代引用
- STW 与安全点:GC 需要暂停用户线程(STW)来保证内存快照一致性,线程在安全点处停止,进入安全区域的线程可随时暂停
- 核心权衡:吞吐量 vs 停顿时间,没有银弹,只有适合业务场景的最优解
下一篇预告:有了算法基础,我们来看算法的"载体"——具体的垃圾收集器。Serial、ParNew、Parallel GC、CMS 各自如何实现?它们适合什么场景?各有哪些坑?第05篇将带你完整梳理传统垃圾收集器体系。
系列导航
- 上一篇:【JVM深度解析】第03篇:运行时数据区深度剖析
- 下一篇:【JVM深度解析】第05篇:传统垃圾收集器总览
- 系列目录:JVM深度解析系列全集
参考资料
- 《深入理解Java虚拟机(第3版)》第3章 — 周志明著
- JVM Specification: Chapter 3 - Garbage Collection
- GC Handbook(权威 GC 理论参考)
- Java Platform, Standard Edition HotSpot Virtual Machine GC Tuning Guide
- Aleksey Shipilёv: Garbage Collection in Java
- Understanding Java Garbage Collection
