mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2170 字
6 分钟
垃圾回收:从引用计数到分代GC
2026-02-11

垃圾回收(GC)是现代语言运行时的核心组件——它让程序员从手动内存管理中解放出来,但也在编译器和运行时之间引入了复杂的协作关系。编译器需要生成正确的栈映射、写屏障和安全点信息,运行时才能正确地回收内存。

GC 是这一章的重点——引用计数为什么有循环引用问题?标记-清除如何工作?分代 GC 为什么能提升性能?三色标记不变式如何保证正确性?

一、GC 的基本问题#

1.1 什么是垃圾回收?#

GC 的核心问题是:哪些内存可以被回收?

flowchart TB ROOTS["GC Roots<br/>栈变量/全局变量/寄存器"] --> REACH["可达对象<br/>正在使用"] REACH --> MORE["更多可达对象"] UNREACH["不可达对象<br/>垃圾"] --> COLLECT["回收内存"] style ROOTS fill:#e3f2fd,stroke:#1565c0 style REACH fill:#e8f5e9,stroke:#2e7d32 style UNREACH fill:#fce4ec,stroke:#c62828 style COLLECT fill:#fff3e0,stroke:#e65100

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.references

2.2 引用计数的问题#

问题说明解决方案
循环引用A→B→A,计数永不为 0弱引用 + 环检测
计数开销每次赋值都要更新计数优化:延迟引用计数
原子性多线程下计数更新需原子操作原子指令
递归释放释放大对象链可能栈溢出缓冲区 + 迭代释放

2.3 循环引用示例#

# 循环引用问题
a = RefCounted("A")
b = RefCounted("B")
a.references.append(b) # A → B, B.ref_count = 2
b.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 基本原理#

标记-清除分两个阶段:标记所有可达对象,清除所有未标记对象:

flowchart TB ROOTS2["GC Roots"] --> MARK1["标记阶段<br/>遍历可达对象"] MARK1 --> SWEEP["清除阶段<br/>回收未标记对象"] SWEEP --> DONE["完成"] style ROOTS2 fill:#e3f2fd,stroke:#1565c0 style MARK1 fill:#e8f5e9,stroke:#2e7d32 style SWEEP fill:#fff3e0,stroke:#e65100

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 时将存活对象从一个半空间复制到另一个:

flowchart LR subgraph From["From 空间(GC 前)"] A1["A "] B1["B "] C1["C "] D1["D "] end subgraph To["To 空间(GC 后)"] A2["A"] C2["C"] FREE["空闲空间<br/>(无碎片)"] end From -->|复制存活对象| To style From fill:#fff3e0,stroke:#e65100 style To fill:#e8f5e9,stroke:#2e7d32

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_addr

4.3 标记-清除 vs 标记-复制#

特性标记-清除标记-复制
内存碎片
内存利用率低(50%)
分配速度慢(需找空闲块)快(指针碰撞)
移动对象
指针更新不需要需要
适用场景大对象小对象、年轻代

五、分代 GC#

5.1 分代假说#

分代 GC 基于弱分代假说:大多数对象很快就会死亡:

graph LR subgraph 对象生命周期分布 YOUNG["年轻对象<br/>90% 很快死亡"] --> OLD["老对象<br/>10% 长期存活"] end style YOUNG fill:#fce4ec,stroke:#c62828 style OLD fill:#e8f5e9,stroke:#2e7d32

5.2 分代 GC 的结构#

flowchart TB HEAP["堆"] --> YOUNG_GEN["年轻代<br/>Eden + Survivor"] HEAP --> OLD_GEN["老年代<br/>Tenured"] YOUNG_GEN --> EDEN["Eden<br/>新对象分配"] YOUNG_GEN --> SURV1["Survivor 0"] YOUNG_GEN --> SURV2["Survivor 1"] EDEN -->|Minor GC| SURV1 SURV1 -->|存活多次| OLD_GEN style HEAP fill:#e3f2fd,stroke:#1565c0 style YOUNG_GEN fill:#fff3e0,stroke:#e65100 style OLD_GEN fill:#e8f5e9,stroke:#2e7d32

5.3 Minor GC vs Major GC#

特性Minor GCMajor 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 结束后白色对象被回收
灰色已访问但引用未处理在工作列表中
黑色已访问且引用已处理不会被回收
flowchart TB ROOTS3["GC Roots"] --> GRAY1["灰色对象<br/>已发现,引用未扫描"] GRAY1 --> BLACK["黑色对象<br/>已扫描完成"] GRAY1 --> WHITE["白色对象<br/>未发现"] BLACK --> GRAY2["灰色对象"] GRAY2 --> WHITE2["白色对象"] style GRAY1 fill:#fff3e0,stroke:#e65100 style GRAY2 fill:#fff3e0,stroke:#e65100 style BLACK fill:#333333,stroke:#000,color:#fff style WHITE fill:#ffffff,stroke:#999 style WHITE2 fill:#ffffff,stroke:#999

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
Note

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.white

6.6 分代 GC 的详细实现#

分代 GC 的关键在于写屏障记忆集——它们让 Minor GC 不需要扫描整个老年代:

flowchart TB WRITE["写入操作 obj.field = new_ref"] --> CHECK_GEN["跨代引用?"] CHECK_GEN -->|"老->新"| BARRIER["写屏障触发 记录到记忆集"] CHECK_GEN -->|"否"| NORMAL["正常写入"] BARRIER --> CARD["Card Table 分页标记"] BARRIER --> REMSET["Remembered Set 精确记录"] MINOR["Minor GC"] --> SCAN_ROOTS["扫描 GC Roots"] SCAN_ROOTS --> SCAN_CARD["扫描 Card Table 脏页中的老年代对象"] SCAN_CARD --> COPY_YOUNG["复制年轻代存活对象"] style WRITE fill:#e3f2fd,stroke:#1565c0 style BARRIER fill:#fff3e0,stroke:#e65100 style COPY_YOUNG fill:#e8f5e9,stroke:#2e7d32

记忆集的实现方式对比:

方式精度开销适用场景
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分代+三色标记平衡最大暂停
Note

Go 选择不分代是一个有争议的决定——分代 GC 通常能减少总 GC 时间(因为大部分对象很快死亡),但分代引入了写屏障开销和实现复杂度。Go 团队认为,对于 Go 的工作负载(大量短生命周期的 goroutine),不分代的并发标记-清除已经足够好,而且实现更简单。

七、不同语言的 GC 对比#

语言GC 类型分代并发写屏障典型暂停
Java (G1)分代+区域Dijkstra10-200ms
Go并发标记-清除混合<1ms
V8分代+OrinocoDijkstra1-50ms
C#分代Dijkstra<10ms
Python引用计数+分代不可预测
Rust无 GC0ms

八、动手实践#

8.1 实验一:观察 Java GC#

# 启用 GC 日志
java -Xlog:gc* -Xmx256m MyApp
# 使用不同 GC
java -XX:+UseG1GC -Xlog:gc MyApp # G1
java -XX:+UseZGC -Xlog:gc MyApp # ZGC(低延迟)
java -XX:+UseSerialGC -Xlog:gc MyApp # Serial(单线程)

8.2 实验二:观察 Go GC#

cat > gc_test.go << 'EOF'
package main
import (
"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.go

8.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 编译器如何实现。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

垃圾回收:从引用计数到分代GC
https://blog.souloss.com/posts/compiler/garbage-collection/
作者
Souloss
发布于
2026-02-11
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时