JVM GC 垃圾回收详解
📚 目录
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的对象包括:
-
虚拟机栈中引用的对象
public void method() {
User user = new User(); // user就是GC Root
} -
方法区中类静态属性引用的对象
public class MyClass {
private static User user = new User(); // user是GC Root
} -
方法区中常量引用的对象
public class Constants {
public static final User USER = new User(); // USER是GC Root
} -
本地方法栈中JNI引用的对象
// Native方法中的引用
public native void nativeMethod(); -
被同步锁(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) │ │
│ └─────────┴─────────────────────┘ │
└─────────────────────────────────────┘
分代收集的特点:
-
新生代(Young Generation)
- Eden区 + 两个Survivor区(S0, S1)
- 默认比例:8:1:1
- 使用复制算法
- Minor GC频繁,回收速度快
-
老年代(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工作流程:
- 新生代GC:暂停所有应用线程,完成新生代的回收
- 并发标记周期:并发标记对象,确定哪些Region需要回收
- 混合回收:回收新生代和部分老年代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垃圾回收机制
答案要点:
- 自动内存管理:JVM自动回收不再使用的对象
- 判断对象存活:可达性分析算法,从GC Roots开始搜索
- 分代收集:新生代和老年代,不同区域使用不同算法
- 主要算法:标记-清除、复制、标记-整理
- 垃圾收集器:Serial、Parallel、CMS、G1等
题目2:CMS收集器的工作流程和优缺点
答案: 工作流程:
- 初始标记(STW):标记GC Roots直接关联的对象
- 并发标记:GC Roots Tracing,与应用线程并发执行
- 重新标记(STW):修正并发标记期间的变动
- 并发清除:清除垃圾对象,与应用线程并发执行
优点:
- 并发收集,低停顿时间
- 适用于对响应时间要求高的场景
缺点:
- 产生内存碎片
- 对CPU资源敏感
- 无法处理浮动垃圾
- 并发模式下可能产生"并发模式失败"
题目3:G1收集器的特点和工作原理
答案要点: 特点:
- Region化堆内存:将堆划分为多个大小相等的Region
- 可预测停顿:可以设定最大停顿时间目标
- 并行并发:充分利用多核CPU
- 分代收集:逻辑上保留分代概念
工作原理:
- 新生代GC:回收新生代Region
- 并发标记周期:标记活跃对象和可回收Region
- 混合回收:回收新生代和最有价值的老年代Region
题目4:什么情况下会触发Full GC?
答案:
- System.gc()调用:建议JVM进行Full GC
- 老年代空间不足:对象晋升导致老年代不足
- 方法区空间不足:类信息、常量池满
- Minor GC晋升到老年代的对象平均大小大于老年代剩余空间
- CMS并发失败:并发模式失败,启用备用方案
题目5:如何监控和调优GC?
答案: 监控工具:
- jstat:查看GC统计信息
- jvisualvm:可视化监控工具
- jmc:Java Mission Control
- GC日志:详细分析GC行为
调优步骤:
- 明确目标:吞吐量优先还是延迟优先
- 监控分析:收集GC日志和性能指标
- 参数调整:选择合适的GC收集器和参数
- 测试验证:压力测试验证调优效果
题目6:什么是内存泄漏?如何检测和避免?
答案: 内存泄漏:程序中动态分配的内存由于某种原因未释放或无法释放,造成系统内存的浪费。
常见原因:
- 静态集合类:静态容器持有对象引用
- 未关闭的资源:数据库连接、文件流等
- 监听器和回调:注册的监听器未注销
- 内部类和外部类:非静态内部类持有外部类引用
检测方法:
- 内存分析工具:MAT、JProfiler
- 堆转储分析:查找占用内存最大的对象
- GC日志分析:观察内存增长趋势
避免策略:
- 及时释放资源:使用try-with-resources
- 避免静态集合长期持有对象
- 及时注销监听器和回调
- 使用弱引用、软引用等
📝 总结
JVM垃圾回收是Java程序性能优化的核心技术之一。掌握GC的工作原理、算法和调优技巧,对于开发高性能的Java应用至关重要。
关键要点回顾:
- 理解GC的基本工作原理和分代收集策略
- 掌握不同垃圾收集器的特点和适用场景
- 学会使用工具监控和分析GC行为
- 掌握常见GC问题的诊断和调优方法
- 熟悉GC相关的面试题和解题思路
学习建议:
- 从基础概念开始,逐步深入到具体算法和收集器
- 多实践,通过代码示例理解GC行为
- 结合监控工具,观察实际应用中的GC表现
- 针对面试,重点掌握高频面试题的回答要点