JVM 垃圾回收(GC)

第一性原理层

为什么需要垃圾回收

根本问题

程序如何判断一块内存是否还“有价值”?

在现代高级语言中:

如果完全依赖人工释放:

结论

垃圾回收的本质,是把“内存生命周期管理”从业务逻辑中剥离,交由运行时系统统一治理。

GC 的本质定义

无论实现如何变化,所有 GC 都在解决同一个问题:

在不破坏程序语义的前提下,找出仍然可能被使用的对象,其余内存可以被安全重用。

抽象为两个不可分割的子问题:

  1. **存活对象发现(Live Object Discovery)**
  2. **内存空间重用(Space Reclamation & Reuse)**

本文主体聚焦“发现”侧;“重用/分配”侧与回收对偶——回收算法反向约束分配方式:指针碰撞分配要求连续内存,正是复制 / 整理式压缩回收存在的理由;空闲列表分配容忍碎片,匹配标记-清除。见 内存结构.html

理论模型层

存活判定:可达 vs 计数

存活判定有两条理论路线,JVM 选后者:

模型 判据 结局 根本原因
引用计数 被引用次数 被否定 无法处理循环引用,真实语义下需大量额外修正——是正确性问题而非性能问题
可达性分析 是否仍可被程序访问 GC 理论基石 从 GC Roots 遍历对象图,能到达即存活

存活取决于"是否仍可被访问"——这是 JVM GC 的第一性理论基础。

指针识别:精确 vs 保守

可达性遍历隐含一个被默认掉的前提——遍历前必须知道栈帧 / 寄存器里哪些字是引用。这个前提本身是一条独立、跨语言稳定的设计权衡轴:

模型 判断引用的方式 主要代价 能否移动对象 代表
精确式(Exact) 靠类型元数据准确定位引用 编译器须生成引用位置表 HotSpot
保守式(Conservative) 值"像地址"就当引用 整数可能被误判为指针 → 误留垃圾 不能(不敢改疑似指针) Boehm GC(C/C++ 无类型安全)

关键推论:指针识别精度反向决定回收算法的可行域——保守式不敢移动"疑似指针",封杀复制 / 整理式压缩;唯精确式解锁移动式回收。HotSpot 用 OopMap 换精确性(见"HotSpot 架构支撑机制")正是为此。

辨析(本文两个"保守"正交无关,勿混):

元信息可得性决定优化空间——类型信息让 GC 敢移动对象,同构于类型信息让 JIT 敢去虚化 / 内联;缺元信息则只能保守。

GC Roots:可达性的锚点

原则:只要程序还能"直接使用"的对象,就必须作为根。据此包括:线程栈局部变量、类静态变量、常量池引用、JNI / 本地代码引用。

引用强度与终结:可达性的完整谱系

引用非二元(有 / 无),而是可达性的一条分级谱系,让程序参与"内存价值排序"

引用类型 本质定位 设计目的
强引用 必须存活 程序正确性
软引用 可牺牲 内存弹性(缓存)
弱引用 不阻止回收 生命周期绑定
虚引用 仅做通知 资源回收协调

理论内核:对象的可达性级别由到它的最强一条引用链决定(强可达 > 软可达 > 弱可达 > 虚可达 > 不可达),此偏序决定回收器清理引用的先后。"引用种类"是工具,"可达性级别"才是判定实际作用的状态。

谱系的最弱端(虚可达)连着对象生命的终点,这里有一条稳定约束:

死亡必须单调——对象一旦被判不可达,这个判断不应被回收流程自身逆转。

违反它(对象复活 resurrection):若回收前的清理钩子能重建引用,判定即失去单调性——单次标记不能定生死,须多趟重扫,回收时机不可测。故稳定设计原则是终结动作与对象本体解耦

终结钩子能否触及对象本体 能否复活 判定单调 定位
能(可写回引用) 反模式
否(只收死亡通知) 稳定解

把"死亡干预"降级为"死亡通知",用不可复活换判定确定性。具体终结器 API 的兴废(finalizeCleaner)是这条原则的工程落地。

