根本问题:
程序如何判断一块内存是否还“有价值”?
在现代高级语言中:
如果完全依赖人工释放:
结论:
垃圾回收的本质,是把“内存生命周期管理”从业务逻辑中剥离,交由运行时系统统一治理。
无论实现如何变化,所有 GC 都在解决同一个问题:
在不破坏程序语义的前提下,找出仍然可能被使用的对象,其余内存可以被安全重用。
抽象为两个不可分割的子问题:
本文主体聚焦“发现”侧;“重用/分配”侧与回收对偶——回收算法反向约束分配方式:指针碰撞分配要求连续内存,正是复制 / 整理式压缩回收存在的理由;空闲列表分配容忍碎片,匹配标记-清除。见 内存结构.html。
存活判定有两条理论路线,JVM 选后者:
| 模型 | 判据 | 结局 | 根本原因 |
|---|---|---|---|
| 引用计数 | 被引用次数 | 被否定 | 无法处理循环引用,真实语义下需大量额外修正——是正确性问题而非性能问题 |
| 可达性分析 | 是否仍可被程序访问 | GC 理论基石 | 从 GC Roots 遍历对象图,能到达即存活 |
存活取决于"是否仍可被访问"——这是 JVM GC 的第一性理论基础。
可达性遍历隐含一个被默认掉的前提——遍历前必须知道栈帧 / 寄存器里哪些字是引用。这个前提本身是一条独立、跨语言稳定的设计权衡轴:
| 模型 | 判断引用的方式 | 主要代价 | 能否移动对象 | 代表 |
|---|---|---|---|---|
| 精确式(Exact) | 靠类型元数据准确定位引用 | 编译器须生成引用位置表 | 能 | HotSpot |
| 保守式(Conservative) | 值"像地址"就当引用 | 整数可能被误判为指针 → 误留垃圾 | 不能(不敢改疑似指针) | Boehm GC(C/C++ 无类型安全) |
关键推论:指针识别精度反向决定回收算法的可行域——保守式不敢移动"疑似指针",封杀复制 / 整理式压缩;唯精确式解锁移动式回收。HotSpot 用 OopMap 换精确性(见"HotSpot 架构支撑机制")正是为此。
辨析(本文两个"保守"正交无关,勿混):
元信息可得性决定优化空间——类型信息让 GC 敢移动对象,同构于类型信息让 JIT 敢去虚化 / 内联;缺元信息则只能保守。
原则:只要程序还能"直接使用"的对象,就必须作为根。据此包括:线程栈局部变量、类静态变量、常量池引用、JNI / 本地代码引用。
引用非二元(有 / 无),而是可达性的一条分级谱系,让程序参与"内存价值排序":
| 引用类型 | 本质定位 | 设计目的 |
|---|---|---|
| 强引用 | 必须存活 | 程序正确性 |
| 软引用 | 可牺牲 | 内存弹性(缓存) |
| 弱引用 | 不阻止回收 | 生命周期绑定 |
| 虚引用 | 仅做通知 | 资源回收协调 |
理论内核:对象的可达性级别由到它的最强一条引用链决定(强可达 > 软可达 > 弱可达 > 虚可达 > 不可达),此偏序决定回收器清理引用的先后。"引用种类"是工具,"可达性级别"才是判定实际作用的状态。
谱系的最弱端(虚可达)连着对象生命的终点,这里有一条稳定约束:
死亡必须单调——对象一旦被判不可达,这个判断不应被回收流程自身逆转。
违反它(对象复活 resurrection):若回收前的清理钩子能重建引用,判定即失去单调性——单次标记不能定生死,须多趟重扫,回收时机不可测。故稳定设计原则是终结动作与对象本体解耦:
| 终结钩子能否触及对象本体 | 能否复活 | 判定单调 | 定位 |
|---|---|---|---|
| 能(可写回引用) | 是 | 否 | 反模式 |
| 否(只收死亡通知) | 否 | 是 | 稳定解 |
把"死亡干预"降级为"死亡通知",用不可复活换判定确定性。具体终结器 API 的兴废(
finalize→Cleaner)是这条原则的工程落地。
可达性按强弱排成一条有序链,和事务隔离级别、锁强度是同一种"状态分级"建模;而死亡单调性 ≈ 分布式墓碑(tombstone)、CRDT 单调收敛、事务不可逆提交——都以"不可逆"换"确定性"。
JVM 并非随意分代,而是基于三条经验假说,每条都兑现为一项具体机制:
GC 分代不是优化技巧,而是时间局部性在对象寿命上的投影——分代假说并非公理,可归约到更底层的局部性原理。
按存活概率把堆切成两类区,每类匹配其假说所指的算法与频率:
| 代 | 本质角色 | 回收算法 | 回收频率 | 依据假说 |
|---|---|---|---|---|
| 新生代 | 高死亡率区 | 标记-复制 | 高频(Minor GC) | 弱分代 |
| 老年代 | 高稳定区 | 标记-整理 / 清除 | 低频(Major / Full GC) | 强分代 |
分代只是"堆按属性分区"的年龄维度一例;大小、等大 Region、NUMA 等其它分区维度属堆结构范畴,权威见 内存结构.html。本篇只取年龄维度——它是唯一有分代假说支撑、且直接决定回收算法的键。
分代的实质:同一套发现机制(可达性),按分区的死亡率差异,匹配不同的重用算法与触发频率。
这是第一性层两大子问题的空间重用侧:三种基础算法即三种重用方式,唯一的区别是对存活对象是否移动、如何移动。
| 对存活对象 | 算法 | 换来 | 代价(牺牲的三角顶点) | 前置条件 |
|---|---|---|---|---|
| 不动 | 标记-清除 | 实现简单 | 碎片 → 伤吞吐 | 无(保守式亦可) |
| 搬到新空间 | 标记-复制 | 无碎片、分配快 | 牺牲空间 → 伤内存 | 需精确式 |
| 原地压缩 | 标记-整理 | 空间连续 | 移动成本 → 伤延迟 | 需精确式 |
一根轴(移动与否)生成三种纯策略,各牺牲不可能三角的一个顶点——选算法即选牺牲哪个顶点。移动类算法(复制 / 整理)是精确式的专利,保守式只能用清除。
标记阶段“看图 vs 改图”,转移阶段“移对象 vs 读旧址”。二者用不同屏障解决——这是 CMS → G1 → ZGC 演进的主线。
根本冲突:GC 在遍历对象图,程序在修改对象图。
| 颜色 | 含义 |
|---|---|
| 白 | 未访问(可能垃圾) |
| 灰 | 已访问,未扫描完 |
| 黑 | 已完全扫描 |
核心不变量:黑对象不能直接引用白对象。
并发标记的风险是漏标:一个已扫完的黑对象,在 GC 不再回看它之后,才新指向某个白对象——GC 遂把这个仍存活的白对象误当垃圾回收。
漏标需同时满足两条件:①黑对象新增了指向白对象的引用 ②删除了其他所有到该白对象的路径。破坏任一即可修正,两条路线均由写屏障实现:
| 修正思想 | 屏障语义 | 代表 | 破坏的条件 |
|---|---|---|---|
| 增量更新(Dijkstra 插入屏障) | 记录新增的引用 | CMS | 条件 ① |
| 原始快照 SATB(Yuasa 删除屏障) | 记录被删除的旧引用 | G1 | 条件 ② |
写屏障守护并发标记:不漏活对象。
标记只读对象图,转移却改变对象的地址:GC 把对象搬走后,程序手里的引用仍指向旧址——若不处理即悬垂引用 / 失效副本。
读屏障守护并发转移:不读到已搬走的对象。
并发转移的解法是一个跨域复用的通用模式——如何在不停止访问者的前提下迁移共享状态:
| 要素 | 作用 | 在 GC 中的体现 |
|---|---|---|
| 间接层 | 旧身份不立即失效,留一层重定向 | 前向指针(旧址 → 新副本) |
| 拦截点 | 访问时才发现不一致 | 读屏障(加载引用时检查) |
| 惰性修正 | 用到才改,成本摊销 | 自愈(顺手把引用改写为新址) |
横向迁移到其他领域:
| 场景 | 间接层 | 拦截点 | 惰性修正 |
|---|---|---|---|
| GC 并发转移 | 前向指针 | 读屏障 | 自愈改址 |
| 数据库 MVCC | 旧版本链 | 可见性判断 | vacuum 回收 |
| 在线分片迁移 | 路由表 / 一致性哈希 | 请求代理 | 逐步搬迁 |
| 虚存 / COW | 页表间接 | 缺页中断 | 写时才拷贝 |
前述原理(可达性、分代、并发屏障)要在 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 用染色指针让停顿与堆大小解耦。每一步都在"用新机制把某个顶点的代价压得更低"。
对象规模 ↑ → 堆容量 ↑ → 人类对停顿的容忍 ↓
堆越大,"一次停顿处理完全堆"的代价越不可接受。这把不可能三角的权重持续压向延迟顶点——演进史就是一部"不断把延迟的代价往下压、且让它不再随堆规模增长"的历史。
| 维度 | 手段 | 把代价挪去了哪 |
|---|---|---|
| 停顿 | STW 阶段并发化 | 停顿 → 换 CPU / 屏障开销(伤吞吐) |
| 全堆 → 局部 | Region 化承载分代 | 全堆扫描 → 换记忆集维护开销(伤吞吐 / 内存) |
| 遍历 → 转移 | 读屏障使转移可并发 | 集中修正 → 摊到每次读取(伤吞吐) |
三者不是"新方案取代旧方案":Region 是承载分代的方式(分代仍在,ZGC 已有分代版);读屏障守护并发转移、写屏障守护并发标记,是正交两难题的两个解,非谁替代谁。每一步的共性是用空间 / 吞吐换延迟——三角不曾被打破,只是选择了牺牲谁。
演进只动机制,不动地基。始终不变的是:
GC 把「局部性、快照隔离、RUM 三角、屏障」这几条跨域稳定原理,在“内存生命周期”这一维度上重演了一遍。
| 本文原理 | 上位/同域枢纽 | 同构关系 | 迁移价值 |
|---|---|---|---|
| 分代假说 | 局部性原理 | 对象死亡率的时间局部性 | 缓存分层 / JIT 热点分层同模式 |
| 不可能三角 | RUM 猜想 | 三元互斥的结构同构 | 索引 / 存储引擎选型同一坐标系 |
| 三色标记 + SATB | 快照隔离 / MVCC | 对可达性计算做快照隔离 | 无锁读写分离的通用范式 |
| STW 安全点 | 并发屏障 / 全局同步点 | 全局一致点暂停 | 分布式 stop-the-world 同理 |
| 记忆集 / 卡表 | 增量视图维护 / 脏页追踪 | 空间换时间的增量重扫 | 物化视图 / 缓存失效同模式 |
| 死亡单调性 | 分布式墓碑 / CRDT 单调 | 以不可逆换判定确定性 | 逻辑删除 / 事务提交同模式 |
“朝生夕灭”不是 GC 的独有经验,而是时间局部性在对象生命周期上的投影:最近分配的对象最可能很快死亡,活过阈值的对象倾向继续存活——这与“最近访问的数据最可能再次访问”是同一条统计规律的两个方向。分代按年龄分层治理,与存储层次按访问频率分层、缓存 LRU、JIT 分层编译共享同一“按局部性分层”母模式。
因此分代的稳定性来自局部性原理;一旦程序违反弱分代假说(对象长寿),分代反成负担(多一次幸存者拷贝),正如缓存对无局部性的访问流失效。
并发标记的根本冲突“GC 在看对象图 / 程序在改对象图”,与数据库“长事务在读 / 其他事务在写”是同一矛盾。SATB 的解法与 MVCC 快照隔离逐点对应:
| SATB(可达性快照) | 快照隔离 / MVCC(数据快照) |
|---|---|
| 初始标记时刻冻结存活集 | 事务开始时刻冻结可见版本 |
| 写前屏障记录被覆盖的旧引用 | 更新保留旧行版本而非原地覆盖 |
| 快照后新分配对象隐式存活 | 快照后新插入行对读事务不可见 |
| 浮动垃圾,下轮 GC 回收 | 死元组膨胀,靠 vacuum 回收 |
SATB 本质是“为可达性计算加了一层快照隔离”:GC 扮演一个长读事务,写前屏障扮演 MVCC 的旧版本保留。二者都用“暂留已死状态”换“无阻塞并发”。
两个“屏障”名字相近、层次无关,必须划清:
| 维度 | GC 写屏障 | JMM 内存屏障(memory fence) |
|---|---|---|
| 解决问题 | 引用图变化的漏标 / 跨代引用 | 指令重排序、跨线程可见性 |
| 机制 | 注入引用写操作的钩子,记录引用 | 约束 CPU / 编译器的读写顺序 |
| 关乎 | GC 正确性(不漏标) | 并发语义(可见性 / 有序性) |
| 权威源 | 本文 | JAVA内存模型.html |
GC 写屏障“记录写了什么引用”,JMM 内存屏障“约束写的顺序何时可见”。