高级 #pl#gc#memory

垃圾回收机制

垃圾回收(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 WorldGC 时程序暂停——GC 优化的主要目标
引用计数每个对象记录被引用的次数——Python 的主要方式

🎯 思考题:为什么说”分代回收”利用了”弱代假设”?如果一个程序中所有对象的生命周期都差不多长(比如一次性创建大量对象,然后全部释放)——分代回收的优势还在吗?

为什么先学这个? GC 是实现细节层面。接下来看更高层次的并发设计思想——并发编程模型(Actor, CSP)