Skip to content

JVM 垃圾回收机制详解

为什么需要垃圾回收

在 C/C++ 中,程序员需要手动管理内存(malloc/free, new/delete)。手动内存管理带来三大问题:

问题说明
内存泄漏(Memory Leak)忘记释放不再使用的内存,导致内存持续增长
悬空指针(Dangling Pointer)释放了还在使用的内存,导致程序崩溃或数据错乱
双重释放(Double Free)对同一块内存释放两次,导致未定义行为

Java 通过垃圾回收(Garbage Collection, GC)解决了这些问题:

C/C++ 内存管理:
  分配内存 [程序员] --> 使用内存 --> 释放内存 [程序员]
                                         |
                                    忘记释放 → 内存泄漏
                                    过早释放 → 悬空指针
                                    重复释放 → 程序崩溃

Java 内存管理:
  分配内存 [程序员 new] --> 使用内存 --> JVM 自动回收 [GC]
                                         |
                                    无需手动释放
                                    安全、可靠

GC 的核心价值:

  • 自动内存管理,降低开发难度
  • 消除内存泄漏和悬空指针
  • 提高开发效率,让程序员专注业务逻辑
  • 现代 GC 性能优异,停顿时间可控

如何判断对象是垃圾

引用计数法(Reference Counting)

每个对象维护一个引用计数器,被引用时 +1,引用失效时 -1,计数器为 0 时回收。

对象 A (refCount=1) <---- 引用1
对象 B (refCount=2) <---- 引用2, 引用3
对象 C (refCount=0)  --> 可以被回收

致命缺陷:无法解决循环引用

java
public class RefCountProblem {
    public Object instance = null;

    public static void main(String[] args) {
        RefCountProblem a = new RefCountProblem();  // a.refCount = 1
        RefCountProblem b = new RefCountProblem();  // b.refCount = 1

        a.instance = b;  // b.refCount = 2
        b.instance = a;  // a.refCount = 2

        a = null;  // a.refCount = 1 (仍被 b 引用)
        b = null;  // b.refCount = 1 (仍被 a 引用)

        // 两个对象互相引用,refCount 永远不为 0 → 内存泄漏!
    }
}

可达性分析(Reachability Analysis)

Java 主流 JVM 采用可达性分析算法。从一组称为 "GC Roots" 的根对象出发,沿着引用链向下搜索,能到达的对象是存活的,不能到达的对象是垃圾。

+------------------+
| GC Roots 集合     |
| (起点)           |
+------------------+
    |    |    |
    v    v    v
  [A]  [B]  [C]  <-- 存活对象(可达)
    |    |
    v    v
  [D]  [E]        <-- 存活对象
    |
    v
  [F]              <-- 存活对象

  [G] -- [H]       <-- 从 GC Roots 不可达 → 垃圾

GC Roots 有哪些

GC Root 类型说明示例
虚拟机栈引用栈帧局部变量表中引用的对象方法中的局部变量
静态属性引用方法区中类的静态属性引用的对象static List<String> list
常量引用方法区中常量池引用的对象static final String s = "hello"
JNI 引用本地方法栈中 JNI 引用的对象Native 代码中的全局引用
活跃线程所有活跃的 Thread 对象正在运行的线程
同步监视器被 synchronized 持有的对象锁对象

垃圾回收算法

四种基本算法对比

算法过程优点缺点适用场景
标记-清除(Mark-Sweep)标记存活对象 → 清除未标记对象简单,无需移动对象产生内存碎片老年代(CMS 的前身)
标记-复制(Mark-Copy)标记存活对象 → 复制到新区域 → 清空旧区域无碎片,速度快浪费一半内存新生代
标记-整理(Mark-Compact)标记存活对象 → 移动到一端 → 清空边界外内存无碎片移动对象开销大,STW 时间长老年代
分代收集(Generational)结合以上算法,按对象年龄分区域综合效率高跨代引用需要处理主流 JVM 默认

