Skip to content

GC 判定与算法演进:从引用计数到分代

本文是 JVM & GC 系统学习系列的 L2 核心篇。前置:JVM 总览与运行时数据区对象的诞生与布局。 学完可以配合面试题食用:引用计数 vs 可达性分析GC Roots 有哪些四种基础 GC 算法

从一个内存泄漏说起:为什么"数引用"数不准

垃圾回收要解决的第一个问题:一个对象死了没有,怎么判?

最直觉的方案是给每个对象挂一个计数器:有人引用我,计数 +1;引用失效,计数 -1;计数归零就是垃圾。这就是引用计数(Reference Counting),实现简单、判定及时,对象一死立刻能回收。

但它有个致命伤:两个对象互相引用,计数永远不为零。

java
class Node {
    Node next;
    public static void main(String[] args) {
        Node a = new Node();
        Node b = new Node();
        a.next = b;  // b 的计数 = 1
        b.next = a;  // a 的计数 = 1
        a = null;    // a 的计数 = 1(来自 b.next)
        b = null;    // b 的计数 = 1(来自 a.next)
        // 两个对象都不可达了,但计数都是 1,永远回收不掉
    }
}

所以 HotSpot 从一开始就没用引用计数,而是走另一条路:从一组确定活着的对象出发,顺着引用关系遍历,能遍历到的就是活的,遍历不到的就是垃圾。这就是可达性分析。

顺带一提,Python 和 Redis 用引用计数却活得好好的,因为它们各自打了补丁:CPython 用分代 GC 周期性扫描疑似循环引用的对象;Redis 的 key 从不做对象间循环引用,引用计数只服务于"key 删了之后值对象何时释放",场景被天然限制了。

可达性分析:从 GC Roots 出发

可达性分析的起点叫 GC Roots。HotSpot 里的 GC Roots 大致五类:

  • 各线程栈帧里的局部变量表引用的对象(最常见的 Root 来源)
  • 方法区/元空间中类静态字段引用的对象
  • 方法区/元空间中字符串常量池引用的对象(String.intern() 存进去的那些)
  • 本地方法栈 JNI 引用的对象
  • JVM 内部引用:基本类型 Class 对象、常驻异常对象(如 NPE)、系统类加载器

一个容易混淆的点:年轻代里大量短命对象本身不是 Root。method() 里 new 出来的对象是 Root,是因为栈帧的局部变量表指向它,而不是它待在年轻代。方法一返回,栈帧弹出,引用消失,对象立刻不可达——这正是 Minor GC 能高效清场的根基。

java
void demo() {
    Object temp = new Object();  // temp 在局部变量表里 -> 是 Root 之一
    // ...
}   // 方法返回,栈帧销毁,temp 指向的对象失去 Root,下次 GC 即回收

注意可达性分析回答的是"谁活着",不回答"垃圾在哪、怎么清"。清法就是下面几种基础算法的事。

三色标记:并发标记为什么需要屏障

实际的收集器不会全程 Stop The World 地做可达性分析,那停顿太长。理想状态是 GC 线程和用户线程并发跑,标记的同时业务代码继续执行。麻烦在于:并发的世界里引用关系一直在变,标记可能出错。

把对象按标记状态抽象成三种颜色:

  • :还没被扫描到
  • :自己被扫到了,但成员引用还没扫完
  • :自己和成员引用都扫完了,本轮不会再碰它
mermaid
flowchart LR
    subgraph 并发标记漏标场景
    A[黑对象 B] --新增引用--> B[白对象 D]
    C[灰对象 A] --删除引用--> B
    end

漏标(把活对象当垃圾)需要同时满足两个条件:灰到白的引用被删了,黑到白的引用新增了。黑对象本轮不会再扫,新增的引用就看不见,这个白对象会被误回收——这是致命 bug,不是性能问题。

解法是在引用变更处插入屏障:写屏障拦截"黑指白"的新增(增量更新,CMS 的做法,重新把黑变灰重扫),或者拦截"灰到白"的删除并记录旧值(SATB,G1 的做法,按快照认为被删引用的对象本轮仍活)。读屏障则出现在 ZGC 这类并发移动对象的收集器里,因为对象地址会变,读取时必须自愈。这里先记住结论,后面 CMS、G1、ZGC 三篇会分别展开。

四种基础算法的权衡

可达性分析之后,怎么处理垃圾和幸存者?历史上演化出四种基础算法,每一种都在解决前一种的痛点:

标记-清除(Mark-Sweep):标记完全部垃圾,直接清掉。简单,但清完内存里全是洞——碎片。后果是总内存明明够,大对象却分配不下,只能提前触发 Full GC 甚至 OOM。

复制(Copying):把内存分两块,只用其中一块。GC 时把活对象复制到另一块,然后整块清空。没有碎片,分配还能用指针碰撞,快;代价是浪费一半空间,且活对象越多复制成本越高——适合"朝生夕死"的年轻代。

标记-压缩(Mark-Compact):标记后把活对象往一端挪,再清掉边界外的垃圾。无碎片也不浪费空间,但挪对象要改所有指向它的引用,成本最高——适合活得久、不常回收的老年代。

分代(Generational):前三种的组合拳。基于弱分代假说——绝大多数对象朝生夕死。年轻代用复制(碎片无所谓,反正整块清),老年代用标记-清除或标记-压缩。HotSpot 里年轻代还按 8:1:1 划分 Eden 和两个 Survivor,每次只浪费 10% 而不是 50%,因为 Minor GC 后通常只有极少数对象存活。

