深入理解 JVM 垃圾回收(GC)
在 C/C++ 等编程语言中,程序员需要像管家一样,手动通过 malloc 申请内存,通过 free 释放内存。一旦忘记释放,就会导致内存泄漏;释放了还在使用的指针,就会产生悬挂指针。这种纯手工的管理方式极易出错。
而在 Java 的世界里,JVM 犹如一位不知疲倦的清洁工,帮我们全自动地打理内存。这位清洁工就是垃圾回收器(Garbage Collector,简称 GC)。
本文将从底层逻辑出发,详尽地回答五个核心问题,带你彻底吃透 Java 垃圾回收机制。
一、 为什么要有垃圾回收?
如果我们没有垃圾回收,Java 程序会面临怎样的灾难?
- 避免内存泄漏(Memory Leak) :
程序中创建的对象在完成使命后,如果没有被及时清理,它们就会一直占用内存。随着时间推移,可用内存越来越少,最终导致系统崩溃。 - 防止内存溢出(Memory Overflow/OOM) :
当堆内存被无用的"死亡对象"彻底填满,此时如果程序还需要创建新对象,JVM 无法再分配出哪怕一字节的空间,就会抛出java.lang.OutOfMemoryError。 - 释放程序员的心智负担 :
自动内存管理让开发者可以专注于业务逻辑。我们只需在需要时new对象,至于对象的"后事",完全交由 JVM 处理,极大地提高了开发效率和代码的安全性。
垃圾回收的作用:将程序中不用的死亡对象也就是垃圾对象进行清除,从而保证了新对象能够正常申请到内存空间,保证程序能够持久、稳定地运行。
二、 垃圾回收主要回收哪个内存区域?
很多人以为 JVM 负责管理所有的内存,但实际上,垃圾回收也是有"地盘划分"的。JVM 运行时数据区分为五大部分,但 GC 的关注点非常集中。
- 不回收的区域(随线程生灭) :
程序计数器、虚拟机栈、本地方法栈。这三个区域的生命周期与线程相同。当线程结束时,它们所占用的内存自然就被销毁了。而且这些区域的内存分配和回收具有确定性(比如方法执行完毕,对应的栈帧就出栈了),不需要 GC 介入。 - 重点回收的区域(线程共享) :
Java 堆(Heap) 和 方法区(Method Area) 。- Java 堆是重灾区,几乎所有的对象实例都在这里分配。它占用了 JVM 内存的绝大部分,是垃圾收集器的主战场。
- 方法区(JDK 8 后称为元空间 Metaspace) 主要回收废弃的常量和不再使用的类信息(类卸载)。
分代收集理论(为什么要划分新生代和老年代?)
在堆中,对象的生命周期是两极分化的:
- 绝大多数对象都是"朝生夕死"的(比如方法内的局部变量),存活时间极短。
- 熬过多次 GC 的极少数对象,往往会存活很久(比如 Spring 容器中的 Bean,缓存的全局对象)。
因此,JVM 将堆划分为两个区域:
- 新生代(Young Generation) :存放新建的对象。又细分为一个 Eden 区 和两个 Survivor 区(From / To)。
- 老年代(Old Generation / Tenured):存放经历多次 GC 依然存活,或者占用内存极大的巨型对象。
三、 标记的过程:如何判断对象是"死"是"活"?
回收垃圾的前提是找出垃圾。JVM 怎么知道哪些对象是死去的?
1. 引用计数算法(Java 未采用)
原理 :给每个对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,减 1。任何时刻计数器为 0 的对象就是可回收的。
致命缺点:无法解决循环引用问题。
java
public class Test {
public Object instance = null;
public static void main(String[] args) {
Test a = new Test();
Test b = new Test();
a.instance = b;
b.instance = a;
a = null;
b = null;
// 此时 a 和 b 互相引用,计数器都不为 0,但外界已无法访问它们,造成了内存泄漏。
}
}
2. 可达性分析算法(Java 采用)
原理 :通过一系列被称为 "GC Roots" 的对象作为起始点,从这些节点开始向下搜索。搜索走过的路径称为"引用链(Reference Chain)"。
如果一个对象到 GC Roots 没有任何引用链相连(即不可达),则证明此对象是不可用的,会被判定为可回收对象。
哪些对象可以作为 GC Roots?
- 虚拟机栈(栈帧中的局部变量表)中引用的对象(如方法内的局部变量)。
- 方法区中类静态属性(
static)引用的对象。 - 方法区中常量(
final)引用的对象。 - 本地方法栈中 JNI(Native 方法)引用的对象。
- Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,常驻的异常对象等。
3. 对象的"缓刑":四种引用类型
即使被标记为不可达,也不是马上就被"处死"。JDK 1.2 之后,Java 扩充了引用的概念,分为四种强度:
- 强引用(Strong Reference) :类似
Object obj = new Object()。只要强引用还在,GC 绝不会回收。 - 软引用(Soft Reference):发生 OOM 之前,GC 会把这些对象列入回收范围进行二次回收。如果回收后内存还不够,才会抛 OOM。适合做缓存。
- 弱引用(Weak Reference) :只能生存到下一次 GC 发生之前。无论内存是否够用,只要 GC 发生,就会被回收。常用于
ThreadLocal和WeakHashMap。 - 虚引用(Phantom Reference):形同虚设,唯一作用是能在对象被回收时收到一个系统通知。
四、 回收的过程:垃圾回收算法
找到垃圾后,如何高效清理?
1. 标记-清除算法(Mark-Sweep)
- 过程:先标记,后统一清除。
- 缺点 :
- 效率低。
- 空间碎片化:清除后会产生大量不连续的内存碎片,导致程序在运行中需要分配较大对象时,无法找到足够连续内存,从而提前触发下一次 GC。
2. 复制算法(Copying)
- 过程:将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块内存满了,将存活的对象复制到另一块,然后清空当前块。
- 优点:实现简单、运行高效、没有内存碎片。
- 缺点:内存利用率极低(通常只有一半)。
- 实际应用(新生代优化) :
新生代中 98% 的对象都会被回收,所以 HotSpot 不按 1:1 划分。而是分为 Eden : From Survivor : To Survivor = 8 : 1 : 1 。
每次使用 Eden 和其中一块 Survivor(共 90%)。GC 时,将存活对象复制到另一块空的 Survivor,然后清理 Eden 和用过的 Survivor。
3. 标记-整理算法(Mark-Compact)
- 过程:标记过程同"标记-清除",但后续不是直接清理,而是让所有存活对象都向一端移动,然后直接清理掉端边界以外的内存。
- 优点:没有内存碎片。
- 缺点:移动对象成本高,需要更新所有引用该对象的指针。
- 应用场景:老年代对象存活率高,不适合复制算法,适合标记-整理。
4. 分代收集算法(Generational Collection)
现代虚拟机的标配。将堆分为新生代和老年代,分别采用不同的算法:
- 新生代 :朝生夕死,存活率低,采用复制算法 。发生 GC 叫 Minor GC,速度极快。
- 老年代 :存活率高,采用标记-清除 或标记-整理 算法。发生 GC 叫 Major GC / Full GC,速度比 Minor GC 慢十倍以上。
对象如何进入老年代?
- 熬过年龄 :对象在 Survivor 区每熬过一次 Minor GC,年龄加 1。当年龄达到阈值(默认 15,通过
-XX:MaxTenuringThreshold设置),晋升老年代。 - 大对象直接进入:需要大量连续内存的 Java 对象(如长字符串、数组)。
- 动态年龄判定:如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象直接进入老年代。
五、 垃圾回收器有哪些典型实现?
算法只是方法论,真正的清洁工是垃圾收集器。垃圾回收器的发展史,就是一部不断缩短"Stop The World(STW,暂停用户线程)"时间的历史。
1. 经典收集器组合
| 收集器 | 区域 | 算法 | 特点与场景 |
|---|---|---|---|
| Serial | 新生代 | 复制 | 单线程。收集时会暂停所有用户线程(STW)。简单高效,适合单核 CPU 的 Client 模式。 |
| ParNew | 新生代 | 复制 | Serial 的多线程版本。是许多运行在 Server 模式下 JVM 的首选。常与 CMS 搭配。 |
| Parallel Scavenge | 新生代 | 复制 | 关注吞吐量(吞吐量 = 运行用户代码时间 / CPU 总时间)。适合后台科学计算、批处理任务。 |
| Serial Old | 老年代 | 标记-整理 | Serial 的老年代版本,单线程。 |
| Parallel Old | 老年代 | 标记-整理 | Parallel Scavenge 的老年代版本。 |
| CMS | 老年代 | 标记-清除 | 关注最短停顿时间(并发收集)。适合 B/S 架构(如电商网站),响应速度快。 |
2. 划时代的 CMS(Concurrent Mark Sweep)
CMS 是 HotSpot 第一款真正意义上的并发收集器 ,它让垃圾收集线程与用户线程同时工作。
工作原理(4个步骤):
- 初始标记(STW):仅仅标记一下 GC Roots 能直接关联到的对象。速度极快。
- 并发标记 :进行 GC Roots Tracing,耗时最长,但无需暂停用户线程。
- 重新标记(STW):修正并发标记期间,因用户程序继续运作而导致标记产生变动的对象。停顿时间比初始标记稍长,但远短于并发标记。
- 并发清除 :清理对象,无需暂停用户线程。
缺点:
- 对 CPU 资源敏感(默认启动回收线程数 = (CPU数+3)/4)。
- 无法处理浮动垃圾 (并发清理时用户线程产生的新垃圾),可能引发
Concurrent Mode Failure而退化为 Serial Old。 - 基于"标记-清除"算法,会产生大量空间碎片。
3. 未来趋势:G1(Garbage First)
G1 是 JDK 9 及以后的默认垃圾收集器,面向大内存、多核处理器的服务端应用。
核心理念 :不再物理上区分新生代和老年代,而是将堆划分为多个大小相等的独立 Region。
- 每个 Region 动态扮演 Eden、Survivor 或 Old 区的角色。
- 新增 Humongous 区域,专门存放大对象(超过 Region 大小的 50%)。
- 可预测的停顿时间模型:G1 会跟踪各个 Region 的回收价值(获得的空间大小与回收所需时间的经验值),在有限的时间内,优先回收价值最大的 Region(这就是 Garbage First 名字的由来)。
六、 总结
垃圾回收是 JVM 最耀眼的明珠之一。我们可以这样串联记忆:
- 为什么回收:防止内存泄漏和溢出,把程序员从内存管理中解放出来。
- 回收哪里 :重点是共享的堆(新生代/老年代)和方法区。
- 怎么标记 :采用可达性分析算法 ,从 GC Roots 向下搜索,不可达的即判死刑。
- 怎么回收 :实行分代收集 。新生代存活率低,用复制算法 (Eden:S0:S1 = 8:1:1);老年代存活率高,用标记-清除/整理算法。
- 谁来回收:从早期的单线程 Serial,到多线程的 Parallel,再到重低延迟的 CMS,最终进化到目前的王者 G1 收集器。
理解这些底层细节,不仅能让你在面试中侃侃而谈,更能帮你在实际生产环境中,面对 Full GC 导致的系统卡顿时,能够从容地进行 JVM 调优。