Skip to content

GC算法

1. 这是什么

GC 算法用于识别和回收不再使用的对象内存。
它是 JVM 自动内存管理能力的核心基础。

一句话理解:

  • GC 不是“随机删对象”
  • 它首先要判断对象还能不能从根出发被找到,然后再决定怎么回收

2. 为什么重要

理解 GC 算法,才能看懂:

  • 不同收集器为什么表现不同
  • 为什么会有停顿和吞吐的权衡
  • 为什么会有内存碎片
  • 为什么新生代和老年代常常采用不同策略

这也是分析这些问题的前提:

  • Full GC 频繁
  • 停顿时间过长
  • 内存碎片严重

3. 先建立直觉:GC 不是“对象置 null 就立刻删除”

很多初学者对 GC 的第一印象是:

  • 对象引用设成 null
  • JVM 就会马上删除它

这不准确。
更正确的理解是:

  • 对象是否可回收,取决于它是否还能从 GC Roots 出发被找到
  • 即使已经不可达,也不代表会立刻马上回收

所以 GC 里最关键的第一步不是“怎么删”,而是:

  • 怎么判断对象还能不能被访问到

4. 核心内容

4.1 可达性分析

现代 JVM 主要通过可达性分析判断对象是否存活。
你可以先把它理解成:

  • 从一组根对象出发
  • 沿引用关系往下找
  • 能找到的对象就是可达对象
  • 找不到的对象才有资格被回收

常见 GC Roots 可以先这样理解:

  • 栈中的引用
  • 静态字段引用
  • 本地方法相关引用
  • 某些运行时系统对象

所以重点不是“有没有名字”,而是:

  • 有没有从根一路引用到它

4.2 标记-清除算法

思路很直接:

  1. 先标记出可回收对象
  2. 再把这些对象占用的空间清掉

优点:

  • 实现思路相对直接

缺点:

  • 可能产生内存碎片

也就是说,空间虽然总量够,但可能不连续。

4.3 标记-整理算法

思路是:

  1. 标记出存活对象
  2. 把存活对象往一端移动
  3. 清理掉边界外的空间

优点:

  • 能减少内存碎片

缺点:

  • 对象移动有成本

4.4 复制算法

思路是:

  1. 只使用一部分空间
  2. 回收时把存活对象复制到另一块区域
  3. 直接清空原区域

优点:

  • 回收简单
  • 没有碎片
  • 对“存活对象少”的场景很高效

缺点:

  • 需要额外可用空间

这也是为什么它特别适合:

  • 新生代

因为新生代里大多数对象本来就活不久,真正要复制的存活对象通常不多。

4.5 分代回收思想

分代思想不是单一算法,而是一种非常重要的组合策略:

  • 大多数对象朝生夕死
  • 少量对象长期存活

因此可以把堆按对象年龄特征分区:

  • 新生代
  • 老年代

然后:

  • 新生代更适合偏复制类思路
  • 老年代更重视碎片控制和长期存活对象处理

4.6 为什么不同算法要做权衡

GC 算法之间不存在“绝对最优”,只有:

  • 更适合某类对象分布
  • 更适合某类延迟目标

典型权衡维度包括:

  • 停顿时间
  • 吞吐量
  • 空间利用率
  • 碎片情况
  • 对象移动成本

5. 学习重点

这一章最重要的是掌握这些判断:

  • JVM 如何判断对象是否可以回收
  • 标记-清除、标记-整理、复制算法的核心差别
  • 内存碎片是怎么来的
  • 为什么复制算法适合新生代
  • 为什么 GC 常常要结合分代思想

6. 常见问题

6.1 把引用为 null 等同于一定会被立即回收

这只是让对象可能变得不可达,不代表 JVM 会立刻回收它。

6.2 不了解算法差异,只记名字

只知道“有这些算法”,但不知道它们分别在解决什么问题,是最常见的学习卡点。

6.3 不理解碎片和压缩的关系

碎片本质上是:

  • 空闲空间总量够
  • 但不连续

这也是标记-整理价值所在。

7. 动手验证

这一节可以直接复制运行,边看边验证。

7.1 准备一个可运行示例

新建文件 GcAlgorithmDemo.java,内容如下:

java
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;

public class GcAlgorithmDemo {
    private static final List<byte[]> LONG_LIVED = new ArrayList<>();

    public static void main(String[] args) throws Exception {
        Object target = new Object();
        WeakReference<Object> weak = new WeakReference<>(target);
        System.out.println("weakBeforeClear=" + (weak.get() != null));

        target = null;
        System.gc();
        Thread.sleep(200);
        System.out.println("weakAfterGc=" + (weak.get() == null));

        for (int i = 0; i < 3; i++) {
            allocateShortLived();
        }
        System.out.println("shortLivedRounds=3");

        for (int i = 0; i < 8; i++) {
            LONG_LIVED.add(new byte[512 * 1024]);
        }
        System.out.println("longLivedCount=" + LONG_LIVED.size());
        System.out.println("demoFinished=true");
    }

    private static void allocateShortLived() {
        for (int i = 0; i < 3000; i++) {
            byte[] data = new byte[8 * 1024];
            if (data.length == -1) {
                System.out.println("never");
            }
        }
    }
}

7.2 编译并运行

先编译:

bash
javac GcAlgorithmDemo.java

再用小堆和 GC 日志运行:

bash
java -Xms32m -Xmx32m -Xlog:gc* GcAlgorithmDemo

7.3 你应该观察到什么

程序输出应包含这些关键信息:

text
weakBeforeClear=true
weakAfterGc=true
shortLivedRounds=3
longLivedCount=8
demoFinished=true

同时 GC 日志中通常还能观察到类似信息:

text
Pause Young
Heap

具体格式和收集器有关,但你通常能观察到:

  • 短命对象会持续推动年轻代回收
  • 被长期持有的对象不会因为短期 GC 立刻消失

7.4 每一行在验证什么

  • weakBeforeClear=true:说明对象一开始仍然可达
  • weakAfterGc=true:说明强引用断开后,对象在 GC 后可被回收
  • shortLivedRounds=3:说明程序确实制造了大量短命对象
  • longLivedCount=8:说明有一批对象被长期持有,不会轻易被回收
  • GC 日志中的年轻代停顿信息:说明对象分布特征会直接影响回收行为

7.5 再做两个延伸验证

你可以继续做下面两个实验:

  1. LONG_LIVED 的对象数量继续加大
  2. 把短命对象分配循环次数再提高

你可以观察:

  • 长寿命对象增多会提高整体内存占用
  • 短命对象增多会放大年轻代 GC 压力

8. 练习建议

下面这些练习做完,这一章会更扎实:

  • 画出标记-清除、标记-整理、复制算法示意图
  • 总结每种算法的优缺点
  • 用自己的话解释为什么复制算法适合新生代
  • 结合对象生命周期思考为什么 JVM 常做分代回收

9. 自测问题

  • JVM 如何判断对象是否可以回收?
  • 标记-清除和标记-整理的核心差别是什么?
  • 复制算法为什么适合新生代?
  • 什么是内存碎片?
  • 分代回收思想解决了什么问题?

10. 自测核对要点

如果你的回答能覆盖下面这些点,说明这一章基本掌握到位了:

  • JVM 主要通过可达性分析判断对象是否存活
  • 标记-清除会带来碎片,标记-整理会通过移动对象降低碎片
  • 复制算法在存活对象少时回收效率高
  • GC 算法需要在停顿、吞吐、空间利用率之间权衡
  • 分代思想是基于对象生命周期分布做出的工程优化