《The BEAM Book》第十二章:垃圾回收器 (Garbage Collection) 深度详析

本章的核心在于解释 BEAM 如何在不停止整个系统的情况下,高效地管理内存。

12.1 复制式垃圾回收 (Copying Garbage Collection)

这一节介绍了 BEAM GC 的基本算法基础:分代(Generational)复制(Copying)

基本原理

  • 触发时机:GC 发生时,系统分配一块新的内存区域(To-Space)。
  • 扫描过程:从根集合(Roots)(主要是栈 Stack、寄存器)开始扫描。
  • 复制操作:将所有“活”的对象从旧堆(From-Space)复制到新堆。
  • 清理:复制完成后,旧堆被整体废弃。

详细步骤(Cheney 算法变体)

  1. 根扫描:找到栈上引用的堆对象。
  2. 复制与转发

    • 当一个对象(如 Cons Cell)被复制到新地址后,BEAM 会在旧对象的内存位置写入一个转发指针(Forwarding Pointer),指向新地址。
    • 目的:如果堆中有多个指针指向同一个对象(共享数据),第一个指针触发复制后,后续指针看到“转发标记”,直接更新指向新地址,而不会重复复制对象。这保证了共享性(Sharing)在 GC 后依然存在
  3. 广度优先遍历 (BFS):BEAM 的 GC 是按照广度优先的顺序进行的。

书中 GDB 调试实验(有趣细节)

  • 书中通过一个 gc_example 模块演示了 GC 前后的内存变化。
  • 指针反转现象:Erlang 构建数据通常是自底向上(Bottom-up,先构建尾部再构建头部),而 GC 复制是自顶向下(Top-down,从根开始)。
  • 结果:GC 后,内存中对象的物理排列顺序往往与创建时相反。这虽然不改变逻辑结构,但会改变内存引用的方向。

12.2 Erlang GC 中的分代机制 (Generations in Erlang's GC)

这一节解释了 BEAM 如何区分“年轻数据”和“年老数据”。

PCB 中的关键字段

  • high_water(高水位线):这是堆中的一个指针。地址低于此线的对象是“老年代”,高于此线的是“新生代”。
  • old_heap:指向老年代堆的指针。
  • gen_gcs:该进程经历过的次级 GC 次数。
  • max_gen_gcs:在强制进行全量回收(Full Sweep)前的最大次级 GC 次数(默认 65535)。

回收类型

  1. Minor GC (次级回收):只回收 high_water 之上的新生代区域。存活下来的对象会被移动到老年代(Old Heap)。
  2. Major GC / Full Sweep (全量回收):回收整个堆(包括老年代和新生代)。

堆外内存管理

  • Off-heap 数据:如大二进制(Refc Binaries)。它们不占堆空间,但占内存。
  • 虚拟堆 (Virtual Heap):BEAM 维护 bin_vheap_sz 等字段,统计进程持有的二进制数据大小。如果虚拟堆太大,即使进程主堆还很空,也会强制触发 GC,以便减少二进制数据的引用计数。

12.3 实际影响与考量 (Practical Implications and Considerations)

这一节讨论了 GC 行为如何映射到我们在 process_info 或 Observer 中看到的指标。

观察 high_water 的移动

  • 如果 high_water 线不断上移,说明该进程正在不断积累长期存活的数据。
  • 这可能是正常的(如 ETS 表的所有者进程),也可能是内存泄漏(无用的数据被意外保留引用)。

消息缓冲区 (mbuf)

  • 当进程接收消息过快,来不及处理,或者发送方无法获取接收方的堆锁时,消息会被放在堆外的碎片区(Heap Fragments / mbufs)。
  • 警示:如果看到 mbuf_sz 很大,说明消息处理有瓶颈,或者发生了严重的锁竞争。

12.4 优化建议 (Suggestions for Optimization)

基于 GC 原理,作者给出了一些具体的优化建议。

数据局部性 (Locality)

由于 GC 采用广度优先复制,可能会破坏数据在内存中的局部性(原本相邻的数据被分散),但在现代大缓存 CPU 上,这通常不是主要瓶颈。

预分配堆大小

  • 问题:如果一个进程启动后立刻处理大量数据,会触发多次扩容和 GC,浪费 CPU。
  • 方案:使用 spawn_opt(Fun, [{min_heap_size, Words}])。直接分配一个大堆,减少初期的 GC 抖动。

减少临时垃圾

虽然 Minor GC 很快,但大量临时数据依然会消耗内存带宽和 CPU 周期。

12.5 栈与堆的增长 (Stack and Heap Growth)

这一节通过物理布局解释了进程何时会“内存不足”。

单一连续内存块

Erlang 进程的栈和堆共用一块内存。

相向生长

  • Stack:从高地址向低地址增长(向下)。
  • Heap:从低地址向高地址增长(向上)。

碰撞检测

每次函数调用或分配数据时,虚拟机只需检查 StackPtr > HeapPtr + Needed。如果检查失败(两者相遇),说明内存不足,触发 GC

扩容

如果 GC 后空间依然不足,VM 会申请一块更大的内存,将栈和堆都搬过去。

12.6 关键内存特征与优化机会 (Key Memory Characteristics)

这一节从理论层面解释了为什么 BEAM 选择复制式 GC 而不是引用计数

不可变性 = 无循环引用 (No Cycles)

  • 这是 Erlang 最重要的内存特征。由于数据不可变,你无法修改一个老对象让它指向一个新对象(只能创建新对象包含老对象)。因此,Erlang 堆中永远不会出现环状引用
  • 知识点:理论上这让引用计数(Reference Counting)变得可行且简单。

为什么不用引用计数?(HiPE 团队的研究数据)

  • 对象极小:统计显示,75% 的 Erlang 对象是 Cons Cell(列表节点,2个字大小),99% 的对象小于 8 个字。
  • 代价分析

    • 引用计数:对海量微小对象频繁更新计数,开销巨大,且无法解决内存碎片。
    • 复制 GC:对于微小对象,复制成本极低(就是拷贝几个字节)。更重要的是,复制 GC 顺便完成了内存整理(Compaction),使得空闲内存永远是连续的,内存分配变成了最快的“指针递增”。

结论

BEAM 的 GC 策略是针对“大量、微小、短命、不可变”的数据特征进行极致优化的结果。


📝 总结出的“隐藏”知识点 (Takeaways)

  1. fullsweep_after 标志
    你可以通过设置进程标志 {fullsweep_after, N} 来控制全量回收的频率。如果设为 0,则该进程每次 GC 都是全量回收(以 CPU 换内存,适合对延迟敏感但不产生大量垃圾的进程)。
  2. binary:copy/1 的作用
    如果你有一个巨大的二进制数据(比如 10MB),然后匹配出了其中的一小段(比如 10字节)。这一小段是 Sub-binary,它在底层依然引用着那 10MB 的大二进制,导致 GC 无法回收那 10MB。

    • 优化:调用 binary:copy(SubBin) 会创建一个新的深拷贝,断开对大数据的引用,从而允许 GC 回收那 10MB 空间。
  3. GC 是“借”来的时间
    Erlang 是软实时的,GC 的开销是分摊在每个进程的时间片里的。如果一个进程 GC 时间太长,调度器会强制挂起它,下次再继续(对于 Major GC)。这保证了单个进程的垃圾回收不会阻塞整个系统的响应。