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;
}
retain 和 release 封装成宏或内联函数,减少函数调用开销。在嵌入式场景下,这点性能差别有时候很关键。
24.3 引用计数的常见陷阱
引用计数看起来简单,但用起来有不少坑。我曾经在一个网络库中踩过循环引用的雷,排查了两天才找到原因。
举个例子:
// 假设有两个结构体互相引用
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 规范强制使用
AddRef和Release
int 做计数器,结果在高并发下出现了计数错误。后来改成 atomic_int 才稳定。如果你做多线程引用计数,一定要用原子操作。
24.6 引用计数的核心流程图
下面这张图展示了引用计数的完整生命周期:
从图中可以看到,引用计数的核心就是「增、减、判零」三个操作。只要保证这三个操作的原子性和正确性,整个机制就能稳定运行。
24.7 总结
引用计数不是什么高深的技术,但它解决了一个很实际的问题:多指针共享内存时,如何安全地释放。我建议你在自己的小项目里试着实现一次,踩踩坑,才能真正理解它的边界在哪里。
嗯,最后提醒一句:引用计数不是银弹。如果你的对象关系复杂、循环引用多,或者对性能要求极高,可能需要考虑其他方案,比如标记-清除 GC 或者 arena 分配器。
公众号:蓝海资料掘金营,微信 deep3321