JVM 垃圾回收机制详解——从对象死亡判定到 GC 算法全景

定位:本文是 JVM 系列的第三篇。面试中"垃圾回收机制"几乎是必考题,很多人背得出"标记-清除、复制、标记-整理"三种算法,但一问"你的应用该用什么收集器""怎么调优"就答不上来。本文从生活比喻出发,结合 JVM 规范逐步拆解 GC 的判定标准、算法原理、收集器选择和调优实战,帮你真正理解而不是死记硬背。

官方规范参考


目录


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 时回收缓存、WeakHashMapWeakReference<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 标记-清除算法——最直观的想法

生活比喻

标记-清除就像大扫除

  1. 先在垃圾上贴标签(标记阶段)
  2. 然后把贴了标签的垃圾扫走(清除阶段)
算法原理

标记-清除算法是最基础的收集算法,分为两个阶段:

// 标记-清除算法的基本思路
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 标记-复制算法——解决碎片问题

生活比喻

标记-复制就像搬家

  1. 准备两个房间,平时只住一个
  2. 搬家时,把有用的东西搬到另一个房间
  3. 原房间整个清空,干干净净
  4. 下次搬家时角色互换
算法原理

为了解决标记-清除的碎片问题,标记-复制算法采用了不同的思路:

// 标记-复制算法的基本思路
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 标记-整理算法——兼顾空间和效率

生活比喻

标记-整理就像整理书架

  1. 先标记要保留的书(标记阶段)
  2. 把要保留的书依次排紧(整理阶段)
  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%");
    }
}

现在你应该完全理解了分代收集的精妙之处:

  1. 基于科学观察:两大假说不是拍脑袋想出来的,而是基于大量统计数据
  2. 算法完美匹配:每个代都用最适合的算法,发挥各自优势
  3. 整体效果最优:虽然每个算法单独看都有缺点,但组合起来效果最好
  4. 实践验证有效:几十年的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 的区别?

对比CMSG1
算法标记-清除标记-整理+复制
碎片有碎片无碎片
分代物理分代逻辑分代(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 三部曲至此完成 🎉

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值