Skip to content

垃圾回收机制

前置知识

在阅读本章前,你需要了解:Java 内存模型基础,面向对象编程,以及基本的Java代码结构。

为什么需要垃圾回收?

想象一下:你写了个 Java 程序,需要频繁创建对象。每次对象不再使用,这部分内存就得释放,要不然程序最终会因为内存不足崩溃。手动管理内存非常容易出错,尤其在复杂系统中,内存泄露和悬挂指针问题屡见不鲜。Java 的垃圾回收(Garbage Collection, GC)机制就是为了解决这个烦恼——它自动帮你清理不再需要的对象,让你专注于业务逻辑,而不用操心“啥时候释放内存”。

但GC究竟怎么知道哪些对象不再需要了呢?它是如何执行这项“隐形保洁工”的工作的?这正是本章要带你深入理解的内容。


什么是垃圾回收?

简单说,垃圾回收就是:自动识别不再被程序引用的对象,并把它们占用的内存释放回系统的过程。

为什么需要它

  1. 避免内存泄露:防止无用对象一直占据内存。
  2. 提升程序健壮性:减少内存错误导致的程序崩溃。
  3. 开发效率:程序员不用写复杂的内存管理代码。

基本概念

  • 引用(Reference):代码中指向对象的“指针”。
  • 可达性(Reachability):一个对象如果还能通过引用链从根(如栈中的引用)找到,它就是“活动”的。
  • 垃圾对象:不再有任何引用指向的对象,准备被回收。

分代回收的思路

Java 的垃圾回收器并不会把堆当作一个整体来回收,而是根据对象的生命周期将堆划分为几个“代”(Generations):

  • 年轻代(Young Generation):新创建的对象先在这里,回收频率高。
  • 老年代(Old Generation):长时间存活的对象会被“晋升”到这里,回收频率低。
  • 永久代(PermGen)/元空间(Metaspace):存放类的元数据(Java 8之后为元空间)。

这个分代设计基于一个经验法则:“大多数对象很快成为垃圾,少数对象寿命很长”。因此,年轻代的回收很频繁且成本要低。


常见垃圾回收算法

Java 中经典的 GC 算法主要有三种,我们一个个拆开讲。

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

这是最原始也最直观的垃圾回收算法。

原理:

  • 标记阶段:扫描从根开始的所有可达对象,给它们做标记。
  • 清除阶段:没有标记的对象就是垃圾,直接清除内存。

优缺点:

  • 优点:实现简单。
  • 缺点:会产生“内存碎片”,释放的空间不连续,影响后续内存分配效率。

2. 复制算法(Copying)

复制算法先将内存空间分为两块(如from区和to区)。只使用其中一块,另一块空着。

原理:

  • 把存活的对象从一块内存复制到另一块,复制完清空“from”区。
  • 碎片问题得以解决,分配空间简单。

优点: 解决了碎片问题,分配快。
缺点: 需要一半内存作为备份空间,内存利用率较低。

复制算法常用在年轻代,因为年轻代对象回收率高且对象生命周期短。

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

标记-整理算法是标记-清除的升级版本,先做标记,然后对存活对象进行“整理”,移动它们,使对象连续存放。

优点:

  • 解决内存碎片问题。
  • 不浪费像复制算法那样需要半区内存。

缺点:

  • 对象移动的开销较大。

代码示例 1:触发GC的简单对象创建

我们先写个程序看看触发GC的简单场景。

java
public class SimpleGcExample {
    public static void main(String[] args) {
        // 不断创建对象,让垃圾回收器有机会工作
        for (int i = 0; i < 100000; i++) {
            String temp = new String("Object_" + i);
            // 这里 temp 变量每次循环都会被新对象覆盖,之前的对象变成垃圾
        }

        System.gc(); // 建议JVM执行垃圾回收(不保证立即执行)
        System.out.println("请求垃圾回收完成");
    }
}

这段代码做了什么:

  1. 循环中不停创建很多临时字符串对象。
  2. 每次循环旧对象没有引用后,会变成垃圾。
  3. System.gc() 告诉JVM可以考虑运行垃圾回收。

这个例子演示了如何产生大量垃圾对象,供GC回收。


代码示例 2:模拟对象晋升(分代回收)

这段代码用较大对象来演示年轻代到老年代的对象晋升。

java
public class ObjectPromotionDemo {
    public static void main(String[] args) throws InterruptedException {
        final int _1MB = 1024 * 1024;

        // 创建几个大对象,这些大对象会绕过eden区,直接进入老年代
        byte[] largeObject1 = new byte[4 * _1MB];
        byte[] largeObject2 = new byte[4 * _1MB];

        // 多次创建大对象数组,触发年轻代垃圾回收
        for (int i = 0; i < 20; i++) {
            byte[] temp = new byte[1 * _1MB];
        }

        // 让程序暂停,方便观察内存变化
        Thread.sleep(1000);

        System.out.println("演示对象晋升结束");
    }
}