标记-清除算法(Mark-Sweep)

回收前:                         回收后:
+---+---+---+---+---+---+       +---+---+---+---+---+---+
| A |   | B |   | C |   |  -->  | A |   |   |   | C |   |
+---+---+---+---+---+---+       +---+---+---+---+---+---+
 存活  垃圾 存活 垃圾 存活 垃圾      存活  碎片 碎片 存活 碎片 碎片

问题: 产生大量不连续的内存碎片,导致大对象无法分配。

标记-复制算法(Mark-Copy)

+------------------+------------------+
|    Eden 区       |  Survivor 区     |
|   (活跃+垃圾)    |   (空)           |
+------------------+------------------+
        |
        | 标记存活,复制到 Survivor
        v
+------------------+------------------+
|    Eden 区       |  Survivor 区     |
|   (清空)         |   (存活对象)     |
+------------------+------------------+

优化: 实际使用 Eden + S0 + S1,不浪费 50% 内存(见下文堆结构)。

标记-整理算法(Mark-Compact)

回收前:                         回收后:
+---+---+---+---+---+---+       +---+---+---+---+---+---+
| A |   | B |   | C |   |  -->  | A | B | C |   |   |   |
+---+---+---+---+---+---+       +---+---+---+---+---+---+
 存活  垃圾 存活 垃圾 存活 垃圾      存活且连续    空闲且连续

分代收集理论

核心假设:

  1. 弱分代假说:绝大多数对象都是朝生夕死的
  2. 强分代假说:熬过越多次 GC 的对象越难消亡
  3. 跨代引用假说:跨代引用相对于同代引用来说数量极少

JVM 堆结构

+---------------------------------------------------------------+
|                        JVM Heap 结构                           |
|                                                               |
|  +----------------------------------------------------------+ |
|  |              新生代 (Young Generation)                     | |
|  |                                                          | |
|  |  +-----------+  +-----------+  +-----------+             | |
|  |  |  Eden     |  | Survivor  |  | Survivor  |             | |
|  |  |  (8/10)   |  |  S0(1/10) |  |  S1(1/10) |             | |
|  |  +-----------+  +-----------+  +-----------+             | |
|  |                                                          | |
|  |  默认比例 Eden:S0:S1 = 8:1:1                              | |
|  +----------------------------------------------------------+ |
|                                                               |
|  +----------------------------------------------------------+ |
|  |              老年代 (Old Generation / Tenured)             | |
|  |                                                          | |
|  |  存放长期存活的对象和大对象                                  | |
|  +----------------------------------------------------------+ |
+---------------------------------------------------------------+

对象分配和晋升流程:

1. 新对象优先在 Eden 分配
2. Eden 满了触发 Minor GC
3. 存活对象复制到 Survivor 区(S0 或 S1),年龄 +1
4. 每次 Minor GC,两个 Survivor 区之间复制存活对象,年龄 +1
5. 年龄达到阈值(默认 15)→ 晋升到老年代
6. 老年代满了触发 Major GC / Full GC

大对象直接进入老年代

java
/**
 * -XX:PretenureSizeThreshold=3145728  (3MB)
 * 大于该阈值的对象直接在老年代分配
 */
byte[] largeObject = new byte[4 * 1024 * 1024];  // 4MB,直接进入老年代

GC 类型

GC 类型触发区域触发条件特点
Minor GC / Young GC新生代Eden 区满频繁,速度快,STW 短
Major GC / Old GC老年代老年代空间不足通常伴随 Minor GC,较慢
Full GC整个堆 + 方法区多种条件最慢,STW 最长,应尽量避免
Mixed GC新生代 + 部分老年代G1 特有G1 的特色,回收部分老年代 region

垃圾回收器

垃圾回收器演进

