18、内存池与垃圾回收:标记-清除、复制算法、分代回收
聊到内存池,我们通常关注的是“怎么分配得快”。但还有一个更棘手的问题——怎么回收得干净。尤其是长时间运行的服务,内存泄漏就像慢性病,不致命但拖垮性能。
我个人习惯把垃圾回收(GC)看作内存池的“保洁阿姨”。你只管申请,它负责打扫。但不同的打扫方式,代价完全不同。今天我们就来拆解三种经典算法:标记-清除、复制算法、分代回收。
核心观点:没有完美的GC算法,只有适合场景的取舍。理解它们的本质,你才能为自己的内存池选对策略。
18.1 标记-清除:最朴素的思路
标记-清除(Mark-Sweep)是GC算法里的“老大哥”。思路非常直白:先遍历所有对象,把还活着的打上标记;然后扫一遍内存,把没标记的当成垃圾清掉。
听起来简单对吧?但坑就藏在细节里。
我的经验:有一次我在嵌入式设备上实现了一个简易内存池,用的就是标记-清除。跑了一段时间后,发现分配越来越慢。一查,原来是内存碎片化严重——虽然总空闲空间够,但都是小碎片,大块分配总是失败。
标记-清除最大的问题就是碎片化。你想想看,清除掉的对象散落在各处,留下的空洞就像瑞士奶酪。下次要分配一个大对象时,可能找不到连续空间。
// 标记-清除的简化伪代码
void mark_sweep_gc() {
// 标记阶段:从根集出发,标记所有可达对象
for (每个根对象 root) {
mark(root);
}
// 清除阶段:遍历堆,回收未标记对象
for (每个内存块 block) {
if (!block->marked) {
free(block);
} else {
block->marked = false; // 清除标记,为下次GC做准备
}
}
}
注意:标记-清除的暂停时间与存活对象数量成正比。如果堆里大部分对象都活着,标记阶段会非常慢。我曾经在一个缓存服务里踩过这个坑——GC一跑,整个服务卡顿好几秒。
18.2 复制算法:用空间换时间
复制算法(Copying)的思路完全不同。它把内存分成两半(From空间和To空间)。分配时只在From空间进行。当From空间满了,就把存活的对象一股脑复制到To空间,然后交换角色。
这样做的好处很明显:没有碎片。所有存活对象被紧凑地排列在To空间里,分配时只需要移动指针就行。
但代价也很大——你有一半的内存是闲置的。说白了,就是拿空间换时间。
// 复制算法的简化实现
void* copying_alloc(size_t size) {
if (from_space_free < size) {
// 触发GC:将存活对象复制到to空间
copy_live_objects();
// 交换from和to
swap(from_space, to_space);
}
// 从from空间分配
void* ptr = from_space_ptr;
from_space_ptr += size;
from_space_free -= size;
return ptr;
}
适用场景:复制算法特别适合“朝生夕死”的对象——大部分对象很快变成垃圾,只有少数存活。这样复制成本很低。我见过一些游戏引擎的内存池就用这个思路,每帧结束后回收临时对象,效率极高。
但反过来,如果对象存活率很高(比如80%以上),复制算法就惨了——你要复制大量对象,而且To空间可能不够用。这时候,标记-清除反而更合适。
18.3 分代回收:组合拳才是王道
你可能会问:能不能把两种算法的优点结合起来?当然可以。这就是分代回收(Generational Collection)的核心思想。
分代回收基于一个观察:大部分对象都是短命的。比如你在一个函数里创建的临时缓冲区,函数返回后就没用了。而一些全局配置、缓存对象,往往活得很久。
所以,我们把堆分成几代:
| 代 | 特点 | 回收策略 | 频率 |
|---|---|---|---|
| 新生代(Young) | 对象刚创建,存活率低 | 复制算法 | 高 |
| 老年代(Old) | 熬过多次GC,存活率高 | 标记-清除 或 标记-整理 | 低 |
| 永久代(Perm) | 类信息、常量等 | 几乎不回收 | 极低 |
新生代用复制算法,因为大部分对象很快死掉,复制成本低。老年代用标记-清除(或标记-整理),因为对象存活率高,复制不划算。
我的建议:如果你自己实现分代内存池,一定要处理好“跨代引用”。老年代对象可能引用新生代对象,GC时不能漏掉。我通常用一个“记忆集”(Remembered Set)来记录这些跨代指针,虽然增加了一点开销,但能保证正确性。
18.4 避坑指南
这些年我踩过不少GC的坑,分享几个典型的:
- 不要忽视GC暂停:我曾经在一个实时音频处理程序里用了简单的标记-清除,结果GC一跑,音频出现卡顿。后来改成增量式GC,把暂停分散到多次分配中,问题才解决。
- 小心指针混淆:复制算法会移动对象,所有指向它的指针都要更新。如果你在C语言里手动管理,很容易漏掉某个指针,导致野指针崩溃。我建议用句柄(Handle)间接引用,或者用写屏障(Write Barrier)自动追踪。
- 分代阈值要调优:新生代升入老年代的阈值不是固定的。对象存活次数、分配速率、内存大小都会影响。我一般先用默认值跑一段时间,然后根据GC日志调整。
重要提醒:GC不是银弹。如果你的内存池场景很简单(比如固定大小的对象池),完全没必要上GC。手动管理反而更高效。只有当你面对复杂、动态的内存生命周期时,GC才值得引入。
18.5 小结
三种算法各有千秋:标记-清除简单但碎片多,复制算法快但浪费空间,分代回收综合了前两者的优点。实际项目中,我倾向于用分代回收作为基础,再根据具体场景微调各代的策略。
记住一点:没有最好的GC,只有最合适的GC。理解它们的本质,你才能为自己的内存池做出正确的选择。
公众号:蓝海资料掘金营,微信deep3321