周末,你和两位朋友借了一间活动室。你先到,管理员开门、开灯,把房间交给你使用;过了一会儿,两位朋友也来了,大家共用这间房间。
你最先忙完,准备离开。这时能顺手关灯锁门吗?当然还不行,因为两位朋友还在里面。可如果每个人都觉得「别人会收尾」,最后又可能谁也没关灯。
问题在于:一个人用完了,不代表所有人都用完了。怎样才能知道,什么时候可以关闭这间活动室?
可以约定一条简单的规则:管理员记录当前还有多少人需要使用房间。每来一位使用者,记录加一;每有一位确认不再使用,记录减一。人数从 1 变成 2、3,再随着大家离开变成 2、1。只有最后一个人离开、记录归零时,才关灯锁门。
这样,先离开的人只需说明「我用完了」,不必猜测别人什么时候结束;最后一次离开,则有了明确的收尾动作。这里假设每个人都按约定登记和离开,现实中若有人忘记登记或注销,这套办法也会出错。
把活动室换成程序里的一段共享数据,问题就很接近了:模块 A 创建数据,模块 B、C 也要继续使用。A 完成工作时不能直接释放内存,必须等 B、C 也不再需要,才能安全清理。
这就引出了引用计数(Reference Counting):为共享资源记录还有多少个需要保持它存活的引用,最后一个引用释放时,再销毁资源。 活动室的「关灯锁门」对应资源清理,并不表示要拆掉房间;程序中的具体清理动作,可以是释放内存、关闭文件或断开连接。
不过,程序不能直接照搬「数人数」:同一个模块可能持有多个引用,也可能只是临时借用别人的引用。要把这套办法写成可靠的代码,我们还需要先弄清楚:哪些引用应该计数,谁负责归还它们?
先理解所有权:数的是引用,不是使用人数
引用计数为资源维护一个计数器:每增加一个拥有所有权的引用,计数加一;每释放一个这样的引用,计数减一;最后一个引用释放时,销毁资源。
这里的关键词是拥有所有权的引用。它承诺在自己仍需使用资源时,维持资源的生命周期。一个模块可以持有多个引用,多个临时访问也可以只借用同一个已有引用,因此计数不能简单理解为用户数、访问次数或指针变量数。
| 操作 | 是否改变引用计数 | 原因 |
|---|---|---|
| 创建资源并交给首个拥有者 | 通常初始化为 1 | 已有一个引用负责保持资源存活 |
| 新增一个共享拥有者 | 加一 | 新拥有者需要独立维持生命周期 |
| 在有效期内临时借用资源 | 不变 | 由已有拥有者保证借用期间不被释放 |
| 转移一个已有引用的所有权 | 不变 | 负责持有它的人变了,引用总数没变 |
| 释放一个拥有型引用 | 减一 | 该拥有者不再维持生命周期 |
| 释放最后一个拥有型引用 | 减到零并销毁 | 已经没有拥有者 |
例如,C 语言里仅执行 Resource *b = a; 只复制地址,不会自动增加计数。若 B 需要独立持有,就必须调用约定的获取引用接口;否则 B 只能在 A 保证资源存活的时间内借用。
回到活动室的例子,拥有型引用更像一张有效的使用凭证:只要还持有凭证,房间就需要保持开放;如果只是跟随朋友临时进去,就必须在朋友交还凭证之前离开。程序里的借用也有这样的有效期约束。
创建、共享、释放:一次完整流程
先看三个拥有者共享同一个对象的过程:
| 步骤 | 操作 | 计数变化 | 对象状态 |
|---|---|---|---|
| 1 | A 创建对象并取得首个引用 | 无对象 → 1 | 分配并初始化 |
| 2 | B 获取一个引用 | 1 → 2 | 继续存活 |
| 3 | C 获取一个引用 | 2 → 3 | 继续存活 |
| 4 | A 释放自己的引用 | 3 → 2 | B、C 仍可使用 |
| 5 | B 释放自己的引用 | 2 → 1 | C 仍可使用 |
| 6 | C 释放自己的引用 | 1 → 0 | 销毁对象 |
引用计数规定的是共享和销毁规则,并不要求在第一次 get() 时创建资源。 可以把创建与获取引用合并,也可以像下面的示例一样,分别提供 create()、get()、put()。接口名称没有统一标准,调用者必须理解每个接口是否交付一个需要归还的引用。
对象销毁后,原来的地址不能再拿来增加计数。下一次需要同类资源时,应创建新对象,或通过有同步保护的注册表等机制取得有效引用。
C语言示例:手动配对get与put
先只看单线程里的三个动作:创建时把计数设为 1,共享时加一,释放时减一。为了突出这条主线,示例直接在 main() 中创建对象,用 get() 和 put() 管理引用。
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int count;
int data;
} Resource;
Resource *get(Resource *r)
{
r->count++; /* 多一个拥有者 */
return r;
}
void put(Resource *r)
{
r->count--; /* 少一个拥有者 */
if (r->count == 0) {
printf("destroyn");
free(r); /* 最后一个拥有者负责释放 */
}
}
int main(void)
{
Resource *a = malloc(sizeof(Resource));
if (a == NULL) return 1;
a->count = 1;
a->data = 42;
Resource *b = get(a); /* 1 → 2,a、b 共享同一个对象 */
put(a); /* 2 → 1,b 还在使用,不能释放 */
a = NULL;
printf("%dn", b->data);
put(b); /* 1 → 0,现在才释放 */
b = NULL;
return 0;
}
保存为 reference_counting.c,使用 GCC 编译并运行:
gcc -std=c99 reference_counting.c -o reference_counting
./reference_counting
预期输出:
42
destroy
关键在于:put(a) 之后,B 还能读到 42;直到 put(b),才输出 destroy 并释放内存。两个指针指向同一个对象,但各自持有一份需要归还的引用。
这里用两个拥有者演示前面三个拥有者的同一套规则,计数路径缩短为 1 → 2 → 1 → 0。如果增加 C,只需再调用一次 get(),并在 C 用完后配对调用一次 put()。
这个教学示例要求:传给 get()、put() 的都是有效引用,每份拥有型引用只归还一次,计数不会溢出。put() 不会自动清空调用者变量,所以用完后手动置空;对空指针或已释放地址再次调用仍是错误。并发同步等问题放到后文讨论。
C++ shared_ptr:把引用配对交给对象生命周期
C++11 的 std::shared_ptr 用控制块记录共享所有权。拷贝一个拥有资源的 shared_ptr 会增加强引用计数;析构或 reset() 会释放它持有的引用;最后一个强引用释放时销毁被管理对象。
#include <iostream>
#include <memory>
struct Resource {
~Resource() { std::cout << "destroyn"; }
};
int main()
{
auto a = std::make_shared<Resource>(); // 计数 1
{
auto b = a; // 计数 2
} // b 离开作用域,计数 1
std::cout << "a still owns itn";
} // a 离开作用域,计数 0,销毁资源
保存为 shared_ptr_demo.cpp,编译并运行:
g++ -std=c++11 shared_ptr_demo.cpp -o shared_ptr_demo
./shared_ptr_demo
预期输出:
a still owns it
destroy
这里不再手动调用 get()、put():拷贝 shared_ptr 相当于获取引用,离开作用域则自动归还。B 先结束时资源保留,A 最后结束时才销毁,与前面的 C 示例一致。
这是 RAII 的一种应用:用 C++ 对象的生命周期管理资源,正常离开作用域或异常展开时都自动归还引用。RAII 不等于引用计数,std::unique_ptr 同样采用 RAII,却使用独占所有权。
实际使用时还要区分几种动作:
| 写法 | 所有权效果 |
|---|---|
auto b = a; |
拷贝共享所有权,强引用加一 |
auto b = std::move(a); |
转移所有权,引用总数不增加,a 变空 |
auto p = a.get(); |
返回借用的裸指针,不增加计数 |
a.reset(); |
释放 a 的引用,可能触发销毁 |
use_count() 可以观察强引用数量,但不应作为并发环境中是否可以安全修改对象的判断条件。也不要用同一个裸指针分别构造两个独立的 shared_ptr:两个控制块会各自认为自己负责释放,导致重复释放。应拷贝已有的 shared_ptr。
关于控制块与接口行为,可参照微软的 shared_ptr 文档。强引用归零会销毁被管理对象;若还有弱引用,控制块会继续保留,以便判断对象是否已经失效。
为什么不直接手写一个智能指针?
仅有构造、拷贝构造和析构还不够。若遗漏拷贝赋值运算符,默认赋值会直接覆盖指针和计数器地址,既不释放左侧原有引用,也不增加右侧引用,造成泄漏或重复释放。
完整实现还需要处理自赋值、空指针、移动操作、异常安全和控制块分配失败等情况。因此,理解原理可以从上面的 C 示例入手;生产代码优先使用标准库,避免把教学用简化实现当成 shared_ptr 的替代品。
循环引用:为什么计数可能永远不归零?
假设 A 用 shared_ptr 持有 B,B 又用 shared_ptr 持有 A。外部引用全部释放后,A、B 仍各有一个来自对方的强引用,计数都不会归零,对象便无法自动销毁。
解决方法是根据所有权关系,把其中一条边改成 std::weak_ptr。弱引用不会增加强引用计数,访问前通过 lock() 尝试取得一个临时强引用:成功后,在该强引用存在期间对象保持存活;失败则说明对象已销毁。
例如,父节点拥有子节点,子节点只需要找到父节点,可以把两种关系写成:
struct Node {
std::shared_ptr<Node> child; // 拥有子节点,增加强引用计数
std::weak_ptr<Node> parent; // 观察父节点,不增加强引用计数
};
这是一段关系定义,需包含 <memory>,并放进自己的程序中。重点只看两条边:父 → 子是拥有,子 → 父是观察,这样就不会形成互相维持存活的强引用环。
需要通过弱引用访问父节点时,用 lock() 取得临时强引用,成功后再使用;不要先检查是否过期,再假定对象一定还在。具体用法可参照微软的 weak_ptr 指南。
线程安全:原子加减计数就够了吗?
多个线程同时修改普通整数计数会产生数据竞争。但把计数改成原子变量,只解决了部分问题:如果一个线程已经释放最后一个引用,另一个线程再通过失效的裸指针增加计数,访问的仍然是已经释放的内存。
因此,并发设计还必须保证获取引用时,对象仍然存活。例如,从共享注册表取对象时,用同一把锁保护查找和获取引用,并与移除、销毁流程协调。对象内部业务数据的读写,也需要另外的同步机制。
shared_ptr 支持多个线程操作各自独立、但共享同一控制块的实例;这不代表多个线程可以无同步地修改同一个 shared_ptr 变量,也不代表它指向的对象自动线程安全。可参照微软的 C++ 标准库线程安全说明。
真实应用:Linux内核对象与文件硬链接
Linux 内核提供 struct kref 来帮助管理对象生命周期:初始化得到首个引用,新增拥有者时获取引用,释放时减少计数,最后一个引用释放时调用清理回调。内核还有其他计数类型,并非每个内核对象都直接使用 kref。具体约定见 Linux 内核 kref 文档。
文件硬链接也能帮助理解计数,不过需要区分两个概念:硬链接计数记录有多少目录项指向文件;进程打开文件后还持有打开文件引用。
ln file_a file_b
rm file_a
对于普通文件,删除 file_a 后,file_b 仍能访问相同内容。即使最后一个文件名也被删除,只要进程仍打开该文件,数据仍会保留;最后一个相关文件描述符关闭后,文件占用空间才可回收。因而「链接计数归零就立即释放数据」并不完整,打开文件并不会增加硬链接计数。这个行为可参照 Linux unlink 手册。
如何检查引用计数的生命周期流程?
检查代码时,可以沿着一个对象从创建到销毁的路径逐项确认:
- 创建成功后,首个引用交给谁? 分配失败是否直接返回,避免读写空指针?
- 哪些操作新增所有权? 裸指针赋值是借用还是持有,接口和注释是否一致?
- 每个引用在哪里释放? 正常结束、提前返回和错误分支都要覆盖;转移所有权时不要额外减计数。
- 最后一次释放后还有谁访问? 回调、异步任务和注册表可能仍保存旧地址,需要明确生命周期保护。
- 对象之间是否形成强引用环? 根据实际所有权选择弱引用,或显式拆除关系。
- 并发时如何取得有效引用? 分别检查计数更新、对象查找与业务数据同步。
验证时可记录创建、获取和释放事件:C 示例应走完 1 → 2 → 1 → 0,且只销毁一次;C++ 示例应验证离开内层作用域后对象仍存活。扩展弱引用示例时,可以验证父节点销毁后 lock() 返回空。还应覆盖分配失败、空引用释放和错误分支清理,条件允许时使用 AddressSanitizer 或 Valgrind 检查非法访问与泄漏。
引用计数、独占所有权与连接池怎么选?
| 场景 | 可以采用的方式 | 需要明确的边界 |
|---|---|---|
| 生命周期由单个对象负责 | 栈对象、RAII、unique_ptr |
谁拥有,何时转移 |
| 多个模块需要独立保持资源存活 | 引用计数、shared_ptr |
每次获取与释放是否配对 |
| 父子结构带反向观察关系 | 强引用配合 weak_ptr |
哪个方向承担所有权 |
| 大量任意对象关系和引用环 | 根据运行环境评估追踪式 GC 等方案 | 回收时机与运行时成本 |
| 数据库连接池 | 池管理连接,租约管理借用期限 | 归还池与销毁连接是不同动作 |
连接池通常希望最后一个借用者结束时把连接归还池中,以便复用,而非每次都断开连接。引用计数可以参与租约设计,却不能替代容量限制、连接健康检查和空闲回收策略。
设计共享资源时,先把「谁必须保证它继续存在」写清楚,再选择管理方式。引用计数让每个拥有者独立表达这个承诺;当最后一个承诺解除时,资源就有了明确的收尾时机。
