内存回收策略:延迟释放、垃圾回收、引用计数

内存管理这事儿,说白了就是「借了要还」。你从堆上申请了一块内存,用完了就得还回去。但问题来了——什么时候还?怎么还?谁来盯着你还?

我早年做嵌入式开发时,就吃过内存泄漏的亏。一个跑了几天的设备突然死机,查了半天,发现是一个定时器回调里每次分配了 128 字节,但从来没释放过。嗯,从那以后我对内存回收就特别敏感。

今天咱们聊三种主流的内存回收策略:延迟释放垃圾回收引用计数。每种策略都有自己的脾气,咱们一个一个看。

一、延迟释放(Deferred Free)

延迟释放,顾名思义——不急着还。你调用 free() 的时候,系统并没有真的把内存还给操作系统,而是先把它放到一个「待回收池」里,等凑够一批再统一处理。

为什么要这么做?因为 free() 本身是有开销的。频繁地申请和释放小块内存,会导致性能抖动。我见过一个网络服务器项目,每秒要处理上万个小请求,每个请求都 malloc/free,结果 CPU 时间全耗在内存管理上了。

核心思路:把释放操作推迟到合适的时机,减少系统调用次数,提升吞吐量。

举个例子,Linux 内核里的 kfree_rcu() 就是一种延迟释放机制。它不立即释放内存,而是等所有读者都离开临界区之后,再安全地回收。

// 伪代码:延迟释放的简单实现
struct deferred_free {
    void *ptr;
    struct deferred_free *next;
};

static struct deferred_free *pending = NULL;

void my_free(void *ptr) {
    struct deferred_free *node = malloc(sizeof(*node));
    node->ptr = ptr;
    node->next = pending;
    pending = node;
}

void flush_deferred() {
    while (pending) {
        struct deferred_free *node = pending;
        pending = node->next;
        free(node->ptr);
        free(node);
    }
}

你看,my_free() 只是把指针存起来,真正的 free()flush_deferred() 里统一执行。这样就能把多次小释放合并成一次大释放。

我的习惯:在实时性要求不高的后台任务里,我经常用延迟释放。比如定时器每 5 秒 flush 一次,既不影响主流程,又能把内存碎片降到最低。

二、垃圾回收(Garbage Collection)

垃圾回收,简称 GC。你不需要手动 free(),系统会自动帮你找到那些「没人用的内存」,然后回收掉。

C 语言本身没有 GC,但你可以用第三方库,比如 Boehm GC。我曾在某个遗留项目里用过它,说实话,接入成本不高,但性能开销你得心里有数。

GC 的核心逻辑分两步:

  1. 标记(Mark):从根集合(全局变量、栈变量)出发,遍历所有可达对象,打上标记。
  2. 清扫(Sweep):遍历整个堆,把没标记的对象回收掉。

说白了,GC 就是「谁在用,谁就活着;没人用,就回收」。听起来很美好,但有个问题——GC 运行时,程序得停下来。这叫 Stop-The-World。你想想看,一个游戏服务器突然卡顿 100 毫秒,玩家会怎么想?

注意:GC 不是银弹。在实时系统、嵌入式设备里,GC 的停顿时间可能是致命的。我曾经在一个无人机飞控项目里尝试引入 GC,结果发现每次 GC 触发时,控制周期直接超时——果断放弃了。

GC 的优点是省心。你不需要记着每个 malloc 都要配一个 free,代码里少了很多配对逻辑。但代价是:你失去了对内存回收时机的精确控制。

三、引用计数(Reference Counting)

引用计数,是另一种思路。每个对象里存一个计数器,记录有多少指针指向它。计数器归零时,立即释放。

这个策略在 C++ 的 shared_ptr 里用得最多。C 语言里你也可以自己实现,比如这样:

struct ref_counted {
    int refs;
    void *data;
};

void ref_inc(struct ref_counted *obj) {
    obj->refs++;
}

void ref_dec(struct ref_counted *obj) {
    if (--obj->refs == 0) {
        free(obj->data);
        free(obj);
    }
}

每次赋值、传参、返回时,你都得手动调用 ref_incref_dec。这活儿很琐碎,但好处是——内存回收是即时的,没有 GC 那种停顿。

引用计数的致命弱点:循环引用。A 引用 B,B 引用 A,两个对象的计数器永远不为零,内存泄漏了。

我遇到过这种情况。一个图形渲染管线里,纹理对象和渲染上下文互相引用,结果程序跑着跑着内存就爆了。后来加了个弱引用(weak reference)才解决。

引用计数的另一个问题是性能。每次指针操作都要修改计数器,多线程环境下还得加锁。你想想看,一个高频调用的函数里,每次赋值都多一次原子操作,性能能好到哪去?

三种策略对比

策略 优点 缺点 适用场景
延迟释放 减少系统调用,降低碎片 内存占用暂时增大 高吞吐、低实时性要求
垃圾回收 自动管理,省心 停顿时间不可控 桌面应用、脚本引擎
引用计数 即时回收,确定性高 循环引用,性能开销大 资源管理、智能指针

知识体系图

内存回收策略 延迟释放 减少系统调用 批量回收 适合高吞吐场景 垃圾回收 标记-清扫 Stop-The-World 适合桌面应用 引用计数 即时回收 循环引用问题 适合资源管理 没有银弹,根据场景选择合适的策略

我的建议

这三种策略没有绝对的好坏。我个人的习惯是:

  • 嵌入式、实时系统:用延迟释放 + 手动管理,别碰 GC。
  • 大型应用、脚本引擎:用 GC,省心,但要做好停顿优化。
  • 资源管理、智能指针:用引用计数,但一定要处理好循环引用。

其实很多时候,你会混合使用。比如一个网络库,核心数据用引用计数管理,日志系统用延迟释放,上层业务逻辑用 GC。嗯,这就是现实——没有银弹,只有权衡。

一个小技巧:如果你不确定用哪种策略,先用手动管理。等性能瓶颈出现了,再针对性优化。别一开始就上复杂方案,容易把自己绕进去。

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