主题
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三个观察点:
- 堆还宽裕时软引用一直活着,别把它当弱引用——弱引用在下一次 GC 就没了,软引用能扛过多轮 GC。
- 压力到来时软引用是成批清空的,不是清一两个试试。这个"悬崖"行为在多数 HotSpot 版本一致,所以软引用缓存不设上限地塞大对象依然危险——瞬时清空会引发缓存集体失效,流量冲进数据库。
- 把
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 与各收集器官方文档