一:基本概念:

  • 在C++98时期,我们在申请资源的时候,都需要手动释放,如果我们忘记了释放,就会导致内存泄漏等一系列问题。
  • 所以在C++11,C++智能指针的目标:让资源自动释放,不依赖人类的记忆。
  • 智能指针的思想来自 RAII(Resource Acquisition Is Initialization):资源的获取应与对象的生命周期绑定。对象销毁时,自动释放资源。

  • 在使用智能指针的时候,有下面三个种类可以供我们挑选

  1. std::unique_ptr
  2. std::shared_ptr
  3. 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. 智能指针的设计演化史—进化历史

  1. 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 取代。
  1. 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 的线程安全可以分为两部分来理解
一是 智能指针自身(引用计数)的线程安全,二是 所管理对象的线程安全。

  1. 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(它内部已经使用了原子操作,保证引用计数安全)。

  1. 被管理对象的线程安全

shared_ptr 只保证“控制块的引用计数”是线程安全的,
但不保证被托管对象本身的线程安全。

例如下面的代码:

copy->_a1++;
copy->_a2++;

这里两个线程同时修改对象 AA 的成员 _a1 和 _a2,这属于对象层面的并发访问问题,shared_ptr 不会为这些操作加锁。
换句话说:

shared_ptr 只负责“对象的生死”,
但不负责“对象的行为是否安全”。

正确做法
使用外部锁保护对象访问。例如:

unique_lock<mutex> lk(mtx);
copy->_a1++;
copy->_a2++;
  1. 程序运行逻辑分析

你的程序大致流程是:

  • 主线程创建 bit::shared_ptr p;
  • 两个线程反复复制智能指针 copy(p);
  • 每次复制都会使引用计数加 1;
  • 每个线程修改 AA 的成员;
  • 最后输出结果。

如果引用计数不是原子的,就可能出现:

  • 崩溃(double free):引用计数被错误地减到 0,导致重复释放;
  • 资源未释放:引用计数没减回 0,析构函数 ~AA() 未被调用。
  1. 为什么 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.总结:两种策略防止内存泄漏

归纳起来,防止内存泄漏的思路有两大类:

  1. 事前预防型
  • 智能指针 (std::unique_ptr, std::shared_ptr)
  • RAII 封装
  • 清晰的资源所有权规划
  • 遵循异常安全设计
  1. 事后查错型
  • 工具检测(Valgrind、ASan 等)
  • 内存监控系统(长期运行服务中定期 dump 内存使用情况)
  • 单元测试 + 压力测试模拟泄漏路径
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