C++智能指针的使用及其原理

本文从异常安全的血泪教训出发,一步步推演出智能指针的设计思路,把它们的原理、用法讲清楚。


目录


一、为什么需要智能指针

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;
}

这段代码中其实有很多问题

  1. Divide 抛异常 → 跳过两个 delete → 内存泄漏
  2. new int[10] 本身也可能抛 std::bad_alloc → 如果 array2 的 new 失败,array1 也没释放
  3. 如果用 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++ 对象生命周期来管理资源的惯用法:

  1. 构造时获取资源:把资源(内存、文件句柄、锁等)交给一个对象管理
  2. 析构时释放资源:对象离开作用域时自动调用析构函数,资源必然被释放
  3. 异常安全:栈展开时局部对象会被自动析构,资源不会泄漏

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_ptrC++98拷贝时转移所有权,原指针悬空绝对不要用!已废弃
unique_ptrC++11独占所有权,不可拷贝,只可移动默认首选,不需要共享就用它
shared_ptrC++11共享所有权,引用计数,支持拷贝需要共享资源时使用
weak_ptrC++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,那么 sp1sp2 各有各的 _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

sp1sp2 是不同的对象(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() 永远不会打印!
}

循环逻辑

  1. 右边的节点什么时候释放?← 左边的 _next 析构后
  2. _next 什么时候析构?← 左边节点释放后
  3. 左边节点什么时候释放?← 右边的 _prev 析构后
  4. _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_ptrunique_ptrshared_ptrweak_ptr
所有权独占(转移)独占共享不拥有
拷贝支持(危险)禁止支持(计数++)支持(不++)
移动支持支持支持支持
删除器不支持模板参数构造函数参数
RAII
operator*
推荐度禁用!首选需要共享时打破循环引用

选择决策树

你需要智能指针管理资源吗?
│
├── 资源只需要一个所有者?
│   └── 用 unique_ptr ✓
│
├── 资源需要被多个对象共享?
│   ├── 用 shared_ptr
│   └── 注意:如果有互相引用,用 weak_ptr 打破循环
│
└── 只是观察资源,不参与生命周期?
    └── 用 weak_ptr

黄金法则:能用 unique_ptr 就不用 shared_ptr。独占所有权的语义更清晰,性能也更好(没有引用计数的原子操作开销)。


参考资料:C++ standard library - memory

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值