算法的故事 / 全书目录0 / 30 章已读

第 22 章

垃圾回收——1960年,为什么让计算机自动释放内存,反而创造了更复杂的算法?

正在加载交互实验…

第一季 · 人类如何驯服数据历史主线:1958—2026年 · Lisp、McCarthy、引用计数、标记—清除、复制式回收与分代垃圾回收1960年4月,一篇论文出现在《Communications of the ACM》上。作者是32岁的计算机科学家 John McCarthy。论文题目是:Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I《符号表达式的递归函数及其机器计算,第一部分》。论文介绍了一种为人工智能研究设计的新语言:LISP。在这种语言中,程序不只是处理数字,还需要不断创建、修改和连接符号表达式。例如:
lisp
(A (B C) D)
这样的表达式,可以通过一系列相互引用的链表单元来表示。但这带来了一个特别现实的问题。如果程序每秒创建成千上万个链表节点,什么时候才能知道其中某个节点已经不再需要?你当然可以要求程序员手动释放。但 Lisp 的递归函数可能创建复杂的动态结构,引用关系也会在运行过程中不断变化。让程序员准确追踪每一个节点的生死,既繁琐又容易出错。McCarthy 的解决方案是:让运行时系统自己判断哪些对象仍然能够被程序访问。无法访问的对象,就可以重新利用其内存。这项技术后来成为现代程序语言的重要基础:Garbage Collection,垃圾回收,简称 GC。McCarthy 的论文发表于1960年4月,第3卷第4期,第184—195页。MIT 档案还保存了1959年3月的早期 Lisp 研究备忘录。[MIT开放学术][dr. heap references][The Medley Interlisp Project]我们今天要研究的问题是:计算机怎么知道一个对象已经没用了?更进一步:如果两个对象互相引用,却没有任何程序能够再找到它们,那么它们究竟算活着,还是已经死亡?

第一幕:Lisp 为什么比普通数值计算更需要自动内存管理?

John McCarthy(1927—2011)

Lisp 设计者 · 人工智能领域的奠基人物之一他也是1956年达特茅斯人工智能研究计划的重要组织者,并在1960年代继续推动分时系统和人工智能语言研究。
在我们第一章学习链表时,已经认识了一个基本节点:
python
class Node:
    def __init__(self, value, next=None):
        self.value = value
        self.next = next
Lisp 早期的基本数据结构则是 cons cell,即具有两个关联部分的单元。传统上,这两部分叫:car和cdr。它们的名称来自 IBM 704 计算机的历史指令与寄存器结构。每个 cons cell 可以引用其他 cons cell,形成复杂的列表与树。例如表达式:
lisp
(A B C)
可以理解为:
三个 cons cell 组成的链
A•B•C•每个单元包含数据及指向后续单元的引用
查看 SVG 配图原件
示意图原件 22-003.svg
22-003.svg · 点击查看大图
假设程序不断创建新列表。旧列表可能被变量引用。也可能被另外一层列表引用。还可能在递归调用时被某个临时变量保存。这时,判断某个节点能否释放,就不只是检查一个 next 指针的问题。你必须知道:是否还有一条引用路径,能够从程序正在使用的变量或运行状态抵达这个节点?这就引出了 GC 最重要的概念:可达性(Reachability)。

第二幕:一个对象是否活着,不取决于它有没有指向别人

我们构造一组对象:A、B、C、D、E、F。其中:A 指向 B。B 指向 C。D 指向 E。E 又指向 D。F 没有引用其他对象。程序当前有一个全局变量:
text
root → A
那么从 root 出发,可以找到:A。B。C。但无法找到:D。E。F。因此,在以 root 为唯一根的图模型里:A、B、C 可达。D、E、F 不可达。即使 D 和 E 互相引用,它们仍然属于垃圾。

关键规则

对象是否需要保留,取决于它能否从 GC Root 出发被找到,而不是它的引用计数是否大于0。GC Roots 可以包括程序栈中的活动引用、全局变量、运行时持有的强引用,以及其他具体语言运行时定义的根。这也是为什么垃圾回收与图论关系极其密切。从根节点开始遍历对象引用图。所有访问得到的节点,都标记为存活。遍历不到的节点,就是可回收候选。

实验一:你来切断引用,看看哪些对象成为垃圾

