【C++11】智能指针使用学习笔记
目录
一:基本概念:
- 在C++98时期,我们在申请资源的时候,都需要手动释放,如果我们忘记了释放,就会导致内存泄漏等一系列问题。
- 所以在C++11,C++智能指针的目标:让资源自动释放,不依赖人类的记忆。
-
智能指针的思想来自 RAII(Resource Acquisition Is Initialization):资源的获取应与对象的生命周期绑定。对象销毁时,自动释放资源。
-
在使用智能指针的时候,有下面三个种类可以供我们挑选
- std::unique_ptr
- std::shared_ptr
- std::weak_ptr
二:RAII(Resource Acquisition Is Initialization)设计思路
- RAII是Resource Acquisition Is Initialization的缩写,他是⼀种管理资源的类的设计思想,本质是⼀种利⽤对象⽣命周期来管理获取到的动态资源,避免资源泄漏,这⾥的资源可以是内存、⽂件指针、⽹络连接、互斥锁等等。RAII在获取资源时把资源委托给⼀个对象,接着控制对资源的访问,资源在对象的⽣命周期内始终保持有效,最后在对象析构的时候释放资源,这样保障了资源的正常释放,避免资源泄漏问题。
- 智能指针类除了满⾜RAII的设计思路,还要⽅便资源的访问,所以智能指针类还会想迭代器类⼀样,重载 operator*/operator->/operator[] 等运算符,⽅便访问资源。
#include<iostream>
using namespace std;
template<class T>
class SmartPtr
{
public :
// RAII
SmartPtr(T* ptr)
: _ptr(ptr)
{
}
~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;
};
double Divide(int a, int b)
{
// 当b == 0时抛出异常
if (b == 0)
{
throw "Divide by zero condition!";
}
else
{
return(double)a / (double)b;
}
}
void Func()
{
// 这⾥使⽤RAII的智能指针类管理new出来的数组以后,程序简单多了
SmartPtr<int> sp1 = new int[10];
SmartPtr<int> sp2 = new int[10];
for (size_t i = 0; i < 10; i++)
{
sp1[i] = sp2[i] = i;
}
int len, time;
cin >> len >> time;
cout << Divide(len, time) << endl;
}
int main()
{
try
{
Func();
}
catch(const char* errmsg)
{
cout << errmsg << endl;
}
catch(const exception & e)
{
cout << e.what() << endl;
}
catch(...)
{
cout << "未知异常" << endl;
}
return 0;
}
三:C++标准库智能指针的使用
1. C++ 标准库的智能指针家族(头文件 )
- 智能指针的核心任务:自动管理动态资源,防止内存泄漏。
- 它们都遵循 RAII 思想——对象的生命周期 = 资源的生命周期。
- 区别主要在于:拷贝行为、所有权语义。
| 智能指针 | 所属标准 | 是否独占 | 是否可拷贝 | 是否可移动 | 底层机制 |
|---|---|---|---|---|---|
auto_ptr |
C++98 | 是(独占) | 可以(但转移所有权) | 不可 | 原始指针,所有权转移 |
unique_ptr |
C++11 | 是(独占) | 不可 | 可以 | 原始指针,移动语义 |
shared_ptr |
C++11 | 否(共享) | 可以 | 可以 | 引用计数(control block) |
weak_ptr |
C++11 | 不拥有 | 可以(指向 shared_ptr) | 可以 | 弱引用计数(不增加引用数) |
2. 智能指针的设计演化史—进化历史
- auto_ptr —— 原罪与启蒙
- auto_ptr 是 C++98 的第一个智能指针。设计初衷是好的:自动释放堆内存。
但问题在于它的拷贝语义是“转移所有权”:
std::auto_ptr<int> p1(new int(10));
std::auto_ptr<int> p2 = p1; // p1的资源被转移给p2,p1变悬空!
*p1 = 20; // 未定义行为
- 这在容器中或函数传参时会导致灾难(悬空指针)。
- 许多公司在员工手册中明确禁止了auto_ptr 的使用!!!!
- 所以 C++11 后直接被弃用,并由 unique_ptr 取代。
- unique_ptr —— “资源的独裁者”
- unique_ptr 的语义就是:这块资源只属于我一个人。
特点:
不支持拷贝(拷贝构造、拷贝赋值被 = delete);
支持移动语义(std::move() 可以转移所有权);
*高效零开销:与裸指针几乎一样的性能;
完全 RAII 化:析构时自动 delete 或调用自定义删除器。
示例:
std::unique_ptr<int> p1 = std::make_unique<int>(10);
std::unique_ptr<int> p2 = std::move(p1); // 所有权转移
if (!p1) cout << "p1空了 " << endl;
当你确定一个资源只会被一个对象拥有时,unique_ptr 是最安全、最高效的选择。
3.== shared_ptr —— “资源共享者”==
有时候你希望多个对象共享同一资源,比如图节点、缓存对象,这时就要用shared_ptr。
它通过 引用计数(Reference Counting) 来管理资源:
每次拷贝时,引用计数 +1;
每次析构时,引用计数 -1;
当计数为 0 时,释放资源。
auto sp1 = std::make_shared<int>(42);
auto sp2 = sp1; // 引用计数 +1
cout << sp1.use_count() << endl; // 输出 2
sp2.reset(); // 引用计数 -1
底层结构:
shared_ptr 实际上维护了两个东西:
1.原始指针(T*)
2.控制块(Control Block),包含引用计数和删除器。
这样就能安全地实现“多者共享,最后一个关灯走人”。
4.== weak_ptr —— “shared_ptr 小弟”==
- weak_ptr 是一种不拥有资源的智能指针,它只“观察”一个 shared_ptr。
用途:
解决循环引用问题(A 拥有 B,B 拥有 A,会导致引用计数永远不为0)。
临时访问资源,不会影响其生命周期。
例子:
struct B;
struct A { std::shared_ptr<B> bptr; };
struct B { std::shared_ptr<A> aptr; };
void circular_ref() {
auto a = std::make_shared<A>();
auto b = std::make_shared<B>();
a->bptr = b;
b->aptr = a; // 循环引用导致泄漏
}
解决方式:
struct B;
struct A { std::shared_ptr<B> bptr; };
struct B { std::weak_ptr<A> aptr; }; // 改成弱引用
weak_ptr 没有 operator->,必须先通过 lock() 获得 shared_ptr 才能访问:
if (auto spt = weak.lock()) {
spt->doSomething();
}
四:shared_ptr和weak_ptr
1. shared_ptr的循环引用问题
shared_ptr 是一种共享所有权的智能指针。 它的机制是这样的:每当有一个新的shared_ptr指向同一个对象,对象的引用计数(use_count)就 +1;当一个 shared_ptr 析构(离开作用域)时,use_count 就-1。 当计数降到 0,对象才真正释放。
所以,对象的生死完全由这个计数决定。
举个例子:
#include <iostream>
#include <memory>
using namespace std;
struct Node {
int val;
shared_ptr<Node> next;
shared_ptr<Node> prev;
Node(int v) : val(v) { cout << "Node(" << val << ") 构造\n"; }
~Node() { cout << "Node(" << val << ") 析构\n"; }
};
int main() {
auto n1 = make_shared<Node>(1);
auto n2 = make_shared<Node>(2);
n1->next = n2;
n2->prev = n1; // n1和n2相互持有 shared_ptr
return 0;
}
然后程序结束了——没有调用析构函数。
- 原因解析
当 main 函数结束时:
n1 和 n2 离开作用域,use_count 各自减 1。
但:
n1->next 还在指向 n2;
n2->prev 还在指向 n1。
所以此时:
n1 的 use_count = 1(被 n2->prev 引用)
n2 的 use_count = 1(被 n1->next 引用)
它们都没法降到 0。
于是——没有任何一个会被释放。
内存就永久泄漏。
- 此时,我们就需要weak_ptr
#include <iostream>
#include <memory>
using namespace std;
struct Node {
int val;
weak_ptr<Node> prev; // 改成 weak_ptr
shared_ptr<Node> next;//这个也可以改成weak_ptr
Node(int v) : val(v) { cout << "Node(" << val << ") 构造\n"; }
~Node() { cout << "Node(" << val << ") 析构\n"; }
};
int main() {
auto n1 = make_shared<Node>(1);
auto n2 = make_shared<Node>(2);
n1->next = n2;
n2->prev = n1; // weak_ptr不会增加引用计数
return 0;
}
weak_ptr 不“拥有”资源,它只是一个“观察者”:
当 shared_ptr 管理的对象还活着,weak_ptr.lock() 可以安全获得一个 shared_ptr;
当对象被释放,weak_ptr 会自动变为空。
所以
weak_ptr 不参与引用计数。
当 n1 离开作用域时:
n1->next(指向 n2)被销毁 → n2 的计数 -1。
n2->prev 是 weak_ptr,不影响计数。
于是 n2 的计数降到 0,释放自己;
释放时 n2->prev 自动析构;
n1 的计数也降到 0,最终也释放。
形成一个良性释放链。
2.shared_ptr的线程安全问题
shared_ptr 的线程安全可以分为两部分来理解:
一是 智能指针自身(引用计数)的线程安全,二是 所管理对象的线程安全。
- shared_ptr 自身的线程安全
shared_ptr 内部维护着一个“控制块”(Control Block),其中包含:
- 被管理对象的指针;
- 引用计数(use_count);
- 弱引用计数(weak_count)。
当我们执行如下语句时:
zhao::shared_ptr<AA> copy(p);//这是我们自己实现的没有考虑线程安全原子性的模拟shared_ptr
这会触发两步操作:
1.拷贝控制块的指针;
2.将引用计数 use_count 加 1。
如果多个线程同时执行这段代码,而引用计数只是普通的 int 类型,就会发生数据竞争。这样会导致引用计数加错、减错,从而出现提前释放对象或内存泄漏的情况。
解决方案:
将引用计数类型从 int 改为 std::atomic(原子性);
或者使用互斥锁(std::mutex)保护引用计数的修改;
或者,直接使用标准库的 std::shared_ptr(它内部已经使用了原子操作,保证引用计数安全)。
- 被管理对象的线程安全
shared_ptr 只保证“控制块的引用计数”是线程安全的,
但不保证被托管对象本身的线程安全。
例如下面的代码:
copy->_a1++;
copy->_a2++;
这里两个线程同时修改对象 AA 的成员 _a1 和 _a2,这属于对象层面的并发访问问题,shared_ptr 不会为这些操作加锁。
换句话说:
shared_ptr 只负责“对象的生死”,
但不负责“对象的行为是否安全”。
正确做法:
使用外部锁保护对象访问。例如:
unique_lock<mutex> lk(mtx);
copy->_a1++;
copy->_a2++;
- 程序运行逻辑分析
你的程序大致流程是:
- 主线程创建 bit::shared_ptr p;
- 两个线程反复复制智能指针 copy(p);
- 每次复制都会使引用计数加 1;
- 每个线程修改 AA 的成员;
- 最后输出结果。
如果引用计数不是原子的,就可能出现:
- 崩溃(double free):引用计数被错误地减到 0,导致重复释放;
- 资源未释放:引用计数没减回 0,析构函数 ~AA() 未被调用。
- 为什么 shared_ptr 不自动加锁对象?
原因很简单:
如果每次访问对象都加锁,性能将极差。
五:内存泄漏
1.什么是内存泄漏
定义:内存泄漏指程序在动态申请内存(例如 new 或 malloc)后,没有在合适的时机释放(delete 或 free),从而导致这块内存空间永久不可访问、无法再被程序使用的情况。
这并不是说物理内存真的“丢失”了,而是——程序自己失去了对这块内存的控制权。
比喻:就像你借了一个仓库放货,却忘了仓库钥匙放哪了。仓库还在,但你再也进不去了
本质:
内存泄漏本质上是“内存分配与释放不匹配”的问题。
例如:
void func() {
int* p = new int(10);
// 忘记 delete
}
这段函数执行完后,指针 p 变量本身销毁了,但堆区那块 int 空间没有被释放。
结果:内存永远“挂在那儿”。
更隐蔽的情况是异常中断路径:
void foo() {
int* p = new int(10);
throw std::runtime_error("oops");
delete p; // 永远执行不到
}
异常抛出后,delete 语句不会执行,于是这块内存也泄漏了。
这种问题在复杂系统中非常常见。
2. 内存泄漏的危害
短期运行的程序(例如只运行几秒的工具)就算泄漏一点内存,操作系统在进程退出时也会回收掉,不会造成明显影响。
但对长期运行的进程来说,危害是巨大的,例如:
-
操作系统内核;
-
数据库服务;
-
后台守护进程;
-
游戏客户端、浏览器、交易程序等。
这些程序往往 持续运行数小时、数天甚至数月。
一旦存在泄漏,内存会像沙漏一样不断被“偷走”,最终导致:
1.可用内存越来越少;
2.程序响应变慢;
3.出现频繁的页换入换出;
4.最后整个系统卡死或崩溃。
现实中的例子包括:
- 某些老旧游戏玩久了越来越卡,其实就是资源(贴图、音频 buffer)没被正确释放;
- Web 服务运行几天后内存占用飙升,原因是缓存对象未清理;
- 驱动程序泄漏内核内存,导致系统蓝屏。
示例说明
int main() {
// 申请 1G 内存但未释放
char* ptr = new char[1024 * 1024 * 1024];
std::cout << (void*)ptr << std::endl;
return 0;
}
表面上看,这段代码“泄漏”了 1GB 内存。
但运行几次也没什么危害,因为程序很快结束,进程退出后操作系统会自动回收这部分资源。
也就是说:“进程结束”是最强的垃圾回收。
但对于长生命周期的服务程序而言,这样的疏忽就是灾难。
3. 如何避免内存泄漏
1.良好的设计与编码规范
在工程初期就应当明确资源的“所有权”和“释放责任”。
凡是调用 new 或 malloc 的地方,必须有清晰的释放逻辑与异常安全策略。
不过,这在大型系统中非常难保证,一旦代码路径复杂或异常中断,容易遗漏释放。
2.使用智能指针(Smart Pointer)
这是现代 C++ 的推荐方案。
智能指针(如 std::unique_ptr、std::shared_ptr)基于 RAII(资源获取即初始化)思想,能在对象生命周期结束时自动释放资源。
例如:
void test() {
std::unique_ptr<int> p = std::make_unique<int>(10);
// 函数结束时自动 delete
}
即使发生异常,unique_ptr 的析构函数也会自动执行,从而避免泄漏。
这也是 C++11 之后几乎所有项目默认采用的方式。
3.RAII思想自定义资源管理类
RAII(Resource Acquisition Is Initialization,资源获取即初始化)思想的核心在于:把资源的“生命周期”绑定到对象的生命周期上。
只要对象在作用域中存在,资源就保持被占用;对象一旦销毁(离开作用域、异常抛出等),它的析构函数自动释放资源。
这样可以彻底摆脱“记得 delete、free、close”这种不可靠的人工管理。
例如,我们自己封装一个文件资源管理类:
class File {
FILE* fp;
public:
File(const char* name, const char* mode) {
fp = fopen(name, mode);
if (!fp) throw std::runtime_error("打开文件失败");
}
~File() {
if (fp) fclose(fp); // 自动释放资源
}
FILE* get() const { return fp; }
};
使用时非常安全:
void writeSomething() {
File f("data.txt", "w");
fprintf(f.get(), "Hello RAII!\n");
// 即使函数抛异常,析构函数也会执行,文件自动关闭
}
这就是 RAII 的威力:异常安全 + 自动释放 + 明确所有权。
同理,你也可以用这种方式管理 socket、互斥锁(std::lock_guard 就是 RAII 的典范)或者数据库连接。
4.定期使用内存泄漏检测工具
再好的规范也会有遗漏,特别是在复杂项目中。
所以“事后检测”同样重要。
常见的检测工具包括:
Valgrind(Linux 下最常用):能精确指出泄漏位置、分配堆栈。
AddressSanitizer (ASan):编译器级检测工具(clang/gcc 都支持),性能开销较小。
Visual Leak Detector (VLD):Windows 下 Visual Studio 的经典内存检测插件。
Dr. Memory:跨平台工具,检测内存泄漏、越界访问、未初始化使用等。
例如:
valgrind --leak-check=full ./my_program
就能输出每个泄漏对象的来源和大小。
不过要注意,某些检测工具可能存在“误报”或性能损耗,不适合在线上环境长期开启。
5.总结:两种策略防止内存泄漏
归纳起来,防止内存泄漏的思路有两大类:
- 事前预防型
- 智能指针 (std::unique_ptr, std::shared_ptr)
- RAII 封装
- 清晰的资源所有权规划
- 遵循异常安全设计
- 事后查错型
- 工具检测(Valgrind、ASan 等)
- 内存监控系统(长期运行服务中定期 dump 内存使用情况)
- 单元测试 + 压力测试模拟泄漏路径
更多推荐


所有评论(0)