这段代码做了什么:

  1. 创建几个4MB的大对象,通常它们不在Eden区,而是直接进入老年代。
  2. 循环创建1MB的小对象,模拟年轻代频繁回收。
  3. 通过这种行为,部分小对象会在多次GC后“晋升”到老年代。

这个例子可以帮我们理解年轻代与老年代的交互。


代码示例 3:自定义标记-清除的简化示范(伪代码)

真正的垃圾回收算法实现非常复杂,不过我们可以写一个简化版的标记-清除逻辑,帮助理解。

java
import java.util.*;

public class SimpleMarkSweep {
    static class ObjectNode {
        boolean marked = false;
        List<ObjectNode> references = new ArrayList<>();
        String name;

        ObjectNode(String name) { this.name = name; }
    }

    public static void main(String[] args) {
        // 构造对象图
        ObjectNode root = new ObjectNode("root");
        ObjectNode child1 = new ObjectNode("child1");
        ObjectNode child2 = new ObjectNode("child2");
        ObjectNode orphan = new ObjectNode("orphan");

        root.references.add(child1);
        child1.references.add(child2);
        // orphan没有任何引用,模拟垃圾对象

        // 标记阶段:以root为起点,标记所有可达对象
        mark(root);

        // 清除阶段:遍历所有对象,收集没标记的
        List<ObjectNode> heap = Arrays.asList(root, child1, child2, orphan);
        List<ObjectNode> garbage = new ArrayList<>();
        for (ObjectNode obj : heap) {
            if (!obj.marked) {
                garbage.add(obj);
            }
        }

        System.out.println("垃圾对象:");
        for (ObjectNode obj : garbage) {
            System.out.println(obj.name);
        }
    }

    // 递归标记所有可达对象
    static void mark(ObjectNode obj) {
        if (obj == null || obj.marked) return;
        obj.marked = true;
        for (ObjectNode ref : obj.references) {
            mark(ref);
        }
    }
}

这段代码做了什么:

  1. 手动构造了“对象图”,其中orphan是一个无引用的垃圾对象。
  2. mark方法从根节点递归标记所有可达对象。
  3. 遍历所有对象,清除没有标记过的,模拟清除阶段。
  4. 输出垃圾对象名单。

这虽然远不能和JVM的GC媲美,但相当直观地演示了标记-清除的核心思想。


对比总结

算法优点缺点适用场景
标记-清除实现简单,可处理复杂对象图产生碎片,内存利用不均匀老年代,堆内存大时使用
复制算法无碎片,分配速度快需要半区空间,内存使用率低年轻代(Eden区)
标记-整理无碎片,内存整理好移动对象开销较大老年代,代替标记清除算法

实战建议

💡 实战建议

  1. 合理划分堆内存大小:年轻代和老年代的比例设定影响GC效率。一般年轻代占堆的1/3到1/4。
  2. 监控GC日志:在生产环境开启GC日志,观察频率和耗时,调整垃圾收集器参数。
  3. 避免过大对象频繁创建:大对象直接进入老年代,回收成本高,避免无意义地创建。
  4. 使用合适的GC策略:JVM提供多种GC收集器(如G1,ZGC,Shenandoah),针对不同场景选择。
  5. 减少长生命周期对象的引用链:避免不必要的对象存活,减少老年代压力。

理解决策的本质,将帮助我们写出既高效又稳定的代码。


常见陷阱

⚠️ 常见陷阱

  • 误以为System.gc()会立即回收:这个调用只是建议JVM执行GC;何时执行不确定。
  • 频繁创建大对象导致老年代频繁GC:会严重影响性能,甚至产生Full GC。
  • 不释放资源产生内存泄漏:虽然对象被GC,但资源如文件句柄、数据库连接未关闭会造成泄露。
  • 忽视内存碎片导致分配失败:即使内存总量够,但碎片过多,引发内存溢出。
  • 错误的对象引用保持:比如静态集合或单例持有大量对象引用,导致对象无法被回收。

小结

  • 垃圾回收是Java自动管理内存的“隐形保姆”,解放程序员双手。
  • 分代回收基于对象生命周期的不同,将堆划分年轻代和老年代,提高回收效率。
  • 标记-清除、复制、标记-整理三种算法各有利弊,配合分代使用。
  • 通过代码示例加深理解GC的核心原理和过程。
  • 真实项目中合理调整内存设置和GC参数,避免常见内存使用陷阱。

延伸思考

  • 除了堆内存,Java还有方法区、栈等其他内存区域。不同区域的管理机制有何异同?
  • 现代JVM引入了哪些更加先进的垃圾回收技术,比如并行GC、增量GC、ZGC、Shenandoah?
  • 如何通过代码设计降低内存压力?有什么模式或技巧能减少GC停顿时间?

欢迎你慢慢消化这些知识,如果你能把GC的工作细节弄清楚,在Java领域的底层优化和故障排查中绝对能如虎添翼。我们下一章继续深入探讨高级GC调优与工具使用吧!