+----------+    +----------+    +----------+    +----------+    +----------+
|  Serial  | -> | Parallel | -> |   CMS    | -> |    G1    | -> | ZGC /   |
|          |    |          |    |          |    |          |    | Shenandoah|
+----------+    +----------+    +----------+    +----------+    +----------+
  单线程           多线程          并发+低延迟      区域化+可预测    亚毫秒级暂停
  JDK1.3          JDK1.4          JDK5           JDK7           JDK11/15+

垃圾回收器特性对比

回收器工作线程算法目标适用场景
Serial单线程复制(新生代)/ 标记整理(老年代)简单高效客户端、小内存
ParNew多线程复制(新生代)缩短暂停配合 CMS 使用
Parallel Scavenge多线程复制(新生代)吞吐量优先后台计算
Parallel Old多线程标记整理(老年代)吞吐量优先配合 Parallel Scavenge
CMS多线程+并发标记清除(老年代)低延迟Web 服务
G1多线程+并发标记整理+复制可预测暂停大堆、JDK 9+ 默认
ZGC多线程+并发标记整理超低延迟大堆+低延迟
Shenandoah多线程+并发标记整理超低延迟大堆+低延迟

G1 (Garbage-First) 详解

G1 是 JDK 9+ 的默认垃圾回收器,面向大内存、多核处理器的服务器端应用。

核心思想:化整为零

传统分代堆:                        G1 堆(Region 布局):
+----------------+    +---+---+---+---+---+---+---+---+
|   Young Gen    |    | E | E | S | O | H | E | O | E |
+----------------+    +---+---+---+---+---+---+---+---+
|   Old Gen      |    |   |   |   |   |   |   |   |   |
+----------------+    +---+---+---+---+---+---+---+---+
                      E = Eden Region
                      S = Survivor Region
                      O = Old Region
                      H = Humongous Region

G1 特点:

特性说明
Region 化堆被划分为大小相等、不连续的 Region(1MB~32MB)
可预测暂停通过 -XX:MaxGCPauseMillis 设置目标暂停时间
并发标记使用 SATB(Snapshot-At-The-Beginning)算法并发标记
混合回收Mixed GC 同时回收新生代和部分老年代 Region
垃圾优先优先回收价值最高的 Region(垃圾最多的 Region)
大对象处理超过 Region 一半大小的对象为 Humongous Object,用连续 Region 存储

G1 回收过程:

+-------------------+   +-------------------+   +-------------------+
| 1. 年轻代回收      |-->| 2. 并发标记        |-->| 3. 混合回收        |
| (Young GC)        |   | (Concurrent Mark)  |   | (Mixed GC)        |
|                   |   |                   |   |                   |
| 只回收新生代       |   | 标记老年代存活对象  |   | 回收新生代+部分    |
| STW               |   | 与应用并发,不 STW  |   | 老年代 Region     |
|                   |   |                   |   | STW               |
+-------------------+   +-------------------+   +-------------------+

CMS 垃圾回收器

CMS(Concurrent Mark Sweep)是最早的并发低延迟回收器,追求最短停顿时间。

CMS 回收过程:

+--------------+    +--------------+    +--------------+    +--------------+
| 1. 初始标记  | -> | 2. 并发标记   | -> | 3. 重新标记  | -> | 4. 并发清除  |
| Initial Mark|    | Concurrent   |    | Remark       |    | Concurrent   |
|              |    | Mark         |    |              |    | Sweep        |
| STW (短)     |    | 并发 (长)     |    | STW (较短)    |    | 并发 (长)     |
+--------------+    +--------------+    +--------------+    +--------------+

CMS 缺点:

  • 内存碎片(标记清除算法,不整理)
  • 对 CPU 敏感(并发阶段占用 CPU 资源)
  • 无法处理浮动垃圾(并发标记期间新产生的垃圾)
  • JDK 14 已移除 CMS

ZGC 和 Shenandoah(简要)

特性ZGC (JDK 11+)Shenandoah (JDK 12+)
目标暂停时间< 1ms< 10ms
核心技术染色指针(Colored Pointers)+ 读屏障布鲁克斯指针(Brooks Pointers)+ 转发指针
堆大小8MB ~ 16TB大堆支持
并发整理
分代JDK 21 开始支持分代不分代

