跳到主要内容

JVM GC 垃圾回收详解

📚 目录

  1. GC 基础概念
  2. 判断对象存活
  3. GC 算法详解
  4. 垃圾收集器
  5. GC 调优实战
  6. 经典面试题

GC 基础概念

什么是垃圾回收?

垃圾回收(Garbage Collection, GC) 是 JVM 自动管理内存的核心机制,它自动回收不再使用的对象,避免内存泄漏和溢出。

为什么要垃圾回收?

JVM 内存区域与GC

需要GC的区域:

  • 堆内存:对象实例存储区域
  • 方法区:存放类信息、常量、静态变量

不需要GC的区域:

  • 程序计数器:线程私有,生命周期与线程相同
  • 虚拟机栈:线程私有,方法执行时创建栈帧
  • 本地方法栈:线程私有,为Native方法服务
JVM内存布局
┌─────────────────────────────────────┐
│ 方法区 │ ← 需要GC
├─────────────────────────────────────┤
│ 堆 │ ← 需要GC
│ ┌─────────┬─────────────────┐ │
│ │ 新生代 │ 老年代 │ │
│ └─────────┴─────────────────┘ │
├─────────────────────────────────────┤
│ 虚拟机栈 │ ← 不需要GC
├─────────────────────────────────────┤
│ 本地方法栈 │ ← 不需要GC
├─────────────────────────────────────┤
│ 程序计数器 │ ← 不需要GC
└─────────────────────────────────────┘

判断对象存活

1. 引用计数法

原理:给对象添加一个引用计数器,当有地方引用它时,计数器+1;引用失效时,计数器-1。

// 引用计数示例
Object obj = new Object(); // 引用计数 = 1
Object objRef = obj; // 引用计数 = 2
obj = null; // 引用计数 = 1
objRef = null; // 引用计数 = 0,可以被回收

缺点:循环引用问题

public class ReferenceCountTest {
public Object instance = null;

public static void main(String[] args) {
ReferenceCountTest a = new ReferenceCountTest();
ReferenceCountTest b = new ReferenceCountTest();

a.instance = b; // b的引用计数 = 2
b.instance = a; // a的引用计数 = 2

a = null; // a的引用计数 = 1
b = null; // b的引用计数 = 1

// 理论上应该被回收,但引用计数法无法识别
System.gc(); // a和b不会被回收
}
}

2. 可达性分析算法(主流算法)

原理:通过一系列称为"GC Roots"的根对象作为起始节点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象没有任何引用链相连时,则证明此对象是不可用的。

可以作为GC Roots的对象包括:

  1. 虚拟机栈中引用的对象

    public void method() {
    User user = new User(); // user就是GC Root
    }
  2. 方法区中类静态属性引用的对象

    public class MyClass {
    private static User user = new User(); // user是GC Root
    }
  3. 方法区中常量引用的对象

    public class Constants {
    public static final User USER = new User(); // USER是GC Root
    }
  4. 本地方法栈中JNI引用的对象

    // Native方法中的引用
    public native void nativeMethod();
  5. 被同步锁(synchronized)持有的对象

    synchronized(user) { // user是GC Root
    // 同步块代码
    }

GC 算法详解

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

原理:先标记出需要回收的对象,标记完成后统一回收。

标记-清除算法过程:
┌─────────────────────────────────────┐
│ [A] [B] [C] [D] [E] [F] [G] │
│ │
│ 步骤1:标记存活对象 │
│ [√] [×] [√] [×] [√] [×] [√] │
│ │
│ 步骤2:清除未标记对象 │
│ [A] [ ] [C] [ ] [E] [ ] [G] │
└─────────────────────────────────────┘

优点:

  • 实现简单,不需要移动对象

缺点:

  • 内存碎片化严重
  • 标记和清除的效率都不高

2. 复制算法(Copying)

原理:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。

复制算法过程:
┌─────────────────┐ ┌─────────────────┐
│ From Space │ │ To Space │
│ │ │ │
│ [A] [B] [C] [D] │ │ │
│ [E] [F] │ │ │
│ [G] [H] [I] │ │ │
└─────────────────┘ └─────────────────┘
│ │
└─── 复制存活对象 ──────┘
┌─────────────────┐ ┌─────────────────┐
│ From Space │ │ To Space │
│ │ │ │
│ [ ] [ ] │ │ [A] [B] [C] [D] │
│ [ ] [ ] [ ] [ ] │ │ [E] [F] │
│ [ ] [ ] [ ] │ │ [G] [H] [I] │
└─────────────────┘ └─────────────────┘