对象可达性实验室

从根集合出发,搜索引用图
3/6 存活
哪些对象被程序根变量直接引用?
AROOTBCDEF
可达对象A、B、C
不可达对象D、E、F
试着取消 Root → A,再添加 Root → D。观察 D 和 E 会一起成为可达对象,即使它们构成循环。
这个实验已经揭示了标记—清除垃圾回收算法的核心。而且你应该已经发现:垃圾回收,本质上可以转换成一次图搜索。我们在第六章学习的 DFS、BFS,终于又找到了一个实际应用。

第三幕:1960年,McCarthy 的标记—清除算法

McCarthy 在1960年的 Lisp 论文中描述了早期存储回收过程。当自由存储链表耗尽时,系统可以执行回收。基本思想是:从活动引用出发,标记仍然可访问的存储单元。再检查分配区,把未标记的存储单元重新加入可用存储结构。后来,人们把这一思想称为:Mark and Sweep,标记—清除。 [dr. heap references][Simon Dobson]

第一阶段:Mark

找到所有 GC Roots。然后从 Roots 开始遍历对象图。凡是能够到达的对象,都做上存活标记。

第二阶段:Sweep

遍历受管理的堆空间。已标记的对象予以保留,并清理其临时标记。未标记的对象可以释放,重新用于后续内存分配。
Mark–Sweep 算法的三个状态
回收前
A
B
C
D
E
F
标记后
A
B
C
D
E
F
清除后
A
B
C
空
空
空
教学示意:假设只有 A、B、C 可从根抵达,D、E、F 被回收。

Python 版简化实现

我们可以用一个字典表示对象图。
python
objects = {
    "A": ["B"],
    "B": ["C"],
    "C": [],
    "D": ["E"],
    "E": ["D"],
    "F": [],
}

roots = ["A"]
先写标记函数:
python
def mark(objects, roots):
    reachable = set()
    stack = list(roots)

    while stack:
        node = stack.pop()

        if node in reachable:
            continue

        if node not in objects:
            continue

        reachable.add(node)
        stack.extend(objects[node])

    return reachable
再清除不可达节点:
python
def collect(objects, roots):
    reachable = mark(objects, roots)

    garbage = [
        node
        for node in objects
        if node not in reachable
    ]

    for node in garbage:
        del objects[node]

    return garbage
执行:
python
removed = collect(objects, roots)

print(removed)
# ['D', 'E', 'F']

print(objects)
# {'A': ['B'], 'B': ['C'], 'C': []}
这个模拟没有真正操作原始内存地址,但已经完整表达了图可达性规则。

时间复杂度

设:VV 为对象数量。EE 为对象引用关系数量。Mark 过程可以使用 DFS 或 BFS,成本为:O(Vreachable+Ereachable)O(V_{\text{reachable}}+E_{\text{reachable}})Sweep 需要扫描受管理的对象或分配块,通常还要计入:O(V)O(V)因此在简单图模型下,总复杂度可以写作:O(V+E)\boxed{O(V+E)}但真实运行时的成本还取决于堆空间大小、对象布局、标记元数据、缓存局部性和内存分配器结构。

第四幕:为什么引用计数如此简单,却解决不了循环引用?

现在我们介绍另一种重要的内存管理方法。Reference Counting,引用计数。它的思想非常直接:每个对象维护一个计数。如果有其他变量或对象引用它,计数增加。引用被移除,计数减少。当计数变成0,就立即释放。例如:
python
a = SomeObject()
b = a

del a
del b
在典型引用计数模型中:创建 a 后有一个强引用。b = a 又增加一个引用。del a 移除其中一个引用。del b 移除最后一个引用。当引用计数下降到0,对象就可以释放。听起来简直完美。因为不一定要等待完整的堆扫描。但有一个严重缺陷。

两个对象互相引用

假设:A 指向 B。B 指向 A。程序有一个变量 root 指向 A。此时:A 至少有来自 root 和 B 的引用。B 有来自 A 的引用。现在删除 root。程序再也无法找到 A 或 B。但 A 仍然被 B 引用。B 仍然被 A 引用。二者引用计数都大于0。所以纯引用计数不会自动释放它们。这就形成:循环垃圾(Cyclic Garbage)。

引用计数的循环陷阱