可达性按强弱排成一条有序链,和事务隔离级别、锁强度是同一种"状态分级"建模;而死亡单调性 ≈ 分布式墓碑(tombstone)、CRDT 单调收敛、事务不可逆提交——都以"不可逆"换"确定性"。

分代:对象行为假说的机制兑现

分代收集的理论前提

JVM 并非随意分代,而是基于三条经验假说,每条都兑现为一项具体机制:

  1. **弱分代假说**:绝大多数对象朝生夕灭 —— 为何要划**新生代**并对其复制式快速回收
  2. **强分代假说**:活得越久的对象越难死亡 —— 为何要有**对象晋升(tenuring)**与老年代
  3. **跨代引用假说**:跨代引用远少于同代引用 —— 为何**记忆集 / 卡表**足够便宜(见"HotSpot 架构支撑机制")

GC 分代不是优化技巧,而是时间局部性在对象寿命上的投影——分代假说并非公理,可归约到更底层的局部性原理。

分代的抽象角色划分

按存活概率把堆切成两类区,每类匹配其假说所指的算法与频率:

本质角色 回收算法 回收频率 依据假说
新生代 高死亡率区 标记-复制 高频(Minor GC) 弱分代
老年代 高稳定区 标记-整理 / 清除 低频(Major / Full GC) 强分代

分代只是"堆按属性分区"的年龄维度一例;大小、等大 Region、NUMA 等其它分区维度属堆结构范畴,权威见 内存结构.html。本篇只取年龄维度——它是唯一有分代假说支撑、且直接决定回收算法的键。

分代的实质:同一套发现机制(可达性),按分区的死亡率差异,匹配不同的重用算法与触发频率

核心回收算法

这是第一性层两大子问题的空间重用侧:三种基础算法即三种重用方式,唯一的区别是对存活对象是否移动、如何移动

对存活对象 算法 换来 代价(牺牲的三角顶点) 前置条件
不动 标记-清除 实现简单 碎片 → 伤吞吐 无(保守式亦可)
搬到新空间 标记-复制 无碎片、分配快 牺牲空间 → 伤内存 需精确式
原地压缩 标记-整理 空间连续 移动成本 → 伤延迟 需精确式

一根轴(移动与否)生成三种纯策略,各牺牲不可能三角的一个顶点——选算法即选牺牲哪个顶点。移动类算法(复制 / 整理)是精确式的专利,保守式只能用清除。

并发 GC 的两个正交难题

标记阶段“看图 vs 改图”,转移阶段“移对象 vs 读旧址”。二者用不同屏障解决——这是 CMS → G1 → ZGC 演进的主线。

第一难题:并发标记 —— 看图 vs 改图

根本冲突:GC 在遍历对象图,程序在修改对象图。

颜色 含义
未访问(可能垃圾)
已访问,未扫描完
已完全扫描

核心不变量:黑对象不能直接引用白对象。

并发标记的风险是漏标:一个已扫完的黑对象,在 GC 不再回看它之后,才新指向某个白对象——GC 遂把这个仍存活的白对象误当垃圾回收。

漏标需同时满足两条件:①黑对象新增了指向白对象的引用 ②删除了其他所有到该白对象的路径。破坏任一即可修正,两条路线均由写屏障实现

修正思想 屏障语义 代表 破坏的条件
增量更新(Dijkstra 插入屏障) 记录新增的引用 CMS 条件 ①
原始快照 SATB(Yuasa 删除屏障) 记录被删除的旧引用 G1 条件 ②

写屏障守护并发标记:不漏活对象。

第二难题:并发转移 —— 移对象 vs 读旧址

标记只对象图,转移却改变对象的地址:GC 把对象搬走后,程序手里的引用仍指向旧址——若不处理即悬垂引用 / 失效副本。

读屏障守护并发转移:不读到已搬走的对象。

解法:间接层 + 拦截点 + 惰性修正

并发转移的解法是一个跨域复用的通用模式——如何在不停止访问者的前提下迁移共享状态:

要素 作用 在 GC 中的体现
间接层 旧身份不立即失效,留一层重定向 前向指针(旧址 → 新副本)
拦截点 访问时才发现不一致 读屏障(加载引用时检查)
惰性修正 用到才改,成本摊销 自愈(顺手把引用改写为新址)