GC 常用参数

参数说明
-XX:+UseSerialGC使用 Serial + Serial Old
-XX:+UseParallelGC使用 Parallel Scavenge + Parallel Old
-XX:+UseConcMarkSweepGC使用 CMS(JDK 14 移除)
-XX:+UseG1GC使用 G1
-XX:+UseZGC使用 ZGC
-XX:+UseShenandoahGC使用 Shenandoah
-Xms / -Xmx初始堆 / 最大堆
-Xmn新生代大小
-XX:SurvivorRatioEden/Survivor 比例,默认 8
-XX:MaxTenuringThreshold晋升老年代年龄阈值,默认 15
-XX:MaxGCPauseMillisG1 目标暂停时间(毫秒)
-XX:G1HeapRegionSizeG1 Region 大小
-XX:+PrintGCDetails打印 GC 详细信息
-XX:+PrintGCDateStamps打印 GC 时间戳
-Xlog:gc*JDK 9+ 统一 GC 日志

代码示例:触发和观察 GC

1. 创建对象触发 GC

java
/**
 * VM Args: -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8
 */
public class GCDemo {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];  // Eden 分配
        allocation2 = new byte[2 * _1MB];  // Eden 分配
        allocation3 = new byte[2 * _1MB];  // Eden 分配
        allocation4 = new byte[4 * _1MB];  // Eden 不够,触发 Minor GC

        System.out.println("GC 完成");
    }
}

2. 大对象直接进入老年代

java
/**
 * VM Args: -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails
 *          -XX:PretenureSizeThreshold=3145728
 */
public class LargeObjectDemo {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation = new byte[5 * _1MB];  // 5MB,直接进入老年代
        System.out.println("大对象分配完成");
    }
}

3. 对象晋升老年代

java
/**
 * VM Args: -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails
 *          -XX:MaxTenuringThreshold=1 -XX:+PrintTenuringDistribution
 */
public class PromotionDemo {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) {
        byte[] allocation1, allocation2, allocation3;

        allocation1 = new byte[_1MB / 4];  // 256KB
        allocation2 = new byte[4 * _1MB];   // 触发 Minor GC,allocation1 进入 Survivor
        allocation3 = new byte[4 * _1MB];   // 再次触发 Minor GC
        allocation3 = new byte[4 * _1MB];   // 再次触发,allocation1 年龄达到阈值,晋升老年代
    }
}

4. 显式触发 GC(不推荐)

java
public class ExplicitGCDemo {
    public static void main(String[] args) {
        for (int i = 0; i < 1000; i++) {
            new Object();
        }

        System.out.println("Before GC - free memory: " + Runtime.getRuntime().freeMemory());

        // 建议 JVM 执行 GC(不保证立即执行)
        System.gc();
        // 或者
        Runtime.getRuntime().gc();

        System.out.println("After GC - free memory: " + Runtime.getRuntime().freeMemory());
    }
}

5. 使用 finalize() 观察对象回收

java
public class FinalizeDemo {
    private static FinalizeDemo SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("我还活着!");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize 方法执行!");
        // 自救:把自己重新引用
        SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Exception {
        SAVE_HOOK = new FinalizeDemo();

        SAVE_HOOK = null;  // 失去引用
        System.gc();       // 触发 GC
        Thread.sleep(500); // 等待 finalize 执行

        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();  // 第一次自救成功
        } else {
            System.out.println("对象已死亡");
        }

        SAVE_HOOK = null;  // 再次失去引用
        System.gc();
        Thread.sleep(500);

        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("对象已死亡");  // finalize 只能执行一次,这次自救失败
        }
    }
}

GC 调优思路

调优目标

目标指标适用场景
吞吐量优先减少 GC 总时间占比后台计算、批处理
低延迟减少单次 GC 停顿时间Web 服务、实时系统
内存占用减少堆内存使用容器化、微服务

