18、内存池与垃圾回收:标记-清除、复制算法、分代回收

聊到内存池,我们通常关注的是“怎么分配得快”。但还有一个更棘手的问题——怎么回收得干净。尤其是长时间运行的服务,内存泄漏就像慢性病,不致命但拖垮性能。

我个人习惯把垃圾回收(GC)看作内存池的“保洁阿姨”。你只管申请,它负责打扫。但不同的打扫方式,代价完全不同。今天我们就来拆解三种经典算法:标记-清除、复制算法、分代回收。

核心观点:没有完美的GC算法,只有适合场景的取舍。理解它们的本质,你才能为自己的内存池选对策略。

垃圾回收三大算法对比 标记-清除 Mark-Sweep 复制算法 Copying 分代回收 Generational 特点: • 标记存活对象 • 清除未标记对象 • 会产生内存碎片 特点: • 将内存分为两半 • 存活对象复制到另一半 • 无碎片,但浪费空间 特点: • 对象按年龄分代 • 新生代频繁回收 • 老年代低频回收 适用:内存充足,容忍碎片 适用:存活率低,追求速度 适用:长期服务,综合性能

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