垃圾回收:自动内存管理的原理与演进
在 C 和 C++ 等语言中,程序员需要手动申请和释放内存。这种方式给予了程序员充分的控制权,但也带来了一系列难以排查的问题:忘记释放导致内存泄漏,释放后继续使用导致悬垂指针,同一块内存释放两次导致重复释放。这些错误往往不会立即暴露,而是在程序运行一段时间后以崩溃或数据损坏的形式出现,并且常常成为安全漏洞的源头。
垃圾回收(Garbage Collection,GC)是解决这一问题的主要方案之一:由运行时系统自动识别不再使用的内存并将其回收,程序员只需申请内存,无需关心释放。Java、Go、C#、JavaScript、Python 等大量主流语言都采用了某种形式的垃圾回收。
本文介绍垃圾回收的基本概念、经典算法、分代回收思想、并发回收面临的挑战,以及主流运行时中垃圾回收器的设计取舍。
一、什么是"垃圾"
垃圾回收器要回收的是程序不再使用的对象。但"将来是否还会使用"是一个无法在运行时精确判断的问题。因此,垃圾回收器采用一个保守的近似标准:可达性。
根集合
垃圾回收从一组根(Roots)出发。根是程序可以直接访问的引用,通常包括:
- 各线程调用栈中的局部变量和参数;
- 全局变量和静态变量;
- 寄存器中保存的引用;
- 运行时内部持有的引用,例如正在被本地代码使用的对象。
可达性
从根出发,沿着对象之间的引用关系可以到达的所有对象,称为可达对象,它们被视为存活。无法从根到达的对象,程序已经没有任何途径再访问它们,即为垃圾,可以被安全回收。
需要注意的是,可达不等于将来一定会被使用。一个对象可能仍然被某个集合引用,但程序逻辑上已不再需要它。这种情况下,垃圾回收器无能为力,仍然会出现逻辑意义上的内存泄漏。例如,不断向一个全局缓存中添加数据却从不清理,即使在有垃圾回收的语言中也会导致内存持续增长。
二、引用计数
最直观的自动内存管理方式是引用计数(Reference Counting):为每个对象维护一个计数器,记录有多少个引用指向它。
- 新增一个指向该对象的引用时,计数加 1;
- 删除一个指向该对象的引用时,计数减 1;
- 计数降为 0 时,立即回收该对象,并递减它所引用的其他对象的计数。
优点
- 回收及时:对象一旦不再被引用就立即释放,内存占用平稳;
- 停顿分散:回收工作分散在程序执行的各个时刻,没有集中的长时间停顿;
- 实现直观:逻辑简单,易于理解。
缺点
循环引用是引用计数最主要的问题。如果对象 A 引用对象 B,对象 B 又引用对象 A,即使外界已经没有任何引用指向它们,两者的计数也都至少为 1,永远不会被回收。
此外,每次引用的赋值都需要更新计数器,带来持续的运行时开销;在多线程环境下,计数器的更新还需要原子操作,开销更大。
实际应用
一些语言以引用计数作为主要的内存管理机制,并辅以其他手段处理循环引用。例如,Python 主要依赖引用计数,同时配备一个周期性运行的循环检测器;Swift 和 Objective-C 采用编译器自动插入的引用计数,并提供弱引用机制,由程序员在可能形成循环的地方显式打破循环;C++ 和 Rust 的标准库也提供了基于引用计数的智能指针。
三、追踪式回收的经典算法
与引用计数相对的是追踪式回收(Tracing GC):不维护计数,而是在需要时从根出发遍历整个对象图,找出所有可达对象,其余即为垃圾。追踪式回收天然能够处理循环引用。
1. 标记-清除
标记-清除(Mark-Sweep)是最基础的追踪式算法,分为两个阶段:
- 标记阶段:从根集合出发,深度优先或广度优先遍历对象图,为每个访问到的对象设置标记;
- 清除阶段:线性扫描整个堆,回收所有未被标记的对象,将其空间加入空闲列表。
标记-清除实现简单,不需要移动对象。但它有两个明显的缺点:
- 内存碎片:回收后的空闲空间分散在堆的各处。即使空闲内存总量充足,也可能找不到一块足够大的连续空间来分配大对象;
- 分配效率低:在碎片化的堆上分配内存,需要在空闲列表中查找合适的块,比在连续空间中分配慢。
2. 复制算法
复制算法(Copying)将堆划分为大小相等的两个半区,每次只使用其中一个:
- 分配时,在当前半区中顺序分配;
- 当前半区用满时,从根出发遍历,将所有存活对象复制到另一个半区,紧密排列;
- 交换两个半区的角色,原半区整体清空。
复制算法的优点包括:
- 没有碎片:存活对象被紧密排列;
- 分配极快:只需移动一个指针,称为指针碰撞分配;
- 回收开销只与存活对象相关:垃圾对象无需任何处理,当大部分对象都是垃圾时,效率非常高。
其缺点同样明显:任意时刻只有一半的堆空间可用,内存利用率低;当存活对象较多时,复制的开销很大。
3. 标记-整理
标记-整理(Mark-Compact)结合了前两者的特点:
- 标记阶段:与标记-清除相同;
- 整理阶段:将所有存活对象向堆的一端移动,紧密排列,然后更新所有指向被移动对象的引用。
标记-整理既没有碎片,又不需要浪费一半空间。代价是整理阶段需要多次遍历堆,并且移动对象和更新引用的开销较大。
三种算法的比较
| 算法 | 内存碎片 | 空间利用率 | 分配速度 | 回收开销与什么相关 |
|---|---|---|---|---|
| 标记-清除 | 有 | 高 | 较慢 | 整个堆的大小 |
| 复制 | 无 | 低(一半) | 快 | 存活对象数量 |
| 标记-整理 | 无 | 高 | 快 | 整个堆的大小,且整理代价较高 |
没有一种算法在所有方面都占优。这促使人们思考:能否根据对象的不同特点,组合使用多种算法?
四、分代回收
弱分代假说
大量实证研究表明,多数程序中的对象生命周期呈现出明显的规律:
- 绝大多数对象朝生夕灭:例如方法中的临时对象、字符串拼接的中间结果、迭代器等,创建后很快就不再被使用;
- 熬过多次回收的对象往往会长期存活:例如缓存、配置、连接池等。
这一观察被称为弱分代假说。
分代的基本思路
基于这一假说,分代回收(Generational GC)将堆划分为不同的区域:
- 年轻代:新创建的对象首先分配在这里。由于其中大部分对象很快死亡,适合使用复制算法,回收时只需复制少量存活对象;
- 老年代:在年轻代中经历了若干次回收仍然存活的对象,会被晋升到老年代。老年代中的对象存活率高,适合使用标记-清除或标记-整理算法。
年轻代的回收频繁但快速,通常只需要几毫秒;老年代的回收较少发生。这样,大部分回收工作只涉及堆中的一小部分区域,整体效率大大提高。
一种常见的年轻代布局是将其分为一个较大的伊甸区和两个较小的幸存区。新对象分配在伊甸区;年轻代回收时,将伊甸区和一个幸存区中的存活对象复制到另一个幸存区;两个幸存区交替使用。由于每次存活的对象很少,幸存区只需较小的空间,从而缓解了复制算法空间利用率低的问题。
跨代引用问题
分代回收引入了一个新的问题:在只回收年轻代时,如何知道哪些年轻代对象被老年代对象引用?如果为此遍历整个老年代,分代就失去了意义。
解决办法是记忆集(Remembered Set):记录所有从老年代指向年轻代的引用。回收年轻代时,将记忆集中的引用也作为根的一部分。
维护记忆集需要在程序修改引用时进行记录,这通过写屏障(Write Barrier)实现:编译器或运行时在每次引用赋值的操作中插入一小段额外代码,检查是否产生了跨代引用,并更新相应的记录。为降低开销,许多实现采用卡表(Card Table),将老年代划分为固定大小的"卡片",只记录哪些卡片中可能包含跨代引用,而不精确记录每一个引用。
五、停顿问题与并发回收
全局停顿
在最简单的实现中,垃圾回收器工作时需要暂停所有应用线程,这称为全局停顿(Stop-The-World,STW)。原因在于,如果应用线程在回收器遍历对象图的同时修改引用关系,回收器可能会错误地判断对象的存活状态。
对于堆容量较小的程序,停顿时间很短,影响不大。但当堆增长到数十 GB 甚至更大时,一次完整的回收可能导致秒级停顿,这对于在线服务、交易系统、交互式应用是难以接受的。
因此,现代垃圾回收器的一个核心目标,就是减少停顿时间。主要的手段包括:
- 并行回收:在停顿期间使用多个线程共同完成回收工作,缩短停顿时长;
- 增量回收:将一次回收拆分为多个小步骤,与应用交替执行;
- 并发回收:让回收器线程与应用线程同时运行,大部分工作在应用不停顿的情况下完成。
三色标记
并发标记通常基于三色标记抽象来描述:
- 白色:尚未被访问的对象。标记结束时仍为白色的对象即为垃圾;
- 灰色:已被访问,但其引用的对象尚未全部扫描;
- 黑色:已被访问,且其引用的对象已全部扫描。
标记开始时,所有对象为白色,根直接引用的对象被置为灰色。回收器不断取出灰色对象,扫描其引用,将引用的白色对象置为灰色,然后将自身置为黑色。当不再有灰色对象时,标记完成。
并发带来的漏标问题
当应用线程与标记过程并发运行时,可能出现以下情况:
- 应用线程让一个黑色对象新增了一个指向白色对象的引用;
- 同时,应用线程删除了所有从灰色对象到该白色对象的引用路径。
由于黑色对象不会被再次扫描,而原本能到达该白色对象的灰色路径已被切断,这个实际上仍然存活的对象会被错误地当作垃圾回收。这是一个严重的错误,会导致程序访问已被释放的内存。
屏障技术
为避免漏标,并发回收器需要在应用线程修改引用时进行干预,同样依赖写屏障,常见的策略有两种:
- 增量更新:当黑色对象新增指向白色对象的引用时,将该黑色对象重新置为灰色,或将该白色对象置为灰色,确保它会被扫描到;
- 起始快照(Snapshot-At-The-Beginning,SATB):当一个引用被删除或覆盖时,将被删除引用所指向的旧对象置为灰色。这相当于以标记开始时刻的对象图快照为准,保证当时存活的对象都不会被漏掉。代价是部分在标记期间死亡的对象要等到下一轮才能回收,称为浮动垃圾。
并发整理
并发标记解决了标记阶段的停顿问题,但整理或复制阶段需要移动对象。如果应用线程在对象被移动的同时访问它,可能读到旧地址上的过期数据。
为实现并发移动,一些回收器引入了读屏障(Read Barrier):在每次读取引用时插入检查代码,如果发现该对象已被移动,就将引用修正为新地址。另一种技术是着色指针,即在指针中嵌入少量元数据位,用于标记对象的状态,使屏障检查能够快速完成。
读屏障的执行频率远高于写屏障,会带来一定的吞吐量损失。但借助这些技术,部分现代回收器能够将停顿时间控制在毫秒甚至亚毫秒级别,并且停顿时间基本不随堆大小增长。
六、主流回收器的设计取舍
垃圾回收器的设计需要在三个目标之间权衡:
- 吞吐量:应用代码执行时间占总运行时间的比例;
- 停顿时间:单次回收导致应用暂停的时长;
- 内存占用:为达到前两个目标所需的额外内存。
这三者通常无法同时达到最优,常被称为垃圾回收的"不可能三角"。不同的回收器在其中做出了不同的选择。
Java 虚拟机
Java 虚拟机提供了多种回收器,是观察这些权衡的典型样本:
- 串行回收器:单线程执行,全程停顿。适合堆较小、单核或客户端场景;
- 并行回收器:多线程并行回收,以最大化吞吐量为目标,停顿时间较长。适合批处理、离线计算等对延迟不敏感的任务;
- G1 回收器:将堆划分为大量大小相等的区域(Region),每个区域可以动态地充当年轻代或老年代。回收时优先选择垃圾比例最高的区域,并允许用户设定期望的停顿时间目标,回收器据此决定每次回收多少区域。G1 在吞吐量和停顿时间之间取得了较好的平衡,是目前许多版本的默认回收器;
- ZGC 和 Shenandoah:以低停顿为首要目标,借助读屏障和并发整理技术,实现与堆大小基本无关的极短停顿,适合大堆、低延迟场景,代价是一定的吞吐量损失和更高的内存需求。
Go
Go 语言的回收器采用并发标记-清除,不分代,也不整理。它的设计目标非常明确:优先保证低延迟。Go 通过写屏障支持并发标记,大部分工作与应用并发执行,停顿通常在毫秒以下。
不进行整理意味着可能存在碎片,Go 通过按大小分级的内存分配器来缓解这一问题。不分代则与 Go 的语言特性有关:Go 编译器会进行逃逸分析,将大量不会逃逸出函数的临时对象直接分配在栈上,从而减少了堆上短命对象的数量,分代的收益相对较小。
此外,Go 提供了简单的调节参数,用于控制回收的触发频率和内存上限,在内存占用与 CPU 开销之间进行权衡。
JavaScript 引擎
浏览器中的 JavaScript 引擎同样采用分代回收。年轻代使用复制算法,老年代使用标记-清除和标记-整理,并大量结合增量标记和并发标记,以避免在页面渲染和用户交互时产生明显的卡顿。
七、编写对回收器友好的代码
垃圾回收是自动的,但程序的写法会显著影响回收器的负担。
减少不必要的对象分配。 在高频执行的路径中,避免创建大量临时对象,例如在循环中重复创建相同的对象、不必要的装箱操作、频繁的字符串拼接。分配越少,回收越少。
注意对象的生命周期。 中等寿命的对象对分代回收最不友好:它们在年轻代中存活了足够长的时间而被晋升,却在进入老年代后不久死亡,导致老年代被频繁填满。
谨慎使用对象池。 对象池可以减少分配,但池中的对象长期存活,会增加老年代的负担和跨代引用的数量。对于创建成本不高的小对象,现代回收器的分配和回收效率通常已经足够高,使用对象池未必有收益。
及时解除不需要的引用。 长期存活的集合、缓存、监听器列表等,如果持有不再需要的对象的引用,会阻止这些对象被回收。可以考虑使用弱引用、设置容量上限或过期策略。
根据场景选择和调整回收器。 批处理任务更关注吞吐量,在线服务更关注停顿时间。在调整之前,应先开启回收日志,了解实际的回收频率、停顿时长和堆使用情况,再有针对性地优化,而非盲目修改参数。
结语
垃圾回收将程序员从手动管理内存的负担中解放出来,消除了一大类常见且危险的错误。从早期的引用计数和标记-清除,到分代回收,再到并发整理,垃圾回收技术经历了数十年的演进,核心始终围绕着吞吐量、停顿时间和内存占用之间的权衡。
自动并不意味着可以完全忽略。理解垃圾回收的基本原理,有助于在编写代码时避免给回收器制造不必要的负担,也有助于在遇到内存或延迟问题时,准确地定位原因并选择合适的解决方案。
- 点赞
- 收藏
- 关注作者
评论(0)