24、引用计数:什么是引用计数、手动实现一个简单的引用计数

说到内存管理,C语言里最让人头疼的就是「谁释放、何时释放」这个问题。我自己早年做嵌入式开发时,就吃过不少野指针的亏。后来接触了引用计数,才觉得这块逻辑清晰了很多。

引用计数,说白了就是一种「共享资源自动回收」的机制。它的核心思想很简单:每个动态分配的对象,都配一个计数器,记录当前有多少指针在引用它。当计数器归零时,就自动释放这块内存。

24.1 引用计数的基本原理

你想想看,我们平时用 malloc 分配一块内存,然后多个函数、多个模块都可能持有指向它的指针。如果其中一个模块 free 了,其他模块的指针就成了野指针。反过来,如果谁都不释放,就内存泄漏了。

引用计数的做法是:

  • 对象创建时,计数器初始化为 1
  • 每次新增一个引用,计数器加 1
  • 每次释放一个引用,计数器减 1
  • 计数器减到 0 时,真正释放内存

嗯,这里要注意:引用计数不是 C 语言标准库的一部分,它需要我们手动实现。但很多高级语言(比如 Python、Swift)的底层都在用这个机制。

核心要点:引用计数解决的是「多所有者」场景下的内存释放问题。谁最后一个放手,谁负责打扫卫生。

24.2 手动实现一个简单的引用计数

我在项目中遇到过类似的需求:一个缓冲区被多个线程共享,需要确保所有线程用完后再释放。当时我就手写了一个简单的引用计数模块。

下面是一个最基础的实现:

#include <stdio.h>
#include <stdlib.h>

// 引用计数结构体
typedef struct {
    int *data;       // 实际数据
    int *ref_count;  // 引用计数器
} RefCounted;

// 创建对象,初始引用计数为 1
RefCounted* rc_create(int value) {
    RefCounted *obj = (RefCounted*)malloc(sizeof(RefCounted));
    obj->data = (int*)malloc(sizeof(int));
    obj->ref_count = (int*)malloc(sizeof(int));
    
    *(obj->data) = value;
    *(obj->ref_count) = 1;  // 初始引用
    
    printf("创建对象,值: %d,引用计数: %d\n", value, *(obj->ref_count));
    return obj;
}

// 增加引用
void rc_retain(RefCounted *obj) {
    if (obj == NULL) return;
    (*(obj->ref_count))++;
    printf("增加引用,当前计数: %d\n", *(obj->ref_count));
}

// 减少引用,归零时释放
void rc_release(RefCounted *obj) {
    if (obj == NULL) return;
    
    (*(obj->ref_count))--;
    printf("释放引用,当前计数: %d\n", *(obj->ref_count));
    
    if (*(obj->ref_count) == 0) {
        printf("引用归零,释放内存\n");
        free(obj->data);
        free(obj->ref_count);
        free(obj);
    }
}

int main() {
    RefCounted *p1 = rc_create(42);
    RefCounted *p2 = p1;  // p2 也指向同一对象
    
    rc_retain(p1);        // 手动增加引用计数
    
    rc_release(p1);       // p1 不再使用
    rc_release(p2);       // p2 不再使用,此时归零释放
    
    return 0;
}
个人习惯:我一般把 retainrelease 封装成宏或内联函数,减少函数调用开销。在嵌入式场景下,这点性能差别有时候很关键。

24.3 引用计数的常见陷阱

引用计数看起来简单,但用起来有不少坑。我曾经在一个网络库中踩过循环引用的雷,排查了两天才找到原因。

循环引用:如果对象 A 引用了对象 B,对象 B 又引用了对象 A,那么两者的引用计数永远不会归零,导致内存泄漏。这是引用计数最经典的坑。

举个例子:

// 假设有两个结构体互相引用
typedef struct NodeA {
    struct NodeB *b;
    int ref_count;
} NodeA;

typedef struct NodeB {
    struct NodeA *a;
    int ref_count;
} NodeB;

// 创建 A 和 B,互相赋值后
// A->ref_count = 1 (被 B 引用)
// B->ref_count = 1 (被 A 引用)
// 外部指针释放后,两者永远无法归零

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

  • 弱引用(weak reference):不增加引用计数,只保存指针。比如父节点强引用子节点,子节点弱引用父节点。
  • 手动打破循环:在适当时机主动将某个引用置为 NULL。

24.4 引用计数的优缺点

优点 缺点
实现简单,容易理解 无法自动处理循环引用
内存释放及时,无延迟 每次增减引用都有性能开销
适合单线程或简单多线程场景 多线程下需要原子操作,复杂度上升
不需要暂停程序(不像 GC) 每个对象都需要额外存储计数器

24.5 引用计数的应用场景

我个人觉得,引用计数最适合以下场景:

  • 共享缓冲区:多个模块读写同一块数据,最后一个用完的负责释放
  • 对象池:对象被多次借出和归还,用引用计数跟踪使用状态
  • 智能指针:C++ 的 shared_ptr 底层就是引用计数
  • COM 组件:Windows 的 COM 规范强制使用 AddRefRelease
避坑指南:我曾经在项目里直接用 int 做计数器,结果在高并发下出现了计数错误。后来改成 atomic_int 才稳定。如果你做多线程引用计数,一定要用原子操作。

24.6 引用计数的核心流程图

下面这张图展示了引用计数的完整生命周期:

引用计数生命周期 创建对象 ref_count = 1 使用中 retain() → ref_count++ retain() release() 引用计数 +1 继续使用 引用计数 -1 检查是否归零 ref_count == 0? 继续使用 释放内存 free(data + ref_count)

从图中可以看到,引用计数的核心就是「增、减、判零」三个操作。只要保证这三个操作的原子性和正确性,整个机制就能稳定运行。

24.7 总结

引用计数不是什么高深的技术,但它解决了一个很实际的问题:多指针共享内存时,如何安全地释放。我建议你在自己的小项目里试着实现一次,踩踩坑,才能真正理解它的边界在哪里。

嗯,最后提醒一句:引用计数不是银弹。如果你的对象关系复杂、循环引用多,或者对性能要求极高,可能需要考虑其他方案,比如标记-清除 GC 或者 arena 分配器。

一句话记住:引用计数 = 共享 + 计数 + 自动释放。三个要素缺一不可。

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