Aref = 1Bref = 1root 已经被删除引用计数不为0,但从根已不可达
查看 SVG 配图原件
示意图原件 22-004.svg
22-004.svg · 点击查看大图
这是理解垃圾回收历史的分水岭。有引用,并不意味着对象仍然有用。真正决定能否继续使用的是可达性。引用计数是一种局部信息。可达性则需要考虑整个引用图。因此,引用计数与追踪式垃圾回收在原理上解决的问题不同。

第五幕:引用计数和追踪式回收,谁更好?

这不是一道可以简单回答的选择题。
维度引用计数标记—清除
基本判断引用数量是否为0是否能从根访问
普通对象释放时机最后引用消失时通常可立即释放等待某次回收
循环引用纯引用计数无法解决能够处理不可达引用环
运行时开销引用变化时需要维护计数回收周期中需要遍历对象
潜在暂停释放对象链可能触发级联工作简单实现可能需要暂停程序
额外问题引用更新、并发计数成本标记成本、碎片、暂停时间
实际上,现代语言运行时并不一定只选择其中一种。比如 CPython 长期结合了:引用计数 + 循环垃圾收集器。引用计数负责及时释放大量不再使用的对象。循环收集器则负责发现那些因为互相引用而无法仅靠计数释放的容器对象。这也是 CPython 内部开发文档明确说明的设计。[GitHub]

Python 里真的能制造循环引用吗?

当然可以。
python
import gc

a = []
b = []

a.append(b)
b.append(a)

del a
del b

collected = gc.collect()

print("GC collected:", collected)
上面两个列表相互引用。删除变量 a 和 b 后,如果没有其他外部引用,两个列表形成一个不可达循环。CPython 的循环垃圾收集器通常能够发现并回收它们。不过,gc.collect() 的返回值会受运行时其他待收集对象影响,不应假设一定只返回2。

一个特别容易产生误解的问题

Python 中执行:
python
del a
并不等于:“直接销毁 a 所引用的对象。”del a 删除的是名称绑定。如果还有其他强引用指向该对象,它仍然可能存活。即使最后一个引用已经消失,对象的最终清理行为也可能受到实现、终结器以及运行时管理策略影响。因此,学习 Python 时,把变量名和对象本身分开理解,非常重要。

第六幕:1960年代末,新的思路出现——不清扫垃圾,而是搬走活对象

标记—清除已经能够处理循环引用。但它还有一个问题:内存碎片(Fragmentation)。假设内存有十个连续槽位。里面分配了若干对象。垃圾回收之后,一些槽位空了。但空闲空间可能分散在多个地方。
清除垃圾后,空闲位置可能分散
A
空
B
空
C
空
D
空
E
空
虽然总共有五个空闲槽位,但它们并不连续。如果一个新对象需要三个连续槽位,就可能无法直接放入。于是研究者提出:为什么不把仍然存活的对象复制到一块新的连续区域?这样,新的空间中只有活对象。垃圾根本不必单独释放。

Copying Collector:复制式垃圾回收

把堆划分成两个区域:From-space。To-space。程序平时在 From-space 中分配对象。回收时,从根开始找到存活对象,把它们复制到 To-space。复制过程中,必须更新相关引用,让指针指向新地址。最终:From-space 中的旧对象全部废弃。To-space 里只留下存活对象。两个空间交换角色。这种思想在1960年代的研究中逐步形成;Fenichel 与 Yochelson 在1969年描述了具有两个半空间的复制式回收方法,Cheney 在1970年提出了著名的非递归复制收集算法。更早的相关复制式回收思想也见于 Marvin Minsky 的研究。[users.notam02.no]

实验二:复制存活对象如何消除碎片?

复制式 GC 实验室

0/4 已复制
From-space:原始分配区
A
X
B
Y
C
Z
D
W
A、B、C、D 存活;X、Y、Z、W 不可达。
To-space:新空间
·
·
·
·
·
·
·
·
下一步复制 A,并在概念上更新指向它的引用。
这种方法有一个特别漂亮的特点。它的主要复制工作量与存活对象的大小密切相关。如果大量对象都已经死亡,就不需要逐个移动那些垃圾对象。但代价同样明显。在经典双半空间实现中,必须预留一块目标空间,导致可直接用于对象分配的有效空间受到限制。此外,复制对象需要更新指针,还涉及暂停时间、对象身份与外部引用等工程问题。

