在 Java 的自动内存管理体系中,垃圾回收(Garbage Collection,GC)是最核心的能力之一。它让开发者无需手动申请与释放内存,大幅降低了内存泄漏与野指针风险,但也成为了服务端调优、线上问题排查的核心考点。
本文将从「如何判定对象死亡」「垃圾回收的核心算法」「主流垃圾收集器实现」三个维度系统梳理 JVM GC 知识体系,最后深度对比 CMS 与 G1 两大经典收集器,覆盖面试与实战中的核心考点。
一、如何判定对象 "已死亡":可达性分析法
垃圾回收的第一步,是判断哪些对象已经不再被使用、可以被回收。主流 JVM 均采用可达性分析算法,而非简单的引用计数法(引用计数法无法解决循环引用问题)。
1. 可达性分析的核心逻辑
以一系列被称为 GC Roots 的对象作为起点,从这些节点开始向下搜索引用链,当一个对象到 GC Roots 没有任何引用链相连时,就判定该对象为不可达、可回收。
2. 哪些对象可以作为 GC Roots
GC Roots 本质是 "一定不能被回收的对象",主要包含以下 6 类:
- 虚拟机栈(栈帧中局部变量表)引用的对象:即方法执行过程中,局部变量指向的对象,是最常见的 GC Roots。
- 本地方法栈中 Native 方法引用的对象:JNI 调用过程中,本地方法持有的 Java 对象引用。
- 方法区中静态属性引用的对象 :类的静态变量(
static修饰)指向的对象,生命周期与类一致。 - 方法区中常量引用的对象:字符串常量池、静态常量等持有的对象引用。
- 所有被同步锁持有的对象 :被
synchronized锁住的对象,在锁释放前不能被回收。 - JNI 引用的对象:Java Native Interface 创建的全局引用、局部引用等。
3. 灵魂拷问:被判可回收的对象,一定会被回收吗?
答案是不一定。对象从被判不可达到真正被回收,还要经历两次标记过程:
- 第一次标记:可达性分析发现对象不可达,进行第一次标记,并筛选是否需要执行
finalize()方法。如果对象没有重写finalize(),或者finalize()已经被 JVM 调用过一次,则直接判定为可回收。 - 第二次标记:如果需要执行
finalize(),对象会被放入F-Queue队列,由低优先级的 Finalizer 线程执行该方法。若对象在finalize()中重新将自己关联到引用链上,第二次标记时就会被移出回收集合,成功 "自救"。
但finalize()机制不确定性高、执行代价大,官方并不推荐使用,它只能让对象最多自救一次。
二、四种引用类型与使用场景
对象的回收时机,和引用的强度直接相关。JDK 1.2 之后,Java 将引用分为 4 个等级,强度从高到低依次为:强引用、软引用、弱引用、虚引用。
- 强引用(Strong Reference) 我们日常代码中绝大多数的引用都是强引用,例如
Object obj = new Object()。只要强引用关系存在,垃圾收集器就永远不会回收该对象,哪怕堆内存不足抛出 OOM 也不会回收。 - 软引用(SoftReference) 当堆内存空间充足时,软引用对象不会被回收;当堆内存即将溢出、发生 OOM 之前,才会把这些软引用对象列入回收范围进行二次回收。 典型使用场景:内存敏感型缓存,比如图片缓存、页面数据缓存,内存够用就保留,不够用就清理,避免撑爆堆内存。
- 弱引用(WeakReference) 强度比软引用更弱,只要触发垃圾回收,无论当前堆内存是否充足,都会直接回收弱引用关联的对象。 典型使用场景:临时性缓存、避免内存泄漏 ,最经典的是
ThreadLocal中的 Entry key,就是弱引用,保证 ThreadLocal 对象被回收后,Entry 可以被正常清理。 - 虚引用(Phantom Reference) 也叫幽灵引用,是最弱的引用类型,完全不影响对象的生命周期,任何时候都可能被回收。虚引用必须配合
ReferenceQueue引用队列一起使用,它唯一的作用是:当对象被垃圾回收器回收时,会向引用队列发送一条通知。 典型使用场景:堆外内存管理 ,比如 NIO 中的DirectByteBuffer,通过虚引用监听对象回收,同步释放对应的堆外内存。
三、四大经典垃圾回收算法
判定完死亡对象后,下一步就是执行回收。不同的回收算法,本质是在回收效率、内存利用率、内存碎片三者之间做权衡。
1. 标记 - 清除算法(Mark-Sweep)
最基础的回收算法,分为 "标记" 和 "清除" 两个阶段:先标记出所有存活的对象,标记完成后统一清除所有未被标记的对象。
- 缺点:
- 标记和清除两个阶段的执行效率都不高;
- 清除后会产生大量不连续的内存碎片,大对象分配时找不到足够连续空间,会提前触发 Full GC。
2. 复制算法(Copying)
为了解决效率与碎片问题诞生,它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当这一块内存用完,就把还存活的对象复制到另一块上,然后一次性清空当前这块内存。
- 优点:实现简单,运行高效,没有内存碎片,内存分配直接使用指针碰撞即可。
- 缺点:可用内存直接缩水为原来的一半,空间浪费严重;如果对象存活率很高,复制的开销会非常大。
- 适用场景:新生代。新生代对象朝生夕死,存活率极低,复制算法性价比极高。商用 JVM 中新生代的 Eden+Survivor 区域,就是优化后的复制算法。
3. 标记 - 整理算法(Mark-Compact)
老年代对象存活率高,复制算法不再适用,因此诞生了标记 - 整理算法。 它的标记阶段和标记 - 清除一致,但后续不是直接清理死亡对象,而是让所有存活对象都向内存一端移动,然后直接清理掉边界以外的内存。
- 优点:没有内存碎片,适合大对象分配。
- 缺点:需要移动存活对象,更新所有引用地址,STW(Stop-The-World)停顿时间更长,执行成本高于标记 - 清除。
- 适用场景:老年代。
4. 分代收集算法(Generational Collection)
分代收集并不是一种全新的算法,而是一种组合思想:根据对象存活周期的不同,将内存划分为新生代和老年代,不同代采用最适合的回收算法。
- 新生代:对象存活率低、生命周期短,采用复制算法;
- 老年代:对象存活率高、生命周期长,采用标记 - 清除 或标记 - 整理算法。
目前几乎所有商用 JVM 的垃圾收集器都基于分代收集思想实现。
四、主流垃圾收集器全盘点
算法是方法论,垃圾收集器就是算法的具体工程实现。不同收集器的定位不同,分别适配客户端、服务端、低延迟、高吞吐量等不同场景。
1. Serial 收集器(新生代)
最基础、历史最悠久的新生代收集器。
- 核心特性:单线程 收集,垃圾回收时只会启动一条 GC 线程,并且必须暂停所有用户线程(STW),直到回收结束。采用复制算法。
- 优势:简单高效,单核 CPU 环境下没有线程切换开销,内存消耗极低。
- 适用场景:客户端模式、单核 CPU、小内存的嵌入式场景,是 Client 模式下默认的新生代收集器。
2. Serial Old 收集器(老年代)
Serial 收集器的老年代版本。
- 核心特性:单线程执行,采用标记 - 整理算法,同样会触发全局 STW。
- 两大用途:
- Client 模式下默认的老年代收集器;
- 作为 CMS 收集器的后备兜底方案,当 CMS 并发回收失败时,退化为 Serial Old 执行单线程 Full GC。
3. ParNew 收集器(新生代)
可以理解为Serial 收集器的多线程并行版本。
- 核心特性:多条 GC 线程并行执行回收,依然会触发 STW,但多核 CPU 下停顿时间远短于 Serial。采用复制算法。
- 关键定位:它是唯一能和 CMS 老年代收集器搭配使用的新生代收集器。
- 适用场景:多核服务端、低延迟场景,是 JDK8 中搭配 CMS 的标准新生代选择。
4. Parallel Scavenge 收集器(新生代)
同样是多线程并行的新生代收集器,采用复制算法,但设计目标和 ParNew 完全不同。
- 核心定位:吞吐量优先。吞吐量 = 运行用户代码的时间 / (运行用户代码时间 + GC 时间),高吞吐量可以最高效地利用 CPU 资源,适合后台计算任务。
- 核心特性:
- 支持自适应调节策略(
-XX:+UseAdaptiveSizePolicy),JVM 会根据系统运行情况自动调整新生代大小、对象晋升阈值等参数,开发者只需设置最大停顿时间、吞吐量目标即可; - 只能搭配 Parallel Old 老年代收集器,无法和 CMS 配合使用。
- 支持自适应调节策略(
- 适用场景:计算密集型、后台批量处理、不需要低延迟交互的服务。
- 备注:JDK8 Server 模式下,默认新生代收集器就是 Parallel Scavenge。
5. Parallel Old 收集器(老年代)
Parallel Scavenge 对应的老年代版本。
- 核心特性:多线程并行执行,采用标记 - 整理算法。
- 定位:和 Parallel Scavenge 组成 "吞吐量优先" 的完整回收组合,是 JDK8 Server 模式的默认老年代收集器。
6. CMS 收集器(老年代)
全称 Concurrent Mark Sweep,是第一款真正意义上的并发低延迟收集器,设计目标是获取最短回收停顿时间。
- 执行流程分为 4 步:
- 初始标记:短暂 STW,仅标记 GC Roots 直接关联的对象,速度极快;
- 并发标记:和用户线程并发执行,遍历完整引用链,耗时较长但不暂停业务;
- 重新标记:STW,修正并发标记期间因用户线程运行而产生的标记变动,停顿时间长于初始标记,但远短于并发标记;
- 并发清除:和用户线程并发执行,清理死亡对象。
- 缺点:
- 对 CPU 资源敏感,并发阶段会占用 CPU 算力,降低用户线程吞吐量;
- 无法处理浮动垃圾:并发清除阶段新产生的垃圾,只能留到下次 GC 回收;
- 基于标记 - 清除算法,回收后会产生大量内存碎片,大对象分配容易提前触发 Full GC。
7. G1 收集器(Garbage First)
面向服务端的全功能收集器,JDK9 及之后成为默认收集器,兼顾吞吐量与低延迟。
- 核心设计:打破传统分代的物理边界,将整个堆内存划分为多个大小相等的 Region 区域,新生代和老年代只是逻辑划分,每个 Region 可以动态充当 Eden、Survivor、Old、大对象专属区(Humongous)。
- 回收流程:
- 初始标记:STW,标记 GC Roots 直接关联对象;
- 并发标记:和用户线程并发,完成全局可达性分析;
- 最终标记:STW,修正并发标记期间的引用变动;
- 筛选回收:STW,根据每个 Region 的回收价值(回收空间大小 + 回收耗时)排序,优先回收垃圾占比最高的 Region,这也是 "Garbage First" 名字的由来。
- 核心优势:
- 并行与并发兼备,充分利用多核算力;
- 逻辑分代、物理统一,兼顾新老年代回收;
- 空间整合:整体看是标记 - 整理算法,局部 Region 间是复制算法,几乎没有内存碎片;
- 可预测停顿:支持设置最大停顿时间目标,基于回收价值模型实现可控的停顿时长。
五、经典面试题:CMS 与 G1 的核心区别
CMS 和 G1 是面试最高频的对比考点,二者的核心差异可以从 6 个维度梳理:
表格
| 对比维度 | CMS 收集器 | G1 收集器 |
|---|---|---|
| 内存布局 | 传统物理分代,新生代、老年代为连续的整块内存 | Region 化分区,新老年代仅为逻辑划分,Region 可动态切换角色 |
| 回收算法 | 老年代基于标记 - 清除算法,会产生内存碎片 | 整体为标记 - 整理,局部 Region 间为复制算法,基本无内存碎片 |
| 停顿控制 | 目标是最小化停顿,但停顿时间不可预测 | 支持设置最大停顿时间目标,基于价值模型实现可预测停顿 |
| 回收粒度 | 整代回收,每次回收整个老年代 | 按 Region 回收,优先回收垃圾密度高的区域,回收效率更高 |
| 适用场景 | 中等堆内存、追求极致低延迟的 Web 应用,JDK8 低延迟首选 | 大堆内存(6G 以上),兼顾吞吐量与延迟,JDK9 + 默认方案 |
| 兜底机制 | 并发失败后退化为 Serial Old 单线程 Full GC,停顿极长 | Region 化设计回收更灵活,兜底风险更低 |
写在最后
垃圾回收的本质,永远是在吞吐量、停顿时间、内存占用三者之间做权衡,没有绝对最优的收集器,只有最适配业务场景的选择。
从可达性分析的对象判定,到回收算法的设计取舍,再到收集器的工程实现,理解底层原理之后,无论是应对面试,还是线上 GC 调优、问题排查,都能做到知其然更知其所以然。