一文掌握各类编程语言的 GC 实现方式

319 阅读6分钟

垃圾回收(GC)的实现方式一般分为 追踪(Tracing)引用计数(Reference Counting) 两大类,这是对 GC 核心思想的一个经典划分。追踪式 GC 通过跟踪对象引用来判断存活性,而引用计数通过维护引用数量来管理内存。下面我先解释这两种方式的基本原理,然后详细对比常见编程语言(如 Go、Java、Python、C# 等)的 GC 实现方式及其区别。


1. 追踪式 GC(Tracing GC)

原理

  • 从“根”对象(如全局变量、栈上变量)开始,递归遍历所有可达对象,标记为存活。
  • 不可达对象被认为是垃圾,回收其内存。
  • 常见的实现包括标记-清除(Mark-and-Sweep)、标记-压缩(Mark-and-Compact)、复制算法(Copying)等。

优点

  • 可以处理循环引用。
  • 不需要为每个对象维护额外计数器,开销相对固定。

缺点

  • 通常需要暂停程序(STW)或复杂的并发机制。
  • 回收时机不即时,依赖 GC 调度。

2. 引用计数 GC(Reference Counting)

原理

  • 为每个对象维护一个引用计数器,记录被引用的次数。
  • 当计数器变为 0 时,立即回收对象。

优点

  • 回收即时,内存释放不需要等待 GC 周期。
  • 实现简单,逻辑直观。

缺点

  • 无法处理循环引用(如 A 引用 B,B 引用 A)。
  • 更新引用计数有性能开销,尤其在多线程环境下。

编程语言的 GC 实现方式及区别

Go 语言

  • GC 类型:追踪式(Tracing)。
  • 具体实现:并发三色标记-清除(Mark-and-Sweep with Tricolor)。
  • 特点
    • 使用三色标记算法(白色、灰色、黑色),结合写屏障支持并发。
    • 非分代,所有对象在同一堆中管理。
    • 增量和并发执行,STW 时间极短(微秒到毫秒级)。
    • 自适应触发(通过 GOGC 参数控制)。
  • 目标:低延迟,适合高并发服务器应用。
  • 区别
    • 与分代式 GC(如 Java)相比,Go 不区分新生代和老年代,设计更简单但可能牺牲部分吞吐量。
    • 强调并发性,避免长时间暂停。

Java(HotSpot JVM)

  • GC 类型:追踪式(Tracing)。
  • 具体实现:多种 GC 算法可选(如 Serial、Parallel、CMS、G1、ZGC)。
    • Serial GC:单线程标记-清除+复制(分代)。
    • Parallel GC:并行标记-清除+复制(分代),高吞吐量。
    • CMS:并发标记-清除(老年代),低延迟。
    • G1:分代+区域化标记-压缩,低延迟与吞吐量平衡。
    • ZGC:并发标记-压缩,超低暂停时间。
  • 特点
    • 分代设计:新生代(Young Generation)和老年代(Old Generation)。
    • 新生代用复制算法,老年代用标记-清除或标记-压缩。
    • 可根据应用需求选择不同 GC(如高吞吐量或低延迟)。
  • 目标:灵活性,支持多种场景(桌面、服务器、大型应用)。
  • 区别
    • 比 Go 更复杂,支持分代和多种算法选择。
    • STW 时间因算法不同而异(ZGC 接近无暂停,Serial 暂停较长)。

Python(CPython)

  • GC 类型:引用计数为主 + 追踪式为辅。
  • 具体实现
    • 引用计数:主要机制,每个对象有引用计数,计数为 0 时立即回收。
    • 标记-清除:辅助机制,解决循环引用问题(如容器对象)。
  • 特点
    • 分代设计(0、1、2 代),但仅用于标记-清除部分。
    • 引用计数是实时的,标记-清除周期性触发。
    • 手动管理选项(如 gc 模块)。
  • 目标:简单性和实时性。
  • 区别
    • 与纯追踪式(如 Go、Java)不同,Python 依赖引用计数,回收更即时但有循环引用缺陷。
    • 多线程环境下引用计数更新需加锁,开销较高。

C#(.NET CLR)

  • GC 类型:追踪式(Tracing)。
  • 具体实现:分代标记-压缩(Generational Mark-and-Compact)。
  • 特点
    • 分三代:Generation 0(新生)、1(短期存活)、2(长期存活)。
    • 新生代用复制算法,老年代用标记-压缩。
    • 支持并发和并行 GC(如 Background GC)。
    • 有 Large Object Heap(LOH)处理大对象。
  • 目标:吞吐量与延迟平衡,适合企业应用。
  • 区别
    • 类似 Java,分代设计优化短生命周期对象。
    • 比 Go 更注重碎片管理(压缩内存),但暂停时间可能稍长。

JavaScript(V8 引擎)

  • GC 类型:追踪式(Tracing)。
  • 具体实现:分代标记-清除+复制。
  • 特点
    • 分新生代(复制算法)和老年代(标记-清除)。
    • 增量和并发优化(如 Orinoco GC)。
    • 针对 JavaScript 的动态特性优化(如隐藏类)。
  • 目标:低延迟,适合浏览器实时渲染。
  • 区别
    • 与 Go 类似注重低延迟,但分代设计更像 Java。
    • 针对 JavaScript 的弱类型和动态性有额外优化。

Ruby(MRI)

  • GC 类型:追踪式(Tracing)。
  • 具体实现:标记-清除,后期引入分代和增量。
  • 特点
    • 早期是简单 STW 标记-清除,暂停时间长。
    • Ruby 2.2+ 引入分代(新生代+老年代)和增量 GC。
    • Ruby 2.7+ 加入压缩功能。
  • 目标:逐步改进延迟和性能。
  • 区别
    • 与 Go 相比,Ruby 的 GC 发展较慢,早期延迟高。
    • 分代和增量特性类似 Java,但实现更轻量。

PHP(Zend Engine)

  • GC 类型:引用计数。
  • 具体实现
    • 每个变量(zval)维护引用计数。
    • PHP 5.3+ 加入循环引用检测(类似 Python 的标记-清除)。
  • 特点
    • 实时回收为主,适合短生命周期脚本。
    • 针对数组和对象循环引用优化。
  • 目标:简单性,适合 Web 请求短生命周期。
  • 区别
    • 与 Python 类似依赖引用计数,但应用场景更单一(Web)。
    • 不像 Go/Java 追求并发和低延迟。

对比总结

语言GC 类型具体实现分代并发STW 时间目标
Go追踪式并发三色标记-清除极短(微秒-毫秒)低延迟
Java追踪式分代+多种算法(G1/ZGC 等)短到中等灵活性
Python引用计数+追踪引用计数+标记-清除较长实时性+简单性
C#追踪式分代标记-压缩中等吞吐量+延迟平衡
JavaScript追踪式分代标记-清除+复制低延迟
Ruby追踪式标记-清除+分代+增量中等到短逐渐优化延迟
PHP引用计数引用计数+循环检测无(实时)简单性+实时性

主要区别

  1. 追踪 vs 引用计数
    • 追踪式(如 Go、Java)适合复杂应用,能处理循环引用,但需要调度。
    • 引用计数(如 Python、PHP)回收即时,但有局限性(如循环引用和高开销)。
  2. 分代设计
    • Java、C#、JavaScript 使用分代,优化短生命周期对象。
    • Go 和 PHP 不分代,设计更简单但可能效率稍低。
  3. 并发性
    • Go、Java、C# 支持并发 GC,低延迟优先。
    • Python、PHP 依赖非并发机制,延迟较高或无 STW。
  4. 目标
    • Go:低延迟,适合服务器。
    • Java/C#:吞吐量与延迟平衡,通用性强。
    • Python/PHP:简单性和实时性,适合脚本。

结论

  • 追踪式 GC 是现代语言的主流(如 Go、Java),通过并发、分代等优化实现高性能。
  • 引用计数 GC 更适合即时回收场景(如 Python、PHP),但需要辅助机制解决缺陷。
  • 各语言的 GC 实现反映了其设计目标和应用场景的差异。