第七幕:1983年,研究者发现——大多数对象年轻时就死去了

现在进入垃圾回收发展史上另一个重要突破。假设你观察一台长期运行的服务。程序不断创建临时对象。例如:HTTP 请求解析结果。短生命周期的字符串。循环中的临时列表。一次查询的中间结果。很多对象只使用几毫秒,之后就再也访问不到。但另一些对象:全局配置。缓存。长期保持的连接管理结构。则可能存活几分钟甚至几小时。于是研究者提出:既然年轻对象通常更容易死亡,为什么不把年轻对象和长期存活对象分开管理?1983年,Henry Lieberman 和 Carl Hewitt 发表:A Real-Time Garbage Collector Based on the Lifetimes of Objects。这项工作是分代垃圾回收发展史上的经典文献之一。其核心思想与后来广泛讨论的弱分代假说密切相关:Most objects die young.即,大多数对象在年轻时就会变成垃圾。

分代垃圾回收(Generational GC)

我们可以把对象按年龄分组。年轻代(Young Generation)。老年代(Old Generation)。新创建对象通常先进入年轻代。经历若干次回收仍然存活的对象,可以逐渐晋升到老年代。因为年轻对象死亡比例通常较高,系统可以更频繁地回收年轻代,而减少对老年代的大规模扫描。

为什么这可能更高效?

假设一个系统中有100万个对象。其中:80万个是长期存活对象。20万个是最近创建的临时对象。如果每次回收都完整遍历全部100万个对象,成本可能很高。但如果大多数新创建对象很快死亡,就可以把大量回收工作集中在年轻代。这样可以减少某些收集周期的扫描量。当然,跨代引用会让问题变复杂。例如:一个老年代对象突然指向一个新生代对象。如果年轻代回收只检查年轻对象自身,就可能错误地把这个仍然被老年代引用的对象当成垃圾。因此,需要记录从老年代指向年轻代的引用。现代分代收集器通常会使用写屏障(Write Barrier)、卡表(Card Table)或其他 remembered set 机制处理这类关系。分代 GC 不是简单把内存划成两个区域,而是用额外的引用跟踪机制,换取更高效的局部回收。

第八幕:Java 的 GC 为什么会出现暂停?

现在让我们把历史引向现代语言运行时。在 Java 虚拟机中,堆垃圾回收通常由 JVM 管理。程序员不必像 C++ 中那样,为每个普通 Java 对象显式调用 delete。但这并不意味着垃圾回收没有成本。

最直接的做法:暂停程序

设想 GC 正在检查对象图。与此同时,应用线程突然修改了一条引用。例如:原来:A → B。现在改成:A → C。如果收集器没有妥善协调这些变化,它可能遗漏新引用,错误地回收仍然存活的对象。最简单的办法是:暂停所有需要协调的应用线程。扫描引用图。完成必要的回收。再恢复应用程序。这叫作:Stop-the-World,STW。暂停能够简化某些一致性问题,但会影响响应延迟。

为什么不能只追求吞吐量?

假设服务器每秒处理1000个请求。其中绝大多数请求在5毫秒内完成。但垃圾回收偶尔造成一次200毫秒暂停。平均响应时间可能仍然很好看。可一些用户恰好遇到暂停,就会明显感觉服务卡顿。因此现代 GC 经常需要在三个目标之间权衡:吞吐量(Throughput)。暂停时间(Pause Time)。内存占用(Memory Footprint)。没有一种回收器可以在所有场景下同时最优。

并发与增量式回收

为了降低长时间暂停,现代运行时发展出:增量式标记。并发标记。并行回收。区域化堆管理。低暂停复制与整理。但这里必须强调:并发垃圾回收不等于完全没有暂停。在很多实现中,仍然需要某些短暂的同步阶段。而且并发执行会带来额外 CPU 消耗和引用屏障成本。这也是为什么高性能服务需要结合对象分配速率、存活数据量和延迟目标进行 GC 调优,而不是盲目追求“零暂停”。

第九幕:Python 与 Java,到底采用了哪一种垃圾回收?

这是一个容易被简化过度的问题。

CPython:引用计数为主,循环收集为辅