调优步骤

1. 明确目标
   ├── 吞吐量?→ Parallel GC
   ├── 低延迟?→ G1/ZGC
   └── 小内存?→ Serial GC

2. 收集 GC 日志
   java -Xlog:gc*:file=gc.log:time,uptime,level,tags

3. 分析 GC 日志
   ├── Minor GC 频率是否过高?→ 增大新生代
   ├── Full GC 是否频繁?→ 增大老年代或检查内存泄漏
   ├── 单次 GC 耗时过长?→ 换 GC 或调整参数
   └── 晋升过早?→ 调整 MaxTenuringThreshold

4. 调整参数并验证
   ├── 每次只改一个参数
   ├── 对比修改前后的 GC 日志
   └── 使用压测工具验证

面试高频问题

Q1: 如何判断一个对象是否应该被回收?

A: JVM 使用可达性分析算法。从 GC Roots 出发,沿着引用链搜索,无法到达的对象判定为垃圾。GC Roots 包括:虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、JNI 引用的对象、活跃线程、被 synchronized 持有的锁对象。

Q2: 为什么不用引用计数法?

A: 引用计数法实现简单,但无法解决循环引用问题。当两个对象互相引用但外部引用都已断开时,引用计数永远不为 0,导致内存泄漏。Java 的 GC 使用可达性分析,可以正确识别循环引用中的垃圾对象。

Q3: Minor GC、Major GC、Full GC 有什么区别?

A: Minor GC 只回收新生代,Eden 区满时触发,频率高但速度快。Major GC 回收老年代,通常伴随 Minor GC。Full GC 回收整个堆和方法区,STW 最长,应尽量避免。G1 还引入了 Mixed GC,同时回收新生代和部分老年代 Region。

Q4: G1 和 CMS 有什么区别?

A: CMS 使用标记-清除算法,会产生内存碎片;G1 使用标记-整理+复制算法,无碎片。CMS 采用分代堆布局,G1 将堆划分为大小相等的 Region。CMS 无法预测暂停时间,G1 可以设置目标暂停时间。G1 适合 6GB 以上的大堆,JDK 9+ 已取代 CMS 成为默认回收器。

Q5: 什么情况下会触发 Full GC?

A: 1)System.gc() 调用(建议性);2)老年代空间不足;3)方法区(元空间)空间不足;4)Minor GC 后存活对象无法放入 Survivor 区,且无法放入老年代(分配担保失败);5)CMS GC 时出现 Concurrent Mode Failure;6)堆内存分配大对象时空间不足。

Q6: 对象什么时候进入老年代?

A: 1)长期存活:对象在 Survivor 区每熬过一次 Minor GC,年龄 +1,达到 MaxTenuringThreshold(默认 15)时晋升;2)大对象:超过 PretenureSizeThreshold 的对象直接在老年代分配;3)动态年龄判断:Survivor 区中相同年龄的对象大小总和超过 Survivor 空间的一半,大于等于该年龄的对象直接晋升;4)分配担保失败:Survivor 区放不下的存活对象直接进入老年代。

Q7: 如何选择垃圾回收器?

A: 小内存(< 4GB)和客户端场景用 Serial GC;吞吐量优先的批处理用 Parallel GC;低延迟的 Web 服务用 G1(JDK 9+ 默认);超低延迟(< 10ms)的大堆场景用 ZGC 或 Shenandoah。一般建议:小堆用 Parallel,大堆用 G1,极致低延迟用 ZGC。

Q8: G1 中的 Humongous Object 是什么?如何处理?

A: 超过 G1 Region 大小一半的对象称为 Humongous Object。G1 会为它分配连续的 Region 来存储。Humongous Object 在 Mixed GC 中不会被回收,只在 Full GC 时回收,因此大量大对象会严重影响 G1 性能。建议尽量避免频繁创建大对象。