算法碎片吞吐停顿适合区域
标记-清除早期 CMS 老年代
复制活对象少时高年轻代
标记-压缩低(要搬对象)老年代
分代混合综合最优可控堆整体策略

没有哪种算法全胜,后续 CMS、G1、ZGC 的演进,本质都是在这张权衡表的不同格子上做文章。

四种引用强度:不是所有"活着"都等价

可达性分析只认强弱:可达就活,不可达就死。但现实需求里有中间态——缓存这类数据,内存够就留着,不够就丢,别陪葬。JDK 因此把引用分了四级:

  • 强引用(Strong):代码里普遍的赋值。只要可达,绝不回收。
  • 软引用(Soft):可达但只被软引用指着。内存不足抛 OOM 之前,JVM 会先清空所有软引用再试一次,还不行才 OOM。
  • 弱引用(Weak):只被弱引用指着的对象,活不过下一轮 GC,一扫描到就回收。
  • 虚引用(Phantom):形同虚设,不影响对象生命周期,只用于回收通知(堆外内存释放,配合 ReferenceQueue)。

软引用这个"OOM 前清空"的行为是用实验验证过的,下面动手复现。

动手实操:软引用在 -Xmx 压力下的回收行为

写一个 SoftReference 缓存,然后故意用小堆把 JVM 逼到 OOM 边缘,观察软引用什么时候被清:

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

public class SoftRefCacheDemo {

    static class CacheBlock {
        final byte[] data;
        CacheBlock(int sizeMb) { data = new byte[sizeMb * 1024 * 1024]; }
    }

    public static void main(String[] args) {
        // 缓存条目用 SoftReference 包着,堆紧张时 JVM 可自动回收
        List<SoftReference<CacheBlock>> cache = new ArrayList<>();
        for (int i = 0; i < 6; i++) {
            cache.add(new SoftReference<>(new CacheBlock(3)));  // 6 条 x 3MB = 18MB 缓存
        }
        System.out.println("装入后缓存条数: " + countAlive(cache));

        // 再申请强引用的大块,把 -Xmx24m 的堆压满
        List<byte[]> pressure = new ArrayList<>();
        try {
            for (int i = 0; i < 10; i++) {
                pressure.add(new byte[4 * 1024 * 1024]);  // 每次 4MB
                System.out.printf("强引用已用 %dMB, 缓存存活 %d 条%n",
                        (i + 1) * 4, countAlive(cache));
            }
        } catch (OutOfMemoryError e) {
            System.out.println("OOM 了,此时缓存还剩: " + countAlive(cache));
        }
    }

    static int countAlive(List<SoftReference<CacheBlock>> cache) {
        int n = 0;
        for (SoftReference<CacheBlock> ref : cache) {
            if (ref.get() != null) n++;  // get() 为 null 说明已被 GC 清掉
        }
        return n;
    }
}

运行参数 -Xmx24m -Xms24m -XX:+PrintGCDetails:

装入后缓存条数: 6
强引用已用 4MB, 缓存存活 6 条
强引用已用 8MB, 缓存存活 6 条
... 中间某次 GC 后 ...
强引用已用 20MB, 缓存存活 0 条    <- 软引用在 OOM 前被整体清空
OOM 了,此时缓存还剩: 0            <- 软引用清完还不够,才真正抛 OOM

三个观察点:

  1. 堆还宽裕时软引用一直活着,别把它当弱引用——弱引用在下一次 GC 就没了,软引用能扛过多轮 GC。
  2. 压力到来时软引用是成批清空的,不是清一两个试试。这个"悬崖"行为在多数 HotSpot 版本一致,所以软引用缓存不设上限地塞大对象依然危险——瞬时清空会引发缓存集体失效,流量冲进数据库。
  3. SoftReference 换成 WeakReference 再跑,缓存存活数在第一次 GC 后就归零,两级引用的差别一眼可见。

生产上更稳妥的做法:Guava Cache 这类带最大容量和 LRU 淘汰的本地缓存,软引用只用于确实"丢了能接受、需要时重建"的数据。

常见误区与小结

  • 引用计数数不准是循环引用的锅,不是计数本身慢;不用它是因为补循环检测的成本比可达性分析更高。
  • 年轻代对象不是 GC Root。是 Root 的是栈帧局部变量表、静态字段、常量、JNI、JVM 内部引用这五类,对象只是被 Root 直接或间接指着。
  • 三色标记的漏标是正确性 bug(回收活对象),多标只是浮动垃圾,两者严重性不同,解法也不同(增量更新 vs SATB)。
  • 复制算法不是"浪费一半内存"的代名词,HotSpot 的 8:1:1 布局把它压到了 10%。
  • 软引用不是弱引用:软引用能扛到 OOM 前最后一刻,弱引用活不过下一轮 GC;拿软引用做无界缓存会踩"集体失效"的坑。

小结:本文把"怎么判死、怎么清、清的力度分几档"三件事串起来了。可达性分析和三色标记是理解 CMS/G1/ZGC 的地基,四种基础算法的权衡表则是它们各自选型的依据。下一篇《GC 收集器演进》进入具体收集器,看 Serial 到 CMS 各自在权衡表上怎么落子。

参考

参考:《深入理解 Java 虚拟机(第 3 版)》第 3 章;HotSpot 源码 src/hotspot/share/gc/shared/referenceProcessor.cpp(软引用清理时机);JSR-133 以来的 JEP 与各收集器官方文档

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1