优点:

  • 不会产生内存碎片
  • 实现简单,运行效率高

缺点:

  • 内存利用率低(实际使用50%)
  • 需要额外的复制开销

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

原理:标记过程与"标记-清除"算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动。

标记-整理算法过程:
┌─────────────────────────────────────┐
│ [A] [B] [C] [D] [E] [F] [G] │
│ │
│ 步骤1:标记存活对象 │
│ [√] [×] [√] [×] [√] [×] [√] │
│ │
│ 步骤2:整理存活对象 │
│ [A] [C] [E] [G] [ ] [ ] [ ] │
└─────────────────────────────────────┘

优点:

  • 不会产生内存碎片
  • 内存利用率高

缺点:

  • 移动对象并更新引用的开销较大

4. 分代收集算法

原理:根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代。

分代收集示意图:
┌─────────────────────────────────────┐
│ 堆内存 │
│ ┌─────────────────────────────┐ │
│ │ 老年代 │ │
│ │ (Old Generation) │ │
│ └─────────────────────────────┘ │
│ ┌─────────┬─────────────────────┐ │
│ │ Eden │ Survivor Space │ │
│ │ 区 │ (From, To) │ │
│ └─────────┴─────────────────────┘ │
└─────────────────────────────────────┘

分代收集的特点:

  1. 新生代(Young Generation)

    • Eden区 + 两个Survivor区(S0, S1)
    • 默认比例:8:1:1
    • 使用复制算法
    • Minor GC频繁,回收速度快
  2. 老年代(Old Generation)

    • 存放生命周期长的对象
    • 使用标记-清除或标记-整理算法
    • Full GC较少,但耗时较长
对象晋升过程:
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
│ Eden │───→│ S0/From │───→│ S1/To │───→│老年代 │
│ (80%) │ │ (10%) │ │ (10%) │ │ │
└─────────┘ └─────────┘ └─────────┘ └─────────┘
│ │ │ │
└─ Minor GC ──┘ │ │
└─ 经历多次GC ─┘

垃圾收集器

1. Serial 收集器

特点

  • 单线程收集器
  • 进行垃圾收集时,必须暂停所有工作线程("Stop-The-World")
  • 新生代采用复制算法,老年代采用标记-整理算法

适用场景

  • 客户端模式下的桌面应用
  • 单核CPU环境
  • 内存较小的应用
# 启用Serial收集器
-XX:+UseSerialGC

2. ParNew 收集器

特点

  • Serial收集器的多线程版本
  • 多条GC线程进行垃圾收集
  • 新生代采用复制算法

适用场景

  • 多CPU环境下的服务器应用
  • 与CMS收集器配合使用
# 启用ParNew收集器
-XX:+UseParNewGC

3. Parallel Scavenge 收集器

特点

  • 新生代收集器,采用复制算法
  • 关注吞吐量(Throughput)
  • 自适应调节策略

重要参数

-XX:MaxGCPauseMillis=<n>     # 最大GC停顿时间
-XX:GCTimeRatio=<n> # 吞吐量大小(0-100)
-XX:+UseAdaptiveSizePolicy # 自适应GC策略

4. Serial Old 收集器

特点

  • Serial收集器的老年代版本
  • 单线程,标记-整理算法
  • 主要用于Client模式下的老年代收集

5. Parallel Old 收集器

特点

  • Parallel Scavenge收集器的老年代版本
  • 多线程,标记-整理算法
  • 关注吞吐量

6. CMS 收集器(Concurrent Mark Sweep)

特点

  • 老年代收集器
  • 以获取最短回收停顿时间为目标
  • 基于标记-清除算法
  • 并发收集

工作过程

缺点**

  • 会产生内存碎片
  • 对CPU资源敏感
  • 无法处理浮动垃圾
# 启用CMS收集器
-XX:+UseConcMarkSweepGC

# 相关参数
-XX:CMSInitiatingOccupancyFraction=70 # 触发CMS的堆使用率
-XX:+UseCMSInitiatingOccupancyOnly # 只用这个比例触发