横向迁移到其他领域:

场景 间接层 拦截点 惰性修正
GC 并发转移 前向指针 读屏障 自愈改址
数据库 MVCC 旧版本链 可见性判断 vacuum 回收
在线分片迁移 路由表 / 一致性哈希 请求代理 逐步搬迁
虚存 / COW 页表间接 缺页中断 写时才拷贝

HotSpot 架构支撑机制

前述原理(可达性、分代、并发屏障)要在 HotSpot 落地,都撞上"精确且全量地做太贵"。四个机制沿两条代价摊销链展开:

链一 · 让精确式遍历可负担

机制 解决什么 手段 / 代价
OopMap 遍历时如何准确知道栈 / 寄存器哪里是引用(精确式的落地,见"指针识别") 预记录引用偏移,空间换时间
Safepoint / Safe Region 处处生成 OopMap 太贵 只在少数点(方法调用、循环回边等)生成,世界只在这些点暂停——GC 无法任意时刻启动的根因

链二 · 让分代回收不扫全堆

机制 解决什么 手段 / 代价
记忆集(Remembered Set) 兑现跨代引用假说:Minor GC 不必扫全老年代(见"分代") 抽象结构,记录"非收集区 → 收集区"的引用来源
卡表(Card Table) 记忆集的具体实现 按卡页(HotSpot 512B)映射为字节数组,脏页置位;精度换性能

卡表由写屏障维护(引用赋值时置脏)——即并发标记那个写屏障的另一用途;此"写屏障"与 JMM 内存屏障同名异指。同属"增量维护视图"模式)。

垃圾收集器 = 策略组合体

没有"最好的收集器",只有"在三角上选择牺牲谁"——每个收集器都是三大指标间的一组固定取舍。

不可能三角:为何不能三者兼得

指标 含义 与另两者的张力
吞吐量 GC 占用的 CPU 越少,程序可用 CPU 越多 要低延迟需并发,并发要抢 CPU → 伤吞吐
延迟 单次停顿时长 要高吞吐可攒一批集中回收(长停顿);要省内存则堆小、GC 频繁
内存占用 达成目标所需的堆 / 元数据开销 并发、分区、染色都需额外空间 → 换延迟

三者不可同时最优(GC 领域的 RUM 三角,见"横向原理关联"):优化任一顶点必以另一或两个为代价。收集器选择 = 定位在三角的哪个角。

典型收集器的取舍定位

收集器 关键机制 机制如何导致该结果 换来 牺牲的顶点
Serial 单线程 无线程协调、无并发数据结构 → 代码最简、额外内存最少 极简、内存省 延迟(长 STW)
Parallel 多线程并行回收 多核并行缩短单次回收总耗时 → GC 占 CPU 比例最低 最高吞吐 延迟(不可控停顿)
CMS 并发标记-清除 标记与程序并发 → STW 只剩短暂标记;但不整理留碎片、并发期产生浮动垃圾 低延迟 吞吐 + 内存(碎片 / 浮动垃圾)
G1 分区 + 增量回收 停顿 ∝ 本次所选 region 数(可控),不再 ∝ 堆大小 → 可反推目标 可预测停顿(可设目标) 吞吐(屏障开销)
ZGC 染色指针 + 读屏障 引用修正挪到并发期 + 读时惰性自愈 → STW 只剩根扫描,与堆无关 亚毫秒停顿,且不随堆增长 吞吐(5-15%)+ 内存(额外开销)

选型:由主关注点倒推

主关注点 场景 选择
吞吐量 批处理、数据分析、科学计算 Parallel
延迟 SLA 服务、交互式、大堆低延迟 G1 → ZGC / Shenandoah
内存 / 简单 客户端、嵌入式、小堆 Serial

演进主线是一条在三角上腾挪的轨迹:Serial 单线程 → Parallel 并行化榨吞吐 → CMS / G1 并发化换延迟 → ZGC 用染色指针让停顿与堆大小解耦。每一步都在"用新机制把某个顶点的代价压得更低"。

现代 GC 的演进趋势

驱动力:为何一直朝低延迟走

对象规模 ↑ → 堆容量 ↑ → 人类对停顿的容忍 ↓

