
JVM垃圾回收:垃圾判定算法 系统性知识体系
一、垃圾判定算法概述
垃圾判定是JVM垃圾回收(GC)的第一步,核心任务是准确识别堆内存中哪些对象已经"死亡"(不再被任何途径使用),为后续的垃圾回收器提供回收目标。
垃圾判定算法的设计目标:
- 准确性:不能误判存活对象为垃圾(导致程序崩溃)
- 高效性:不能占用过多CPU时间和内存资源
- 实时性:尽量减少对应用程序执行的影响
JVM主流实现中主要采用两种垃圾判定算法:引用计数法和可达性分析算法。
二、引用计数法(Reference Counting)
2.1 基本原理
为每个对象添加一个引用计数器,用于记录该对象被引用的次数:
- 当有一个新的引用指向该对象时,计数器值+1
- 当一个引用失效时(如引用变量被赋值为null、超出作用域),计数器值-1
- 当计数器值变为0时,判定该对象为垃圾,可以被回收
2.2 实现细节
- 计数器通常存储在对象头(Object Header)中
- 引用的赋值和失效操作需要原子性保证(避免多线程竞争导致计数错误)
- 回收时直接遍历所有对象,收集计数器为0的对象
2.3 核心优点
- 实现简单:逻辑直观,易于理解和编码实现
- 判定效率高:垃圾判定是O(1) 操作,无需遍历整个堆内存
- 回收及时:对象一旦变为垃圾(计数为0),可以立即被回收
- 停顿时间短:没有全局的堆扫描过程,对应用程序的停顿影响小
2.4 致命缺点:循环引用问题
这是引用计数法最严重的缺陷,也是主流JVM不采用它的主要原因。
示例:
class A {
public B b;
}
class B {
public A a;
}
public class Test {
public static void main(String[] args) {
A a = new A(); // A对象计数=1
B b = new B(); // B对象计数=1
a.b = b; // B对象计数=2
b.a = a; // A对象计数=2
a = null; // A对象计数=1
b = null; // B对象计数=1
// 此时A和B对象已经无法被访问,但它们的引用计数器都不为0
// 引用计数法无法判定它们为垃圾,导致内存泄漏
}
}
2.5 其他缺点
- 空间开销:每个对象都需要额外的内存存储引用计数器
- 时间开销:频繁的引用赋值和失效会导致大量的计数器加减操作
- 无法处理软引用、弱引用等特殊引用类型
2.6 优化方案
针对循环引用问题,有一些优化思路,但都存在局限性:
- 手动解除循环引用:要求程序员在代码中显式断开循环引用,容易出错且增加开发负担
- 使用弱引用打破循环:将循环中的一个引用改为弱引用,当其他强引用消失时,弱引用不会阻止对象被回收
- 定期遍历检测循环:定期扫描堆内存,检测并回收循环引用的对象,这会引入额外的开销,且失去了引用计数法的高效性优势
2.7 实际应用
虽然主流JVM(HotSpot、OpenJ9)不使用引用计数法作为主要的垃圾判定算法,但它在其他领域有广泛应用:
- Python(CPython)的垃圾回收机制(结合了引用计数和分代回收)
- PHP的垃圾回收机制
- Objective-C和Swift的ARC(自动引用计数)
- 一些数据库和文件系统的资源管理
三、可达性分析算法(Reachability Analysis)
3.1 基本原理
也称为根搜索算法,是目前所有主流JVM采用的垃圾判定算法。
核心思想:以一系列称为GC Roots的对象作为起点,从这些起点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到GC Roots没有任何引用链相连时(即从GC Roots不可达),则判定该对象为垃圾。
3.2 GC Roots集合(核心重点)
GC Roots是必须存活的对象,它们永远不会被垃圾回收。在Java中,可作为GC Roots的对象包括:
| GC Roots类型 | 具体说明 |
|---|---|
| 虚拟机栈(栈帧中的本地变量表)中引用的对象 | 方法执行时创建的局部变量、参数等引用的对象 |
| 本地方法栈中JNI(Native方法)引用的对象 | 本地方法调用时使用的Java对象 |
| 方法区中类静态属性引用的对象 | 类的static变量引用的对象 |
| 方法区中常量引用的对象 | 字符串常量池、final常量等引用的对象 |
| 同步锁(synchronized关键字)持有的对象 | 作为锁的对象,在被持有期间不能被回收 |
| JVM内部引用的对象 | 如基本数据类型对应的Class对象、常驻异常对象、系统类加载器等 |
| 反映JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等 | JVM运行时需要的内部对象 |
3.3 实现细节
- 枚举GC Roots:GC开始时,需要暂停所有用户线程(Stop-The-World,STW),枚举所有GC Roots
- 遍历引用链:从GC Roots出发,遍历所有可达的对象,标记它们为存活
- 清理未标记对象:遍历完成后,所有未被标记的对象即为垃圾
3.4 核心优点
- 解决了循环引用问题:即使两个对象互相引用,只要它们都不可达GC Roots,就会被判定为垃圾
- 准确性高:能够准确识别所有真正的垃圾对象
- 支持各种引用类型:可以很好地处理强引用、软引用、弱引用、虚引用等不同强度的引用
3.5 主要缺点
- 需要STW停顿:枚举GC Roots和遍历引用链时必须暂停所有用户线程,这是GC停顿的主要来源之一
- 时间开销大:垃圾判定是O(n) 操作,堆内存越大,存活对象越多,遍历时间越长
- 实现复杂:需要处理各种复杂的引用关系和多线程场景
3.6 优化方案
为了减少STW时间和提高效率,现代JVM对可达性分析算法进行了大量优化:
- OopMap(Ordinary Object Pointer Map):记录栈帧和寄存器中哪些位置是对象引用,避免全栈扫描
- 安全点(Safepoint):让用户线程在特定的位置暂停,而不是任意位置,提高GC效率
- 安全区域(Safe Region):解决线程处于Sleep或Blocked状态时无法到达安全点的问题
- 并发标记:在用户线程运行的同时进行标记,减少STW时间(如CMS、G1、ZGC等垃圾回收器)
3.7 实际应用
- 所有主流JVM实现(HotSpot、OpenJ9、GraalVM等)
- C#的CLR垃圾回收机制
- Go语言的垃圾回收机制
四、两种算法的全面对比
| 对比维度 | 引用计数法 | 可达性分析算法 |
|---|---|---|
| 核心思想 | 基于对象被引用的次数 | 基于对象到GC Roots的可达性 |
| 循环引用处理 | 无法处理,会导致内存泄漏 | 可以完美处理 |
| 判定效率 | O(1),非常高 | O(n),与存活对象数量成正比 |
| STW停顿 | 几乎没有 | 必须有,且停顿时间与堆大小相关 |
| 空间开销 | 每个对象需要存储计数器 | 几乎没有额外空间开销 |
| 时间开销 | 频繁的计数器加减操作 | 一次性的堆遍历操作 |
| 实现复杂度 | 简单 | 复杂 |
| 支持特殊引用类型 | 不支持 | 完全支持 |
| 主流JVM采用 | 否 | 是 |
五、扩展知识:引用类型与垃圾判定的关系
在Java中,引用的强度从强到弱分为4种,它们与垃圾判定算法密切相关:
5.1 强引用(Strong Reference)
- 最常见的引用类型,如
Object obj = new Object() - 只要强引用存在,垃圾回收器永远不会回收被引用的对象
- 可达性分析中,强引用连接的对象都是存活的
5.2 软引用(SoftReference)
- 用来描述一些有用但非必需的对象
- 在系统将要发生内存溢出之前,会把这些对象列进回收范围进行二次回收
- 适合实现缓存
5.3 弱引用(WeakReference)
- 用来描述非必需对象,强度比软引用更弱
- 当垃圾回收器工作时,无论内存是否足够,都会回收被弱引用关联的对象
- 适合实现临时缓存
5.4 虚引用(PhantomReference)
- 最弱的一种引用类型
- 无法通过虚引用获取对象实例
- 唯一目的是在对象被回收时收到一个系统通知
- 必须与引用队列(ReferenceQueue)一起使用
注意:引用计数法只能处理强引用,而可达性分析算法可以根据不同的引用类型采取不同的回收策略。
六、常见面试考点
- 为什么主流JVM不使用引用计数法?
- 什么是GC Roots?哪些对象可以作为GC Roots?
- 可达性分析算法为什么需要STW停顿?
- 如何解决循环引用问题?
- 四种引用类型的区别和应用场景是什么?
- OopMap和安全点的作用是什么?
JVM垃圾判定算法 面试问答卡片(可直接背诵)
一、基础核心题(必背)
Q1:什么是垃圾判定?它在JVM垃圾回收中的作用是什么?
答案:
垃圾判定是JVM垃圾回收(GC)的第一步,核心任务是准确识别堆内存中哪些对象已经"死亡"(不再被任何途径使用)。
- 作用:为后续垃圾回收器提供明确的回收目标,避免误删存活对象导致程序崩溃,同时确保真正的垃圾被及时回收释放内存。
- 设计目标:准确性(不能误判)、高效性(不占用过多资源)、低影响(尽量减少对应用程序的停顿)。
Q2:简述引用计数法的基本原理和优缺点。
答案:
基本原理:为每个对象添加一个引用计数器,记录该对象被引用的次数。
- 新引用指向对象 → 计数器+1
- 引用失效 → 计数器-1
- 计数器=0 → 判定为垃圾,可立即回收
核心优点:
- 实现简单,逻辑直观
- 判定效率极高(O(1)操作)
- 回收及时,对象死亡立即回收
- 几乎没有全局STW停顿
主要缺点:
- 致命缺陷:无法处理循环引用,会导致内存泄漏
- 空间开销:每个对象需额外内存存储计数器
- 时间开销:频繁的引用赋值会产生大量计数器加减操作
- 不支持软引用、弱引用等特殊引用类型
Q3:引用计数法的致命缺陷是什么?请举例说明。
答案:
致命缺陷是无法处理循环引用,即两个或多个对象互相引用,但它们都没有被外部引用指向,此时它们的引用计数器都不为0,无法被判定为垃圾,导致内存泄漏。
示例:
class A {
public B b; }
class B {
public A a; }
public class Test {
public static void main(String[] args) {
A a = new A(); // A计数=1
B b = new B(); // B计数=1
a.b = b; // B计数=2
b.a = a; // A计数=2
a = null; // A计数=1
b = null; // B计数=1
// 此时A和B已无法被外部访问,但计数器都不为0
// 引用计数法无法回收它们,造成内存泄漏
}
}
Q4:简述可达性分析算法的基本原理。
答案:
也称为根搜索算法,是所有主流JVM采用的垃圾判定算法。
核心思想:以一系列称为GC Roots的"根对象"作为起点,从这些起点开始向下搜索,搜索走过的路径称为引用链。
- 若一个对象到GC Roots没有任何引用链相连(即从GC Roots不可达),则判定该对象为垃圾。
- 若一个对象通过引用链与GC Roots相连,则判定为存活对象,不会被回收。
Q5:什么是GC Roots?哪些对象可以作为GC Roots?(高频必背)
答案:
GC Roots是必须存活的对象,它们永远不会被垃圾回收,是可达性分析的起点。
可作为GC Roots的对象包括:
- 虚拟机栈(栈帧本地变量表)中引用的对象:方法执行时的局部变量、参数等
- 本地方法栈中JNI(Native方法)引用的对象
- 方法区中类静态属性引用的对象:类的static变量
- 方法区中常量引用的对象:字符串常量池、final常量等
- 同步锁(synchronized)持有的对象:被作为锁的对象
- JVM内部引用的对象:基本数据类型的Class对象、常驻异常对象、系统类加载器等
- 其他:JMXBean、JVMTI回调、本地代码缓存等
二、进阶对比题
Q6:引用计数法和可达性分析算法的核心区别是什么?
答案:
| 对比维度 | 引用计数法 | 可达性分析算法 |
|---|---|---|
| 核心思想 | 基于对象被引用的次数 | 基于对象到GC Roots的可达性 |
| 循环引用处理 | 无法处理,导致内存泄漏 | 完美处理 |
| 判定效率 | O(1),极高 | O(n),与存活对象数量成正比 |
| STW停顿 | 几乎没有 | 必须有,停顿时间与堆大小相关 |
| 空间开销 | 每个对象需存储计数器 | 几乎无额外空间开销 |
| 时间开销 | 频繁的计数器加减 | 一次性堆遍历 |
| 实现复杂度 | 简单 | 复杂 |
| 特殊引用支持 | 不支持 | 完全支持 |
| 主流JVM采用 | 否 | 是 |
Q7:为什么主流JVM(如HotSpot)选择使用可达性分析算法而不是引用计数法?
答案:
根本原因是引用计数法存在无法解决的循环引用问题,会导致严重的内存泄漏。
此外还有以下原因:
- 引用计数法不支持Java的软引用、弱引用、虚引用等特殊引用类型,无法实现灵活的内存管理策略
- 现代JVM堆内存越来越大,引用计数法的频繁计数器加减操作带来的开销会变得不可忽视
- 可达性分析算法虽然有STW停顿,但现代JVM通过OopMap、安全点、并发标记等技术已经大幅降低了停顿时间,能够满足绝大多数应用的需求
三、扩展优化题
Q8:可达性分析算法为什么需要Stop-The-World(STW)停顿?
答案:
可达性分析必须在一致性快照下进行,即分析过程中对象的引用关系不能发生变化。
- 如果不暂停用户线程,在分析过程中可能会出现:一个对象刚被标记为垃圾,又被其他线程引用,导致该对象被错误回收,程序崩溃
- 或者一个对象刚被标记为存活,又被所有线程引用失效,导致该对象成为浮动垃圾,本次GC无法回收
- 因此,枚举GC Roots和遍历引用链的核心阶段必须暂停所有用户线程,这是GC停顿的主要来源之一。
Q9:HotSpot虚拟机为了优化可达性分析的效率,做了哪些关键优化?
答案:
OopMap(对象指针映射表):
- 记录栈帧和寄存器中哪些位置是对象引用
- 避免GC时全栈扫描,大幅加快GC Roots枚举速度
安全点(Safepoint):
- 规定用户线程只能在特定的安全点位置暂停
- 安全点通常选在方法调用、循环跳转、异常跳转等指令处
- 避免线程在任意位置暂停,提高GC效率
安全区域(Safe Region):
- 解决线程处于Sleep或Blocked状态时无法到达安全点的问题
- 当线程进入安全区域时,会标记自己处于安全区域,GC可以直接处理这些线程
- 线程离开安全区域前,必须检查GC是否完成
并发标记:
- 在用户线程运行的同时进行大部分标记工作
- 仅在初始标记和重新标记阶段需要短暂STW
- 被CMS、G1、ZGC等现代垃圾回收器广泛采用
Q10:Java中的四种引用类型分别是什么?它们的区别和应用场景是什么?(高频必背)
答案:
Java中引用强度从强到弱分为4种,只有可达性分析算法支持所有引用类型:
| 引用类型 | 强度 | 回收时机 | 应用场景 |
|---|---|---|---|
| 强引用 | 最强 | 只要强引用存在,永远不会回收 | 最常见的引用,如Object obj = new Object() |
| 软引用(SoftReference) | 次强 | 系统将要发生内存溢出之前,进行二次回收 | 实现内存敏感的缓存(如图片缓存) |
| 弱引用(WeakReference) | 较弱 | 垃圾回收器工作时,无论内存是否足够,都会回收 | 实现临时缓存、ThreadLocal内部实现 |
| 虚引用(PhantomReference) | 最弱 | 无法通过虚引用获取对象,仅在对象被回收时收到通知 | 跟踪对象回收状态,管理堆外内存 |
注意:引用计数法只能处理强引用,无法区分其他三种引用类型。
背诵小贴士
- 先理解原理再背诵,不要死记硬背
- 重点记忆GC Roots类型和四种引用类型,这是面试最高频考点
- 对比记忆两种算法的优缺点,特别是循环引用问题
- 结合例子理解抽象概念,如循环引用的示例
- 面试时先答核心要点,再根据面试官的追问展开细节
JVM垃圾判定算法 一页纸速记版(考前必看)
一、基础核心(优先级:★★★★★)
- 垃圾判定:GC第一步,识别堆中"死亡"对象;目标:准确、高效、低影响
- 引用计数法
- 原理:对象头存计数器;引用+1,失效-1;=0即垃圾
- 优点:O(1)判定;回收及时;几乎无STW
- 致命缺陷:无法处理循环引用→内存泄漏
- 其他缺点:空间/时间开销;不支持特殊引用
- 可达性分析算法(主流JVM采用)
- 原理:以GC Roots为起点,无引用链相连即垃圾
- GC Roots(高频必背):虚拟机栈引用;本地方法栈JNI引用;方法区静态属性/常量引用;同步锁持有对象;JVM内部对象
二、进阶对比(优先级:★★★★☆)
| 维度 | 引用计数法 | 可达性分析 |
|---|---|---|
| 核心 | 引用次数 | GC Roots可达性 |
| 循环引用 | ❌ 无法处理 | ✅ 完美处理 |
| 效率 | O(1) | O(n)(与存活对象数成正比) |
| STW | 几乎无 | 必须有 |
| 特殊引用 | ❌ 不支持 | ✅ 完全支持 |
核心结论:主流JVM选可达性分析→根本原因是引用计数法无法解决循环引用
三、扩展优化(优先级:★★★☆☆)
- STW原因:必须在一致性快照下分析,避免引用关系变化导致误判
- HotSpot关键优化
- OopMap:记录对象引用位置,避免全栈扫描
- 安全点:线程只能在特定位置暂停
- 安全区域:解决Sleep/Blocked线程无法到达安全点问题
- 并发标记:用户线程运行时标记,仅短时间STW
- 四种引用类型(高频必背)
- 强引用:永不回收;最常见
- 软引用:OOM前回收;内存敏感缓存
- 弱引用:GC即回收;临时缓存、ThreadLocal
- 虚引用:无法获取对象;回收通知、堆外内存管理
背诵优先级
- 第一梯队:GC Roots列表、四种引用类型、循环引用问题
- 第二梯队:两种算法核心对比、STW原因
- 第三梯队:HotSpot优化细节