22、内存压缩与GC:标记-清除、复制算法、分代收集

说到C语言的内存管理,很多朋友第一反应就是malloc和free。没错,这是咱们C程序员的看家本领。但今天我想聊点不一样的——当内存碎片化严重时,我们该怎么办?说白了,就是给堆内存做一次「大扫除」和「搬家」。

我在做嵌入式数据库项目时,遇到过这么个情况:系统跑了三天三夜,突然malloc返回NULL了。可我一算,剩余内存明明还有30%啊!这就是典型的内存碎片问题。嗯,这时候就需要内存压缩和GC(垃圾回收)技术登场了。

22.1 标记-清除算法

标记-清除是最基础的GC算法。它的思路很简单:先标记出所有存活的对象,然后把没标记的统统清掉。

我习惯把这个过程分成两步:

  1. 标记阶段:从根节点(全局变量、栈上的指针)出发,遍历所有可达对象,打上标记
  2. 清除阶段:扫描整个堆,把没标记的内存块回收

来看个简化版的实现:

// 标记-清除的简化实现
typedef struct MemBlock {
    int marked;          // 标记位
    size_t size;         // 块大小
    struct MemBlock* next;
    char data[0];        // 柔性数组
} MemBlock;

void mark(MemBlock* root) {
    if (!root || root->marked) return;
    root->marked = 1;
    // 递归标记所有引用的对象
    for (每个引用的子对象 child) {
        mark(child);
    }
}

void sweep(MemBlock* head) {
    MemBlock* curr = head;
    while (curr) {
        if (!curr->marked) {
            // 没标记的,回收
            free(curr);
        } else {
            curr->marked = 0;  // 清除标记,为下次GC做准备
        }
        curr = curr->next;
    }
}
⚠️ 注意:标记-清除有个大坑——会产生内存碎片。你想想看,清除掉的对象可能分散在各处,剩下的空闲块就像瑞士奶酪一样,全是小洞。我曾经在一个长期运行的服务里吃过这个亏,后来不得不加上内存压缩。

22.2 复制算法

复制算法就聪明多了。它把堆分成两个半区:From空间和To空间。每次只用From空间,等它满了,就把存活对象一股脑儿复制到To空间,然后交换角色。

这样做的好处是:

  • 没有碎片问题——复制过去是紧凑排列的
  • 分配速度快——只需要移动指针
  • 回收也快——只需要交换两个半区的指针

但代价也很明显:你永远有一半的内存是闲置的。我记得在做一个内存只有64KB的嵌入式项目时,这种浪费简直要了我的命。

// 复制算法的核心逻辑
void garbage_collect() {
    // From空间已满,开始GC
    scan_root_set();  // 从根开始遍历
    
    // 遍历From空间,复制存活对象到To空间
    for (每个存活对象 obj in From空间) {
        void* new_addr = To空间指针;
        memcpy(new_addr, obj, obj->size);
        obj->forwarding = new_addr;  // 记录转发地址
        To空间指针 += obj->size;
    }
    
    // 更新所有指针
    update_all_pointers();
    
    // 交换From和To
    swap(From空间, To空间);
    To空间指针 = To空间起始地址;
}
💡 小技巧:复制算法特别适合「朝生夕死」的场景——大部分对象活不过一次GC。如果你发现你的程序里对象存活率很高,那复制算法反而效率低下,因为每次都要复制大量数据。

22.3 分代收集

分代收集是我个人最喜欢的一种策略。它基于一个观察:大部分对象都是短命的。

你想想看,一个临时字符串、一个局部缓冲区,用完就扔。而全局配置、缓存池这些,往往能活到程序结束。

分代收集把堆分成几代:

特点 GC频率 算法
新生代(Young) 对象存活率低 复制算法
老年代(Old) 对象存活率高 标记-清除+压缩
永久代(Perm) 几乎不回收 极低 不回收或特殊处理

我在项目中实现过分代收集器,核心思路是这样的:

  1. 新对象都分配在新生代
  2. 新生代满了,触发Minor GC(用复制算法)
  3. 熬过几次GC的对象,晋升到老年代
  4. 老年代满了,触发Major GC(用标记-清除+压缩)

核心要点:分代收集的关键在于「代际指针」的维护。老年代对象可能引用新生代对象,所以Minor GC时不能只扫描新生代,还得检查老年代中指向新生代的引用。我曾经在这里踩过坑——忘了更新老年代对象的引用,结果程序跑着跑着就野指针了。

22.4 三种算法对比

说了这么多,咱们来做个对比总结:

算法 优点 缺点 适用场景
标记-清除 实现简单,不移动对象 碎片严重,分配效率低 内存充裕,对象存活率低
复制算法 无碎片,分配快 浪费一半内存 对象存活率低,内存够用
分代收集 综合性能好,适应性强 实现复杂,需要调优 大型系统,长期运行

说实话,在纯C环境里,我们很少自己实现完整的GC。但理解这些算法,对写好内存管理代码非常有帮助。比如我写内存池时,就会借鉴分代的思想——热数据用小块池,冷数据用大块池。

💡 避坑指南:我曾经在一个网络服务器里用标记-清除做内存压缩,结果因为标记阶段遍历了所有对象,导致GC停顿时间长达200ms。后来改成增量式标记,每次只标记一部分,才把停顿降到10ms以内。所以,如果你的程序对实时性有要求,一定要考虑GC的停顿时间。
三种GC算法核心逻辑对比 标记-清除算法 ① 从根遍历,标记存活对象 ② 扫描堆,回收未标记块 ⚠ 产生碎片,需压缩 复制算法 ① 将堆分为From/To两个半区 ② 存活对象复制到To空间 ⚠ 浪费50%内存 分代收集 ① 新生代:复制算法 ② 老年代:标记-清除+压缩 ✓ 综合性能最优 关键指标对比 指标 标记-清除 复制算法 分代收集 内存利用率 低(碎片多) 50% 分配速度 中等 实现复杂度 简单 中等 复杂

最后说一句,内存压缩和GC不是银弹。在C语言里,最好的内存管理还是「谁分配,谁释放」这个朴素原则。GC只是在你无法精确控制对象生命周期时的补救措施。我个人的经验是:能用栈就别用堆,能用静态分配就别用动态分配。实在躲不过去了,再考虑这些高级玩法。


公众号:蓝海资料掘金营,微信deep3321