15、引用计数与垃圾回收:引用计数原理、循环引用问题、标记-清除算法简介

内存管理做到一定深度,你就会发现一个尴尬的事实:手动管理内存虽然可控,但太容易出错了。忘了 free、double free、野指针……这些坑我踩过不止一次。

那有没有一种机制,能帮我们自动回收不再使用的内存?有。这就是我们今天要聊的——垃圾回收(GC)。在 C 语言的世界里,虽然没有像 Java 那样的内置 GC,但很多嵌入式系统、脚本引擎、数据库内核,都自己实现了轻量级的 GC 机制。

今天我就带你拆解两种最基础的 GC 策略:引用计数标记-清除。尤其是引用计数里的循环引用问题,我曾经在一个物联网项目里被它坑惨了——嗯,后面我会细说。

垃圾回收核心知识体系 引用计数 (Reference Counting) 原理:每个对象维护引用计数器 优点:实时回收、延迟低 致命缺陷:循环引用 标记-清除 (Mark-Sweep) 原理:从根集出发,标记可达对象 优点:天然解决循环引用 缺点:STW暂停、碎片化 实战选择:小型系统用引用计数 + 弱引用;大型系统用标记-清除

15.1 引用计数:最直观的自动回收

引用计数的思路很简单:每个对象都带一个计数器,记录有多少指针指向它。当计数器归零时,就说明没人再用这个对象了,直接释放。

我习惯用一个结构体来包装所有需要 GC 管理的对象:

typedef struct GCObject {
    int refCount;           // 引用计数器
    void *data;             // 实际数据
    void (*freeData)(void*); // 自定义释放函数
} GCObject;

// 增加引用
void retain(GCObject *obj) {
    if (obj) {
        obj->refCount++;
    }
}

// 减少引用,归零时自动释放
void release(GCObject *obj) {
    if (obj == NULL) return;
    obj->refCount--;
    if (obj->refCount == 0) {
        if (obj->freeData) {
            obj->freeData(obj->data);
        }
        free(obj);
    }
}

你看,代码其实很简洁。每次你让一个指针指向对象,就调一次 retain();不再需要时,调 release()。计数器归零,自动回收。

我的经验:在嵌入式系统里,引用计数特别适合管理缓冲区、字符串、传感器数据包这类生命周期明确的对象。我做过一个温湿度采集器,每个采样数据包用引用计数管理,内存泄漏率直接降到零。

15.2 循环引用:引用计数的阿喀琉斯之踵

引用计数有个硬伤——循环引用。说白了就是两个对象互相指着对方,计数器永远不为零,内存就泄漏了。

举个例子:

typedef struct Node {
    int value;
    struct Node *next;
    GCObject gc;  // 内嵌GC对象
} Node;

void createCycle() {
    Node *a = malloc(sizeof(Node));
    Node *b = malloc(sizeof(Node));
    a->gc.refCount = 1;
    b->gc.refCount = 1;
    
    a->next = b;
    b->next = a;  // 循环引用!
    
    // 此时 a 和 b 的 refCount 都是 2
    // 即使外部指针消失,它们也永远不会被释放
}
我曾经踩过的坑:在一个智能家居网关项目中,我用引用计数管理设备对象间的依赖关系。结果设备 A 引用设备 B,设备 B 又回调引用设备 A,形成了循环引用。系统跑了三天后内存耗尽,网关死机。排查了一整天才找到原因——从那以后,我在设计引用计数系统时,一定会加上弱引用机制。

解决循环引用的常见方法:

  • 弱引用(Weak Reference):不增加计数器,只保存指针。需要使用时先尝试提升为强引用。
  • 手动打破循环:在适当时机将其中一个引用置为 NULL。
  • 结合标记-清除:定期用标记-清除算法扫描,回收循环引用的垃圾。

15.3 标记-清除算法:更彻底的解决方案

标记-清除(Mark-Sweep)是另一种经典的 GC 策略。它不依赖计数器,而是从根集(Root Set)出发,遍历所有可达对象,然后回收不可达的对象。

算法分两步:

  1. 标记阶段:从全局变量、栈上的指针等根对象出发,递归标记所有能访问到的对象。
  2. 清除阶段:遍历整个堆,回收所有未被标记的对象。

一个简化的实现思路:

// 标记位
#define MARKED 1
#define UNMARKED 0

typedef struct Object {
    int mark;
    struct Object *children[4]; // 假设最多4个子对象
    int childCount;
    // ... 其他数据
} Object;

// 标记阶段:递归标记
void mark(Object *obj) {
    if (obj == NULL || obj->mark == MARKED) return;
    obj->mark = MARKED;
    for (int i = 0; i < obj->childCount; i++) {
        mark(obj->children[i]);
    }
}

// 清除阶段:回收未标记对象
void sweep(Object *heap[], int heapSize) {
    for (int i = 0; i < heapSize; i++) {
        if (heap[i] && heap[i]->mark == UNMARKED) {
            free(heap[i]);
            heap[i] = NULL;
        } else if (heap[i]) {
            heap[i]->mark = UNMARKED; // 重置标记,为下一轮准备
        }
    }
}
关键区别:引用计数是增量式的,每次指针操作都更新计数器;标记-清除是全局式的,需要暂停程序(Stop-The-World)来执行完整扫描。前者适合实时性要求高的场景,后者适合内存较大的系统。

15.4 两种策略的对比与选择

特性 引用计数 标记-清除
回收时机 立即回收(计数器归零时) 延迟回收(GC触发时)
暂停时间 无暂停,分散到每次操作 需要STW暂停
循环引用 无法处理 天然解决
内存碎片 较少(及时释放) 可能产生碎片
实现复杂度 中高
适用场景 嵌入式、实时系统 桌面、服务器应用

我个人习惯这样选:如果系统内存小于 64KB,或者对实时性要求极高,我优先用引用计数 + 弱引用。如果内存充裕、可以接受偶尔的暂停,标记-清除更省心。

一个实用技巧:很多现代系统采用混合策略——大部分时间用引用计数快速回收,每隔一段时间或内存紧张时,触发一次标记-清除来清理循环引用。这种方案在嵌入式 JavaScript 引擎(比如 JerryScript)里很常见。

好了,关于引用计数和标记-清除,我们就聊到这里。这两种方法各有千秋,没有银弹。关键是根据你的项目场景,选对工具,避开陷阱。


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