垃圾回收(GC)是现代语言运行时的核心组件——它让程序员从手动内存管理中解放出来,但也在编译器和运行时之间引入了复杂的协作关系。编译器需要生成正确的栈映射、写屏障和安全点信息,运行时才能正确地回收内存。
GC 是这一章的重点——引用计数为什么有循环引用问题?标记-清除如何工作?分代 GC 为什么能提升性能?三色标记不变式如何保证正确性?
一、GC 的基本问题
1.1 什么是垃圾回收?
GC 的核心问题是:哪些内存可以被回收?
1.2 GC 的评价维度
| 维度 | 说明 | 权衡 |
|---|---|---|
| 吞吐量 | GC 占总时间的比例 | 高吞吐 → 低延迟 |
| 延迟 | 单次 GC 暂停时间 | 低延迟 → 低吞吐 |
| 内存开销 | GC 元数据占用 | 低开销 → 简单算法 |
| 实现复杂度 | GC 算法的实现难度 | 简单 → 功能有限 |
| 空间局部性 | 对象在内存中的布局 | 压缩 → 暂停长 |
1.3 GC 与编译器的协作
| 编译器职责 | 说明 |
|---|---|
| 栈映射 | 记录每个安全点处栈上的指针位置 |
| 写屏障 | 对象引用更新时通知 GC |
| 安全点 | 可中断的执行点,GC 可以在此发生 |
| 根枚举 | 编译器提供 GC Roots 的位置信息 |
二、引用计数(Reference Counting)
2.1 基本原理
引用计数为每个对象维护一个引用计数器——引用增加时加 1,引用减少时减 1,计数为 0 时回收:
class RefCounted: """引用计数对象"""
def __init__(self, value): self.value = value self.ref_count = 1 self.references = [] # 引用的其他对象
def add_ref(self): self.ref_count += 1
def release(self): self.ref_count -= 1 if self.ref_count == 0: # 递归释放引用的对象 for obj in self.references: obj.release() self.free()
def free(self): del self.value del self.references2.2 引用计数的问题
| 问题 | 说明 | 解决方案 |
|---|---|---|
| 循环引用 | A→B→A,计数永不为 0 | 弱引用 + 环检测 |
| 计数开销 | 每次赋值都要更新计数 | 优化:延迟引用计数 |
| 原子性 | 多线程下计数更新需原子操作 | 原子指令 |
| 递归释放 | 释放大对象链可能栈溢出 | 缓冲区 + 迭代释放 |
2.3 循环引用示例
# 循环引用问题a = RefCounted("A")b = RefCounted("B")a.references.append(b) # A → B, B.ref_count = 2b.references.append(a) # B → A, A.ref_count = 2
# 释放 a 和 b 的外部引用a.release() # A.ref_count = 1 (B 还引用 A)b.release() # B.ref_count = 1 (A 还引用 B)# 内存泄漏!A 和 B 的计数都不为 0,但已不可达三、标记-清除(Mark-Sweep)
3.1 基本原理
标记-清除分两个阶段:标记所有可达对象,清除所有未标记对象:
3.2 标记-清除的实现
class MarkSweepGC: """标记-清除垃圾回收器"""
def __init__(self): self.heap = {} # 对象 ID → 对象 self.marked = set() # 已标记的对象集合 self.roots = [] # GC Roots
def allocate(self, obj): """分配对象""" obj_id = id(obj) self.heap[obj_id] = obj return obj_id
def collect(self): """执行垃圾回收""" # 阶段 1:标记 self.marked = set() worklist = list(self.roots)
while worklist: obj = worklist.pop() obj_id = id(obj) if obj_id not in self.marked: self.marked.add(obj_id) # 将引用的对象加入工作列表 for ref in obj.get_references(): worklist.append(ref)
# 阶段 2:清除 unreachable = set(self.heap.keys()) - self.marked for obj_id in unreachable: del self.heap[obj_id]
return len(unreachable)3.3 标记-清除的优缺点
| 优点 | 缺点 |
|---|---|
| 实现简单 | 内存碎片 |
| 无循环引用问题 | STW(Stop-The-World)暂停 |
| 紧凑的内存布局 | 标记阶段需要遍历所有可达对象 |
四、标记-复制(Mark-Copy / Semi-Space)
4.1 基本原理
标记-复制将堆分为两个半空间,GC 时将存活对象从一个半空间复制到另一个:
4.2 Cheney 算法
class SemiSpaceGC: """半空间复制 GC(Cheney 算法)"""
def __init__(self, size): self.from_space = bytearray(size) self.to_space = bytearray(size) self.size = size self.free_ptr = 0
def collect(self, roots): """执行 GC""" self.free_ptr = 0 scan_ptr = 0
# 复制根对象到 To 空间 for root in roots: root.forward = self._copy(root)
# 扫描 To 空间中的对象 while scan_ptr < self.free_ptr: obj = self._get_object(scan_ptr) for ref in obj.references: ref.forward = self._copy(ref) scan_ptr += obj.size
# 交换 From 和 To 空间 self.from_space, self.to_space = self.to_space, self.from_space
def _copy(self, obj): """将对象复制到 To 空间""" if hasattr(obj, 'forward'): return obj.forward # 已复制
# 复制到 To 空间 new_addr = self.free_ptr self._write_object(new_addr, obj) self.free_ptr += obj.size
# 设置转发指针 obj.forward = new_addr return new_addr4.3 标记-清除 vs 标记-复制
| 特性 | 标记-清除 | 标记-复制 |
|---|---|---|
| 内存碎片 | 有 | 无 |
| 内存利用率 | 高 | 低(50%) |
| 分配速度 | 慢(需找空闲块) | 快(指针碰撞) |
| 移动对象 | 否 | 是 |
| 指针更新 | 不需要 | 需要 |
| 适用场景 | 大对象 | 小对象、年轻代 |
五、分代 GC
5.1 分代假说
分代 GC 基于弱分代假说:大多数对象很快就会死亡:
5.2 分代 GC 的结构
5.3 Minor GC vs Major GC
| 特性 | Minor GC | Major GC / Full GC |
|---|---|---|
| 范围 | 年轻代 | 整个堆 |
| 频率 | 高 | 低 |
| 暂停时间 | 短(毫秒级) | 长(百毫秒级) |
| 回收比例 | 高(90%) | 低(10-30%) |
| 算法 | 复制 | 标记-清除/标记-压缩 |
5.4 跨代引用
老年代可能引用年轻代对象——Minor GC 时需要扫描老年代吗?
写屏障解决了这个问题:当老年代引用年轻代对象时,通过写屏障记录这个跨代引用:
class WriteBarrier: """写屏障:记录跨代引用"""
def __init__(self): self.remembered_set = set() # 记忆集
def write_ref(self, holder, field, new_ref): """对象引用更新时的写屏障""" field.__set__(holder, new_ref)
# 如果老年代对象引用了年轻代对象 if holder.generation == 'old' and new_ref.generation == 'young': self.remembered_set.add(holder) # 记录跨代引用六、三色标记不变式
6.1 三色标记法
三色标记法是并发 GC的基础——它将对象分为三种颜色:
| 颜色 | 含义 | 说明 |
|---|---|---|
| 白色 | 未访问 | GC 结束后白色对象被回收 |
| 灰色 | 已访问但引用未处理 | 在工作列表中 |
| 黑色 | 已访问且引用已处理 | 不会被回收 |
6.2 三色不变式
并发 GC 需要保证三色不变式——防止黑色对象引用白色对象(否则白色对象会被错误回收):
| 不变式 | 条件 | 实现方式 |
|---|---|---|
| 强三色不变式 | 黑色对象不引用白色对象 | Dijkstra 写屏障 |
| 弱三色不变式 | 黑色对象引用的白色对象被灰色对象保护 | Yuasa 写屏障 |
6.3 写屏障类型
# Dijkstra 写屏障(插入屏障)# 新引用指向白色对象时,将白色对象标记为灰色def dijkstra_write_barrier(holder, field, new_ref): if is_black(holder) and is_white(new_ref): mark_gray(new_ref) # 将新引用标记为灰色 field.__set__(holder, new_ref)
# Yuasa 写屏障(删除屏障)# 删除旧引用时,将旧引用标记为灰色def yuasa_write_barrier(holder, field, new_ref): old_ref = field.__get__(holder) if is_black(holder) and is_white(old_ref): mark_gray(old_ref) # 将旧引用标记为灰色 field.__set__(holder, new_ref)6.4 写屏障对比
| 写屏障 | 类型 | 条件 | 适用场景 |
|---|---|---|---|
| Dijkstra | 插入屏障 | 新引用白色→标记灰色 | Go(旧版)、V8 |
| Yuasa | 删除屏障 | 旧引用白色→标记灰色 | Go(1.8+)、LuaJIT |
| Snapshot | 快照屏障 | 读屏障 | Azul Zing |
Go 1.5 使用 Dijkstra 写屏障,Go 1.8 切换为 Yuasa 写屏障(混合写屏障),减少了栈扫描的开销。这是 GC 设计中写屏障选择的经典案例。
6.5 三色标记的工作流程
三色标记的完整工作流程可以用一个具体例子来说明:
class TriColorMarker: # 三色标记垃圾回收器
def __init__(self): self.white = set() # 未访问 self.gray = set() # 已发现,引用未扫描 self.black = set() # 已扫描完成 self.roots = []
def mark(self): # 初始化:所有对象为白色 self.white = set(self.all_objects) self.gray = set() self.black = set()
# 根对象标记为灰色 for root in self.roots: self.white.discard(root) self.gray.add(root)
# 处理灰色对象,直到灰色集合为空 while self.gray: obj = self.gray.pop() for ref in obj.references: if ref in self.white: self.white.discard(ref) self.gray.add(ref) self.black.add(obj)
# 此时白色对象就是垃圾 return self.white6.6 分代 GC 的详细实现
分代 GC 的关键在于写屏障和记忆集——它们让 Minor GC 不需要扫描整个老年代:
记忆集的实现方式对比:
| 方式 | 精度 | 开销 | 适用场景 |
|---|---|---|---|
| Card Table | 页级(512 字节) | 低(1 字节/页) | HotSpot 默认 |
| Remembered Set | 对象级 | 高(每引用一条记录) | 需要精确扫描 |
| BitMap | 字级 | 中 | 实时 GC |
6.7 GC 对比:各语言的设计权衡
不同语言在 GC 设计上做了不同的权衡——没有最好的 GC,只有最适合的 GC:
| 语言 | GC 算法 | 分代 | 并发 | 目标 | 牺牲的 |
|---|---|---|---|---|---|
| Java ZGC | 着色指针+读屏障 | 是 | 是 | 极低延迟 | 吞吐量 |
| Java G1 | 分区+混合回收 | 是 | 是 | 延迟/吞吐平衡 | 内存开销 |
| Go | 并发三色标记清除 | 否 | 是 | 极低延迟+简单 | 吞吐量+碎片 |
| V8 | 分代+Orinoco | 是 | 是 | 浏览器响应 | 内存开销 |
| C# | 分代+压缩 | 是 | 是 | 全面性能 | 实现复杂度 |
| Python | 引用计数+分代 | 是 | 否 | 简单 | 多线程性能 |
| Ruby | 分代+三色标记 | 是 | 是 | 平衡 | 最大暂停 |
Go 选择不分代是一个有争议的决定——分代 GC 通常能减少总 GC 时间(因为大部分对象很快死亡),但分代引入了写屏障开销和实现复杂度。Go 团队认为,对于 Go 的工作负载(大量短生命周期的 goroutine),不分代的并发标记-清除已经足够好,而且实现更简单。
七、不同语言的 GC 对比
| 语言 | GC 类型 | 分代 | 并发 | 写屏障 | 典型暂停 |
|---|---|---|---|---|---|
| Java (G1) | 分代+区域 | 是 | 是 | Dijkstra | 10-200ms |
| Go | 并发标记-清除 | 否 | 是 | 混合 | <1ms |
| V8 | 分代+Orinoco | 是 | 是 | Dijkstra | 1-50ms |
| C# | 分代 | 是 | 是 | Dijkstra | <10ms |
| Python | 引用计数+分代 | 是 | 否 | — | 不可预测 |
| Rust | 无 GC | — | — | — | 0ms |
八、动手实践
8.1 实验一:观察 Java GC
# 启用 GC 日志java -Xlog:gc* -Xmx256m MyApp
# 使用不同 GCjava -XX:+UseG1GC -Xlog:gc MyApp # G1java -XX:+UseZGC -Xlog:gc MyApp # ZGC(低延迟)java -XX:+UseSerialGC -Xlog:gc MyApp # Serial(单线程)8.2 实验二:观察 Go GC
cat > gc_test.go << 'EOF'package mainimport ( "fmt" "runtime" "time")func main() { var stats runtime.MemStats for i := 0; i < 100; i++ { _ = make([]byte, 1024*1024) // 分配 1MB runtime.ReadMemStats(&stats) fmt.Printf("Alloc: %d MB, NumGC: %d\n", stats.Alloc/1024/1024, stats.NumGC) }}EOF
GODEBUG=gctrace=1 go run gc_test.go8.3 实验三:手写简单 GC
class SimpleGC: """简单的标记-清除 GC"""
def __init__(self): self.objects = {} self.roots = set()
def alloc(self, obj): oid = id(obj) self.objects[oid] = obj return oid
def gc(self): marked = set() worklist = list(self.roots)
while worklist: obj = worklist.pop() oid = id(obj) if oid in marked: continue marked.add(oid) for ref in getattr(obj, '_refs', []): worklist.append(ref)
freed = 0 for oid in list(self.objects.keys()): if oid not in marked: del self.objects[oid] freed += 1
return freed九、本章小结
在上一章中,JIT 编译让解释型语言在运行时获得了接近原生的性能,但运行时除了编译代码,还要管理内存——对象分配在堆上,谁来负责回收不再使用的对象?手动管理容易出错,自动回收则需要编译器与运行时密切协作。这就是垃圾回收要解决的问题。
| 概念 | 要点 |
|---|---|
| 引用计数 | 简单但有循环引用问题,Python 使用 |
| 标记-清除 | 无循环引用问题,但有碎片和 STW |
| 标记-复制 | 无碎片,分配快,但只用 50% 内存 |
| 分代 GC | 基于弱分代假说,年轻代频繁回收,老年代少回收 |
| 三色标记 | 白/灰/黑三色,并发 GC 的基础 |
| 写屏障 | Dijkstra(插入)、Yuasa(删除),保证三色不变式 |
| 编译器协作 | 栈映射、安全点、根枚举 |
以上就是垃圾回收的核心内容。下一章进入 V8 引擎深入,看看工业级 JIT 编译器如何实现。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






