垃圾回收机制
垃圾回收(Garbage Collection, GC)自动回收不再使用的内存——程序员不用手动 free/delete。标记-清除、复制、分代回收是三种主要算法
🧹 自习室关门时的”清场”
晚上 10 点,自习室要关门了。管理员需要清场——但怎么区分”有人在用”和”没人要了”的座位?
方法一:管理员挨个看每个人的学生证,标记出”还在的同学”。所有没标记的座位上的东西就是”垃圾”,清走。
方法二:管理员先假设所有人都走了。然后喊”张三到了吗?”——“到了!“——好,张三的座位不是垃圾。再喊”李四到了吗?“——没人应——李四座位的垃圾清走。
这就是垃圾回收的核心思想:从”根”出发,找到所有”可达”的对象,剩下的都是垃圾。
📐 垃圾回收(Garbage Collection, GC):自动管理内存的机制——程序员不再需要手动
free()或delete,语言运行时自动回收不再使用的内存。
🌱 什么是”垃圾”?
在自动内存管理的语言中,内存中的对象只要有引用(Reference) 指向它,就是”活着的”(可达的)。当没有引用指向它时——它就成了垃圾(Garbage)。
Eden(新创建的对象):
[对象A] → [对象B] → [对象C]
↑ ↑
root(全局变量) 容器 List → [对象C]
此时 A 和 C 是"活的"(有引用指向它们),B 是"垃圾"(没有引用指向它)。
↓ GC 后
[对象A] → [对象C]
↑
root 容器 List
B 被回收了
根对象(Root):程序中”天生的”活跃引用——全局变量、栈上的局部变量、寄存器中的引用。
🔧 三种经典 GC 算法
算法 1:标记-清除(Mark-Sweep)
思路:分两步——先标记所有”活着的”对象,然后清除所有没被标记的。
第 1 步——标记(Mark):
从根对象出发,沿着引用遍历所有可达对象,给它们打上标记
第 2 步——清除(Sweep):
遍历整个堆,把所有没标记的对象释放掉,把标记清除(以备下次使用)
# 标记-清除的简化伪代码
def mark_sweep(root):
# 标记阶段
mark(root)
# 清除阶段
for each object in heap:
if not object.marked:
free(object)
else:
object.marked = False # 清除标记
def mark(obj):
if obj.marked:
return
obj.marked = True
for child in obj.references:
mark(child)
优点:实现简单,不需要移动对象。 缺点:产生内存碎片(空闲内存被分割成小块);GC 执行时”Stop The World”(暂停程序)。
💡 内存碎片就像宿舍衣柜——你把不穿的衣服扔了(清除),但留下了一些小空隙(碎片)。后来想放一件大衣,发现没有一整块够大的空间。
算法 2:复制(Copying)
思路:把堆分成两半(From 空间和 To 空间)。只在一半上分配对象,满了就把活对象”复制”到另一半,然后整半清除。
GC 前: GC 后:
From: [A][B][C][D] From: (空)
To: (空) To: [A][C](只复制活对象)
A 和 C 有引用 → 复制到 To 空间
B 和 D 没有引用 → 不复制(相当于回收)
然后 From 整个清空,交换 From 和 To 的角色
优点:没有碎片(紧密排列);分配速度快(指针碰撞)。 缺点:内存利用率减半;长生命周期对象被反复复制。
算法 3:分代回收(Generational Collection)
思路:大多数对象很快就死了——这个观察是 GC 性能的关键(称为”弱代假设”)。
对象按"年龄"分到不同区域:
┌─────────┐ ┌─────────┐ ┌─────────┐
│ 年轻代 │ │ 老年代 │ │ 永久代 │
│(Young) │→│(Old) │→│(Perm) │
│ 频繁 GC │ │ 少 GC │ │ 几乎不 GC │
└─────────┘ └─────────┘ └─────────┘
- 年轻代(Young Generation):新对象分配在这里。空间小,GC 频繁但快
- 老年代(Old Generation):熬过多次 GC 的对象晋升到这里。空间大,GC 次数少
- 永久代(Permanent Generation):类元数据、方法信息等
// JVM 的 GC 日志(直观理解分代)
// [GC (Allocation Failure) [DefNew: 2048K->256K(3072K), 0.003s]
// 年轻代:2MB → 256KB,只花了 3ms
//
// [Full GC [Tenured: 4096K->2048K(8192K), 0.1s]
// 老年代 GC:花了 100ms(慢多了)
🏢 实际 GC 实现
Java(HotSpot JVM)
// G1 GC——Java 9 起的默认 GC
// 把堆分成多个 Region(区域),
// 优先回收垃圾最多的 Region(Garbage First 的名字来源)
// 目标:可预测的暂停时间
Java 的 GC 发展史:
Serial GC(串行,单线程)
→ Parallel GC(并行,多线程)
→ CMS(并发标记清除)
→ G1(分区,可预测暂停)
→ ZGC(超低延迟,TB 级堆)
Go 的三色标记法
// Go 的 GC 使用三色标记 + 并发执行
// 颜色含义:
// 白色:未访问的对象(可能是垃圾)
// 灰色:已访问但子对象未访问
// 黑色:已访问且子对象已访问
// 步骤:
// 1. 开始时所有对象都是白色
// 2. 从根对象出发,把直接引用的对象标记为灰色
// 3. 从灰色对象中选一个,把它的子对象标记为灰色,自己变成黑色
// 4. 重复步骤 3,直到没有灰色对象
// 5. 剩余白色对象就是"垃圾"
Python 的引用计数 + 分代回收
# Python 主要使用引用计数(Reference Counting)
# 每个对象记录了有多少引用指向它
# 引用计数归零时立即回收
# 引用计数的局限:循环引用
a = []
b = []
a.append(b)
b.append(a) # 互相引用——引用计数永远不会归零
# 所以 Python 补充了"分代回收"来处理循环引用
import gc
gc.collect() # 手动触发 GC
📊 引用计数 vs 追踪式 GC:
- 引用计数(Python、PHP):立即回收(无延迟)、但处理循环引用麻烦
- 追踪式 GC(Java、Go、C#):定时暂停”STW”、但能处理所有情况,吞吐量更高
⏸️ Stop The World(STW)
不管哪种 GC 算法,都存在Stop The World(STW,暂停所有程序线程) 的问题——GC 执行期间程序被冻结。
程序运行时间线:
──[运行]──[GC暂停]──[运行]──[GC暂停]──[运行]──
GC 优化的目标:减少 STW 时间
- CMS:并发标记(大部分工作和程序同时运行)
- ZGC:几乎不暂停(毫秒级)
- Go GC:从 1.5 版本后,目标 STW < 10ms
📝 小结
| 概念 | 一句话 |
|---|---|
| GC(垃圾回收) | 自动回收不再使用的内存 |
| 标记-清除 | 标记可达对象 → 清除不可达对象 |
| 复制 | 活对象复制到新空间 → 整半清除 |
| 分代回收 | 大多数对象很快死亡——年轻代/老年代分开处理 |
| 三色标记 | 白/灰/黑三色标记法——并发 GC 的基础 |
| Stop The World | GC 时程序暂停——GC 优化的主要目标 |
| 引用计数 | 每个对象记录被引用的次数——Python 的主要方式 |
🎯 思考题:为什么说”分代回收”利用了”弱代假设”?如果一个程序中所有对象的生命周期都差不多长(比如一次性创建大量对象,然后全部释放)——分代回收的优势还在吗?
为什么先学这个? GC 是实现细节层面。接下来看更高层次的并发设计思想——并发编程模型(Actor, CSP)。