7. G1 收集器(Garbage-First)

特点

  • 面向服务端的收集器
  • 将堆划分为多个Region(大小相同的独立区域)
  • 兼顾吞吐量和低延迟
  • 可预测的停顿时间模型

G1堆内存结构

G1堆内存布局:
┌─────────────────────────────────────┐
│ Eden Regions │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ R │ │ R │ │ R │ │ R │ │ R │ │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
├─────────────────────────────────────┤
│ Survivor Regions │
│ ┌───┐ ┌───┐ │
│ │ R │ │ R │ │
│ └───┘ └───┘ │
├─────────────────────────────────────┤
│ Old Regions │
│ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ │
│ │ R │ │ R │ │ R │ │ R │ │ R │ │
│ └───┘ └───┘ └───┘ └───┘ └───┘ │
├─────────────────────────────────────┤
│ Humongous Regions │
│ ┌─────────────────────────────┐ │
│ │ 大对象Region (>8M) │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘

G1工作流程

  1. 新生代GC:暂停所有应用线程,完成新生代的回收
  2. 并发标记周期:并发标记对象,确定哪些Region需要回收
  3. 混合回收:回收新生代和部分老年代Region
# 启用G1收集器
-XX:+UseG1GC

# G1重要参数
-XX:MaxGCPauseMillis=200 # 最大停顿时间目标
-XX:G1HeapRegionSize=16m # Region大小
-XX:G1NewSizePercent=20 # 新生代占比
-XX:G1MaxNewSizePercent=80 # 新生代最大占比
-XX:G1MixedGCCountTarget=8 # 混合回收目标次数

8. ZGC 收集器

特点

  • JDK 11引入的低延迟垃圾收集器
  • 目标:停顿时间不超过10ms
  • 支持TB级别的堆内存
  • 基于着色指针和读屏障
# 启用ZGC收集器
-XX:+UseZGC

GC 调优实战

1. GC 日志分析

开启GC日志

# Java 9+
-Xlog:gc*:file=gc.log:time,tid,level:filecount=5,filesize=10m

# Java 8
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintGCDateStamps
-Xloggc:gc.log

GC日志解读

[GC (Allocation Failure) [PSYoungGen: 1024K->512K(2048K)] 1024K->640K(4096K), 0.0051234 secs]
[Full GC (Ergonomics) [PSYoungGen: 512K->0K(2048K)] [ParOldGen: 128K->128K(2048K)] 640K->128K(4096K), [Metaspace: 2048K->2048K(1056768K)], 0.0567890 secs]

参数说明:
- PSYoungGen: 新生代(Parallel Scavenge)
- ParOldGen: 老年代(Parallel Old)
- 1024K->512K(2048K): GC前->GC后(该区域总大小)
- 0.0051234 secs: GC耗时

2. JVM 内存分配策略

对象优先在Eden分配

public class EdenTest {
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]; // 触发Minor GC
}
private static final int _1MB = 1024 * 1024;
}

大对象直接进入老年代

// 大对象阈值配置
-XX:PretenureSizeThreshold=3145728 // 3MB

byte[] bigObject = new byte[4 * 1024 * 1024]; // 直接进入老年代

长期存活对象进入老年代

// 年龄计数器配置
-XX:MaxTenuringThreshold=15 // 15次GC后进入老年代

-XX:+PrintTenuringDistribution // 打印年龄分布

3. 常用GC调优参数

堆内存设置

-Xms4g          # 初始堆大小
-Xmx4g # 最大堆大小
-Xmn2g # 新生代大小
-XX:SurvivorRatio=8 # Eden:Survivor = 8:1

GC策略选择

# 小型应用(<2GB堆)
-XX:+UseSerialGC

# 中型应用(2-8GB堆)
-XX:+UseParallelGC

# 大型应用(>8GB堆,低延迟要求)
-XX:+UseG1GC

# 超大型应用(>16GB堆,超低延迟)
-XX:+UseZGC

OOM异常处理

# 生成堆转储文件
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dump

4. GC调优实战案例

案例1:频繁Full GC

问题现象:
- GC日志显示频繁Full GC
- 老年代使用率持续增长
- 应用响应时间变长

