定位:本文是 JVM 系列的第三篇。面试中"垃圾回收机制"几乎是必考题,很多人背得出"标记-清除、复制、标记-整理"三种算法,但一问"你的应用该用什么收集器""怎么调优"就答不上来。本文从生活比喻出发,结合 JVM 规范逐步拆解 GC 的判定标准、算法原理、收集器选择和调优实战,帮你真正理解而不是死记硬背。
官方规范参考:
- The Java Virtual Machine Specification - Garbage Collection — JVM 堆与垃圾回收
- Java Garbage Collection Tuning Guide — GC 调优官方指南
目录
- 1. 为什么需要垃圾回收?
- 2. 垃圾回收全景图
- 3. 对象死亡判定——谁该被回收?
- 4. 垃圾收集算法——怎么回收?
- 5. 分代收集理论——为什么分代?
- 6. 垃圾收集器——谁来回收?
- 7. GC 日志解读——看懂 GC 在干什么
- 8. GC 调优实战——让应用跑得更快
- 9. 常见面试题精选
- 10. 总结
1. 为什么需要垃圾回收?
先从生活说起
你住在一间公寓里,每天买东西回来堆在房间里:
不清理的后果:
Day 1: 房间整洁
Day 7: 有点拥挤
Day 30: 无法走路
Day 90: 房间被垃圾淹没,无法居住
→ 必须有人定期清理不需要的东西!
JVM 也是一样,程序运行时不断 new 对象,如果不回收不再使用的对象,内存迟早会被耗尽。
手动回收 vs 自动回收
| 语言 | 回收方式 | 风险 |
|---|---|---|
| C/C++ | 手动 malloc/free | 忘记释放 → 内存泄漏;重复释放 → 崩溃 |
| Java | 自动 GC | 无需手动管理,但无法精确控制回收时机 |
| Rust | 编译期所有权管理 | 零开销,但学习曲线陡峭 |
GC 需要解决三个问题
垃圾回收的三个核心问题:
═══════════════════════════════════════
1. 哪些对象需要回收? → 对象死亡判定
2. 什么时候回收? → GC 触发时机
3. 怎么回收? → GC 算法和收集器
2. 垃圾回收全景图
先看全貌,再逐一深入:
┌─────────────────────────────────────────────────────────────────────────┐
│ 垃圾回收全景图 │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 1. 对象死亡判定 │ │
│ │ ├─ 引用计数法(已淘汰) │ │
│ │ └─ 可达性分析(当前使用) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 2. 垃圾收集算法 │ │
│ │ ├─ 标记-清除(Mark-Sweep) │ │
│ │ ├─ 标记-复制(Mark-Copy) │ │
│ │ └─ 标记-整理(Mark-Compact) │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 3. 分代收集理论 │ │
│ │ ├─ 新生代(Young Generation)→ Minor GC │ │
│ │ └─ 老年代(Old Generation) → Major GC / Full GC │ │
│ └───────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 4. 垃圾收集器 │ │
│ │ ├─ 新生代收集器:Serial、ParNew、Parallel Scavenge │ │
│ │ ├─ 老年代收集器:Serial Old、Parallel Old、CMS │ │
│ │ └─ 全堆收集器:G1、ZGC、Shenandoah │ │
│ └───────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
3. 对象死亡判定——谁该被回收?
为什么需要判断对象死亡?
在开始讲垃圾回收算法之前,我们得先搞清楚一个根本问题:怎么知道哪些对象该被回收?
这就像你要清理房间,首先得知道哪些东西是垃圾,哪些还有用。如果把还有用的东西当垃圾扔了,那就麻烦了;如果把垃圾当宝贝留着,房间永远清理不干净。
生活比喻:判断员工是否还在职
想象你是公司HR,要判断一个员工是否还在职:
方法一:看工牌引用次数(引用计数法)
员工张三的工牌被引用了几次?
- 部门花名册引用了1次
- 项目组名单引用了1次
- 总共2次引用,说明还在职
问题:如果两个离职员工的工牌互相引用呢?
张三的工牌写着"我的搭档是李四"
李四的工牌写着"我的搭档是张三"
虽然他们都离职了,但引用计数还是1,系统认为他们还在职!
方法二:从老板开始找(可达性分析)
从CEO开始,沿着组织架构往下找:
CEO → 部门总监 → 项目经理 → 张三 ✓ 能找到,还在职
CEO → 部门总监 → 项目经理 → ? 找不到李四,已离职
3.1 引用计数法——看起来简单,实际有坑
基本原理
引用计数法的想法很直观:给每个对象维护一个计数器,记录有多少个引用指向它。
// 引用计数法的基本思路
public class ReferenceCountingExample {
public static void main(String[] args) {
// 创建对象A,引用计数 = 1
Object objA = new Object();
// 又有一个引用指向A,引用计数 = 2
Object anotherRef = objA;
// 断开一个引用,引用计数 = 1
anotherRef = null;
// 断开最后一个引用,引用计数 = 0,可以回收
objA = null;
}
}
引用计数法工作示意:
═══════════════════════════════════════
步骤1:创建对象
栈帧 堆
┌─────┐ ┌─────────────────┐
│objA ┼────→ │ Object A │
└─────┘ │ refCount = 1 │
└─────────────────┘
步骤2:增加引用
┌─────┐ ┌─────────────────┐
│objA ┼────→ │ Object A │
└─────┘ ┌→ │ refCount = 2 │
┌─────┐ │ └─────────────────┘
│another┼──┘
└─────┘
步骤3:减少引用
┌─────┐ ┌─────────────────┐
│objA ┼────→ │ Object A │
└─────┘ │ refCount = 1 │
┌─────┐ └─────────────────┘
│another┼──null
└─────┘
步骤4:引用为0,可回收
┌─────┐ ┌─────────────────┐
│objA ┼──null│ Object A │
└─────┘ │ refCount = 0 ←回收
└─────────────────┘
致命缺陷:循环引用问题
引用计数法有个致命缺陷,就是处理不了循环引用。什么是循环引用?就是A引用B,B又引用A,形成一个环。
// 循环引用导致内存泄漏
public class CircularReferenceDemo {
static class Person {
String name;
Person friend;
Person(String name) {
this.name = name;
}
}
public static void main(String[] args) {
// 创建两个人
Person zhang = new Person("张三");
Person li = new Person("李四");
// 他们互相成为朋友(循环引用)
zhang.friend = li; // 张三引用李四
li.friend = zhang; // 李四引用张三
// 外部不再需要他们了
zhang = null;
li = null;
// 问题来了:
// 张三对象的引用计数 = 1(李四在引用他)
// 李四对象的引用计数 = 1(张三在引用他)
// 引用计数法认为他们都还"活着",不会回收
// 但实际上外部已经无法访问他们了!
System.gc(); // 即使手动触发GC,也回收不了(如果用引用计数法)
}
}
循环引用示意图:
═══════════════════════════════════════
回收前:
栈帧 堆
┌─────┐ ┌─────────────────────┐
│zhang┼────→ │ Person("张三") │
└─────┘ ┌→ │ friend ────┐ │
┌─────┐ │ └────────────┼────────┤
│ li ┼───┘ │ │
└─────┘ ▼ │
┌─────────────────────┤
│ Person("李四") │
│ friend ─────────────┘
└─────────────────────┘
外部引用断开后:
栈帧 堆
┌─────┐ ┌─────────────────────┐
│zhang┼──null│ Person("张三") │
└─────┘ ┌→ │ friend ────┐ │
┌─────┐ │ │ refCount=1 ┼────────┤
│ li ┼──null │ │
└─────┘ ▼ │
┌─────────────────────┤
│ Person("李四") │
│ friend ─────────────┘
│ refCount=1 │
└─────────────────────┘
引用计数法的判断:两个对象的refCount都是1,不能回收
实际情况:外部已无法访问,应该回收 → 内存泄漏!
这就是为什么现在主流的JVM都不用引用计数法的原因。
3.2 可达性分析——现在JVM用的方法
基本思路
可达性分析的思路很像公司的组织架构查找:从最高层的"根"开始,沿着引用关系往下找,能找到的对象就是"活着的",找不到的就是"死了的"。
这些"根"在JVM里叫做GC Roots(垃圾回收根节点)。
// 可达性分析示例
public class ReachabilityAnalysisDemo {
// 静态变量,是GC Root
private static Object staticObj = new Object();
public static void main(String[] args) {
// 局部变量,是GC Root
Object localObj = new Object();
// 创建一些对象
Object obj1 = new Object();
Object obj2 = new Object();
Object obj3 = new Object();
// 建立引用关系
localObj = obj1; // GC Root → obj1
obj1 = obj2; // obj1 → obj2
// obj3没有任何引用指向它
obj1 = null; // 断开引用
obj2 = null; // 断开引用
obj3 = null; // 断开引用
// 可达性分析:
// staticObj:从GC Root可达 → 存活
// localObj指向的对象:从GC Root可达 → 存活
// obj2:通过localObj→obj1→obj2可达 → 存活
// obj3:从任何GC Root都不可达 → 可回收
}
}
什么是GC Roots?
GC Roots就是那些"根节点",从这些节点开始搜索。在JVM中,以下对象可以作为GC Roots:
GC Roots的种类:
═══════════════════════════════════════
1. 虚拟机栈中的引用
→ 正在执行的方法里的局部变量和参数
void method() {
Object obj = new Object(); // obj是GC Root
// 方法执行期间,obj引用的对象不会被回收
}
2. 方法区中的静态变量引用
→ 类的static字段
public class MyClass {
private static Object cache = new Object(); // cache是GC Root
// 只要MyClass还被加载着,cache引用的对象就不会被回收
}
3. 方法区中的常量引用
→ static final常量
public static final String CONSTANT = "Hello"; // CONSTANT是GC Root
4. 本地方法栈中的JNI引用
→ native方法中的引用
5. JVM内部的引用
→ 基本类型的Class对象、异常对象、类加载器等
6. 被同步锁持有的对象
→ synchronized锁住的对象
可达性分析的工作过程
可达性分析工作流程:
═══════════════════════════════════════
步骤1:确定GC Roots
GC Roots = {栈中的引用, 静态变量, 常量, JNI引用, ...}
步骤2:从GC Roots开始遍历
从每个GC Root出发,沿着引用链向下搜索
步骤3:标记可达对象
能从GC Roots到达的对象 → 标记为"存活"
步骤4:确定垃圾对象
没有被标记的对象 → 确定为"垃圾",可以回收
// 可达性分析详细示例
public class ReachabilityExample {
// 静态变量 - GC Root
private static Object staticRoot = new Object();
public static void testReachability() {
// 局部变量 - GC Root
Object localRoot = new Object();
// 创建对象链
Object obj1 = new Object();
Object obj2 = new Object();
Object obj3 = new Object();
Object obj4 = new Object();
Object obj5 = new Object();
// 建立引用关系
staticRoot = obj1; // GC Root → obj1
obj1 = obj2; // obj1 → obj2
localRoot = obj3; // GC Root → obj3
obj3 = obj4; // obj3 → obj4
// obj5没有任何引用
// 可达性分析结果:
// staticRoot: GC Root ✓
// obj1: staticRoot可达 ✓
// obj2: staticRoot→obj1→obj2可达 ✓
// localRoot: GC Root ✓
// obj3: localRoot可达 ✓
// obj4: localRoot→obj3→obj4可达 ✓
// obj5: 不可达 ✗ (垃圾对象)
}
}
解决循环引用问题
可达性分析完美解决了循环引用问题:
// 可达性分析处理循环引用
public class CircularReferenceSolution {
static class Node {
String name;
Node next;
Node(String name) {
this.name = name;
}
}
public static void main(String[] args) {
// 创建循环引用
Node nodeA = new Node("A"); // GC Root
Node nodeB = new Node("B");
Node nodeC = new Node("C");
// 建立循环引用:A→B→C→A
nodeA.next = nodeB;
nodeB.next = nodeC;
nodeC.next = nodeA; // 形成环
// 断开外部引用
nodeA = null;
nodeB = null;
nodeC = null;
// 可达性分析:
// 从GC Roots开始搜索,发现没有任何路径能到达A、B、C
// 虽然A、B、C之间有循环引用,但它们整体不可达
// 结论:A、B、C都是垃圾,可以回收 ✓
System.gc(); // 可以正确回收
}
}
循环引用的可达性分析:
═══════════════════════════════════════
外部引用断开前:
GC Roots 堆
┌─────────┐ ┌─────────────────┐
│ nodeA ┼──→ │ Node("A") │
└─────────┘ │ next ──┐ │
└────────┼────────┤
▼ │
┌─────────────────┤
│ Node("B") │
│ next ──┐ │
└────────┼────────┤
▼ │
┌─────────────────┤
│ Node("C") │
│ next ───────────┘
└─────────────────┘
外部引用断开后:
GC Roots 堆
┌─────────┐ ┌─────────────────┐
│ nodeA ┼null│ Node("A") │
└─────────┘ │ next ──┐ │
└────────┼────────┤
▼ │
┌─────────────────┤
│ Node("B") │
│ next ──┐ │
└────────┼────────┤
▼ │
┌─────────────────┤
│ Node("C") │
│ next ───────────┘
└─────────────────┘
可达性分析结果:
从GC Roots出发,无法到达A、B、C中的任何一个
→ 整个循环引用链都是垃圾,可以回收 ✓
小结:为什么选择可达性分析?
| 对比项 | 引用计数法 | 可达性分析 |
|---|---|---|
| 实现复杂度 | 简单 | 相对复杂 |
| 执行效率 | 高(实时更新) | 中等(需要遍历) |
| 内存开销 | 每个对象需要计数器 | 需要额外的标记空间 |
| 循环引用 | ❌ 无法处理 | ✅ 完美处理 |
| 准确性 | 有缺陷 | 准确 |
| 实际应用 | 已淘汰 | 主流JVM都在用 |
现在你明白了为什么JVM选择可达性分析:虽然复杂一点,但它能正确处理各种复杂的引用关系,包括循环引用。这为后面的垃圾回收算法奠定了坚实的基础。
接下来,我们就基于"哪些对象该回收"这个基础,来看看"怎么回收"这些垃圾对象。
3.3 四种引用类型
JVM 提供了四种引用强度不同的类型,让开发者可以灵活控制对象的生命周期:
引用强度(强 → 弱):
═══════════════════════════════════════
强引用 > 软引用 > 弱引用 > 虚引用
| 引用类型 | 回收时机 | 用途 | 示例 |
|---|---|---|---|
| 强引用 | 永不回收(只要引用存在) | 普通变量赋值 | Object obj = new Object() |
| 软引用 | 内存不足时回收 | 缓存 | SoftReference<Object> |
| 弱引用 | 下次 GC 时回收 | 缓存、WeakHashMap | WeakReference<Object> |
| 虚引用 | 随时可能回收,无法获取对象 | 跟踪 GC 回收、管理堆外内存 | PhantomReference<Object> |
软引用示例
import java.lang.ref.SoftReference;
// 软引用:内存不足时才会被回收,适合做缓存
SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024]);
// 使用时先判断是否被回收
byte[] data = cache.get();
if (data != null) {
// 缓存还在,使用缓存
} else {
// 缓存已被回收(内存不足),重新加载
data = new byte[1024 * 1024];
cache = new SoftReference<>(data);
}
弱引用示例
import java.lang.ref.WeakReference;
import java.util.WeakHashMap;
// 弱引用:GC 一来就被回收
WeakReference<Object> ref = new WeakReference<>(new Object());
System.out.println(ref.get()); // 有值
System.gc(); // 触发 GC
System.out.println(ref.get()); // 可能是 null
// WeakHashMap:键是弱引用,适合做缓存
WeakHashMap<Key, Value> map = new WeakHashMap<>();
map.put(key, value);
// 当 key 没有强引用时,下次 GC 会自动移除该条目
3.4 对象的自我拯救(finalize)
/**
* 对象在被判定死亡后,还有一次自我拯救的机会
* 流程:
* 1. 可达性分析发现对象不可达 → 第一次标记
* 2. 判断对象是否覆盖了 finalize() 且未被调用过
* 3. 如果是 → 放入 F-Queue,由 Finalizer 线程执行 finalize()
* 4. finalize() 中如果重新建立引用链 → 自我拯救成功
* 5. 如果 finalize() 没有建立引用链 → 真正回收
*
* 注意:finalize() 只会被系统调用一次!
* 不推荐使用,仅作了解
*/
public class FinalizeEscapeDemo {
private static FinalizeEscapeDemo SAVE_HOOK = null;
@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 FinalizeEscapeDemo();
// 第一次:对象自我拯救
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
System.out.println("对象存活!"); // 会输出
} else {
System.out.println("对象已死!");
}
// 第二次:finalize() 只执行一次,无法再拯救
SAVE_HOOK = null;
System.gc();
Thread.sleep(500);
if (SAVE_HOOK != null) {
System.out.println("对象存活!");
} else {
System.out.println("对象已死!"); // 会输出
}
}
}
4. 垃圾收集算法——怎么回收?
现在我们知道了哪些对象该被回收,接下来的问题就是:怎么回收这些垃圾对象?
这就像你知道了房间里哪些是垃圾,现在要考虑用什么方法来清理。不同的清理方法有不同的效果和代价。
垃圾回收算法的核心问题
在设计垃圾回收算法时,需要考虑几个关键问题:
垃圾回收算法要解决的问题:
═══════════════════════════════════════
1. 效率问题:回收速度要快,不能让程序停顿太久
2. 空间问题:回收后的内存要能高效利用,不能有太多碎片
3. 成本问题:回收过程本身不能消耗太多资源
4. 吞吐量问题:不能为了回收而严重影响程序正常运行
基于这些考虑,业界发展出了三种基础的垃圾回收算法。让我们一个一个来看。
4.1 标记-清除算法——最直观的想法
生活比喻
标记-清除就像大扫除:
- 先在垃圾上贴标签(标记阶段)
- 然后把贴了标签的垃圾扫走(清除阶段)
算法原理
标记-清除算法是最基础的收集算法,分为两个阶段:
// 标记-清除算法的基本思路
public class MarkSweepDemo {
public static void markSweepGC() {
// 阶段1:标记(Mark)
// 从GC Roots开始,标记所有可达的对象
markReachableObjects();
// 阶段2:清除(Sweep)
// 遍历整个堆,回收没有被标记的对象
sweepUnmarkedObjects();
}
private static void markReachableObjects() {
// 从GC Roots开始深度优先搜索
// 标记所有可达对象为"存活"
System.out.println("标记阶段:标记所有存活对象");
}
private static void sweepUnmarkedObjects() {
// 遍历堆中所有对象
// 回收没有标记的对象,释放内存
System.out.println("清除阶段:回收未标记对象");
}
}
执行过程详解
标记-清除算法执行过程:
═══════════════════════════════════════
初始状态(堆内存):
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │D │E │F │G │H │I │J │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
所有对象都在内存中
步骤1:标记阶段
从GC Roots开始,标记可达对象:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │✗B│C │✗D│E │✗F│G │✗H│I │✗J│
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
✓ ✗ ✓ ✗ ✓ ✗ ✓ ✗ ✓ ✗
存活 垃圾 存活 垃圾 存活 垃圾 存活 垃圾 存活 垃圾
步骤2:清除阶段
回收被标记为垃圾的对象:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │ │C │ │E │ │G │ │I │ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
✓ 空闲 ✓ 空闲 ✓ 空闲 ✓ 空闲 ✓ 空闲
结果:垃圾被回收,但留下了内存碎片
优缺点分析
优点:
- 实现简单:逻辑直观,容易理解和实现
- 不需要移动对象:对象位置不变,引用不需要更新
缺点:
- 产生内存碎片:回收后内存不连续,像被虫子咬过的苹果
- 分配效率低:需要维护空闲列表,分配大对象时可能找不到连续空间
// 内存碎片问题演示
public class FragmentationDemo {
public static void demonstrateFragmentation() {
// 假设经过标记-清除后的内存状态
// [对象A][空闲2KB][对象C][空闲1KB][对象E][空闲3KB]
// 现在要分配一个4KB的大对象
// 虽然总空闲空间 = 2KB + 1KB + 3KB = 6KB > 4KB
// 但没有连续的4KB空间,分配失败!
System.out.println("内存碎片导致大对象分配失败");
System.out.println("总空闲:6KB,需要:4KB,但无连续空间");
}
}
4.2 标记-复制算法——解决碎片问题
生活比喻
标记-复制就像搬家:
- 准备两个房间,平时只住一个
- 搬家时,把有用的东西搬到另一个房间
- 原房间整个清空,干干净净
- 下次搬家时角色互换
算法原理
为了解决标记-清除的碎片问题,标记-复制算法采用了不同的思路:
// 标记-复制算法的基本思路
public class MarkCopyDemo {
// 将内存分为两个相等的区域
private static Object[] fromSpace = new Object[100]; // 当前使用区
private static Object[] toSpace = new Object[100]; // 空闲区
public static void markCopyGC() {
// 阶段1:标记存活对象
markAliveObjects();
// 阶段2:复制存活对象到toSpace
copyAliveObjects();
// 阶段3:清空fromSpace,交换角色
swapSpaces();
}
private static void markAliveObjects() {
System.out.println("标记fromSpace中的存活对象");
}
private static void copyAliveObjects() {
System.out.println("将存活对象复制到toSpace");
// 复制时对象是紧密排列的,没有碎片
}
private static void swapSpaces() {
System.out.println("交换fromSpace和toSpace的角色");
// fromSpace变成新的空闲区
// toSpace变成新的使用区
}
}
执行过程详解
标记-复制算法执行过程:
═══════════════════════════════════════
初始状态:
┌─────────────────────────┬─────────────────────────┐
│ From Space(使用中) │ To Space(空闲) │
│ A B C D E F G H │ │
└─────────────────────────┴─────────────────────────┘
步骤1:标记存活对象
┌─────────────────────────┬─────────────────────────┐
│ From Space │ To Space │
│ A ✗B C ✗D E ✗F G ✗H│ │
│ ✓ ✗ ✓ ✗ ✓ ✗ ✓ ✗│ │
└─────────────────────────┴─────────────────────────┘
存活 垃圾 存活 垃圾 存活 垃圾 存活 垃圾
步骤2:复制存活对象
┌─────────────────────────┬─────────────────────────┐
│ From Space │ To Space │
│ A ✗B C ✗D E ✗F G ✗H│ A C E G │
└─────────────────────────┴─────────────────────────┘
紧密排列,无碎片
步骤3:清空From,交换角色
┌─────────────────────────┬─────────────────────────┐
│ From Space(新空闲) │ To Space(新使用) │
│ │ A C E G │
└─────────────────────────┴─────────────────────────┘
结果:无碎片,对象紧密排列,分配效率高
优缺点分析
优点:
- 无内存碎片:存活对象紧密排列,内存连续
- 分配效率高:使用指针碰撞分配,只需移动指针
- 实现简单:不需要复杂的空闲列表管理
缺点:
- 浪费一半内存:任何时候都有一半内存空闲
- 复制开销大:存活对象多时,复制成本高
// 复制算法的内存利用率问题
public class CopyAlgorithmDemo {
public static void analyzeCopyAlgorithm() {
// 假设堆内存总共100MB
int totalMemory = 100;
int usableMemory = totalMemory / 2; // 只能使用50MB
System.out.println("总内存:" + totalMemory + "MB");
System.out.println("可用内存:" + usableMemory + "MB");
System.out.println("内存利用率:" + (usableMemory * 100 / totalMemory) + "%");
// 如果存活对象很多,复制开销会很大
int aliveObjects = 40; // 假设40MB对象存活
System.out.println("需要复制:" + aliveObjects + "MB对象");
System.out.println("复制开销:很大!");
}
}
4.3 标记-整理算法——兼顾空间和效率
生活比喻
标记-整理就像整理书架:
- 先标记要保留的书(标记阶段)
- 把要保留的书依次排紧(整理阶段)
- 后面就是连续的空闲空间
算法原理
标记-整理算法结合了前两种算法的优点:
// 标记-整理算法的基本思路
public class MarkCompactDemo {
public static void markCompactGC() {
// 阶段1:标记存活对象
markAliveObjects();
// 阶段2:整理内存,将存活对象向一端移动
compactMemory();
// 阶段3:更新所有引用
updateReferences();
}
private static void markAliveObjects() {
System.out.println("标记所有存活对象");
}
private static void compactMemory() {
System.out.println("将存活对象向内存一端移动");
// 移动后对象紧密排列,后面是连续空闲空间
}
private static void updateReferences() {
System.out.println("更新所有指向移动对象的引用");
// 这是开销最大的部分
}
}
执行过程详解
标记-整理算法执行过程:
═══════════════════════════════════════
初始状态:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │B │C │D │E │F │G │H │I │J │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
步骤1:标记存活对象
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │✗B│C │✗D│E │✗F│G │✗H│I │✗J│
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
✓ ✗ ✓ ✗ ✓ ✗ ✓ ✗ ✓ ✗
步骤2:整理阶段(移动存活对象)
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │C │E │G │I │ │ │ │ │ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
存活对象紧密排列 连续的空闲空间
步骤3:更新引用
所有指向A、C、E、G、I的引用都要更新为新地址
结果:无碎片,不浪费内存,但移动开销大
优缺点分析
优点:
- 无内存碎片:存活对象紧密排列
- 不浪费内存:不像复制算法浪费一半空间
- 分配效率高:后续分配使用指针碰撞
缺点:
- 移动开销大:需要移动对象并更新所有引用
- 停顿时间长:移动和更新引用需要时间
// 标记-整理算法的引用更新问题
public class CompactReferenceUpdate {
static class ObjectA {
ObjectC ref; // 引用需要更新
}
static class ObjectC {
// 对象内容
}
public static void demonstrateReferenceUpdate() {
// 假设整理前:
// ObjectA在地址0x1000,ObjectC在地址0x3000
// ObjectA.ref = 0x3000
// 整理后:
// ObjectA移动到地址0x1000,ObjectC移动到地址0x1008
// ObjectA.ref需要更新为0x1008
System.out.println("整理前:A.ref指向0x3000");
System.out.println("整理后:A.ref需要更新为0x1008");
System.out.println("所有引用都需要这样更新,开销很大!");
}
}
三种算法的对比总结
现在我们来对比一下这三种基础算法:
| 对比维度 | 标记-清除 | 标记-复制 | 标记-整理 |
|---|---|---|---|
| 内存碎片 | ❌ 有碎片 | ✅ 无碎片 | ✅ 无碎片 |
| 内存利用率 | ✅ 100% | ❌ 50% | ✅ 100% |
| 分配效率 | ❌ 慢(空闲列表) | ✅ 快(指针碰撞) | ✅ 快(指针碰撞) |
| 回收效率 | ✅ 快(不移动对象) | 中等(复制开销) | ❌ 慢(移动+更新引用) |
| 适用场景 | 存活对象多的区域 | 存活对象少的区域 | 存活对象多且要求无碎片 |
为什么需要不同的算法?
看到这里你可能会问:既然标记-整理看起来最完美,为什么不都用它呢?
答案是:**没有银弹!**不同的场景需要不同的算法:
// 不同场景的算法选择
public class AlgorithmSelection {
public static void chooseAlgorithm() {
// 场景1:新生代(大部分对象很快死亡)
// 存活对象少,复制开销小 → 选择标记-复制
System.out.println("新生代:存活率10%,选择标记-复制算法");
// 场景2:老年代(对象长期存活)
// 存活对象多,复制开销大 → 选择标记-清除或标记-整理
System.out.println("老年代:存活率90%,选择标记-整理算法");
// 场景3:内存紧张时
// 不能浪费50%内存 → 不选择标记-复制
System.out.println("内存紧张:不能浪费空间,选择标记-整理");
// 场景4:要求低延迟时
// 不能有长时间停顿 → 选择标记-清除(配合并发收集)
System.out.println("低延迟要求:选择标记-清除+并发收集");
}
}
这就引出了一个重要问题:既然不同场景需要不同算法,那能不能把内存分成不同区域,每个区域用最适合的算法呢?
这就是我们接下来要讲的分代收集理论的核心思想!
5. 分代收集理论——为什么分代?
前面我们学了三种基础的垃圾回收算法,每种都有自己的优缺点。现在问题来了:能不能把它们的优点结合起来,在不同的场景下用不同的算法呢?
这就是分代收集理论要解决的问题。
分代收集的核心观察
在讲分代之前,我们先来观察一下程序中对象的生命周期规律:
生活比喻:公司员工的生命周期
想象你是一家公司的HR,观察员工的工作周期:
员工类型分析:
═══════════════════════════════════════
新员工(试用期):
- 大部分人试用期就离职了(90%)
- 只有少数人能转正(10%)
- 需要频繁招聘和办理离职手续
老员工(正式员工):
- 已经稳定下来,很少离职
- 偶尔有人跳槽,但比例很低
- 管理成本低,不需要频繁处理
结论:新员工和老员工需要不同的管理策略!
对象的生命周期规律
Java程序中的对象也有类似的规律:
// 对象生命周期的典型例子
public class ObjectLifecycleDemo {
public static void processRequest() {
// 这些对象生命周期很短,方法结束就死了
String requestId = UUID.randomUUID().toString(); // 短命对象
StringBuilder response = new StringBuilder(); // 短命对象
List<String> tempList = new ArrayList<>(); // 短命对象
// 处理逻辑...
response.append("处理完成");
// 方法结束,上面的对象都变成垃圾
}
// 这些对象生命周期很长,可能伴随整个应用生命周期
private static final Map<String, Object> CACHE = new HashMap<>(); // 长命对象
private static final Logger LOGGER = LoggerFactory.getLogger(...); // 长命对象
public static void main(String[] args) {
// 每次请求都会创建很多短命对象
for (int i = 0; i < 1000; i++) {
processRequest(); // 每次调用产生大量短命对象
}
// CACHE和LOGGER一直存活
}
}
通过大量的统计和观察,研究人员发现了对象生命周期的两个重要规律:
分代收集的两大假说
弱分代假说(Weak Generational Hypothesis)
绝大多数对象都是朝生夕死的
// 弱分代假说的例子
public class WeakGenerationalDemo {
public static void handleHttpRequest(HttpRequest request) {
// 这些对象都是"朝生夕死"的
String requestBody = request.getBody(); // 请求结束就死
Map<String, String> params = parseParams(requestBody); // 请求结束就死
ValidationResult result = validate(params); // 请求结束就死
ResponseData data = processData(params); // 请求结束就死
String jsonResponse = toJson(data); // 请求结束就死
// 方法结束,上面所有对象都成为垃圾
// 统计显示:90%以上的对象都是这样短命的
}
}
强分代假说(Strong Generational Hypothesis)
熬过越多次GC的对象,越有可能继续存活
// 强分代假说的例子
public class StrongGenerationalDemo {
// 这个对象如果熬过了很多次GC,说明它很重要,可能会一直存活
private static final ApplicationConfig CONFIG = loadConfig();
// 这个缓存对象如果存活了很久,说明经常被使用,应该继续保留
private static final Map<String, User> USER_CACHE = new ConcurrentHashMap<>();
public static void demonstrateStrongHypothesis() {
// 假设这些对象已经经历了10次GC还没死
// 那么它们在第11次GC中死亡的概率就很低
// 经历的GC次数越多,继续存活的概率越大
}
}
基于假说的分代设计
基于这两个假说,我们可以设计一个分代的内存管理策略:
分代收集的设计思路:
═══════════════════════════════════════
既然对象有不同的生命周期特征,
那就把内存分成不同的区域,
每个区域用最适合的回收策略:
新生代(Young Generation):
- 存放新创建的对象
- 大部分对象很快死亡 → 存活率低(~10%)
- 适合用复制算法(复制开销小)
- 回收频率高,但每次很快
老年代(Old Generation):
- 存放长期存活的对象
- 大部分对象继续存活 → 存活率高(~90%)
- 适合用标记-整理算法(不浪费空间)
- 回收频率低,但每次较慢
堆的分代结构详解
现在我们来看看JVM是怎么具体实现分代的:
JVM堆内存的分代结构:
═══════════════════════════════════════
┌─────────────────────────────────────────────────────────────────────────┐
│ JVM 堆内存 │
│ │
│ ┌─────────────────────────────────────────────┐ ┌─────────────────┐ │
│ │ 新生代(Young Generation) │ │ 老年代(Old Gen) │ │
│ │ 默认占堆的 1/3 │ │ 默认占堆的 2/3 │ │
│ │ │ │ │ │
│ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ │
│ │ │ Eden │ │Survivor0│ │Survivor1│ │ │ │ │
│ │ │ 区域 │ │ (S0) │ │ (S1) │ │ │ 长期存活对象 │ │
│ │ │ 80% │ │ 10% │ │ 10% │ │ │ │ │
│ │ │ │ │ │ │ │ │ │ │ │
│ │ │新对象的 │ │存活对象 │ │存活对象 │ │ │ 经历多次GC后 │ │
│ │ │诞生地 │ │暂存区1 │ │暂存区2 │ │ │ 晋升到这里 │ │
│ │ └─────────┘ └─────────┘ └─────────┘ │ │ │ │
│ │ │ │ │ │
│ │ 特点:对象朝生夕死,存活率低 │ │ 特点:对象长期存活 │ │
│ │ 算法:复制算法(Eden + 一个Survivor) │ │ 算法:标记-整理 │ │
│ │ 频率:高频回收(Minor GC) │ │ 频率:低频回收 │ │
│ └─────────────────────────────────────────────┘ └─────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
各区域的详细说明
Eden区(伊甸园):
- 作用:所有新对象的诞生地
- 大小:新生代的80%
- 特点:对象创建速度快,死亡速度也快
Survivor区(幸存者区):
- 作用:存放从Eden区存活下来的对象
- 大小:各占新生代的10%
- 特点:有两个区域(S0和S1),同时只使用一个
Old区(老年代):
- 作用:存放长期存活的对象
- 大小:堆内存的2/3
- 特点:对象存活时间长,回收频率低
分代收集的工作流程
让我们通过一个完整的例子来看看分代收集是怎么工作的:
// 分代收集工作流程演示
public class GenerationalGCDemo {
public static void demonstrateGenerationalGC() {
// 第1步:新对象在Eden区创建
System.out.println("=== 第1步:对象创建 ===");
Object obj1 = new Object(); // 在Eden区创建
Object obj2 = new Object(); // 在Eden区创建
Object obj3 = new Object(); // 在Eden区创建
System.out.println("新对象都在Eden区");
// 第2步:Eden区满了,触发Minor GC
System.out.println("\n=== 第2步:Eden区满了 ===");
// 假设obj1被引用,obj2和obj3没有引用
obj2 = null;
obj3 = null;
System.out.println("触发Minor GC...");
// 第3步:存活对象复制到Survivor区
System.out.println("\n=== 第3步:Minor GC执行 ===");
System.out.println("obj1存活,复制到S0区");
System.out.println("obj2和obj3死亡,被回收");
System.out.println("Eden区被清空");
// 第4步:继续创建新对象
System.out.println("\n=== 第4步:继续创建对象 ===");
Object obj4 = new Object(); // 在Eden区创建
Object obj5 = new Object(); // 在Eden区创建
// 第5步:再次Minor GC
System.out.println("\n=== 第5步:再次Minor GC ===");
System.out.println("obj1从S0复制到S1,年龄+1");
System.out.println("obj4存活,从Eden复制到S1");
System.out.println("obj5死亡,被回收");
// 第6步:多次GC后,对象晋升到老年代
System.out.println("\n=== 第6步:对象晋升 ===");
System.out.println("obj1经历了15次Minor GC,晋升到老年代");
}
}
详细的GC流程图
分代GC的详细流程:
═══════════════════════════════════════
初始状态:
┌─────────────────────────────────────────────┐ ┌─────────────┐
│ 新生代 │ │ 老年代 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ Eden │ │ S0 │ │ S1 │ │ │ │
│ │ │ │ (空) │ │ (空) │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │ │ │
└─────────────────────────────────────────────┘ └─────────────┘
步骤1:创建对象
┌─────────────────────────────────────────────┐ ┌─────────────┐
│ 新生代 │ │ 老年代 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ Eden │ │ S0 │ │ S1 │ │ │ │
│ │ A B C D │ │ (空) │ │ (空) │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │ │ │
└─────────────────────────────────────────────┘ └─────────────┘
步骤2:Eden满了,触发Minor GC(假设A、C存活)
┌─────────────────────────────────────────────┐ ┌─────────────┐
│ 新生代 │ │ 老年代 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ Eden │ │ S0 │ │ S1 │ │ │ │
│ │ (空) │ │ A C │ │ (空) │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │ │ │
└─────────────────────────────────────────────┘ └─────────────┘
A和C复制到S0,年龄=1;B和D被回收
步骤3:继续创建对象
┌─────────────────────────────────────────────┐ ┌─────────────┐
│ 新生代 │ │ 老年代 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ Eden │ │ S0 │ │ S1 │ │ │ │
│ │ E F G H │ │ A C │ │ (空) │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │ │ │
└─────────────────────────────────────────────┘ └─────────────┘
步骤4:再次Minor GC(假设A、E、G存活)
┌─────────────────────────────────────────────┐ ┌─────────────┐
│ 新生代 │ │ 老年代 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ Eden │ │ S0 │ │ S1 │ │ │ │
│ │ (空) │ │ (空) │ │ A E G │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │ │ │
└─────────────────────────────────────────────┘ └─────────────┘
A从S0复制到S1,年龄=2;E、G从Eden复制到S1,年龄=1
步骤5:经过多次GC,A达到晋升年龄(默认15)
┌─────────────────────────────────────────────┐ ┌─────────────┐
│ 新生代 │ │ 老年代 │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │
│ │ Eden │ │ S0 │ │ S1 │ │ │ A │
│ │ ... ... │ │ E G ... │ │ (空) │ │ │ │
│ └─────────┘ └─────────┘ └─────────┘ │ │ │
└─────────────────────────────────────────────┘ └─────────────┘
A晋升到老年代,成为"老员工"
为什么这样设计效果好?
1. 算法匹配度高
// 算法选择的合理性
public class AlgorithmMatching {
public static void explainAlgorithmChoice() {
// 新生代:存活率低(~10%),适合复制算法
System.out.println("=== 新生代算法选择 ===");
System.out.println("存活对象少,复制开销小");
System.out.println("虽然浪费Survivor空间,但新生代本身就小");
System.out.println("复制后无碎片,分配效率高");
// 老年代:存活率高(~90%),适合标记-整理
System.out.println("\n=== 老年代算法选择 ===");
System.out.println("存活对象多,复制开销太大");
System.out.println("不能浪费50%空间(老年代占堆的2/3)");
System.out.println("标记-整理虽然慢,但回收频率低");
}
}
2. 回收频率优化
// 回收频率的优化
public class GCFrequencyOptimization {
public static void explainFrequency() {
System.out.println("=== 回收频率优化 ===");
// 新生代:高频快速回收
System.out.println("新生代Minor GC:");
System.out.println("- 频率:高(Eden区很快满)");
System.out.println("- 速度:快(存活对象少)");
System.out.println("- 影响:小(停顿时间短)");
// 老年代:低频深度回收
System.out.println("\n老年代Major GC:");
System.out.println("- 频率:低(对象死亡慢)");
System.out.println("- 速度:慢(存活对象多)");
System.out.println("- 影响:大(停顿时间长)");
System.out.println("\n总体效果:");
System.out.println("大部分垃圾在新生代被快速回收");
System.out.println("减少了老年代GC的压力和频率");
}
}
3. 内存利用率提升
// 内存利用率分析
public class MemoryUtilization {
public static void analyzeUtilization() {
System.out.println("=== 内存利用率分析 ===");
// 如果全堆都用复制算法
System.out.println("如果全堆用复制算法:");
System.out.println("- 浪费50%内存");
System.out.println("- 1GB堆只能用500MB");
// 分代设计的利用率
System.out.println("\n分代设计的利用率:");
System.out.println("- 新生代浪费10%(一个Survivor)");
System.out.println("- 老年代不浪费");
System.out.println("- 总体浪费:1/3 * 10% = 3.3%");
System.out.println("- 1GB堆可以用966MB");
System.out.println("\n结论:内存利用率大幅提升!");
}
}
分代收集的优势总结
| 优势 | 说明 | 效果 |
|---|---|---|
| 算法匹配 | 不同代用最适合的算法 | 回收效率高 |
| 频率优化 | 新生代高频快速,老年代低频深度 | 整体停顿时间短 |
| 空间效率 | 只在新生代浪费少量空间 | 内存利用率高 |
| 性能稳定 | 大部分垃圾在新生代处理 | 老年代压力小 |
现在你应该明白了为什么JVM要采用分代收集:这不是一个简单的技术选择,而是基于对象生命周期规律的深刻洞察,通过合理的分工来达到整体的最优效果。
就像一个好的管理者,不会用同一套方法管理所有员工,而是根据员工的特点采用不同的管理策略,这样才能让整个团队的效率最高。
│ │ GC 算法:标记-复制 │ │ GC 算法:多样 │ │
│ │ 对象特点:朝生夕死 │ │ 对象特点:稳定 │ │
│ └───────────────────────────────────────────────┘ └───────────────┘ │
│ │
│ 新生代 : 老年代 = 1 : 2 (-XX:NewRatio=2) │
│ Eden : S0 : S1 = 8 : 1 : 1 (-XX:SurvivorRatio=8) │
└─────────────────────────────────────────────────────────────────────────┘
### 对象的年龄与晋升机制
#### 什么是对象年龄?
在分代收集中,每个对象都有一个"年龄"(Age),这个年龄记录了对象经历了多少次Minor GC还没死。
```java
// 对象年龄的概念
public class ObjectAgeDemo {
public static void explainObjectAge() {
System.out.println("=== 对象年龄的含义 ===");
// 新创建的对象,年龄 = 0
Object newObj = new Object();
System.out.println("新对象年龄:0(在Eden区)");
// 第一次Minor GC存活,年龄 = 1
System.out.println("第1次Minor GC存活 → 年龄 = 1(进入S0)");
// 第二次Minor GC存活,年龄 = 2
System.out.println("第2次Minor GC存活 → 年龄 = 2(进入S1)");
// ...继续经历GC
System.out.println("...");
// 达到晋升年龄阈值(默认15),晋升到老年代
System.out.println("第15次Minor GC存活 → 年龄 = 15(晋升到老年代)");
}
}
对象晋升的条件
对象从新生代晋升到老年代有几种情况:
对象晋升到老年代的条件:
═══════════════════════════════════════
条件1:年龄达到阈值
- 默认阈值:15次Minor GC
- 参数控制:-XX:MaxTenuringThreshold=15
- 含义:经历15次GC还活着,说明是长期对象
条件2:大对象直接晋升
- 大对象:超过-XX:PretenureSizeThreshold的对象
- 原因:避免在Eden和Survivor之间复制大对象
- 典型:大数组、大字符串
条件3:Survivor区空间不足
- 当Survivor区放不下所有存活对象时
- 多余的对象直接晋升到老年代
- 这叫"空间分配担保"
条件4:动态年龄判定
- 如果Survivor中相同年龄对象的总大小 > Survivor空间的一半
- 那么年龄 >= 该年龄的对象都晋升到老年代
- 这是一种自适应的优化策略
晋升过程的详细演示
// 对象晋升过程演示
public class PromotionDemo {
public static void demonstratePromotion() {
System.out.println("=== 对象晋升演示 ===");
// 模拟对象的生命历程
simulateObjectLifecycle();
}
private static void simulateObjectLifecycle() {
// 第1次GC:新对象在Eden区
System.out.println("创建对象A,在Eden区,年龄=0");
// 第2次GC:存活,进入S0
System.out.println("第1次Minor GC:A存活,复制到S0,年龄=1");
// 第3次GC:存活,进入S1
System.out.println("第2次Minor GC:A存活,复制到S1,年龄=2");
// 继续经历GC...
for (int age = 3; age <= 14; age++) {
String survivor = (age % 2 == 1) ? "S0" : "S1";
System.out.println("第" + age + "次Minor GC:A存活,复制到" +
survivor + ",年龄=" + age);
}
// 达到晋升年龄
System.out.println("第15次Minor GC:A存活,年龄=15,晋升到老年代!");
System.out.println("A成为老年代对象,不再参与Minor GC");
}
}
分代收集的实际运行效果
让我们看看分代收集在实际运行中的效果:
Minor GC的高效性
// Minor GC的效率演示
public class MinorGCEfficiency {
public static void demonstrateMinorGC() {
System.out.println("=== Minor GC效率分析 ===");
// 假设Eden区有1000个对象
int edenObjects = 1000;
int survivalRate = 10; // 存活率10%
int aliveObjects = edenObjects * survivalRate / 100;
System.out.println("Eden区对象数:" + edenObjects);
System.out.println("存活对象数:" + aliveObjects);
System.out.println("死亡对象数:" + (edenObjects - aliveObjects));
System.out.println("\nMinor GC执行:");
System.out.println("1. 标记" + aliveObjects + "个存活对象");
System.out.println("2. 复制" + aliveObjects + "个对象到Survivor");
System.out.println("3. 清空Eden区(" + (edenObjects - aliveObjects) + "个对象被回收)");
System.out.println("\n效果:");
System.out.println("- 回收了90%的垃圾");
System.out.println("- 只需要复制10%的对象");
System.out.println("- 速度很快!");
}
}
为什么需要两个Survivor区?
这是一个经常被问到的问题,让我详细解释:
// 为什么需要两个Survivor区?
public class WhyTwoSurvivors {
public static void explainTwoSurvivors() {
System.out.println("=== 为什么需要两个Survivor区? ===");
// 如果只有一个Survivor区会怎样?
System.out.println("如果只有一个Survivor区:");
System.out.println("Eden满了 → 存活对象复制到Survivor");
System.out.println("Eden再满 → 存活对象要复制到哪里?");
System.out.println("Survivor已经有对象了,新旧对象混在一起");
System.out.println("无法区分对象的年龄!");
System.out.println("\n有两个Survivor区的好处:");
System.out.println("Eden满了 → 存活对象复制到S0");
System.out.println("Eden再满 → Eden+S0的存活对象都复制到S1");
System.out.println("S0被清空,S1中的对象年龄都是连续的");
System.out.println("下次GC时,Eden+S1的存活对象复制到S0");
System.out.println("这样S0和S1轮流使用,保证对象年龄的准确性");
}
}
分代收集的完整流程
分代收集的完整生命周期:
═══════════════════════════════════════
阶段1:对象诞生
新对象 → Eden区(年龄=0)
阶段2:第一次筛选
Eden满 → Minor GC → 存活对象复制到S0(年龄=1)
阶段3:反复筛选
Eden满 → Minor GC → Eden+S0存活对象复制到S1(年龄+1)
Eden满 → Minor GC → Eden+S1存活对象复制到S0(年龄+1)
...反复进行
阶段4:晋升老年代
年龄达到15 → 晋升到老年代
或者Survivor区放不下 → 直接晋升
阶段5:老年代回收
老年代满 → Major GC → 标记-整理算法回收
整个过程就像员工从试用期到正式员工的过程:
试用期(新生代)→ 考察期(Survivor)→ 正式员工(老年代)
分代收集解决了什么问题?
通过分代收集,我们巧妙地解决了之前三种基础算法各自的问题:
// 分代收集的问题解决方案
public class GenerationalSolutions {
public static void explainSolutions() {
System.out.println("=== 分代收集解决的问题 ===");
// 解决复制算法的空间浪费问题
System.out.println("1. 解决复制算法的空间浪费:");
System.out.println(" - 只在新生代用复制算法");
System.out.println(" - 新生代只占堆的1/3");
System.out.println(" - 总体空间浪费很小");
// 解决标记-清除的碎片问题
System.out.println("\n2. 解决标记-清除的碎片问题:");
System.out.println(" - 新生代用复制算法,无碎片");
System.out.println(" - 老年代用标记-整理,无碎片");
System.out.println(" - 整个堆都没有碎片问题");
// 解决标记-整理的效率问题
System.out.println("\n3. 解决标记-整理的效率问题:");
System.out.println(" - 大部分垃圾在新生代快速回收");
System.out.println(" - 老年代GC频率很低");
System.out.println(" - 虽然老年代GC慢,但不常发生");
System.out.println("\n总结:分代收集 = 各算法优点的完美结合!");
}
}
分代收集的性能数据
让我们看一些实际的性能数据来验证分代收集的效果:
// 分代收集的性能分析
public class GenerationalPerformance {
public static void analyzePerformance() {
System.out.println("=== 分代收集性能分析 ===");
// 典型的GC统计数据
System.out.println("典型Java应用的GC统计:");
System.out.println("- Minor GC频率:每秒几次到几十次");
System.out.println("- Minor GC耗时:1-10毫秒");
System.out.println("- Major GC频率:每分钟0-几次");
System.out.println("- Major GC耗时:几十到几百毫秒");
System.out.println("\n对象分布统计:");
System.out.println("- 98%的对象在新生代死亡");
System.out.println("- 只有2%的对象晋升到老年代");
System.out.println("- 老年代对象平均存活时间:分钟到小时级别");
System.out.println("\n内存回收效率:");
System.out.println("- 新生代回收率:90-99%");
System.out.println("- 老年代回收率:10-50%");
System.out.println("- 整体内存利用率:>95%");
}
}
现在你应该完全理解了分代收集的精妙之处:
- 基于科学观察:两大假说不是拍脑袋想出来的,而是基于大量统计数据
- 算法完美匹配:每个代都用最适合的算法,发挥各自优势
- 整体效果最优:虽然每个算法单独看都有缺点,但组合起来效果最好
- 实践验证有效:几十年的Java应用实践证明了这种设计的成功
这就是为什么分代收集成为了现代JVM垃圾回收的基础理论!
对象的成长历程:
═══════════════════════════════════════
1. new Object() → 对象诞生在 Eden 区
┌──────────────────────────────────────────┐
│ Eden: [新对象] │
└──────────────────────────────────────────┘
2. Minor GC → 存活对象复制到 Survivor0,年龄+1
┌──────────────────────────────────────────┐
│ Eden: [空] │
│ S0: [存活对象(age=1)] │
└──────────────────────────────────────────┘
3. 下一次 Minor GC → 存活对象复制到 Survivor1,年龄+1
┌──────────────────────────────────────────┐
│ Eden: [空] │
│ S1: [存活对象(age=2)] │
└──────────────────────────────────────────┘
4. 反复在 S0 和 S1 之间复制,年龄不断增加
5. 年龄达到阈值(默认15)→ 晋升到老年代
┌──────────────────────────────────────────┐
│ 老年代: [晋升对象(age=15)] │
└──────────────────────────────────────────┘
晋升阈值:-XX:MaxTenuringThreshold=15
动态年龄判断
JVM 还有一个动态年龄判断机制:
═══════════════════════════════════════
如果 Survivor 区中相同年龄的所有对象大小总和
超过 Survivor 空间的一半,则年龄 >= 该年龄的对象
直接晋升到老年代,不必等到 MaxTenuringThreshold
这保证了 Survivor 区不会被年龄较大的对象占满
GC 类型对比
| GC 类型 | 回收区域 | 频率 | 速度 | 影响 |
|---|---|---|---|---|
| Minor GC | 新生代 | 频繁 | 快 | 短暂停(STW) |
| Major GC | 老年代 | 较少 | 慢 | 较长暂停 |
| Full GC | 新生代+老年代+元空间 | 最少 | 最慢 | 最长暂停 |
STW(Stop-The-World):GC 时暂停所有用户线程,等 GC 完成后再恢复。这是 GC 调优的核心目标——减少 STW 时间。
6. 垃圾收集器——谁来回收?
生活比喻
垃圾收集器就像不同的清洁公司,各有特色:
- 有的便宜但慢(Serial)
- 有的快但贵(Parallel)
- 有的边工作边打扫(CMS、G1)
- 有的几乎不打扰你(ZGC)
收集器全景图
┌─────────────────────────────────────────────────────────────────────────┐
│ 垃圾收集器组合关系 │
│ │
│ 新生代收集器 老年代收集器 │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Serial │ ──────────────→ │ Serial Old │ │
│ │ (单线程) │ │ (单线程) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ParNew │ ──────────────→ │ CMS │ │
│ │ (多线程Serial) │ │ (低停顿) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │Parallel Scavenge │ ──────────────→ │ Parallel Old │ │
│ │ (吞吐量优先) │ │ (吞吐量优先) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ G1(全堆收集器) │ │
│ │ (可预测停顿,JDK9默认) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ ZGC / Shenandoah(超低停顿) │ │
│ │ (JDK11+ / JDK12+) │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
6.1 Serial 收集器
特点:单线程,最古老,最简单
适用:客户端模式、小内存应用
工作方式:
用户线程 ┃███┃ ┃███┃ ┃███┃
GC 线程 ┃ ┃████████┃ ┃████████┃ ┃
└────────────────────────────────┘
STW STW
优点:简单高效,单个 CPU 环境下没有线程切换开销
缺点:STW 时间长,用户体验差
参数:-XX:+UseSerialGC
6.2 ParNew 收集器
特点:Serial 的多线程版本
适用:配合 CMS 使用(CMS 只能搭配 ParNew)
工作方式:
用户线程 ┃███┃ ┃███┃
GC 线程 ┃ ┃████████┃ ┃ ← 多个 GC 线程并行
└──────────────────────┘
STW
优点:多线程回收,比 Serial 快
缺点:仍然有 STW
参数:-XX:+UseParNewGC
6.3 Parallel Scavenge 收集器
特点:吞吐量优先
适用:后台计算型任务(不需要和用户交互)
吞吐量 = 运行用户代码时间 / (运行用户代码时间 + GC时间)
例如:应用运行100分钟,GC花了1分钟,吞吐量 = 100/101 ≈ 99%
优点:高吞吐量,适合后台任务
缺点:STW 时间可能较长
参数:-XX:+UseParallelGC
-XX:MaxGCPauseMillis=200 最大GC停顿时间目标
-XX:GCTimeRatio=99 吞吐量大小(1/(1+99)=1%的GC时间)
6.4 CMS 收集器(Concurrent Mark Sweep)
特点:低停顿,第一次实现并发收集
适用:重视响应速度的应用(Web服务)
四个阶段:
═══════════════════════════════════════
1. 初始标记(STW,很短) → 标记 GC Roots 直接引用的对象
2. 并发标记(不STW) → 从 GC Roots 遍历整个引用链
3. 重新标记(STW,较短) → 修正并发标记期间变动的引用
4. 并发清除(不STW) → 清除死亡对象
用户线程 ┃██┃██████████████┃██┃██████████████
GC 线程 ┃██┃██████████████┃██┃██████████████
└──┘ └──┘
初始标记 重新标记
(STW) (STW)
优点:停顿时间短,用户体验好
缺点:
1. 并发阶段占用 CPU,影响吞吐量
2. 标记-清除算法 → 有碎片
3. 浮动垃圾 → 并发清除阶段新产生的垃圾无法回收
4. Concurrent Mode Failure → 老年代预留空间不足,退回 Serial Old
参数:-XX:+UseConcMarkSweepGC
-XX:CMSInitiatingOccupancyFraction=70 老年代使用70%时触发GC
6.5 G1 收集器(Garbage First)
特点:可预测停顿,JDK9 默认收集器
适用:大内存(6GB+)、需要可控停顿的应用
核心创新:将堆划分为多个大小相等的 Region
═══════════════════════════════════════
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ S │ O │ E │ H │ O │ E │ O │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O │ E │ S │ O │ E │ O │ E │ H │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ E │ O │ E │ H │ O │ E │ S │ O │
└────┴────┴────┴────┴────┴────┴────┴────┘
E = Eden S = Survivor O = Old H = Humongous
G1 不再是物理分代,而是逻辑分代
每个 Region 可以是 Eden、Survivor、Old 或 Humongous(大对象)
GC 优先回收垃圾最多的 Region(Garbage First 名称由来)
G1 的工作流程:
═══════════════════════════════════════
1. 初始标记(STW,很短) → 标记 GC Roots 直接引用的对象
2. 并发标记(不STW) → 遍历引用链,记录每个 Region 的存活比例
3. 最终标记(STW,很短) → 修正并发标记期间的变动
4. 筛选回收(STW,可控) → 按回收价值排序 Region,回收收益最高的
关键:第4步可以控制回收哪些 Region,从而控制停顿时间
用户线程 ┃██┃██████████████┃██┃████┃
GC 线程 ┃██┃██████████████┃██┃████┃
└──┘ └──┘
初始标记 最终标记
(STW) (STW) 筛选回收(STW,可控)
参数:-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 期望最大停顿时间(默认200ms)
-XX:G1HeapRegionSize=4m 每个 Region 大小
6.6 ZGC 收集器
特点:超低停顿(<1ms),JDK11 引入
适用:超大内存、对延迟极其敏感的应用
核心创新:
├─ 染色指针(Colored Pointers)→ 在指针中存储元数据
├─ 读屏障(Load Barrier) → 并发整理时修正引用
└─ 不分代 → 全堆统一管理
停顿时间:
├─ JDK 11: < 10ms
├─ JDK 14: < 10ms
├─ JDK 16: < 1ms(亚毫秒级)
└─ 与堆大小无关!8GB 堆和 8TB 堆的停顿时间一样
参数:-XX:+UseZGC
-XX:ZCollectionInterval=0 GC 间隔
-XX:ZAllocationSpikeTolerance=2 分配飙升容忍度
收集器选择指南
| 场景 | 推荐收集器 | 原因 |
|---|---|---|
| 小内存/客户端 | Serial | 简单高效 |
| 后台计算 | Parallel Scavenge + Parallel Old | 吞吐量优先 |
| Web 服务(JDK8) | ParNew + CMS | 低停顿 |
| Web 服务(JDK9+) | G1 | 可控停顿,JDK9默认 |
| 大内存/超低延迟 | ZGC | 亚毫秒级停顿 |
7. GC 日志解读——看懂 GC 在干什么
启用 GC 日志
# JDK8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:gc.log
# JDK9+
-Xlog:gc*:file=gc.log:time,uptime,level,tags
Minor GC 日志示例
2024-01-15T10:30:15.123+0800: [GC (Allocation Failure)
[PSYoungGen: 65536K->7680K(76288K)]
65536K->8200K(251392K), 0.0123456 secs]
[Times: user=0.05 sys=0.01, real=0.01 secs]
逐段解读:
2024-01-15T10:30:15.123+0800 → GC 发生时间
[GC → Minor GC(Full GC 会显示 [Full GC)
(Allocation Failure) → 触发原因:Eden 区不够分配新对象
[PSYoungGen: → 新生代(Parallel Scavenge)
65536K->7680K(76288K)] → 新生代:65MB → 7.5MB(总容量76MB)
65536K->8200K(251392K) → 全堆:65MB → 8MB(总容量251MB)
0.0123456 secs → GC 耗时约12毫秒
[Times: user=0.05 sys=0.01, real=0.01 secs]
user → 用户态 CPU 时间(多线程累加,可能 > real)
sys → 内核态 CPU 时间
real → 实际经过时间(STW 时间)
Full GC 日志示例
2024-01-15T10:35:20.456+0800: [Full GC (Metadata GC Threshold)
[PSYoungGen: 7680K->0K(76288K)]
[ParOldGen: 123456K->98765K(175104K)]
131136K->98765K(251392K),
[Metaspace: 34567K->34567K(107520K)],
0.2345678 secs]
解读:
[Full GC → Full GC(全堆回收)
(Metadata GC Threshold) → 触发原因:元空间达到阈值
[PSYoungGen: 7680K->0K] → 新生代:7.5MB → 0(全部回收)
[ParOldGen: 123456K->98765K] → 老年代:120MB → 96MB(回收了24MB)
131136K->98765K → 全堆:128MB → 96MB
[Metaspace: 34567K->34567K] → 元空间:33MB → 33MB(没变化)
0.2345678 secs → GC 耗时约234毫秒(比 Minor GC 慢很多!)
8. GC 调优实战——让应用跑得更快
调优的核心目标
GC 调优的两个核心目标:
═══════════════════════════════════════
1. 降低停顿时间(低延迟) → 用户体验好
适合:Web 服务、实时系统
2. 提高吞吐量 → 总体效率高
适合:后台计算、批处理
两者通常是矛盾的:
→ 停顿短 → GC 更频繁 → 吞吐量下降
→ 吞吐高 → GC 少但长 → 停顿时间长
需要根据业务场景做权衡
常见调优参数
# ============ 堆大小 ============
-Xms2g # 初始堆大小(建议与 -Xmx 相同,避免动态扩容)
-Xmx2g # 最大堆大小
# ============ 新生代 ============
-Xmn1g # 新生代大小
-XX:NewRatio=2 # 老年代:新生代 = 2:1
-XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1
-XX:MaxTenuringThreshold=15 # 对象晋升老年代的年龄阈值
# ============ 收集器选择 ============
-XX:+UseSerialGC # Serial + Serial Old
-XX:+UseParallelGC # Parallel Scavenge + Parallel Old(JDK8默认)
-XX:+UseParNewGC -XX:+UseConcMarkSweepGC # ParNew + CMS
-XX:+UseG1GC # G1(JDK9默认)
-XX:+UseZGC # ZGC(JDK11+)
# ============ G1 调优 ============
-XX:MaxGCPauseMillis=200 # 期望最大停顿时间
-XX:G1HeapRegionSize=8m # Region 大小(1/2/4/8/16/32MB)
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的堆占用率
# ============ CMS 调优 ============
-XX:CMSInitiatingOccupancyFraction=70 # 老年代使用70%时触发GC
-XX:+UseCMSCompactAtFullCollection # Full GC 后执行整理
-XX:CMSFullGCsBeforeCompaction=5 # 5次Full GC后整理一次
# ============ 元空间 ============
-XX:MetaspaceSize=256m # 初始元空间大小
-XX:MaxMetaspaceSize=512m # 最大元空间大小
调优实战案例
案例1:Web 服务启动慢
问题:Spring Boot 应用启动耗时 30 秒,GC 日志显示频繁 Full GC
分析:
- 元空间初始大小太小(默认约20MB),类加载时不断扩容触发 Full GC
- 每次扩容 → Full GC → 加载更多类 → 再扩容 → 再 Full GC
解决:
-XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m
→ 设置初始元空间大小与最大值相同,避免扩容
→ 启动时间从 30 秒降到 10 秒
案例2:接口偶尔卡顿
问题:Web 接口平均响应 50ms,但偶尔出现 500ms+ 的卡顿
分析:
- GC 日志显示偶发 Full GC,耗时 300-500ms
- 老年代空间不足,大量对象晋升导致 Full GC
解决方案1(调大新生代):
-Xmn512m -XX:MaxTenuringThreshold=30
→ 新生代更大,Minor GC 更少,对象在 Survivor 区多待一会儿
→ 减少晋升到老年代的对象数量
解决方2(换 G1 收集器):
-XX:+UseG1GC -XX:MaxGCPauseMillis=100
→ G1 可控停顿时间,Full GC 停顿更短
案例3:内存泄漏排查
问题:应用运行几天后 OOM
排查步骤:
1. 添加 JVM 参数:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dump.hprof
→ OOM 时自动生成堆转储文件
2. 使用 MAT(Memory Analyzer Tool)分析 dump 文件
→ 找到占用内存最大的对象
→ 查看引用链,定位泄漏根源
3. 常见原因:
├─ 静态集合类不断添加元素,从不清理
├─ 未关闭的资源(连接、流)
├─ ThreadLocal 使用后未 remove
├─ 监听器/回调未注销
└─ 缓存没有淘汰策略
9. 常见面试题精选
Q1:对象存活判定方法?
两种方法:
1. 引用计数法 — 每个对象维护引用计数器,为0时回收
缺陷:无法解决循环引用,已淘汰
2. 可达性分析 — 从 GC Roots 出发沿引用链搜索,不可达的对象可回收
当前 JVM 使用的标准方法
Q2:GC Roots 有哪些?
1. 虚拟机栈中的局部变量引用
2. 方法区中静态属性引用
3. 方法区中常量引用
4. 本地方法栈中 JNI 引用
5. JVM 内部引用(基本类型 Class、常驻异常、类加载器等)
6. 同步锁持有的对象
Q3:四种引用的区别?
| 引用 | 回收时机 | 用途 |
|---|---|---|
| 强引用 | 不回收 | 普通变量 |
| 软引用 | OOM前回收 | 缓存 |
| 弱引用 | 下次GC回收 | WeakHashMap |
| 虚引用 | 随时,无法获取对象 | 跟踪GC、管理堆外内存 |
Q4:三种垃圾收集算法的对比?
| 算法 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 标记-清除 | 简单 | 有碎片 | CMS |
| 标记-复制 | 无碎片 | 浪费50%空间 | 新生代 |
| 标记-整理 | 无碎片不浪费 | 移动开销大 | 老年代 |
Q5:CMS 和 G1 的区别?
| 对比 | CMS | G1 |
|---|---|---|
| 算法 | 标记-清除 | 标记-整理+复制 |
| 碎片 | 有碎片 | 无碎片 |
| 分代 | 物理分代 | 逻辑分代(Region) |
| 停顿 | 较短但不可控 | 可控(MaxGCPauseMillis) |
| 大内存 | 不适合(>8GB) | 适合(6GB+) |
| 浮动垃圾 | 有 | 有 |
| JDK版本 | JDK8 常用 | JDK9+ 默认 |
Q6:什么情况下对象会进入老年代?
四种情况:
1. 年龄达到阈值(默认15次GC后晋升)
-XX:MaxTenuringThreshold=15
2. 大对象直接进入老年代
-XX:PretenureSizeThreshold=1m 超过1MB的对象直接进老年代
3. 动态年龄判断
Survivor 区中同年龄对象总和超过 Survivor 空间一半
4. Survivor 空间不足
Minor GC 后存活对象放不下 Survivor,通过担保机制进入老年代
Q7:Full GC 的触发条件?
1. 老年代空间不足
→ 长期存活对象太多,或大对象直接进入
2. 元空间/永久代空间不足
→ 加载的类太多
3. System.gc() 被调用
→ 建议 JVM 执行 Full GC(不保证立即执行)
4. CMS 的 Concurrent Mode Failure
→ CMS 回收期间老年代预留空间不足
5. 空间分配担保失败
→ Minor GC 后存活对象大于老年代剩余空间
10. 总结
GC 速记口诀
"可达标复整,分代选收集"(可达标复整,分代选收集)
可达 — 可达性分析,从 GC Roots 判断对象生死
标复整 — 标记-清除、标记-复制、标记-整理 三大算法
分代 — 新生代(朝生夕死)+ 老年代(长期存活)
选收集 — 根据场景选收集器:Serial/Parallel/CMS/G1/ZGC
关键知识点回顾
| 知识点 | 核心内容 |
|---|---|
| 对象死亡判定 | 引用计数法(已淘汰)、可达性分析(当前使用) |
| GC Roots | 栈引用、静态变量、常量、JNI引用、锁对象 |
| 四种引用 | 强→软→弱→虚,回收条件越来越宽松 |
| 标记-清除 | 有碎片,CMS 使用 |
| 标记-复制 | 无碎片但浪费空间,新生代使用 |
| 标记-整理 | 无碎片不浪费,移动开销大,老年代使用 |
| 分代收集 | 新生代 Minor GC 频繁快速,老年代 Full GC 少但慢 |
| 收集器选择 | 小内存用 Serial,后台用 Parallel,Web 用 G1,超低延迟用 ZGC |
| 调优核心 | 减少停顿时间 vs 提高吞吐量,根据业务权衡 |
上一篇:《JVM 类加载机制详解——从 .class 文件到对象诞生的完整旅程》
系列完结:JVM 三部曲至此完成 🎉

1145

被折叠的 条评论
为什么被折叠?



