Appearance
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 | | | |
+---+---+---+---+---+---+ +---+---+---+---+---+---+
存活 垃圾 存活 垃圾 存活 垃圾 存活且连续 空闲且连续分代收集理论
核心假设:
- 弱分代假说:绝大多数对象都是朝生夕死的
- 强分代假说:熬过越多次 GC 的对象越难消亡
- 跨代引用假说:跨代引用相对于同代引用来说数量极少
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 RegionG1 特点:
| 特性 | 说明 |
|---|---|
| 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:SurvivorRatio | Eden/Survivor 比例,默认 8 |
-XX:MaxTenuringThreshold | 晋升老年代年龄阈值,默认 15 |
-XX:MaxGCPauseMillis | G1 目标暂停时间(毫秒) |
-XX:G1HeapRegionSize | G1 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 性能。建议尽量避免频繁创建大对象。