在常见的 CPython 实现中,大量对象因为引用计数归零而及时释放。另外还有一个针对容器引用循环的垃圾收集机制。CPython 当前内部文档也明确指出,引用计数是主要对象释放机制,而循环垃圾收集器负责处理无法仅靠引用计数回收的引用环。[GitHub]特别有意思的是,Python 3.14 的循环垃圾收集器曾经历实现调整。3.14.0—3.14.4 采用过新的增量式方案,随后由于生产环境中的内存压力问题,从3.14.5起恢复到此前的分代方案。官方变更文档明确记录了这一变化。[Python 3.15.2 documentation]这段近期工程史提醒我们:即使一种算法在理论上可以降低暂停时间,也需要验证它在实际内存占用和复杂工作负载中的表现。

Java:不同 GC 实现采用不同策略

Java 没有规定所有 JVM 必须使用同一种垃圾回收算法。例如 HotSpot JVM 长期提供多种收集器,侧重点不同。一些收集器重视高吞吐量。一些收集器重视低暂停。一些采用分代设计。还有一些为了处理大堆和低延迟,引入并发标记、区域划分与对象移动等机制。因此不能简单地说:“Java 使用 Mark–Sweep。”或者:“Java 一定采用复制式回收。”更准确的说法是:Java 的垃圾回收由具体 JVM 实现与选定收集器决定,现代收集器往往组合多种基础算法思想。

C++:默认由程序结构管理对象生命周期

C++ 通常不依赖一个通用的追踪式垃圾收集器来管理所有对象。它更强调:对象所有权。作用域生命周期。RAII。智能指针。例如:
cpp
#include <memory>

struct Node {
    int value;
};

int main() {
    auto p = std::make_unique<Node>();
    p->value = 100;
}
当 p 离开作用域时,其持有的对象会自动销毁。这不是追踪式垃圾回收。而是基于所有权和确定性对象生命周期的管理方式。但 C++ 也存在循环引用问题。例如:两个 shared_ptr 相互持有对方,可能导致引用计数无法归零。这时通常需要使用 std::weak_ptr 打破拥有关系。所以,“有没有 GC”并不能代替对对象所有权的设计。

第十幕:为什么有垃圾回收的程序,仍然可能内存泄漏?

这是本章最重要的工程问题之一。很多程序员会有一种直觉:既然语言能够自动回收垃圾,那就不会再发生内存泄漏。事实上并非如此。GC 回收的是:不可达对象。但如果程序仍然保存着某个对象的强引用,即使业务上已经不再需要它,GC 也会认为它必须存活。例如:
python
cache = []

def handle_request(request):
    result = {
        "request": request,
        "payload": "x" * 1_000_000,
    }

    cache.append(result)
如果这个服务每秒处理100个请求,并且一直把结果追加到全局列表中:cache那么列表会越来越大。即使这些历史结果再也不会被实际查询,GC 也不能随意回收。为什么?因为全局变量 cache 仍然能够访问它们。从可达性规则看,它们都是活对象。从业务需求看,它们可能早就没有价值。这就是:逻辑上的内存泄漏。与 C/C++ 中忘记 free 或 delete 不完全相同,但可能造成类似的内存持续增长结果。

解决方法是什么?

不是简单地频繁调用:
python
gc.collect()
因为如果这些对象依然可达,调用 GC 也无法回收它们。真正需要修复的是:不再无条件保留所有历史对象。例如为缓存设置容量上限。添加过期策略。明确对象所有权。或者在业务完成后清除不再需要的强引用。这就与我们第十九章学习的缓存替换算法形成了直接联系。LRU、LFU、TTL 等策略,不只是性能优化机制。它们还可以防止某些缓存无限增长。垃圾回收器不会替应用程序决定,哪些业务数据已经没有价值。

第十一幕:垃圾回收是不是一种“自动求解程序未来”的算法?

