0、大纲
0.1场景引入
下面这段归并排序算法故意留了内存泄漏问题,请分析:
void MergeSort(int* arr, int left, int right) { // 1. 分配临时数组(仅在需要合并时分配) int* tmp = new int[right - left + 1]; // 2. 递归终止条件:区间长度为 1 或无效时直接返回,防止内存泄漏 if (left >= right) { return; } // 3. 计算中间点,防止溢出 int mid = left + ((right - left) >> 1); // 4. 递归排序左右子区间 MergeSort(arr, left, mid); MergeSort(arr, mid + 1, right); // 5. 合并两个有序区间 int begin1 = left, end1 = mid; int begin2 = mid + 1, end2 = right; int index = 0; // tmp 数组的索引 // 比较并拷贝到 tmp while (begin1 <= end1 && begin2 <= end2) { if (arr[begin1] <= arr[begin2]) { tmp[index++] = arr[begin1++]; } else { tmp[index++] = arr[begin2++]; } } // 拷贝剩余元素 while (begin1 <= end1) { tmp[index++] = arr[begin1++]; } while (begin2 <= end2) { tmp[index++] = arr[begin2++]; } // 6. 将 tmp 中的数据拷回原数组 for (int i = 0; i < (right - left + 1); ++i) { arr[left + i] = tmp[i]; } // 7. 释放内存 delete[] tmp; }这段代码分为7部分,核心内存泄漏问题集中在提前返回路径未释放已分配的堆内存:
- 递归终止条件的提前返回路径:代码在第1部分就
new int[right - left + 1]分配堆内存,第2部分才判断if (left >= right) { return; }。此时若满足if判断,delete[] tmp语句就完全没有机会执行,新分配的临时数组内存直接丢失引用,形成永久内存泄漏。 - 递归场景下的泄漏放大:归并排序是递归调用,当递归到区间长度为1的子问题时,每一层满足
left >= right的调用都会执行一次new分配内存,然后直接返回不释放,递归深度越大,累积的泄漏内存越多。 - 异常安全缺失:如果归并逻辑中任意一行代码抛出异常,程序执行流会直接跳过后续的
delete[] tmp,已分配的tmp数组内存无法被回收,进一步加剧泄漏风险。 - 重复分配冗余内存:当前写法在每一层递归都分配一次临时数组,不仅放大了泄漏概率,还会造成大量不必要的堆内存申请释放开销,降低排序效率。
修复方案参考:
- 把临时数组的分配操作移到递归终止条件判断之后,确保只有当区间需要合并时才分配内存,从根源避免提前返回导致的泄漏。
- 采用RAII机制,使用智能指针实现归并排序中的临时数组内存,彻底避免手动
new/delete带来的内存泄漏问题;或用std::vector<int>替代裸指针new数组,vector会在出作用域时自动释放内存,完全避免手动管理内存的泄漏风险。(std::vector< >封装更完善,自带边界检查、迭代器等容器特性,使用更灵活,是数组类资源RAII管理的通用首选,但本文重点讲解智能指针,所以此处只简单提一下)。 - 优化为一次性在外部分配全局临时数组,递归过程中复用该数组,既消除泄漏点,又大幅提升排序性能。
智能指针实现归并排序示例
#include <memory> void MergeSort(int* arr, int left, int right) { // 递归终止条件先判断,避免无效分配 if (left >= right) {return;} int mid = left + ((right - left) >> 1); // 使用unique_ptr管理动态数组,自动RAII释放 std::unique_ptr<int[]> tmp = std::make_unique<int[]>(right - left + 1); MergeSort(arr, left, mid); MergeSort(arr, mid + 1, right); // 后续归并逻辑完全不变,直接通过智能指针下标访问数组 int begin1 = left, end1 = mid; int begin2 = mid + 1, end2 = right; int index = 0; while (begin1 <= end1 && begin2 <= end2) { if (arr[begin1] <= arr[begin2]) { tmp[index++] = arr[begin1++]; } else { tmp[index++] = arr[begin2++]; } } while (begin1 <= end1) tmp[index++] = arr[begin1++]; while (begin2 <= end2) tmp[index++] = arr[begin2++]; for (int i = 0; i < (right - left + 1); ++i) { arr[left + i] = tmp[i]; } // 无需手动写delete,tmp出作用域时自动释放内存 }1、为什么需要智能指针
根源就是裸指针极易引发各类内存问题:
内存泄漏:分配内存后忘记调用delete/delete[],堆内存无法释放,长期运行会耗尽系统资源;空悬指针/野指针:内存已释放,但指针未置空,后续访问会触发非法内存访问,导致程序崩溃;重复释放:多个裸指针指向同一内存,多次释放会触发未定义行为;生命周期混乱:复杂场景下(如多线程、循环引用),难以精准把控对象的销毁时机。
智能指针本质是封装了裸指针的类模板,重载了->和\*操作符,让你用得跟指针一样,但析构的时候利用C++的RAII(资源获取即初始化)机制,在智能指针对象生命周期结束时,自动调用析构函数释放管理的内存,从根源上解决裸指针的内存管理难题。
1.1内存泄漏
C/C++ 两类典型内存泄漏:
其他伪泄漏 / 同类内存问题”:这些问题裸指针全都很难管控,这就是智能指针诞生的核心原因。
- 野指针:指针指向已释放的堆内存、非法地址,解引用会崩溃;
- 双重释放:同一块内存连续两次
delete,触发未定义行为; - 内存越界读写:数组下标越界、改写堆边界,破坏堆结构;
1.2内存泄露预防检测
(事前预防为主)
(事后排查)
2.boost库与智能指针
2.1历史发展
2.2现代智能指针体系
C++11:
C++14:
C++17:
C++20:
C++23:
2.3未来发展
3.unique_ptr
3.1核心语义与特点
独占所有权、禁止拷贝:它所管理的堆资源,同一时刻,只能被这一个unique_ptr持有。没有第二个指针可以共享这份资源。谁拿到这个unique_ptr,谁就是资源的唯一主人。指针销毁,资源立刻释放。
不允许拷贝,但允许移动(std::move):把资源所有权转移出去,因此unique_ptr 能传递。移动是转移所有权,原指针放弃控制权,新指针接管资源。整个过程,始终只有一个指针管理资源,完全符合独占语义。
//up1、up2 各自独占一块堆内存。 std::unique_ptr<A> up1(new A(1)); std::unique_ptr<A> up2(new A(2)); // 允许移动,up1变成空 std::unique_ptr<A> up3 = std::move(up1);这种设计带来了两个极致的优点。第一,没有引用计数开销,性能和原生裸指针完全一致,是真正的零开销抽象。第二,所有权唯一,不会出现共享冲突、循环引用的问题,安全性拉满。
3.2简易版 unique_ptr 实现
#include <iostream> #include <utility> // std::move template<typename T> class MyUniquePtr { private: T* _ptr = nullptr; public: // 构造函数 接管资源 explicit MyUniquePtr(T* p = nullptr) : _ptr(p) { } // 析构:释放资源 ~MyUniquePtr() { delete _ptr; } // 禁止拷贝构造、拷贝赋值 MyUniquePtr(const MyUniquePtr&) = delete; MyUniquePtr& operator=(const MyUniquePtr&) = delete; // 移动构造:创建全新对象,从别的 unique_ptr 抢夺资源 MyUniquePtr(MyUniquePtr&& other) noexcept : _ptr(nullptr) { // 接管对方资源 _ptr = other._ptr; other._ptr = nullptr; } // 移动赋值:对象已经存在,先释放自己手里旧资源,再抢夺对方资源; MyUniquePtr& operator=(MyUniquePtr&& other) noexcept { if (this != &other) { //需要判断`this != &other`防止自移动 // 释放自己旧资源 delete _ptr; // 抢夺other的指针 _ptr = other._ptr; other._ptr = nullptr; } return *this; } // 解引用 T& operator*() const { return *_ptr; } // ->重载 T* operator->() const { return _ptr; } // 显式bool转换,判断是否持有资源 explicit operator bool() const { return _ptr != nullptr; } // get:获取裸指针,不移交所有权 T* get() const { return _ptr; } // release:释放所有权,返回裸指针,内部置空,不释放内存 T* release() { T* temp = _ptr; _ptr = nullptr; return temp; } // reset:释放旧资源,接管新指针 void reset(T* p = nullptr) { delete _ptr; _ptr = p; } }; // 测试用例 struct TestObj { int val; TestObj(int v) : val(v) { std::cout << "构造 TestObj " << val << "\n"; } ~TestObj() { std::cout << "析构 TestObj " << val << "\n"; } }; int main() { MyUniquePtr<TestObj> p1(new TestObj(100)); std::cout << p1->val << "\n"; // 移动语义 MyUniquePtr<TestObj> p2 = std::move(p1); if (!p1) { std::cout << "p1已经为空\n"; } std::cout << p2->val << "\n"; MyUniquePtr<TestObj> p3(new TestObj(200)); p3 = std::move(p2); std::cout << p3->val << "\n"; // release TestObj* raw = p3.release(); std::cout << "release拿到裸指针 val=" << raw->val << "\n"; delete raw; // release之后需要手动释放! return 0; }只展示unique_ptr封装裸指针、禁止拷贝、支持移动、析构自动释放等核心功能,以下内容暂不展示:
- 只支持单个对象,不支持数组
MyUniquePtr<int> arr(new int[10]);会 UB,析构调用delete而不是delete[]。标准库unique_ptr<T[]>会特化,使用delete[]。 - 没有自定义删除器,标准
std::unique_ptr支持传入自定义 deleter。 - 没有
swap辅助函数。 - 此处用了new没用make_unique,下面会讲二者区别。
3.3代码说明
(1)为什么 unique_ptr 禁止拷贝?
如果unique_ptr支持拷贝,那一个资源就会被两个unique_ptr同时管理。第一个指针出作用域,释放资源。第二个指针再出作用域,会再次释放同一块资源,直接触发双重释放崩溃。为了守住独占所有权的核心语义,标准库直接删掉了unique_ptr的拷贝构造、拷贝赋值函数。从语法层面杜绝拷贝操作,从根源上避免bug。若试着写一行拷贝代码,编译器会直接报错,根本不让你编译通过,这就是编译期安全保障。
(2)移动语义与所有权转移
禁止拷贝,不代表不能传递资源。unique_ptr支持移动语义,这是C++11移动语法的经典应用,通过std::move()把左值强制转换为右值引用,告诉编译器“这个对象可以被移动”,接着unique_ptr的移动构造函数让调用者接管资源。拷贝是复制一份资源,两个指针共存。移动是转移所有权,原指针放弃控制权,新指针接管资源。整个过程,始终只有一个指针管理资源,完全符合独占语义。
这个特性在函数传参、函数返回值场景里用得特别多。比如函数返回一个unique_ptr,本质就是把资源所有权转移给调用方,安全又高效。