【Java 底层】JVM 垃圾回收机制深度剖析:从对象生死判定到收集器实战
【Java 底层】JVM 垃圾回收机制深度剖析:从对象生死判定到收集器实战
Java 之所以被称为 “开发效率利器”,很大程度上得益于其自动内存管理机制 —— 开发者无需手动分配和释放内存,一切由 JVM 垃圾回收器(Garbage Collector)自动完成。但 “自动” 不代表 “无感知”:内存泄漏、OOM 异常、GC 频繁卡顿等问题,本质上都是对垃圾回收机制理解不深导致的。
本文将从 “如何判断对象该回收”“用什么算法回收”“不同场景选哪种回收器” 三个维度,深入拆解 JVM 垃圾回收的底层逻辑,帮你从根源上解决内存相关问题。
一、前置问题:JVM 为什么需要垃圾回收?
在 C/C++ 中,开发者需要手动调用 malloc()
分配内存、free()
释放内存,一旦遗漏 free()
就会导致内存泄漏(无用内存占用不释放),最终可能引发内存溢出(OOM)。
Java 通过垃圾回收器解决了这个问题:
-
自动识别 “不再被使用的对象”(垃圾);
-
自动释放这些对象占用的内存;
-
优化内存碎片,提高内存利用率。
但垃圾回收并非 “免费午餐”—— 回收过程会暂停应用线程(STW,Stop-The-World),频繁或过长的 STW 会导致应用卡顿。因此,垃圾回收的核心目标是:在尽可能短的 STW 时间内,高效回收无用内存。
二、第一步:如何判定对象 “已死”?两种核心算法的博弈
垃圾回收的前提是 “识别垃圾”。JVM 采用两种主流算法判断对象是否存活:引用计数法和可达性分析。
1. 引用计数法:简单但 “有漏洞” 的方案
原理:给每个对象设置一个 “引用计数器”,当有地方引用该对象时,计数器 + 1;引用失效时,计数器 - 1。当计数器为 0 时,认为对象可回收。
优点:实现简单,判断效率高。
缺点:无法解决 “循环引用” 问题。例如:
class A {B b;
}
class B {A a;
}public static void main(String[] args) {A a = new A();B b = new B();a.b = b; // A 引用 Bb.a = a; // B 引用 Aa = null; // 取消外部对 A 的引用b = null; // 取消外部对 B 的引用
}
此时 A 和 B 的引用计数器都是 1(互相引用),但已无外部引用,理应被回收,可引用计数法会误判为 “存活”,导致内存泄漏。
因此,JVM 未采用引用计数法,而是选择了更可靠的可达性分析。
2. 可达性分析法:JVM 的 “标准方案”
原理:以 “GC Roots” 为起点,向下搜索引用链(Reference Chain)。如果一个对象到 GC Roots 没有任何引用链相连(即不可达),则认为该对象可回收。
GC Roots 包含哪些对象?
-
虚拟机栈中引用的对象(如局部变量、方法参数);
-
方法区中类静态属性引用的对象(如
static
变量); -
方法区中常量引用的对象(如
final
变量); -
本地方法栈中 JNI(Native 方法)引用的对象。
示例:上述 A 和 B 的循环引用案例中,a 和 b 被置为 null 后,A 和 B 到 GC Roots 均无引用链,因此会被判定为可回收,解决了循环引用问题。
3. 补充:引用的 “强度分级”
Java 中的 “引用” 并非非黑即白。JDK 1.2 后,引用被分为四级,强度从强到弱依次为:
-
强引用:普通引用(如
Object obj = new Object()
),只要强引用存在,对象永远不会被回收; -
软引用:
SoftReference
包装,内存不足时才会被回收(适合缓存场景); -
弱引用:
WeakReference
包装,下次 GC 时必定被回收(适合临时数据); -
虚引用:
PhantomReference
包装,唯一作用是在对象被回收时收到通知(几乎不用)。
这四种引用让垃圾回收更灵活 —— 例如,缓存数据可用软引用,既保证内存不足时自动释放,又能在有内存时保留缓存。
三、第二步:如何回收?三大核心算法与内存整理
判定对象 “已死” 后,需要回收其占用的内存。JVM 采用三种经典回收算法,各有适用场景。
1. 标记 - 清除算法(Mark-Sweep):最基础但 “有瑕疵”
步骤:
-
标记:通过可达性分析,标记所有可回收的对象;
-
清除:遍历内存,回收所有被标记的对象,释放内存空间。
优点:实现简单,不需要移动对象。
缺点:
-
产生内存碎片:回收后内存中会出现大量不连续的空闲区域,当需要分配大对象时,可能因找不到足够大的连续空间而触发新的 GC;
-
效率较低:标记和清除过程都需要遍历大量对象。
2. 复制算法(Copying):牺牲空间换效率
步骤:
-
将内存分为大小相等的两块(From 区和 To 区);
-
只使用 From 区,To 区空闲;
-
GC 时,将 From 区中存活的对象复制到 To 区(按顺序排列,无碎片);
-
清空 From 区,交换 From 和 To 区的角色,重复使用。
优点:
-
无内存碎片;
-
回收效率高(只需复制存活对象,存活对象少的时候效率极高)。
缺点:
-
内存利用率低(仅能使用一半内存);
-
不适合存活对象多的场景(如老年代,复制成本太高)。
应用:JVM 年轻代(Eden 区和 Survivor 区)主要采用复制算法。年轻代中对象存活率低,复制成本小,且通过 “Eden + 2 个 Survivor 区(8:1:1)” 的设计,将内存浪费控制在 10%(只留 1 个 Survivor 区空闲)。
3. 标记 - 整理算法(Mark-Compact):老年代的 “专属方案”
步骤:
-
标记:同标记 - 清除算法,标记可回收对象;
-
整理:将所有存活对象向内存一端移动,然后直接清理掉边界外的内存。
优点:
-
无内存碎片;
-
内存利用率 100%(无需牺牲一半空间)。
缺点:
- 增加了 “移动对象” 的成本,效率比复制算法低。
应用:JVM 老年代主要采用标记 - 整理算法。老年代中对象存活率高,移动成本虽高,但避免了内存碎片和空间浪费,是更平衡的选择。
四、第三步:谁来执行回收?经典垃圾收集器的 “看家本领”
垃圾收集器是算法的具体实现。JVM 提供了多种收集器,各有侧重,需根据应用场景选择。
1. Serial GC:单线程回收,简单高效但卡顿明显
特点:
-
单线程执行 GC,GC 时暂停所有应用线程(STW);
-
采用 “复制算法(年轻代)+ 标记 - 整理算法(老年代)”;
-
实现简单,内存占用少,适合单核 CPU 或小内存应用(如嵌入式设备)。
缺点:STW 时间长(尤其老年代回收时),不适合大内存、高并发场景。
2. Parallel GC:多线程 “吞吐量优先”
特点:
-
多线程执行 GC(年轻代和老年代均用多线程),缩短 STW 时间;
-
目标是 “高吞吐量”(吞吐量 = 运行用户代码时间 / 总时间);
-
可通过参数控制吞吐量(如
-XX:MaxGCPauseMillis
限制最大 STW 时间,-XX:GCTimeRatio
控制 GC 时间占比)。
应用:适合后台任务、批处理程序等对吞吐量敏感,对延迟要求不高的场景。
3. CMS GC:“低延迟” 的并发回收器(已逐渐被淘汰)
CMS(Concurrent Mark Sweep) 是第一款真正意义上的并发收集器,目标是 “最短 STW 时间”。
步骤(分四阶段):
-
初始标记(STW):快速标记 GC Roots 直接关联的对象(耗时短);
-
并发标记:GC 线程与应用线程并发执行,遍历引用链,标记所有可达对象(耗时最长,但不阻塞应用);
-
重新标记(STW):修正并发标记期间因应用线程运行导致的标记变动(耗时较短);
-
并发清除:GC 线程与应用线程并发执行,回收被标记的对象(不阻塞应用)。
优点:STW 时间短,适合对延迟敏感的应用(如 Web 服务)。
缺点:
-
并发阶段占用 CPU 资源,可能导致应用响应变慢;
-
采用标记 - 清除算法,会产生内存碎片;
-
对大内存支持不好(并发标记时内存占用高);
-
JDK 9 中被标记为 deprecated,JDK 14 中移除。
4. G1 GC:面向大内存的 “区域化” 收集器
G1(Garbage-First)是为替代 CMS 设计的,适合 4GB 以上大内存场景,兼顾吞吐量和延迟。
核心创新:
-
区域化内存布局:将堆内存划分为多个大小相等的独立区域(Region),每个区域可动态扮演年轻代或老年代,灵活分配内存;
-
Mixed GC:优先回收 “垃圾最多的区域”(Garbage-First),提高回收效率;
-
停顿预测模型:根据历史数据预测 STW 时间,确保不超过用户设置的目标(如
-XX:MaxGCPauseMillis=200
)。
步骤:类似 CMS,但增加了 “筛选回收” 阶段,只回收垃圾多的区域,减少 STW 时间。
优点:
-
大内存下表现优异,STW 时间可控;
-
无内存碎片(区域内采用复制算法,整体类似标记 - 整理);
-
兼顾吞吐量和延迟,是当前主流收集器之一。
5. ZGC/Shenandoah:超低延迟的新一代收集器
JDK 11 引入 ZGC,JDK 12 引入 Shenandoah,二者均为 “低延迟、高并发” 收集器,STW 时间可控制在毫秒级甚至微秒级,适合超大内存(TB 级)场景。
核心技术:
-
着色指针:通过指针标记对象状态(如是否被标记、是否可回收),避免传统的 “标记 - 清除” 流程;
-
读屏障 / 写屏障:在对象引用读写时插入少量代码,实现并发标记和移动,几乎不阻塞应用线程。
应用:对延迟要求极高的场景(如高频交易、实时数据分析),但目前在生产环境中的应用不如 G1 广泛。
五、实战避坑:GC 问题的排查与优化思路
1. 常见问题及原因:
-
频繁 Full GC:可能是内存泄漏(对象长期被引用无法回收)、老年代对象增长过快(如大对象直接进入老年代);
-
STW 时间过长:收集器选择不当(如用 Serial GC 处理大内存)、堆内存设置不合理(太大或太小);
-
OOM 异常:堆内存不足(需调大
-Xmx
)、永久代 / 元空间不足(调大-XX:MaxMetaspaceSize
)。
2. 优化原则:
-
根据场景选收集器:延迟敏感用 G1/ZGC,吞吐量优先用 Parallel GC;
-
合理设置堆内存:避免太小(GC 频繁)或太大(单次 GC 时间长),通常建议为物理内存的 1/2 ~ 1/4;
-
减少大对象:大对象直接进入老年代,易触发 Full GC,尽量拆分或复用对象;
-
监控与调优:通过
jstat
、jconsole
或可视化工具(如 GCEasy)监控 GC 频率和 STW 时间,逐步调整参数。
六、总结:垃圾回收的 “本质” 是平衡的艺术
JVM 垃圾回收的核心是 “在效率、延迟、内存利用率之间找平衡”:
-
年轻代用复制算法,优先效率;
-
老年代用标记 - 整理算法,优先避免碎片;
-
收集器选择则根据 “吞吐量” 或 “延迟” 需求,从 Serial 到 ZGC,本质是对 “并发” 和 “STW” 的权衡。
理解这些底层逻辑,不仅能解决 GC 相关问题,更能帮你写出更 “内存友好” 的代码 —— 比如避免创建不必要的对象、及时释放无用引用、合理设计缓存策略等。毕竟,最好的 GC 优化,是从代码层面减少垃圾的产生。