现在我们提出一个更加深刻的问题。假设某个对象目前能够从根访问。但程序以后再也不会访问它。GC 能否现在就判断它实际上没有价值,然后提前释放?在一般情况下,这不是普通垃圾回收器能够安全完成的事情。因为它需要预测未来的程序行为。而任意程序是否会在未来访问某个对象,可能涉及复杂的程序分析,不能普遍精确判定。因此,主流追踪式 GC 往往采用一种保守的存活原则:只要从根可达,就认为它需要保留。这可能保留某些业务上已经不再使用的对象。但优点是避免错误释放仍然可能被程序访问的对象。这里需要注意一个理论区别。“不可达”是判断垃圾的充分条件之一。而“实际上以后永远不会再访问”,是更强的语义概念。后者不等于普通 GC 能够轻易判断的引用图可达性。这也揭示了垃圾回收的工程智慧:不是努力预测程序全部未来。而是采用一种可以高效计算、且在适当内存模型下安全的近似存活准则。

终章挑战:两个对象互相引用,却没有任何变量指向它们

假设程序创建了两个对象:A → B。B → A。随后,所有外部根引用都被移除。那么:
请选择正确结论

尾声:McCarthy 让程序员摆脱了释放节点的负担,却创造了一门新的算法学科

1958年前后,John McCarthy 和 MIT 的人工智能研究团队设计 Lisp。1960年,McCarthy 正式发表论文,介绍符号表达式、递归程序和早期自动存储回收机制。随后,研究者不断改进垃圾回收:从标记—清除,到引用计数。从复制式回收,到分代回收。从全局暂停,到增量与并发执行。直到今天,垃圾回收仍然是语言运行时、虚拟机和服务器性能工程的重要组成部分。但这段历史最值得记住的思想,不是“自动释放内存很方便”。而是:对象有没有价值,并不能简单地由它创建了多久、被引用了多少次,或者占用了多少字节决定。GC 首先需要明确一种可计算的存活定义。然后根据对象引用关系找到应该保留的部分。再通过不同的内存布局、分代策略和并发技术,控制回收成本。你会发现,我们之前学习过的很多算法都在这里重新出现:DFS 和 BFS 负责遍历引用图。链表与空闲表负责管理可分配的存储。引用计数体现局部状态维护。复制式回收改变内存布局。分代回收利用对象生命周期的统计规律。而并发 GC 又把我们带回上一章的锁、线程协调与同步问题。垃圾回收不是自动调用 free(),而是一套关于可达性、对象生命周期、存储布局与并发正确性的算法体系。

第二十二章知识总结

概念核心思想
Garbage Collection自动识别并回收不再需要的存储
GC Roots程序中用于追踪活动对象的根引用集合
Reachability从根出发能否沿引用路径到达对象
Mark–Sweep标记可达对象,清除不可达对象
Reference Counting通过引用数量管理对象释放
Cyclic Garbage互相引用但整体不可达的对象
Copying Collector复制存活对象,形成连续可分配空间
Generational GC根据对象存活年龄分组回收
Write Barrier跟踪可能影响 GC 正确性的引用修改
Stop-the-World暂停应用线程以执行某些回收阶段
Logical Memory Leak对象仍可达,但业务已不需要
RAIIC++ 中利用对象生命周期管理资源

本章历史与技术资料

McCarthy(1960):《Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I》MIT 档案:McCarthy 1959年 Lisp 原始研究备忘录Lieberman、Hewitt(1983):《A Real-Time Garbage Collector Based on the Lifetimes of Objects》CPython 官方内部文档:引用计数与循环垃圾回收的设计Python 官方文档:gc 模块、回收统计与代际管理Python 3.14 官方变更记录:增量 GC 与后续回退
下一章预告

第二十三章:一致性模型——1979年,为什么两台数据库都说“写入成功”,用户却读到了不同答案?

1979年,Leslie Lamport 发表了关于多处理器系统中共享数据正确性的研究。他提出的顺序一致性(Sequential Consistency)概念,成为后来理解并发内存与分布式系统的重要基础。接下来几十年,研究者进一步发展了线性一致性(Linearizability)、因果一致性(Causal Consistency)、最终一致性(Eventual Consistency)等模型。假设你在一台服务器上写入:balance = 900服务器告诉你:“成功。”紧接着,你向另一台服务器查询余额。却得到:balance = 1000究竟是数据库出错了?还是系统采用了一种允许副本暂时不同步的一致性模型?下一章,我们将从 Lamport、Herlihy、Wing 等人的真实论文出发,理解:为什么“两个副本的数据最终会相同”,与“每一次读取都立即看到最新写入”,是两种完全不同的承诺。