《The BEAM Book》第十二章:垃圾回收器 (Garbage Collection) 深度详析
本章的核心在于解释 BEAM 如何在不停止整个系统的情况下,高效地管理内存。
12.1 复制式垃圾回收 (Copying Garbage Collection)
这一节介绍了 BEAM GC 的基本算法基础:分代(Generational) 和 复制(Copying)。
基本原理
- 触发时机:GC 发生时,系统分配一块新的内存区域(To-Space)。
- 扫描过程:从根集合(Roots)(主要是栈 Stack、寄存器)开始扫描。
- 复制操作:将所有“活”的对象从旧堆(From-Space)复制到新堆。
- 清理:复制完成后,旧堆被整体废弃。
详细步骤(Cheney 算法变体)
- 根扫描:找到栈上引用的堆对象。
复制与转发:
- 当一个对象(如 Cons Cell)被复制到新地址后,BEAM 会在旧对象的内存位置写入一个转发指针(Forwarding Pointer),指向新地址。
- 目的:如果堆中有多个指针指向同一个对象(共享数据),第一个指针触发复制后,后续指针看到“转发标记”,直接更新指向新地址,而不会重复复制对象。这保证了共享性(Sharing)在 GC 后依然存在。
- 广度优先遍历 (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)。
回收类型
- Minor GC (次级回收):只回收
high_water之上的新生代区域。存活下来的对象会被移动到老年代(Old Heap)。 - 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)
fullsweep_after标志:
你可以通过设置进程标志{fullsweep_after, N}来控制全量回收的频率。如果设为 0,则该进程每次 GC 都是全量回收(以 CPU 换内存,适合对延迟敏感但不产生大量垃圾的进程)。binary:copy/1的作用:
如果你有一个巨大的二进制数据(比如 10MB),然后匹配出了其中的一小段(比如 10字节)。这一小段是 Sub-binary,它在底层依然引用着那 10MB 的大二进制,导致 GC 无法回收那 10MB。- 优化:调用
binary:copy(SubBin)会创建一个新的深拷贝,断开对大数据的引用,从而允许 GC 回收那 10MB 空间。
- 优化:调用
- GC 是“借”来的时间:
Erlang 是软实时的,GC 的开销是分摊在每个进程的时间片里的。如果一个进程 GC 时间太长,调度器会强制挂起它,下次再继续(对于 Major GC)。这保证了单个进程的垃圾回收不会阻塞整个系统的响应。