堆越大,"一次停顿处理完全堆"的代价越不可接受。这把不可能三角的权重持续压向延迟顶点——演进史就是一部"不断把延迟的代价往下压、且让它不再随堆规模增长"的历史。

变的方向:代价往哪腾挪

维度 手段 把代价挪去了哪
停顿 STW 阶段并发化 停顿 → 换 CPU / 屏障开销(伤吞吐)
全堆 → 局部 Region 化承载分代 全堆扫描 → 换记忆集维护开销(伤吞吐 / 内存)
遍历 → 转移 读屏障使转移可并发 集中修正 → 摊到每次读取(伤吞吐)

三者不是"新方案取代旧方案":Region 是承载分代的方式(分代仍在,ZGC 已有分代版);读屏障守护并发转移、写屏障守护并发标记,是正交两难题的两个解,非谁替代谁。每一步的共性是用空间 / 吞吐换延迟——三角不曾被打破,只是选择了牺牲谁。

不变量:什么几十年没变

演进只动机制,不动地基。始终不变的是:

横向原理关联

GC 把「局部性、快照隔离、RUM 三角、屏障」这几条跨域稳定原理,在“内存生命周期”这一维度上重演了一遍。

本文原理 上位/同域枢纽 同构关系 迁移价值
分代假说 局部性原理 对象死亡率的时间局部性 缓存分层 / JIT 热点分层同模式
不可能三角 RUM 猜想 三元互斥的结构同构 索引 / 存储引擎选型同一坐标系
三色标记 + SATB 快照隔离 / MVCC 对可达性计算做快照隔离 无锁读写分离的通用范式
STW 安全点 并发屏障 / 全局同步点 全局一致点暂停 分布式 stop-the-world 同理
记忆集 / 卡表 增量视图维护 / 脏页追踪 空间换时间的增量重扫 物化视图 / 缓存失效同模式
死亡单调性 分布式墓碑 / CRDT 单调 以不可逆换判定确定性 逻辑删除 / 事务提交同模式

归约一:分代假说 = 局部性原理的时间变体

“朝生夕灭”不是 GC 的独有经验,而是时间局部性在对象生命周期上的投影:最近分配的对象最可能很快死亡,活过阈值的对象倾向继续存活——这与“最近访问的数据最可能再次访问”是同一条统计规律的两个方向。分代按年龄分层治理,与存储层次按访问频率分层、缓存 LRU、JIT 分层编译共享同一“按局部性分层”母模式。

因此分代的稳定性来自局部性原理;一旦程序违反弱分代假说(对象长寿),分代反成负担(多一次幸存者拷贝),正如缓存对无局部性的访问流失效。

同构二:三色标记 + SATB = 可达性计算的快照隔离

并发标记的根本冲突“GC 在看对象图 / 程序在改对象图”,与数据库“长事务在读 / 其他事务在写”是同一矛盾。SATB 的解法与 MVCC 快照隔离逐点对应:

SATB(可达性快照) 快照隔离 / MVCC(数据快照)
初始标记时刻冻结存活集 事务开始时刻冻结可见版本
写前屏障记录被覆盖的旧引用 更新保留旧行版本而非原地覆盖
快照后新分配对象隐式存活 快照后新插入行对读事务不可见
浮动垃圾,下轮 GC 回收 死元组膨胀,靠 vacuum 回收

SATB 本质是“为可达性计算加了一层快照隔离”:GC 扮演一个长读事务,写前屏障扮演 MVCC 的旧版本保留。二者都用“暂留已死状态”换“无阻塞并发”。

辨析三:GC 写屏障 ≠ JMM 内存屏障

两个“屏障”名字相近、层次无关,必须划清:

维度 GC 写屏障 JMM 内存屏障(memory fence)
解决问题 引用图变化的漏标 / 跨代引用 指令重排序、跨线程可见性
机制 注入引用写操作的钩子,记录引用 约束 CPU / 编译器的读写顺序
关乎 GC 正确性(不漏标) 并发语义(可见性 / 有序性)
权威源 本文 JAVA内存模型.html

GC 写屏障“记录写了什么引用”,JMM 内存屏障“约束写的顺序何时可见”。

关联内容(自动生成)