分析思路:
1. 检查老年代大小是否合适
2. 查看对象晋升年龄设置
3. 分析是否存在内存泄漏

解决方案:
- 增大老年代大小
- 调整对象晋升年龄
- 排查内存泄漏代码

案例2:Minor GC耗时过长

问题现象:
- Minor GC停顿时间过长
- 新生代对象存活率高
- Eden区 Survivor区使用不平衡

解决方案:
- 调整Survivor区大小比例
- 增大新生代大小
- 调整对象晋升策略

经经典面试题

题目1:简述Java垃圾回收机制

答案要点

  1. 自动内存管理:JVM自动回收不再使用的对象
  2. 判断对象存活:可达性分析算法,从GC Roots开始搜索
  3. 分代收集:新生代和老年代,不同区域使用不同算法
  4. 主要算法:标记-清除、复制、标记-整理
  5. 垃圾收集器:Serial、Parallel、CMS、G1等

题目2:CMS收集器的工作流程和优缺点

答案工作流程

  1. 初始标记(STW):标记GC Roots直接关联的对象
  2. 并发标记:GC Roots Tracing,与应用线程并发执行
  3. 重新标记(STW):修正并发标记期间的变动
  4. 并发清除:清除垃圾对象,与应用线程并发执行

优点

  • 并发收集,低停顿时间
  • 适用于对响应时间要求高的场景

缺点

  • 产生内存碎片
  • 对CPU资源敏感
  • 无法处理浮动垃圾
  • 并发模式下可能产生"并发模式失败"

题目3:G1收集器的特点和工作原理

答案要点特点

  • Region化堆内存:将堆划分为多个大小相等的Region
  • 可预测停顿:可以设定最大停顿时间目标
  • 并行并发:充分利用多核CPU
  • 分代收集:逻辑上保留分代概念

工作原理

  1. 新生代GC:回收新生代Region
  2. 并发标记周期:标记活跃对象和可回收Region
  3. 混合回收:回收新生代和最有价值的老年代Region

题目4:什么情况下会触发Full GC?

答案

  1. System.gc()调用:建议JVM进行Full GC
  2. 老年代空间不足:对象晋升导致老年代不足
  3. 方法区空间不足:类信息、常量池满
  4. Minor GC晋升到老年代的对象平均大小大于老年代剩余空间
  5. CMS并发失败:并发模式失败,启用备用方案

题目5:如何监控和调优GC?

答案监控工具

  1. jstat:查看GC统计信息
  2. jvisualvm:可视化监控工具
  3. jmc:Java Mission Control
  4. GC日志:详细分析GC行为

调优步骤

  1. 明确目标:吞吐量优先还是延迟优先
  2. 监控分析:收集GC日志和性能指标
  3. 参数调整:选择合适的GC收集器和参数
  4. 测试验证:压力测试验证调优效果

题目6:什么是内存泄漏?如何检测和避免?

答案内存泄漏:程序中动态分配的内存由于某种原因未释放或无法释放,造成系统内存的浪费。

常见原因

  1. 静态集合类:静态容器持有对象引用
  2. 未关闭的资源:数据库连接、文件流等
  3. 监听器和回调:注册的监听器未注销
  4. 内部类和外部类:非静态内部类持有外部类引用

检测方法

  1. 内存分析工具:MAT、JProfiler
  2. 堆转储分析:查找占用内存最大的对象
  3. GC日志分析:观察内存增长趋势

避免策略

  1. 及时释放资源:使用try-with-resources
  2. 避免静态集合长期持有对象
  3. 及时注销监听器和回调
  4. 使用弱引用、软引用等

📝 总结

JVM垃圾回收是Java程序性能优化的核心技术之一。掌握GC的工作原理、算法和调优技巧,对于开发高性能的Java应用至关重要。

关键要点回顾

  • 理解GC的基本工作原理和分代收集策略
  • 掌握不同垃圾收集器的特点和适用场景
  • 学会使用工具监控和分析GC行为
  • 掌握常见GC问题的诊断和调优方法
  • 熟悉GC相关的面试题和解题思路

学习建议

  1. 从基础概念开始,逐步深入到具体算法和收集器
  2. 多实践,通过代码示例理解GC行为
  3. 结合监控工具,观察实际应用中的GC表现
  4. 针对面试,重点掌握高频面试题的回答要点