1960年,John McCarthy在MIT实现第一个LISP解释器时,面临一个看似简单却影响深远的问题:如何在程序运行时自动回收不再使用的内存?他给出的答案是tracing garbage collection——周期性地遍历所有可达对象,标记并清理不可达的内存。这个方案统治了函数式语言数十年。
然而在同一时期,IBM的George Collins提出了一个截然不同的思路:为什么不为每个对象维护一个计数器,记录有多少引用指向它?当计数降为零时,立即释放内存。这就是引用计数的雏形。
六十年后,这场技术路线的分野依然深刻影响着编程语言的格局。Java、Go选择了tracing GC,而Python、Swift、Rust却坚定地拥抱引用计数。为什么?答案远比"计数器"三个字复杂得多。
看似简单的计数背后
引用计数的基本原理直观得令人发指:每个对象携带一个计数器,每次赋值时加一,离开作用域时减一,归零即释放。教科书会告诉你这是"即时回收"的典范——内存一旦无用立即释放,没有GC暂停,没有world-stop。
graph LR
A[对象创建] -->|计数=1| B[对象存活]
B -->|引用赋值| C[计数+1]
B -->|引用释放| D[计数-1]
C --> B
D -->|计数>0| B
D -->|计数=0| E[立即释放]
但现实从不如此简单。
考虑一段最基础的代码:
def process_data(data):
temp = data
result = temp.transform()
return result
在引用计数的实现中,temp = data这行看似无害的赋值实际上触发了:读取data的引用计数,原子地加一,存储新值。result = temp.transform()又是一次原子加一。当函数返回时,temp和result离开作用域,又各触发一次原子减一。
每个赋值,每次参数传递,每回函数调用,都在默默消耗着CPU周期。
原子操作的真实代价
在单线程环境下,引用计数的开销或许可以接受。但现代程序几乎都是多线程的,这把问题推向了全新的维度。
线程安全的引用计数必须使用原子操作。一个看似简单的ref_count++,在x86架构上的实现是这样的:
lock inc [ref_count] ; 30-50个时钟周期
这还只是无竞争的情况。当多个核心同时修改同一对象的引用计数时,缓存一致性协议会介入,延迟可能飙升至数百个周期。Reddit上一位开发者曾做过测试:在16核机器上,高频率的引用计数操作能让程序性能下降50%以上。
graph TD
A[CPU核心1] -->|原子操作| D[共享内存]
B[CPU核心2] -->|原子操作| D
C[CPU核心3] -->|原子操作| D
subgraph 缓存一致性开销
A --> E[缓存行锁定]
B --> F[等待释放]
C --> G[等待释放]
end
E --> D
F --> D
G --> D
2012年,Steve Blackburn等人在论文《Down for the Count? Getting Reference Counting Back in the Ring》中给出了量化数据:现代引用计数实现比tracing GC平均慢30%。这个数字来自对多个基准测试的综合分析,不是空穴来风。
更隐蔽的问题在于:引用计数把读操作变成了写操作。读取一个指针,本应是缓存友好的只读访问,现在却需要修改内存。这直接违反了现代CPU的优化假设——读操作可以并行,写操作必须串行。
循环引用:优雅设计的阿喀琉斯之踵
如果说性能开销还可以通过优化来缓解,循环引用则是引用计数先天的结构性缺陷。
最简单的例子:双向链表。每个节点持有前后两个指针,形成完美的循环。在纯引用计数系统中,这些节点永远不会被回收——每个节点的引用计数都至少为1(来自相邻节点的指针)。
graph LR
A[节点A<br/>计数=1] -->|next| B[节点B<br/>计数=2]
B -->|next| C[节点C<br/>计数=2]
C -->|next| A
A -.->|prev| C
B -.->|prev| A
C -.->|prev| B
style A fill:#ff9999
style B fill:#ff9999
style C fill:#ff9999
Python最初就是纯引用计数系统,结果大量真实代码产生了内存泄漏。1992年,Python社区不得不引入一个附加的循环检测垃圾回收器,定期扫描对象图找出循环引用。这套混合方案沿用至今,但代价是双重的:既有引用计数的持续开销,又有GC的周期性暂停。
Swift和Objective-C采用了另一种策略:弱引用。开发者必须主动标注哪些引用不参与计数,手动打破潜在的循环。这把责任推给了程序员,换来了更简单的运行时。
class Node {
var next: Node?
weak var prev: Node? // 弱引用,不增加计数
}
但弱引用本身也有开销。2019年的测试显示,Swift的弱引用访问比强引用慢10倍以上,因为需要额外的运行时检查。
各语言的技术抉择
Python、Swift、Rust都选择了引用计数,但实现细节和权衡各不相同,这本身就是一个有趣的观察点。
graph TD
subgraph Python方案
A1[引用计数主系统] --> A2[即时释放]
A3[循环检测GC] --> A4[后台处理循环引用]
end
subgraph Swift方案
B1[编译期ARC] --> B2[自动插入retain/release]
B3[弱引用标注] --> B4[手动打破循环]
end
subgraph Rust方案
C1[所有权系统] --> C2[编译期确定生命周期]
C3[Rc/Arc] --> C4[显式选择引用计数]
end
Python的策略是混合方案:主要依赖引用计数,辅以循环检测GC。这套方案的哲学是"常见情况快速处理,异常情况延后处理"。大多数对象的引用计数能正常归零,立即释放;少数循环引用由GC在后台处理。
Instagram的工程师在2017年发现一个有趣现象:在他们的生产环境中,将GC阈值设为0(禁用循环检测)反而提升了性能。原因是Python的循环检测GC有显著开销,而他们的代码恰好很少产生循环引用。这个案例说明,不存在完美的通用方案。
Swift和Objective-C的ARC(Automatic Reference Counting)采取了另一条路:把引用计数管理从运行时移到编译期。编译器分析代码,插入retain/release调用,并尽可能优化掉不必要的操作。这是典型的"用编译时间换运行时间"策略。
但ARC无法解决所有问题。Swift的编译器优化在某些场景下会失效,比如跨模块边界、动态派发、闭包捕获等。开发者仍需理解底层机制,才能写出高性能代码。
Rust提供了两种引用计数类型:Rc(单线程)和Arc(原子引用计数)。这种显式区分让开发者能根据场景选择——单线程代码无需原子操作的开销,多线程代码必须承担这个成本。Rust的设计哲学是"零成本抽象",但Arc仍然不是零成本的,它只是让成本可见、可控。
控制块:看不见的空间税
引用计数的开销不仅是时间,还有空间。
C++的std::shared_ptr是理解这个问题的绝佳案例。每个shared_ptr对象本身占两个指针大小:一个指向被管理对象,一个指向控制块。控制块里存储着引用计数、弱引用计数、删除器等信息。
graph TD
subgraph shared_ptr对象
A[对象指针<br/>8字节]
B[控制块指针<br/>8字节]
end
subgraph 控制块
C[强引用计数<br/>8字节]
D[弱引用计数<br/>8字节]
E[删除器<br/>8字节]
F[其他元数据<br/>约8字节]
end
A --> G[实际对象]
B --> C
一个简单的shared_ptr<int>,原本只需要4字节存储整数,现在却需要:8字节(指针本身)+ 约24字节(控制块)= 32字节。空间膨胀了8倍。
侵入式引用计数(intrusive reference counting)试图解决这个问题:把计数器直接嵌入对象内部,省去独立的控制块。COM(Component Object Model)就是这种设计的典范。但侵入式设计要求对象布局预先知道引用计数的需求,与C++的模板元编程结合困难,因此shared_ptr选择了非侵入式。
延迟引用计数:折中的艺术
如果原子操作太昂贵,能否推迟它们?
延迟引用计数(Deferred Reference Counting)的核心思想是:栈上的引用不需要立即更新计数器。编译器知道栈帧的生命周期,可以在函数退出时统一处理。
sequenceDiagram
participant 线程栈
participant 延迟缓冲区
participant 引用计数器
participant 内存池
线程栈->>延迟缓冲区: 栈引用入栈(不更新计数)
线程栈->>引用计数器: 堆引用(立即更新)
延迟缓冲区->>引用计数器: 函数退出时批量更新
引用计数器->>内存池: 计数归零立即释放
这套方案的理论基础来自1990年代的学术研究。Blackburn和McKinley在2002年的论文中展示了延迟引用计数如何将开销降低到与tracing GC竞争的水平。
但延迟意味着复杂性。运行时必须维护额外的数据结构追踪栈引用,必须有机制处理对象可能在延迟期间被释放的情况。这是典型的复杂度转移:从热路径移到冷路径。
偏置引用计数:打破对称性
2018年,UIUC的研究团队提出了偏置引用计数(Biased Reference Counting)。核心洞察是:大多数对象的引用计数操作都发生在创建它的线程上。
如果线程A创建了一个对象,后续的引用计数更新大多也来自线程A。那么,为什么不用一个非原子的本地计数器加速这些操作,只在跨线程访问时才使用昂贵的原子操作?
graph LR
subgraph 线程A本地
A[本地计数器<br/>非原子操作]
end
subgraph 全局共享
B[原子计数器<br/>跨线程更新]
end
C[线程A操作] -->|快速路径| A
D[线程B操作] -->|慢速路径| B
A --> E[定期同步]
B --> E
实验显示,BRC能将引用计数的开销降低60-70%。这套方案后来被一些研究型语言和实验性系统采用,但尚未进入主流语言的实现。
Perceus:无垃圾的引用计数
2020年,Microsoft Research提出了Perceus算法,声称实现了"无垃圾的引用计数"(Garbage Free Reference Counting)。这个名字有些标题党——并非真的没有垃圾,而是指回收过程不产生额外的垃圾对象。
传统引用计数在释放对象时,可能触发级联释放:对象A被释放,导致它引用的对象B计数减一,B又被释放,依此类推。这种级联释放难以预测,可能造成延迟尖峰。
Perceus的思路是:在对象被释放前,尝试重用它的内存。如果对象A即将被释放,同时需要创建一个相似大小的对象B,为什么不直接在A的内存上构造B?这消除了释放A、分配B两次内存操作,性能和碎片化都有改善。
这套方案要求编译器深度配合,精确分析对象的生命周期。目前只在Koka等研究型语言中实现,代表了引用计数优化的前沿方向。
Tracing GC vs 引用计数:永恒的权衡
这场争论持续了六十年,不会因为某篇论文或某个语言的成功而终结。核心原因是:两种方案优化的目标不同。
graph LR
subgraph Tracing GC优势
A1[高吞吐量]
A2[批量处理效率高]
A3[适合长生命周期对象]
end
subgraph Tracing GC劣势
B1[不可预测的暂停]
B2[高峰值内存需求]
B3[不适合实时系统]
end
subgraph 引用计数优势
C1[即时释放]
C2[无暂停时间]
C3[低内存占用]
end
subgraph 引用计数劣势
D1[持续性能开销]
D2[循环引用问题]
D3[原子操作成本]
end
Tracing GC优化的是分配/释放的吞吐量。批量处理整个堆,分摊单次操作的成本,在对象大量存活时效率最高。代价是不可预测的暂停时间和更高的峰值内存需求。
引用计数优化的是延迟和峰值内存。对象一旦无用立即释放,没有世界停止的暂停,内存占用更平滑。代价是每次操作的持续开销和循环引用的复杂性。
哪个更好?答案取决于场景。实时系统无法容忍100毫秒的GC暂停,但可以接受持续10%的性能损失。批处理任务不在乎暂停,只想最快完成任务。桌面应用追求响应流畅,移动设备看重内存占用。
Chris Lattner(Swift的创始人)在2022年的讨论中总结了Swift选择ARC的原因:与C/Objective-C的无缝互操作性、可预测的性能、无需运行时支持。这些是Swift设计目标的产物,不是绝对真理。
编译器的角色:隐藏复杂性
现代引用计数的真正突破,在于把复杂性从运行时移到编译期。
Swift的ARC会进行大量的优化:当编译器能证明某个引用不会逃逸当前作用域,就完全不插入retain/release;当对象在函数内创建并返回,可以优化掉中间的引用计数操作;当检测到强引用循环的常见模式,会给出警告。
graph TD
A[源代码] --> B[编译器分析]
B --> C{引用是否逃逸?}
C -->|否| D[省略引用计数]
C -->|是| E[插入retain/release]
D --> F[生成优化代码]
E --> G[检测循环引用]
G --> H[编译期警告]
这些优化不是万能的,但覆盖了大量常见代码模式。结果是:开发者用着像垃圾回收一样简单的语法,实际执行的是精心优化的引用计数代码。
Rust的所有权系统走得更远。在编译期就确定对象的生命周期,大部分情况不需要引用计数,只有显式选择Rc/Arc时才引入运行时开销。这是"用编译期保证换运行时效率"的极致体现。
未来:融合还是分化?
最近的研究趋势是模糊两者的界限。2012年Bacon等人的《A Unified Theory of Garbage Collection》提出了统一框架,将tracing和引用计数视为同一谱系的两端,而非对立的方案。
实践层面,混合方案正在流行。Python、PHP等语言已经是引用计数+tracing GC的混合体。Rust在需要时可以引入第三方GC库。Swift 5.9引入了新的内存管理工具,为特定场景提供更细粒度的控制。
硬件的发展也在改变权衡。ARM的芯片引入了更高效的原子操作指令,降低了引用计数在多核环境下的开销。非易失性内存(NVM)技术的兴起,让即时释放的特性变得更有价值——数据不能等到GC时才写回,必须随时准备好应对断电。
六十年前,McCarthy和Collins的选择开启了这场技术分歧。今天,我们看到的不是一方压倒另一方,而是各自进化、相互借鉴、在特定场景下各展所长的格局。引用计数不是过时的技术,它在持续进化,与现代编译器、硬件特性深度结合,成为内存管理工具箱中不可或缺的一员。
理解引用计数,就是理解"没有完美方案"这个软件工程的永恒主题。每一个设计选择都是权衡:即时释放vs吞吐量,简单实现vs复杂优化,程序员负担vs运行时开销。优秀的工程师不是寻找银弹,而是在深刻理解这些权衡后,为特定问题选择最合适的工具。
参考资料
-
Blackburn, S. M., & McKinley, K. S. (2002). Ulterior reference counting: Efficient garbage collection in the presence of concurrency. ACM SIGPLAN Notices.
-
Bacon, D. F., Cheng, P., & Rajan, V. T. (2004). A unified theory of garbage collection. ACM SIGPLAN Notices.
-
Blackburn, S. M., et al. (2012). Down for the count? Getting reference counting back in the ring. ACM SIGPLAN Notices.
-
Auerbach, J., et al. (2018). Biased reference counting: Minimizing atomic operations in garbage collection. ACM SIGPLAN Notices.
-
Reinking, A., et al. (2020). Perceus: Garbage free reference counting with reuse. Microsoft Research Technical Report.
-
Collins, G. E. (1960). A method for overlapping and erasure of lists. Communications of the ACM.
-
Python Documentation. (2024). Garbage collector interface. Python.org.
-
LLVM Documentation. (2024). Automatic Reference Counting. LLVM.org.
-
Rust Documentation. (2024). std::rc and std::sync::Arc. Rust-lang.org.
-
Lattner, C. (2022). Discussion on garbage collection vs ARC. Hacker News.
-
McKinley, K. S. (2002). Reference counting and garbage collection. University of Texas at Austin Lecture Notes.
-
Appel, A. W. (1998). Modern compiler implementation in ML. Cambridge University Press.
-
Jones, R., & Lins, R. (1996). Garbage collection: Algorithms for automatic dynamic memory management. John Wiley & Sons.
-
Boehm, H. J. (2004). Destructors, finalizers, and synchronization. ACM SIGPLAN Notices.
-
Detlefs, D., et al. (2002). Concurrent cycle collection in reference counted systems. European Conference on Object-Oriented Programming.