1. 可达性与根集
一句话总结: 垃圾回收不去问「这块内存还有没有用」,而是问「从根集出发能不能走到它」,走不到的就是垃圾。
手动内存管理要求程序员精确匹配 malloc 与 free,任何一处遗漏就是泄漏、任何一处重复就是崩溃。垃圾回收(Garbage Collection,GC)把这件事自动化:运行时自己维护「哪些对象还在使用」的信息,定期回收不再使用的部分。判定标准不是「程序员是否还需要它」(这无法判定),而是一个可计算的近似——可达性(reachability)。
可达性的定义依赖根集(root set):从根出发,沿对象之间的引用边做图遍历,能到达的对象为存活,其余为垃圾。根集通常包括:
| 根来源 | 内容 | 说明 |
|---|---|---|
| 栈帧 | 局部变量、临时值 | 需精确识别栈上哪些槽位是指针 |
| 寄存器 | 当前活跃的指针值 | 安全点处保存 |
| 全局/静态区 | 全局变量、静态字段 | 包括常量池中的引用 |
| 线程本地存储 | TLS 中的引用 | 每个线程一份 |
| 运行时内部 | JNI 引用、类加载器、常量池 | 语言相关 |
| 弱引用表 | 仅弱可达的对象 | 不作为存活依据 |
import sys
class Obj:
def __init__(self, name):
self.name = name
self.refs = []
def mark_from_roots(roots, heap):
"""朴素可达性标记: 从根集做图遍历."""
marked, stack = set(), list(roots)
while stack:
o = stack.pop()
if id(o) in marked:
continue
marked.add(id(o))
stack.extend(heap.get(id(o), [])) # 对象的出边
return marked
a, b = Obj("a"), Obj("b")
heap = {id(a): [b], id(b): []}
alive = mark_from_roots([a], heap)
print("存活:", [o.name for o in (a, b) if id(o) in alive])
# 引用计数是另一种判定方式: 计数归零即可回收
class RCObj:
def __init__(self, name):
self.name, self.rc = name, 0
def add_ref(self):
self.rc += 1
def release(self):
self.rc -= 1
return self.rc == 0 # True 表示可立即回收
x = RCObj("x")
x.add_ref()
print("释放后是否可回收:", x.release())
引用计数(reference counting)是可达性的一个特例实现:每个对象记录被引用次数,归零即回收。它实现简单、回收及时、暂停极短,但有两个致命弱点——无法处理循环引用(两个对象互指,计数永远不归零)与写操作开销(每次赋值都要增减计数,多线程下还需要原子操作)。Python、Swift、Objective-C 用引用计数为主并辅以循环检测器,而 Java、Go、C#、JavaScript 的现代引擎则以追踪式 GC(tracing GC)为主,因为它们能天然处理循环引用,且把开销集中到回收时刻而非每次赋值。
2. 三大基础算法
一句话总结: 标记清除不移动对象但有碎片,复制算法无碎片但要浪费一半空间,标记整理两者兼顾但移动成本高。
所有追踪式 GC 都由两个阶段构成:标记(找出所有存活对象)与回收(把垃圾占用的空间交还给分配器)。三种基础算法的差别在于回收阶段怎么做。
# 标记-清除: 标记存活, 清扫未标记的
def mark_sweep(heap, roots, allocated):
marked = mark_from_roots(roots, heap)
freed = [oid for oid in allocated if oid not in marked]
for oid in freed:
allocated.remove(oid) # 回收, 但留下的空洞难以利用
return freed
# 标记-复制: 存活对象搬到另一半, 原空间整体清空
def mark_copy(heap, roots, allocated, to_space):
marked = mark_from_roots(roots, heap)
mapping = {}
for i, oid in enumerate(sorted(marked)):
mapping[oid] = ("to", i) # 新地址: 紧凑排列
return mapping # 无碎片, 但要预留一半空间
# 标记-整理: 存活对象向一端滑动, 保持顺序
def mark_compact(heap, roots, allocated):
marked = mark_from_roots(roots, heap)
order = sorted(marked) # 按原地址排序, 保持相对顺序
return {oid: i for i, oid in enumerate(order)}
| 算法 | 碎片 | 空间开销 | 移动对象 | 典型实现 |
|---|---|---|---|---|
| 标记-清除 | 有(需空闲链表) | 无额外 | 否 | 早期 JVM CMS、Boehm GC |
| 标记-复制 | 无(半区交替) | 50% | 是 | JVM 新生代、Cheney 算法 |
| 标记-整理 | 无(紧凑) | 无额外 | 是 | JVM G1 老年代、Go GC |
| 引用计数 | 有 | 每对象计数 | 否 | Python、Swift |
复制算法的实现非常优雅:把堆分为 from/to 两个半区,回收时把存活对象从 from 复制到 to 的头部,free_ptr 顺序前移。复制成本只与存活对象数量成正比,与垃圾数量无关——这对「大部分对象都是垃圾」的场景极为有利。Cheney 算法进一步用「扫描指针 + 分配指针」的双指针技巧实现了无栈的广度优先复制,连遍历用的显式栈都省了。
def cheney(from_space, roots):
"""Cheney 复制: scan 指针遍历新空间, free 指针分配."""
to_space, forward = [], {}
free = 0
def copy(oid):
nonlocal free
if oid in forward: return forward[oid]
to_space.append(oid)
forward[oid] = free
free += 1
return forward[oid]
queue = [copy(r) for r in roots]
scan = 0
while scan < len(to_space):
oid = to_space[scan]
scan += 1
for child in from_space.get(oid, []):
queue.append(copy(child))
return to_space
print(cheney({1: [2], 2: [3], 3: []}, [1]))
标记-整理(mark-compact)试图兼顾两者:不浪费一半空间,也不留下碎片。代价是移动对象后必须更新所有指向它们的引用,实现复杂度显著高于前两者,且通常需要多遍扫描(标记 → 计算新地址 → 修正引用 → 移动对象)。Lisp 的滑动式整理(sliding compaction)与 JVM G1 的疏散(evacuation)都属于这一类。真实运行时几乎不会只用一种算法,而是混合策略:新生代用复制(存活率低、复制便宜),老年代用标记-整理或标记-清除(存活率高、移动昂贵)。
3. 分代假设与晋升
一句话总结: 弱分代假设指出「绝大多数对象朝生夕死」,据此把堆分层,让廉价的年轻代回收承担大部分工作。
1984 年 Lieberman 与 Ungar 提出的弱分代假设(weak generational hypothesis)是几乎所有现代 GC 的理论基石:绝大多数对象在创建后很快死亡,越老的对象越可能继续存活。实测数据支持这一点——在典型的 Java 应用中,新生代的对象存活率通常不到 5%,而在经历了若干次回收后仍存活的对象,其后续存活率会急剧上升。
import random
def simulate_lifetimes(n=100_000, max_age=20):
"""模拟对象寿命分布: 大多数很快死亡, 少数长寿."""
ages = []
for _ in range(n):
if random.random() < 0.9: # 90% 的对象很短命
ages.append(random.randint(1, 2))
else:
ages.append(random.randint(3, max_age))
young = sum(1 for a in ages if a <= 2)
return young / n, ages
ratio, ages = simulate_lifetimes()
print(f"短命对象占比: {ratio:.2%}")
基于这个假设,堆被划分为新生代(young generation)与老年代(old generation),新生代再细分为 Eden 与两块 Survivor。新对象在 Eden 分配;Eden 满时触发 Minor GC,把存活对象复制到 Survivor;对象在 Survivor 之间来回复制,每经历一次回收年龄(age)加一;年龄达到阈值(JVM 默认 15)后晋升(promote)到老年代。
class GenerationalHeap:
def __init__(self, promote_age=15, eden_cap=100):
self.eden, self.survivor, self.old = [], [], []
self.age, self.promote_age, self.eden_cap = {}, promote_age, eden_cap
def allocate(self, obj):
self.eden.append(obj)
if len(self.eden) > self.eden_cap:
self.minor_gc()
def minor_gc(self):
survivors = [o for o in self.eden if self.is_alive(o)]
for o in survivors:
self.age[o] = self.age.get(o, 0) + 1
if self.age[o] >= self.promote_age:
self.old.append(o) # 晋升
else:
self.survivor.append(o)
self.eden = []
def is_alive(self, obj):
return getattr(obj, "alive", False)
分代带来了一个必须解决的副作用:跨代引用。老年代对象可能指向新生代对象,如果 Minor GC 只扫描新生代,就会漏掉这些「从老年代来的根」。如果每次 Minor GC 都扫描整个老年代,分代就失去意义了。解决方案是记忆集(remembered set):在写屏障里记录「老年代 → 新生代」的引用位置,Minor GC 时把记忆集并入根集。JVM 用卡表(card table)实现记忆集——把老年代切成 512 字节的卡页,脏卡记录在字节数组中,一次 Minor GC 只需扫描脏卡对应的区域。
| 概念 | 作用 | 实现代价 |
|---|---|---|
| 新生代 / 老年代 | 按存活率分层,分别用合适算法 | 需两套策略 |
| 年龄与晋升阈值 | 控制对象何时进入老年代 | 每对象一个 age 字段 |
| 卡表 / 记忆集 | 记录跨代引用,避免全堆扫描 | 写屏障开销 |
| 大对象直接进老年代 | 避免在新生代反复复制 | 阈值需调优 |
4. 三色标记与写屏障
一句话总结: 三色标记把对象分成白灰黑三种状态,写屏障在并发标记期间维护不变式,防止存活对象被误判为垃圾。
当标记阶段必须与用户线程并发执行时,情况变得复杂:用户线程可能在标记进行中修改引用关系,导致标记结果不正确。三色标记(tri-color marking)提供了分析这个问题的模型:白色表示尚未访问(默认是垃圾候选),灰色表示已访问但其子引用尚未扫描完,黑色表示已访问且子引用全部扫描完毕。
WHITE, GRAY, BLACK = 0, 1, 2
def tri_color_mark(roots, children):
color = {}
def get(o): return color.get(o, WHITE)
gray = [r for r in roots]
for r in gray: color[r] = GRAY
while gray:
o = gray.pop()
for c in children(o):
if get(c) == WHITE:
color[c] = GRAY
gray.append(c)
color[o] = BLACK
return color
children = lambda o: {1: [2, 3], 2: [3], 3: []}.get(o, [])
print(tri_color_mark([1], children))
标记结束时,黑色与灰色对象是存活的,白色是垃圾。并发场景下有两种错误的引用变更可能让存活对象被漏标:
| 错误类型 | 场景 | 后果 |
|---|---|---|
| 漏标-插入 | 黑对象新增指向白对象的引用 | 白对象被误回收 |
| 漏标-删除 | 灰对象删除了指向白对象的最后一条引用 | 同上 |
要避免漏标,必须维护不变式:黑色对象不能直接指向白色对象。两种经典维护方式对应两类写屏障:
# 增量更新 (incremental update): 记录「黑->白」的新引用, 事后重新扫描黑对象
def incremental_update_barrier(black_obj, new_ref, dirty_set):
dirty_set.add(black_obj) # 把黑对象重新变灰
return new_ref
# 快照-at-the-beginning (SATB): 记录被覆盖的旧引用, 保证按标记开始时的快照不丢对象
def satb_barrier(old_ref, buffer):
if old_ref is not None:
buffer.append(old_ref) # 保存旧值, 后续作为根重扫
return old_ref
dirty, buf = set(), []
incremental_update_barrier("A", "C", dirty)
satb_barrier("B", buf)
print("增量更新脏集:", dirty, "| SATB 缓冲:", buf)
增量更新(incremental update)对应 CMS 的做法:只要出现「黑指向白」就记录黑对象,标记尾声重新扫描脏集。SATB(snapshot-at-the-beginning)对应 G1 的做法:写屏障记录被覆盖的旧引用,逻辑上等价于「标记开始时所有存活对象的快照」,因此可以在标记开始时确定性地判断哪些对象必须存活。SATB 的优点是标记结果对应一个一致的时间点,缺点是「浮动垃圾」——标记期间新生成、随后又死亡的对象会被保留到下一轮。
// 写屏障在生成代码中的位置: 每次引用字段赋值都要经过它
void set_field(Obj *owner, Obj **field, Obj *value) {
write_barrier(owner, field, value); // 记录卡表或 SATB 缓冲
*field = value;
}
写屏障的代价是每次引用赋值都要多几条指令,在指针密集的代码里可能造成 5%~20% 的吞吐损失。因此现代运行时都尽量减少写屏障的触发范围:只在老年代对象的字段赋值时记录卡表(新生代对象赋值无需屏障,因为它本来就是每次 Minor GC 的扫描对象);用位图而非字节数组压缩卡表;在 JIT 编译时把屏障内联展开成几条指令。这也是「GC 设计会影响代码生成」的直接证据——写屏障是编译期与运行期的契约。
5. 并发与增量 GC
一句话总结: 并发 GC 让标记与用户线程同时运行以缩短暂停,代价是需要写屏障、读屏障与更多的空间预留。
早期的 GC 是完全停顿(stop-the-world,STW)的:GC 期间所有用户线程挂起。堆越大、存活对象越多,暂停越长——几 GB 的堆上一轮 Full GC 停几秒到几十秒都不罕见,对延迟敏感的服务是灾难。于是出现了三类缓解策略:
| 策略 | 做法 | 代表实现 | 特点 |
|---|---|---|---|
| 增量 GC | 把一轮 GC 拆成小步,穿插在用户代码间 | 早期 HotSpot 的 Train GC | 实现简单,总吞吐下降 |
| 并发 GC | 标记/清扫与用户线程真正并行 | CMS、G1、ZGC、Shenandoah、Go GC | 暂停短,需屏障 |
| 分区 GC | 把堆切成区域,只回收收益高的部分 | G1、ZGC | 可预测暂停模型 |
并发标记的核心困难在于「用户线程在标记期间还在改引用」,这正是上一节三色标记与写屏障要解决的问题。并发清扫(concurrent sweep)则相对安全:清扫只处理白色对象,若用户线程在清扫期间重新引用了某个白色对象,那必然是它先拿到了这个对象的引用——而拿到引用的前提是它可达,因此该对象在标记阶段就已经被标黑,不会出现在白色集合里。
def concurrent_cycle(roots, children, user_mutations):
"""并发标记: 用户线程改引用时把黑对象重新入脏集重扫."""
color = tri_color_mark(roots, children)
dirty = {owner for owner, ref in user_mutations
if color.get(owner) == BLACK and color.get(ref, WHITE) == WHITE}
return tri_color_mark(list(dirty), children) if dirty else color
分区与并发结合的代表是 G1(Garbage-First):堆被切成大小相等的 Region(默认 1~32 MB),每个 Region 动态扮演 Eden、Survivor 或 Old 角色;GC 时优先回收「垃圾最多」的 Region(这正是 Garbage-First 名字的由来),并通过停顿预测模型控制每次回收的 Region 数量,使暂停时间落在用户设定的目标(如 -XX:MaxGCPauseMillis=200)之内。ZGC 与 Shenandoah 走得更远:它们用染色指针(colored pointers)或** Brooks 转发指针**把标记与整理也做到并发,暂停时间与堆大小基本无关,代价是需要读屏障与更高的内存占用。
def g1_select_regions(regions, pause_budget):
"""G1 的选择策略: 按垃圾比例排序, 在预算内尽量多回收."""
ordered = sorted(regions, key=lambda r: r["garbage_ratio"], reverse=True)
chosen, cost = [], 0
for r in ordered:
if cost + r["cost"] > pause_budget:
break
chosen.append(r["id"])
cost += r["cost"]
return chosen, cost
regions = [{"id": i, "garbage_ratio": 0.9 - i * 0.1, "cost": 1} for i in range(5)]
print(g1_select_regions(regions, pause_budget=3))
读屏障是并发整理的额外成本。当用户线程读取一个引用时,读屏障检查该引用是否需要修正(对象已被移动,或需要帮忙完成标记),这比写屏障更频繁——每一次引用读都要付出。ZGC 用染色指针把标记位塞进 64 位指针的高位,使读屏障退化为极少的几条指令;Shenandoah 则用转发指针 + 读屏障的组合。这类设计的共同思路是:用 CPU 指令换取更短的暂停,在延迟敏感场景下这笔交易非常划算。
6. STW 暂停与调优
一句话总结: 暂停时间由存活对象量、根集大小与屏障开销共同决定,调优的本质是在吞吐、延迟与内存占用之间选一个点。
GC 调优没有「最优参数」,只有「匹配目标负载的参数」。先要明确服务的优先级:吞吐优先(批处理、离线计算,容忍长暂停)还是延迟优先(在线服务,暂停必须可控)还是内存优先(容器环境,堆不能太大)。三者的权衡是此消彼长的。
# 常用诊断: 先观察, 再调参
java -Xlog:gc*:file=gc.log:time,uptime,level,tags -jar app.jar
# 关键指标: 各代回收频率、暂停时长分布、晋升速率、堆使用曲线
# 延迟优先: 选低延迟收集器并给暂停目标
java -XX:+UseZGC -XX:MaxGCPauseMillis=10 -Xmx8g -jar app.jar
java -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -Xmx8g -jar app.jar
# 吞吐优先: 加大堆、减少回收次数
java -XX:+UseParallelGC -Xms16g -Xmx16g -jar app.jar
| 现象 | 可能原因 | 调优方向 |
|---|---|---|
| Minor GC 频繁但暂停短 | Eden 太小 | 增大新生代 |
| Full GC 频繁 | 晋升过快、老年代小 | 增大老年代、调晋升阈值 |
| 单次暂停很长 | 存活对象多、堆大 | 换并发收集器、分区回收 |
| 吞吐下降明显 | 写屏障 / 读屏障开销 | 换吞吐型收集器 |
| 内存持续增长 | 泄漏或缓存未限界 | 查引用链、用弱引用 |
| 容器中被 OOM Kill | 未感知 cgroup 限制 | 设置 -XX:MaxRAMPercentage |
# 用弱引用做缓存: 让 GC 在内存紧张时自动清理
import weakref
class Cache:
def __init__(self):
self._store = weakref.WeakValueDictionary()
def get(self, k):
return self._store.get(k)
def put(self, k, v):
self._store[k] = v # 无强引用时自动消失, 不会撑爆堆
c = Cache()
print(c.get("missing"))
还有一类问题与算法无关,纯粹是用法导致的:把大对象放进长生命周期容器(缓存、静态 Map、监听器列表)会造成「逻辑泄漏」——对象在可达性意义上活着,但业务上早已无用。定位这类问题要靠堆转储与支配树分析:jmap -dump 拿到快照,用 MAT 或 VisualVM 查看「支配树」(dominator tree)——若某个对象被移除后能连带释放大量内存,它就在支配树的深处,通常就是泄漏源头。理解了可达性分析,也就理解了这类工具为什么这么工作。
7. 语言运行时的 GC 实践
一句话总结: 不同语言的 GC 选择反映了各自的目标:Go 追求低延迟与简单,JVM 追求可调与吞吐,JavaScript 追求分代与增量,Rust 干脆取消 GC。
Go 的 GC 是并发标记清扫、非分代、非移动的。它用写屏障 + 混合屏障(hybrid barrier)实现并发标记,通过 GOGC 控制触发阈值(默认 100,即堆增长一倍触发一次),并用 GOMEMLIMIT 设定内存上限。非分代是刻意的取舍:分代需要写屏障覆盖更多场景,而 Go 更看重简单性与短暂停;非移动意味着对象地址稳定,C 互操作方便,代价是无法整理碎片。
JVM 提供了一整套可替换的收集器:Serial(单线程,适合小堆)、Parallel(吞吐优先)、CMS(已废弃的并发标记清除)、G1(分区并发,默认)、ZGC / Shenandoah(超低延迟)。这种「把选择权交给用户」的哲学,配合丰富的诊断工具与参数,使 JVM 能适应从手机到 TB 级服务器的巨大跨度。
JavaScript 引擎(V8)用分代 + 增量 + 并发标记的组合:新生代用 Cheney 复制(Scavenger),老年代用标记-清除-整理,标记分片执行避免长暂停。V8 还引入了并行标记(多线程一起标)与并发标记(标记线程与主线程并行),把暂停压缩到毫秒级。
Rust 则走向另一个极端:用所有权与生命周期在编译期证明内存安全,完全取消运行时 GC。代价是程序员必须显式表达所有权转移与借用关系,某些数据结构(双向链表、图)写起来更费劲,需要 Rc<RefCell<T>> 或 arena 等模式绕开借用检查。
| 运行时 | 收集算法 | 是否分代 | 是否移动 | 暂停目标 |
|---|---|---|---|---|
| Go | 并发标记清扫 | 否 | 否 | 亚毫秒级 |
| JVM G1 | 分区复制 + 标记整理 | 是 | 是 | 可配置(默认 200ms) |
| JVM ZGC | 并发标记 + 并发整理 | 是 | 是 | < 1ms |
| V8 | Scavenger + 标记清除整理 | 是 | 是 | 毫秒级 |
| CPython | 引用计数 + 循环检测 | 是 | 否 | 无(即时回收) |
| Rust | 无(编译期所有权) | 不适用 | 不适用 | 无 |
这些差异背后是同一个权衡三角:吞吐(单位时间完成多少工作)、延迟(单次暂停多长)、内存开销(需要多少额外空间与元数据)。任何设计都只能在这个三角里选一个位置,不可能三者同时最优。理解这一点,就不会再问「哪个 GC 最好」,而会问「我的负载更需要哪一个」。
8. 总结
| 环节 | 要点 |
|---|---|
| 可达性 | 从根集图遍历,走不到的对象即为垃圾 |
| 根集 | 栈、寄存器、全局区、TLS、运行时内部引用 |
| 标记-清除 | 不移动、有碎片,需空闲链表管理 |
| 标记-复制 | 无碎片、成本与存活量成正比、浪费半区 |
| 标记-整理 | 紧凑无碎片,但移动与指针修正成本高 |
| 分代假设 | 多数对象朝生夕死,据此分层并晋升 |
| 记忆集/卡表 | 记录跨代引用,避免 Minor GC 扫全堆 |
| 三色标记 | 白灰黑三态模型,分析并发标记的正确性 |
| 写屏障 | 增量更新或 SATB,维护黑不指白的不变式 |
| 并发 GC | 标记/清扫/整理并发化,用屏障换短暂停 |
| 暂停调优 | 在吞吐、延迟、内存之间按负载目标取舍 |
垃圾回收是「用运行时的确定性开销,换程序员的手动管理负担」。它的每一个设计决策都能追溯到同一个根源——可达性判定的时机与代价:什么时候算、和谁并行算、要不要移动对象、移动后谁来修指针。掌握了这套推理链,面对任何陌生运行时的 GC 描述,都能迅速定位它在权衡三角中的位置,以及它为此付出的具体代价(写屏障、读屏障、额外空间或吞吐)。下一篇从运行时的内存转向开发者日常接触的工具链——语言服务器与 IDE 工具,看看编译器技术如何支撑补全、跳转与重构。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。