本文从异常安全的血泪教训出发,一步步推演出智能指针的设计思路,把它们的原理、用法讲清楚。
目录
- 一、为什么需要智能指针
- 二、RAII:用对象的生命周期管理资源
- 三、标准库智能指针家族速览
- 四、auto_ptr:C++98 的黑历史
- 五、unique_ptr:独占所有权的首选
- 六、shared_ptr:共享所有权的引用计数
- 七、手搓 shared_ptr:从零实现引用计数
- 八、weak_ptr 与循环引用
- 九、shared_ptr 的线程安全
- 十、内存泄漏
- 十一、总结
一、为什么需要智能指针
1.1 异常 + 裸指针 = 内存泄漏
先看一段看起来很正常的代码:
double Divide(int a, int b) {
if (b == 0) {
throw "Divide by zero condition!"; // 可能抛异常
}
return (double)a / (double)b;
}
void Func() {
int* array1 = new int[10]; // ① 申请资源
int* array2 = new int[10]; // ② 又申请资源(这里也可能抛异常!)
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl; // ③ Divide 可能抛异常
// ④ 如果上面抛异常了,这两行根本执行不到!
delete[] array1;
delete[] array2;
}
这段代码中其实有很多问题:
Divide抛异常 → 跳过两个 delete → 内存泄漏new int[10]本身也可能抛std::bad_alloc→ 如果 array2 的 new 失败,array1 也没释放- 如果用 try/catch 补救,每多一个 new 就要多套一层 try/catch,非常麻烦
1.2 用 try/catch 挽救的结果
void Func() {
int* array1 = new int[10];
int* array2 = nullptr;
try {
array2 = new int[10]; // 如果这里抛异常……
try {
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl; // 或者这里抛异常……
}
catch (...) {
// 释放两个数组,再重新抛出
delete[] array1;
delete[] array2;
throw;
}
}
catch (...) {
delete[] array1; // array2 还没分配成功,只释放 array1
throw;
}
delete[] array1;
delete[] array2;
}
三个资源就套了两层 try/catch。十个资源呢?一百个呢?这就是 C 语言风格手动管理资源在异常面前的无力。
二、RAII:用对象的生命周期管理资源
2.1 核心思想
RAII(Resource Acquisition Is Initialization)是一种利用 C++ 对象生命周期来管理资源的惯用法:
- 构造时获取资源:把资源(内存、文件句柄、锁等)交给一个对象管理
- 析构时释放资源:对象离开作用域时自动调用析构函数,资源必然被释放
- 异常安全:栈展开时局部对象会被自动析构,资源不会泄漏
2.2 手搓一个最简智能指针
template<class T>
class SmartPtr {
public:
// RAII 核心:构造函数获取资源
SmartPtr(T* ptr) : _ptr(ptr) {}
// RAII 核心:析构函数释放资源
~SmartPtr() {
cout << "delete[] " << _ptr << endl;
delete[] _ptr;
}
// 像指针一样访问资源
T& operator*() { return *_ptr; }
T* operator->() { return _ptr; }
T& operator[](size_t i) { return _ptr[i]; }
private:
T* _ptr;
};
2.3 使用智能指针后的代码
代码变得清爽多了
void Func() {
SmartPtr<int> sp1(new int[10]);
SmartPtr<int> sp2(new int[10]); // 即使这里抛异常,sp1 也会被正确析构
SmartPtr<int> sp3(new int[10]);
SmartPtr<int> sp4(new int[10]);
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl; // 抛异常也不怕
// sp1~sp4 在函数退出(无论正常还是异常)时自动析构
sp1[5] = 50;
cout << sp1[5] << endl;
}
不再需要任何 try/catch 手动清理资源。就算 Divide 抛异常、栈展开退出,C++ 保证 sp1~sp4 的析构函数都会被调用,资源全部正确释放。
三、标准库智能指针家族速览
C++ 标准库在 <memory> 头文件中提供了四种智能指针:
| 智能指针 | 版本 | 核心特点 | 建议 |
|---|---|---|---|
auto_ptr | C++98 | 拷贝时转移所有权,原指针悬空 | 绝对不要用!已废弃 |
unique_ptr | C++11 | 独占所有权,不可拷贝,只可移动 | 默认首选,不需要共享就用它 |
shared_ptr | C++11 | 共享所有权,引用计数,支持拷贝 | 需要共享资源时使用 |
weak_ptr | C++11 | 不参与资源管理,绑定 shared_ptr 不增加引用计数 | 解决循环引用问题 |
四、auto_ptr:C++98 的黑历史
auto_ptr 的拷贝语义是"管理权转移"——拷贝构造后,被拷贝对象被置空,变成悬空指针:
// auto_ptr 的危险行为
auto_ptr<Date> ap1(new Date);
auto_ptr<Date> ap2(ap1); // 构造时,ap1 被置为 nullptr!
// ap1->_year++; // 空指针访问,运行时报错!
// ap1 看起来是个正常的对象,但它内部管理的指针是空的
// 编译完全通过,运行时才崩——这是最坑的地方
问题:
- 拷贝语义不符合直觉(我拷贝了一个东西,原件居然不能用了?)
- 传给函数、放入容器都会触发隐式所有权转移
- C++11 直接废弃了它,C++17 彻底移除
很多公司明令禁止使用
auto_ptr。对于auto_ptr的态度就是:别用,忘掉它。
五、unique_ptr:独占所有权的首选
5.1 基本用法
unique_ptr 直接禁止了拷贝,从编译器层面杜绝了 auto_ptr 的问题:
#include <memory>
struct Date {
int _year, _month, _day;
Date(int y = 1, int m = 1, int d = 1)
: _year(y), _month(m), _day(d) {}
~Date() { cout << "~Date()" << endl; }
};
int main() {
unique_ptr<Date> up1(new Date);
// unique_ptr<Date> up2(up1); // 编译错误!不支持拷贝
unique_ptr<Date> up3(move(up1)); // 支持移动,但 up1 悬空
return 0;
}
5.2 管理 new[] 数组
// 默认使用 delete 释放,管理 new[] 数组需要特化版本
unique_ptr<Date[]> up(new Date[5]); // 析构时自动调用 delete[]
5.3 自定义删除器
unique_ptr 的删除器通过第二个模板参数指定:
// 方式一:仿函数
class Fclose {
public:
void operator()(FILE* ptr) {
cout << "fclose:" << ptr << endl;
fclose(ptr);
}
};
unique_ptr<FILE, Fclose> up1(fopen("test.txt", "r"));
// 方式二:lambda
auto fcloseFunc = [](FILE* ptr) { fclose(ptr); };
unique_ptr<FILE, decltype(fcloseFunc)> up2(fopen("test.txt", "r"), fcloseFunc);
unique_ptr的删除器类型是模板参数的一部分,不同类型删除器的 unique_ptr 是不同的类型。
六、shared_ptr:共享所有权的引用计数
6.1 基本用法
shared_ptr的本质是多个智能指针共同管理一份资源。拷贝的时候,不会让原指针悬空,而是通过“引用计数器+1”的方式表示“有两个/多个指针共同管理这份资源”
shared_ptr<Date> sp1(new Date);
shared_ptr<Date> sp2(sp1); // 拷贝构造,引用计数 +1
shared_ptr<Date> sp3 = sp2; // 赋值,引用计数 +1
cout << sp1.use_count() << endl; // 输出 3——三个 shared_ptr 共同管理
sp1->_year++;
// sp1、sp2、sp3 指向同一个 Date 对象
cout << sp1->_year << endl; // 2
cout << sp2->_year << endl; // 2(同一个对象!)
cout << sp3->_year << endl; // 2
6.2 make_shared
// 直接 new 构造
shared_ptr<Date> sp1(new Date(2024, 9, 11));
// make_shared:更高效(一次分配内存给对象和引用计数块)
shared_ptr<Date> sp2 = make_shared<Date>(2024, 9, 11);
auto sp3 = make_shared<Date>(2024, 9, 11); // auto 自动推导
为什么推荐 make_shared:
shared_ptr<Date> sp(new Date)分两次分配内存:一次 Date,一次引用计数make_shared<Date>()一次分配一块连续内存,同时包含对象和引用计数,效率更高
6.3 operator bool:判空很方便
operator bool 是类型转换运算符,作用是允许对象隐式转换成 bool 类型,专门用来支持 if(sp)、while(sp)、!sp 这类判断写法。
当你写下 if (sp),编译器会自动调用 sp.operator bool()。 shared_ptr 的规则是:如果智能指针持有有效资源,返回 true;没有托管任何资源(空智能指针),返回 false。
shared_ptr<Date> sp;
if (sp) // 隐式调用 operator bool()
cout << "sp 管理着资源" << endl;
if (!sp)
cout << "sp 是空的" << endl;
6.4 构造函数是 explicit 的
// shared_ptr<Date> sp = new Date(2024, 9, 11); // 编译错误!
// 构造函数加了 explicit,禁止隐式转换
shared_ptr<Date> sp(new Date(2024, 9, 11)); // 正确:显式调用
6.5 shared_ptr 的自定义删除器
和 unique_ptr 不同,shared_ptr 的删除器通过构造函数参数传递:
// 管理 new[] 数组
shared_ptr<Date> sp1(new Date[5], [](Date* ptr) { delete[] ptr; });
// 管理 FILE*
shared_ptr<FILE> sp2(fopen("test.txt", "r"), Fclose());
shared_ptr<FILE> sp3(fopen("test.txt", "r"), [](FILE* ptr) {
cout << "fclose:" << ptr << endl;
fclose(ptr);
});
// 特化版本:管理数组更简单
shared_ptr<Date[]> sp4(new Date[5]);
shared_ptr的删除器不是模板参数,不同类型删除器的shared_ptr<T>是同一个类型,可以互相赋值。
七、手搓 shared_ptr:从零实现引用计数
纸上得来终觉浅,绝知此事要躬行。下面我们一步一步拆解,把一个功能完整的 shared_ptr 造出来。
7.1 第一个问题:引用计数放哪里?
一个 shared_ptr<T> 对象管着两个东西:资源指针和引用计数。引用计数不能用普通 int 成员变量——每个 shared_ptr 对象都有自己的成员,互相独立,没法共享。也不能用 static int——那会让所有 shared_ptr 对象共用一个计数,不同资源之间全串了。
正确的做法是:每创建一份资源管理的起点,就在堆上 new 一个引用计数出来,所有共享这份资源的 shared_ptr 都指向它。
sp1 ──→ [_pcount → 1] ──→ [Date对象]
↖ 堆上的引用计数
sp2 ──→ [_pcount 是同一个指针,值已变成 2]
一个 shared_ptr<T> 只存两个裸指针:
T* _ptr; // 指向被管理的资源
atomic<int>* _pcount; // 指向堆上的引用计数
7.2 构造:管理权的诞生
// 构造函数:接管一个裸指针,引用计数从 1 开始
explicit shared_ptr(T* ptr = nullptr)
: _ptr(ptr)
, _pcount(new atomic<int>(1)) // 堆上 new 出一个计数,初值为 1
{}
这里三个细节值得停下来想一想:
(1)为什么 explicit? 如果不加,shared_ptr<Date> sp = new Date(...) 就能编译通过,裸指针隐式变成智能指针极易误用。前面说过,标准库的实现同样加了 explicit。
(2)为什么 _pcount 要在堆上 new? 因为一个 shared_ptr 对象本身占的是栈空间,如果 _pcount 是栈上的普通成员 int _pcount,那么 sp1 和 sp2 各有各的 _pcount,彼此不互通。只有把它放在堆上,多个 shared_ptr 对象共享同一个 int*,才能联动。
(3)为什么是 atomic<int>? 多线程环境下,不同线程可能同时对同一个 shared_ptr 做拷贝或析构,这就涉及对引用计数的并发 ++/–。用 atomic<int> 保证这个操作是原子的,不用额外加锁。
7.3 拷贝构造:新成员入伙
// 拷贝构造:加入同一份资源的"管理小组"
shared_ptr(const shared_ptr<T>& sp)
: _ptr(sp._ptr) // 指向同一个资源
, _pcount(sp._pcount) // 指向同一份引用计数
{
++(*_pcount); // 管理小组多了一个成员
}
拷贝构造做的事情非常简单:指针复制过去,引用计数 +1。不复制资源本身,这就是"共享"的含义。
7.4 析构:最后一个走的关灯
~shared_ptr() {
// 先把自己的引用计数 -1
// 如果减到 0,说明自己是最后一个管理者,负责释放一切
if (--(*_pcount) == 0) {
_del(_ptr); // 用删除器释放资源
delete _pcount; // 引用计数本身也销毁
}
}
这个逻辑是整个 shared_ptr 的灵魂:谁都不需要知道别人还在不在,只在自己走的时候看一眼——“我是最后一个吗?是的话我收拾。”
如果析构函数里不判断计数直接 delete,那第一个析构的 shared_ptr 就把资源删了,剩下的 shared_ptr 全变成悬空指针。引用计数正是为此而生。
7.5 拷贝赋值:最复杂的操作
赋值运算符是整段代码中最容易写错的地方,因为它要同时处理"离开旧资源"和"加入新资源"两件事,而且顺序不能错:
shared_ptr<T>& operator=(const shared_ptr<T>& sp) {
// 步骤 0:如果两个指针指向的是同一份资源,什么也不用做
// 这里可以用 this != &sp 来判断,但不好
// 因为两个不同的 shared_ptr 对象可能通过不同的路径管理同一份资源
if (_ptr != sp._ptr) {
// 步骤 1:先离开当前的"管理小组"
if (--(*_pcount) == 0) {
_del(_ptr); // 自己是最后一个 → 释放资源
delete _pcount; // 释放引用计数
}
// 步骤 2:再加入新的"管理小组"
_ptr = sp._ptr;
_pcount = sp._pcount;
++(*_pcount);
}
return *this;
}
为什么用 _ptr != sp._ptr 而不是 this != &sp?
考虑这个场景:
shared_ptr<Date> sp1(new Date);
shared_ptr<Date> sp2(sp1); // sp1 和 sp2 管理同一份资源
sp1 = sp2; // 自己赋值给自己?不,this != &sp2 成立
// 但它们管理的是同一份资源!_ptr == sp2._ptr
sp1 和 sp2 是不同的对象(this != &sp2 为 true),但它们的 _ptr 指向同一份资源。如果只用 this != &sp 判断,赋值操作仍然会进入逻辑,先 --(*_pcount) 把计数减到 1,再 ++(*_pcount) 加回 2——虽然结果正确,但做了无谓的原子操作。
另一个常见坑:
shared_ptr<Date> sp1(new Date);
shared_ptr<Date> sp2(new Date);
sp1 = sp2; // sp1 和 sp2 管理不同资源
// sp1 原来的资源如果计数减到 0 就释放
// sp2 的资源计数 +1
sp1 原来独自管理一个 Date,sp2 也是。赋值后 sp1 离开原来的,加入 sp2 的。原来的如果没别人管了(计数归零),顺带释放。
7.6 自定义删除器
默认情况下,shared_ptr 析构时用 delete 释放资源。但如果你管理的是 new[] 出来的数组,或者是 fopen 打开的文件,就需要告诉 shared_ptr 怎么正确释放:
template<class D>
shared_ptr(T* ptr, D del)
: _ptr(ptr)
, _pcount(new atomic<int>(1))
, _del(del) // 把删除器存起来
{}
删除器的类型是 function<void(T*)>,本质是一个能接收 T* 的可调用对象——可以是函数指针、仿函数、lambda:
function<void(T*)> _del = [](T* ptr) { delete ptr; }; // 默认删除器
有了自定义删除器,析构函数里就不能再写死 delete _ptr 了,而要统一调用 _del(_ptr):
~shared_ptr() {
if (--(*_pcount) == 0) {
_del(_ptr); // 不管怎么删,由删除器决定
delete _pcount;
}
}
使用时的灵活性:
// 管理 new[] 数组
shared_ptr<Date> sp1(new Date[10], [](Date* ptr) { delete[] ptr; });
// 管理文件
shared_ptr<FILE> sp2(fopen("test.txt", "r"), [](FILE* ptr) { fclose(ptr); });
// 仿函数删除器
struct Fclose {
void operator()(FILE* ptr) { fclose(ptr); }
};
shared_ptr<FILE> sp3(fopen("test.txt", "r"), Fclose());
unique_ptr vs shared_ptr 删除器的区别:unique_ptr 的删除器类型是模板参数的一部分(不同删除器 = 不同类型),而 shared_ptr 的删除器是构造函数参数(不同删除器仍是同一类型),所以 shared_ptr 可以放进容器而 unique_ptr 的灵活性稍差。
7.7 像指针一样使用
除了管理生命周期,智能指针还要能让用户像用裸指针一样访问资源:
T& operator*() { return *_ptr; } // *sp 得到资源的引用
T* operator->() { return _ptr; } // sp-> 直接访问资源的成员
T* get() const { return _ptr; } // 取出裸指针(谨慎使用)
int use_count() const { return *_pcount; } // 查看当前有多少个管理者
八、weak_ptr 与循环引用
8.1 循环引用问题
看一个双向链表的节点结构:
struct ListNode {
int _data;
shared_ptr<ListNode> _next;
shared_ptr<ListNode> _prev;
~ListNode() { cout << "~ListNode()" << endl; }
};
int main() {
shared_ptr<ListNode> n1(new ListNode);
shared_ptr<ListNode> n2(new ListNode);
cout << n1.use_count() << endl; // 1
cout << n2.use_count() << endl; // 1
n1->_next = n2; // n2 的引用计数变为 2
n2->_prev = n1; // n1 的引用计数变为 2
cout << n1.use_count() << endl; // 2
cout << n2.use_count() << endl; // 2
return 0;
// n1 析构,n1 的引用计数从 2 变 1(n2._prev 还指着)
// n2 析构,n2 的引用计数从 2 变 1(n1._next 还指着)
// 两个节点都没释放!~ListNode() 永远不会打印!
}
循环逻辑:
- 右边的节点什么时候释放?← 左边的
_next析构后 _next什么时候析构?← 左边节点释放后- 左边节点什么时候释放?← 右边的
_prev析构后 _prev什么时候析构?← 右边节点释放后
这就形成了一个死循环:谁都不先释放,内存全部泄漏。
8.2 weak_ptr 破局
weak_ptr 的设计哲学很明确——绑定到 shared_ptr,但不增加引用计数。把它看作一个"旁观者",它可以观察资源还在不在,但不参与资源的生命周期管理。
struct ListNode {
int _data;
weak_ptr<ListNode> _next; // 关键修改!从 shared_ptr 换成 weak_ptr
weak_ptr<ListNode> _prev;
~ListNode() { cout << "~ListNode()" << endl; }
};
int main() {
shared_ptr<ListNode> n1(new ListNode); // n1 计数 = 1
shared_ptr<ListNode> n2(new ListNode); // n2 计数 = 1
n1->_next = n2; // weak_ptr 绑定 shared_ptr → n2 计数仍为 1!
n2->_prev = n1; // weak_ptr 绑定 shared_ptr → n1 计数仍为 1!
cout << n1.use_count() << endl; // 1 ← 不受影响
cout << n2.use_count() << endl; // 1 ← 不受影响
return 0;
// n2 析构:计数 1→0,释放 ListNode B!
// → B 的 _prev(weak_ptr)自动置空
// n1 析构:计数 1→0,释放 ListNode A!
// → A 的 _next(weak_ptr)自动置空
// 两次 ~ListNode(),干净利落。
}
weak_ptr 为什么不能直接管理资源?因为它不参与引用计数,如果允许 weak_ptr 直接 new 资源,资源什么时候释放就没人说得清了。
// weak_ptr 不支持直接管理资源
// weak_ptr<ListNode> wp(new ListNode); // 编译错误!
// 只能从 shared_ptr 绑定
shared_ptr<ListNode> sp(new ListNode);
weak_ptr<ListNode> wp = sp; // 正确
8.3 weak_ptr 的常规用法
shared_ptr<string> sp1 = make_shared<string>("111111");
shared_ptr<string> sp2(sp1);
weak_ptr<string> wp = sp1; // 绑定,不增加计数
cout << wp.use_count() << endl; // 2(sp1 和 sp2 各持 1)
cout << wp.expired() << endl; // 0(false,资源还没释放)
// sp1 和 sp2 都转向其他资源后
sp1 = make_shared<string>("222222");
sp2 = make_shared<string>("333333");
cout << wp.expired() << endl; // 1(true,原来的 string 已释放)
// 安全访问:lock() 返回一个 shared_ptr
wp = sp1;
auto sp3 = wp.lock(); // 返回管理资源的 shared_ptr
if (sp3) {
*sp3 += "###"; // 安全:sp3 在就不会被释放
cout << *sp1 << endl; // sp1 也能看到修改
}
weak_ptr 的定位:
- 不支持 RAII:不管理资源,不参与构造/析构决策
- 不重载 operator/operator->*:不能直接访问资源
- expired():检查绑定的资源是否已被释放
- lock():返回一个
shared_ptr,如果资源还在就能安全访问,否则返回空
九、shared_ptr 的线程安全
多线程编程中,shared_ptr 的线程安全性常常被误解。关键要分清楚两件事:引用计数的安全和数据的安全,它们是两个完全不同的层面。
9.1 引用计数层:shared_ptr 内部保证线程安全
// shared_ptr 的引用计数用 atomic<int> 实现
// 多个线程同时拷贝/析构 shared_ptr,计数的 ++/-- 是原子的
shared_ptr<AA> p(new AA);
// 线程1:反复拷贝 p
thread t1([&p]() {
for (int i = 0; i < 100000; ++i) {
shared_ptr<AA> copy(p); // ++引用计数,原子操作
} // copy 析构,--引用计数,原子操作
});
// 线程2:也反复拷贝 p
thread t2([&p]() {
for (int i = 0; i < 100000; ++i) {
shared_ptr<AA> copy(p); // 同时 ++/--,原子操作保证不出错
}
});
t1.join();
t2.join();
// 引用计数最终仍是 1 —— 原子操作保证了精确性
cout << p.use_count() << endl; // 1
9.2 数据层:使用者自己负责
struct AA {
int _a1 = 0;
int _a2 = 0;
~AA() { cout << "~AA()" << endl; }
};
int main() {
shared_ptr<AA> p(new AA);
const size_t n = 100000;
mutex mtx; // ← 这个锁 shared_ptr 不会替你加!
auto func = [&]() {
for (size_t i = 0; i < n; ++i) {
shared_ptr<AA> copy(p); // 引用计数:安全(atomic 保证)
{
unique_lock<mutex> lk(mtx); // 数据:必须自己加锁
copy->_a1++; // 不加锁 = 数据竞争 = 未定义行为
copy->_a2++;
}
}
};
thread t1(func);
thread t2(func);
t1.join();
t2.join();
cout << p->_a1 << endl; // 200000(加锁保证了正确性)
cout << p->_a2 << endl; // 200000
cout << p.use_count() << endl; // 1
return 0;
}
9.3 如果引用计数不用 atomic 会怎样?
这是最常见的错误——图省事用普通 int* 做引用计数:
// 错误示范:用 int* 而非 atomic<int>*
template<class T>
class shared_ptr_bad {
private:
int* _pcount; // 普通 int,不是 atomic<int>
};
// 多线程场景:
// 线程A:++(*_pcount) → 读-改-写,不是原子的
// 线程B:--(*_pcount) → 同时执行,数据竞争!
// 结果:计数少 1 → 导致资源提前释放 / 计数多 1 → 导致资源泄漏
两种后果都不堪设想:计数少 1 导致某个 shared_ptr 变成悬空指针,访问时崩溃;计数多 1 导致最后一个 shared_ptr 析构时以为还有人管着,跳过 delete,资源永久泄漏。
9.4 shared_ptr 对象本身的线程安全——容易被忽略的第三层
还有一个更容易踩的坑:多个线程同时修改同一个 shared_ptr 变量。
shared_ptr<int> p = make_shared<int>(42);
// 线程1:让 p 指向新资源
thread t1([&p]() {
p = make_shared<int>(100); // p 本身被修改!
});
// 线程2:拷贝 p
thread t2([&p]() {
shared_ptr<int> copy = p; // 同时读取 p!
});
// 危险!p 这个 shared_ptr 对象本身不是线程安全的
// 一个线程在写 p(赋值),另一个线程在读 p(拷贝)
// → 数据竞争!
总结三层线程安全问题:
| 层面 | 问题 | 谁负责 | 解决方案 |
|---|---|---|---|
| 引用计数的读写 | 多线程同时 ++/-- 计数 | shared_ptr 内部 | atomic<int> |
| 被管理对象的数据 | 多线程同时读写对象成员 | 使用者 | 外层加 mutex |
| shared_ptr 对象本身 | 多线程同时修改同一个 sp 变量 | 使用者 | 外层加 mutex 或 atomic<shared_ptr<T>>(C++20) |
记忆口诀:shared_ptr 保你引用计数不乱,但对象数据得自己锁,shared_ptr 对象本身也得自己保护。
十、内存泄漏
10.1 什么是内存泄漏
内存泄漏的本质是:程序分配了一块堆内存,但失去了指向它的所有指针,这块内存既不能使用也无法释放。
注意,内存泄漏不等于"物理内存消失"——泄漏的内存仍然被你的进程占据着,只是你找不到它了。
void leak_demo() {
int* p = new int[100]; // 分配 100 个 int
p = new int[200]; // 又分配 200 个 int,覆盖了 p
// 原来的 100 个 int 没人指了 → 内存泄漏!
// p 现在指向新分配的 200 个 int
delete[] p; // 只释放了 200 个
// 100 个 int 永远找不回来了
}
10.2 内存泄漏的常见场景
场景一:忘记 delete
void func() {
int* p = new int(42);
// 函数结束,p 本身被销毁,但 *p 留在堆上
// 没有任何指针指向它 → 泄漏
}
场景二:异常导致跳过 delete
void func() {
int* p = new int[1000];
risky_operation(); // 抛异常!
delete[] p; // 永远执行不到
}
// 这也正是智能指针要解决的核心问题
场景三:循环引用(shared_ptr 的唯一盲区)
// 两个 shared_ptr 互相指着 → 计数永远到不了 0 → 泄漏
// 详见第八章
场景四:容器里存裸指针
vector<int*> pointers;
pointers.push_back(new int(1));
pointers.push_back(new int(2));
// 容器销毁时,销毁的是指针本身,不是指针指向的内存!
// 每一个 new int 都会泄漏
// 应该用 vector<unique_ptr<int>> 或 vector<shared_ptr<int>>
10.3 内存泄漏的危害:取决于程序的生命周期
int main() {
// 这个程序泄漏 1GB,但几秒后就结束了
// 危害程度:★☆☆☆☆
// 进程退出时,操作系统回收所有物理内存,泄漏的内存也一并回收
char* ptr = new char[1024 * 1024 * 1024];
cout << (void*)ptr << endl;
return 0;
}
| 程序类型 | 典型例子 | 泄漏危害 |
|---|---|---|
| 一次性命令行工具 | 图片转换脚本、数据导出 | 低:几秒结束,OS 回收 |
| 桌面应用 | 浏览器、IDE | 中:运行几小时内存占用越来越大,用户感知到卡顿 |
| 长期服务端 | 数据库、Web 服务器、消息队列 | 高:7×24 小时运行,每次泄漏一点,几天后内存耗尽崩溃 |
| 嵌入式/内核 | 路由器固件、航天器控制 | 致命:无法重启,泄漏 = 物理损坏或任务失败 |
10.4 检测工具
Linux:Valgrind(经典)
# 编译时加 -g 保留调试信息
g++ -g test.cpp -o test
# 运行内存检查
valgrind --leak-check=full ./test
# 输出示例:
# ==12345== LEAK SUMMARY:
# ==12345== definitely lost: 400 bytes in 1 blocks
# ==12345== indirectly lost: 0 bytes in 0 blocks
# ==12345== possibly lost: 0 bytes in 0 blocks
# ==12345== still reachable: 0 bytes in 0 blocks
Linux:AddressSanitizer(更现代,运行时开销更小)
# 编译时加 -fsanitize=address
g++ -g -fsanitize=address test.cpp -o test
# 直接运行,泄漏时自动报告
./test
# 会精确报告泄漏发生在哪个文件的哪一行
Windows:VLD(Visual Leak Detector)
// 在 main 所在文件最前面加上
#include <vld.h>
int main() {
// 程序正常运行
// 程序结束后,VLD 在输出窗口报告所有泄漏
}
10.5 防范策略——从代码到流程
| 策略 | 方法 | 适用阶段 |
|---|---|---|
| RAII + 智能指针 | 用 unique_ptr / shared_ptr 管理所有堆资源,禁止裸 new/delete | 编码阶段 |
| 代码审查 | 每次 new 都必须在审查中标注对应的 delete 位置 | 提交前 |
| 静态分析 | Clang-Tidy、Cppcheck 自动检测可疑模式 | CI 流程 |
| 动态检测 | Valgrind / ASan 跑一遍测试用例 | 提测前 |
| 上线前扫描 | 用 VLD 或类似工具对 Release 构建做最终检查 | 发版前 |
最有效的策略只有一条:从源头杜绝裸 new 和裸 delete。 能用
make_unique/make_shared就不要手动 new,能用 RAII 就不手动管理。这样绝大多数泄漏问题在编码阶段就被消灭了。
十一、总结
四大智能指针对比
| auto_ptr | unique_ptr | shared_ptr | weak_ptr | |
|---|---|---|---|---|
| 所有权 | 独占(转移) | 独占 | 共享 | 不拥有 |
| 拷贝 | 支持(危险) | 禁止 | 支持(计数++) | 支持(不++) |
| 移动 | 支持 | 支持 | 支持 | 支持 |
| 删除器 | 不支持 | 模板参数 | 构造函数参数 | 无 |
| RAII | 是 | 是 | 是 | 否 |
| operator* | 是 | 是 | 是 | 否 |
| 推荐度 | 禁用! | 首选 | 需要共享时 | 打破循环引用 |
选择决策树
你需要智能指针管理资源吗?
│
├── 资源只需要一个所有者?
│ └── 用 unique_ptr ✓
│
├── 资源需要被多个对象共享?
│ ├── 用 shared_ptr
│ └── 注意:如果有互相引用,用 weak_ptr 打破循环
│
└── 只是观察资源,不参与生命周期?
└── 用 weak_ptr
黄金法则:能用
unique_ptr就不用shared_ptr。独占所有权的语义更清晰,性能也更好(没有引用计数的原子操作开销)。
2022

被折叠的 条评论
为什么被折叠?



