22、内存压缩与GC:标记-清除、复制算法、分代收集
说到C语言的内存管理,很多朋友第一反应就是malloc和free。没错,这是咱们C程序员的看家本领。但今天我想聊点不一样的——当内存碎片化严重时,我们该怎么办?说白了,就是给堆内存做一次「大扫除」和「搬家」。
我在做嵌入式数据库项目时,遇到过这么个情况:系统跑了三天三夜,突然malloc返回NULL了。可我一算,剩余内存明明还有30%啊!这就是典型的内存碎片问题。嗯,这时候就需要内存压缩和GC(垃圾回收)技术登场了。
22.1 标记-清除算法
标记-清除是最基础的GC算法。它的思路很简单:先标记出所有存活的对象,然后把没标记的统统清掉。
我习惯把这个过程分成两步:
- 标记阶段:从根节点(全局变量、栈上的指针)出发,遍历所有可达对象,打上标记
- 清除阶段:扫描整个堆,把没标记的内存块回收
来看个简化版的实现:
// 标记-清除的简化实现
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空间起始地址;
}
22.3 分代收集
分代收集是我个人最喜欢的一种策略。它基于一个观察:大部分对象都是短命的。
你想想看,一个临时字符串、一个局部缓冲区,用完就扔。而全局配置、缓存池这些,往往能活到程序结束。
分代收集把堆分成几代:
| 代 | 特点 | GC频率 | 算法 |
|---|---|---|---|
| 新生代(Young) | 对象存活率低 | 高 | 复制算法 |
| 老年代(Old) | 对象存活率高 | 低 | 标记-清除+压缩 |
| 永久代(Perm) | 几乎不回收 | 极低 | 不回收或特殊处理 |
我在项目中实现过分代收集器,核心思路是这样的:
- 新对象都分配在新生代
- 新生代满了,触发Minor GC(用复制算法)
- 熬过几次GC的对象,晋升到老年代
- 老年代满了,触发Major GC(用标记-清除+压缩)
核心要点:分代收集的关键在于「代际指针」的维护。老年代对象可能引用新生代对象,所以Minor GC时不能只扫描新生代,还得检查老年代中指向新生代的引用。我曾经在这里踩过坑——忘了更新老年代对象的引用,结果程序跑着跑着就野指针了。
22.4 三种算法对比
说了这么多,咱们来做个对比总结:
| 算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 标记-清除 | 实现简单,不移动对象 | 碎片严重,分配效率低 | 内存充裕,对象存活率低 |
| 复制算法 | 无碎片,分配快 | 浪费一半内存 | 对象存活率低,内存够用 |
| 分代收集 | 综合性能好,适应性强 | 实现复杂,需要调优 | 大型系统,长期运行 |
说实话,在纯C环境里,我们很少自己实现完整的GC。但理解这些算法,对写好内存管理代码非常有帮助。比如我写内存池时,就会借鉴分代的思想——热数据用小块池,冷数据用大块池。
最后说一句,内存压缩和GC不是银弹。在C语言里,最好的内存管理还是「谁分配,谁释放」这个朴素原则。GC只是在你无法精确控制对象生命周期时的补救措施。我个人的经验是:能用栈就别用堆,能用静态分配就别用动态分配。实在躲不过去了,再考虑这些高级玩法。
公众号:蓝海资料掘金营,微信deep3321