资讯详情

资讯详情

C++全面覆盖内存管理知识讲解

前言C 的内存管理经常被概括成「记得new完要delete」这个说法既对又不完整。它漏掉了三件更重要的事对象有生命周期不只是内存有分配与释放所有权需要被明确表达而不是靠注释和约定错误的形式远不止「忘记释放」还包括重复释放、释放后使用、越界写、new[]配delete、以及析构函数不是虚函数导致的未定义行为。另一个误解是把「手动管理」等同于「必须手写new/delete」。现代 C 的主流做法恰恰相反用 RAIIresource acquisition is initialization把资源绑定到对象生命周期上用std::unique_ptr、std::shared_ptr、容器和std::string表达所有权让编译器生成的析构函数去释放资源。手写new/delete应当被视为例外而不是默认。本文按「存储期 → 分配与释放 → 所有权抽象 → 错误分类 → 检测手段」展开。所有涉及未定义行为undefined behavior, UB的地方都会明确标注不会把「可能崩溃」写成「一定崩溃」也不会给出没有测量依据的性能数字。一、存储期与对象生命周期C 对象有存储期storage duration决定它占用的内存在何时分配、何时回收。常见四类如下。存储期典型来源分配时机释放时机自动automatic局部变量、函数参数进入作用域离开作用域静态static全局变量、命名空间变量、static局部变量程序启动或首次执行到声明处程序结束线程threadthread_local变量线程启动线程结束动态dynamicnew、malloc等显式申请显式释放自动存储期通常落在栈上但标准不保证「栈」这个实现细节。栈空间有限递归过深或在栈上开超大数组会导致栈溢出这类问题在不同平台上的阈值不同不应依赖具体数值。动态存储期通常落在堆上容量受进程地址空间和系统限制。需要区分「存储期」与「生命周期」。存储期描述内存何时可用生命周期描述对象何时存在。对类类型来说生命周期从构造函数完成开始到析构函数开始前结束。new表达式先分配内存再在其中构造对象delete先调用析构函数再释放内存。手工把这两步拆开例如operator new配 placement new就必须自己保证顺序正确。还有一个容易忽略的点分配了内存不等于存在对象。用malloc得到一块内存后里面没有std::string对象直接把它当std::string用是 UB。必须通过 placement new 或 C20 的std::construct_at在指定地址上开始对象生命周期。二、new/delete 与 malloc/freenew T做两件事调用operator new分配sizeof(T)字节然后在这块内存上构造T。delete p做两件事调用p的析构函数再调用operator delete释放内存。new[]/delete[]是数组版本对类类型数组实现可能额外存储元素个数俗称 cookie因此必须配对使用new[]只能用delete[]释放new只能用delete释放。混用是 UB。malloc/free只管理原始字节不调用构造/析构。它们的配对规则同样严格malloc配free不能配deletenew也不能配free。标准明确规定这两种分配机制不能交叉使用。// 适用于 C17#include cstdlib#include stringvoid correct_usage() {int* a new int(42); // 分配 初始化delete a; // 析构对 int 无操作 释放int* arr new int[10]{}; // 10 个 0delete[] arr; // 必须用 delete[]void* raw std::malloc(sizeof(int));if (raw) {std::free(raw); // 与 malloc 配对}auto* s new std::string(hello);delete s; // 调用 ~string 再释放}delete nullptr是合法的什么也不做因此析构函数里写delete ptr_;不需要额外判空但把指针置空仍然是好习惯能防止重复释放。对同一指针delete两次是 UB不保证崩溃也可能静默破坏堆结构。C17 起还有对齐版本operator new(size, std::align_val_t)用于需要超过默认对齐的场合例如 SIMD 类型。普通的new在 C17 之前不保证支持过度对齐类型C17 起对alignas超过默认值的类型会自动选择对齐版本。手写内存池时要留意这一点。placement new 允许在已有内存上构造对象// 适用于 C17#include new#include stringvoid placement_example() {alignas(std::string) unsigned char buffer[sizeof(std::string)];auto* p new (buffer) std::string(temporary);// 使用 p ...p-~basic_string(); // 必须显式调用析构函数// 不要 delete pbuffer 不是 new 分配的}placement new 的配对操作是显式析构而不是delete。这块内存如果来自new unsigned char[]最终要用delete[]释放原始数组而不是释放p。若在const成员或带引用的对象上反复 placement new取回指针时可能还需要std::launderC17这属于较冷门但标准明确要求的细节。三、RAII 与智能指针RAII 的核心是把资源的获取放在构造函数里释放放在析构函数里。这样无论函数正常返回、提前return还是抛异常析构函数都会执行栈展开保证资源不会泄漏。// 适用于 C17#include cstdio#include memory#include stringclass FileHandle {public:explicit FileHandle(const char* path): fp_(std::fopen(path, rb)) {}~FileHandle() {if (fp_) std::fclose(fp_);}FileHandle(const FileHandle) delete;FileHandle operator(const FileHandle) delete;FileHandle(FileHandle other) noexcept: fp_(other.fp_) { other.fp_ nullptr; }FileHandle operator(FileHandle other) noexcept {if (this ! other) {if (fp_) std::fclose(fp_);fp_ other.fp_;other.fp_ nullptr;}return *this;}std::FILE* get() const noexcept { return fp_; }private:std::FILE* fp_ nullptr;};这个类遵循「规则五」定义了析构函数就同时处理拷贝构造、拷贝赋值、移动构造、移动赋值。更省事的做法是「规则零」——把std::FILE*换成一个自带析构的包装或者直接用std::unique_ptrstd::FILE, decltype(std::fclose)让编译器生成所有特殊成员函数。标准智能指针的分工类型所有权语义拷贝适用场景std::unique_ptrT独占不可拷贝可移动默认选择表达唯一所有者std::unique_ptrT[]独占数组不可拷贝可移动动态数组析构用delete[]std::shared_ptrT共享可拷贝引用计数多个所有者生命周期不确定std::weak_ptrT不拥有可拷贝观察者、打破循环引用std::make_uniqueT(args...)是 C14 引入的std::make_sharedT(args...)是 C11 的。优先用它们make_unique避免在unique_ptrT(new T)中显式写newmake_shared通常把控制块和对象放在一次分配里减少分配次数。但make_shared有一个副作用对象内存要等最后一个weak_ptr也销毁后才回收因为控制块与对象在同一块分配中。shared_ptr的引用计数操作是线程安全的但被指向对象的访问不是。两个线程通过各自的shared_ptr副本修改同一个对象仍然是数据竞争是 UB。控制块安全只保证「拷贝/销毁shared_ptr本身」不会把计数弄坏。循环引用是shared_ptr的经典陷阱A 持有shared_ptrBB 持有shared_ptrA两个引用计数都降不到零。解决办法是把其中一条边改成std::weak_ptr使用时用lock()提升为shared_ptr并检查是否为空。std::shared_ptrT(ptr)对同一个裸指针构造两次会生成两个独立的控制块最终导致双重释放。正确做法是先用make_shared或unique_ptr接管再从已有智能指针拷贝/移动。四、常见内存错误的分类把错误分类比笼统地说「内存问题」更有助于排查。泄漏leak分配后失去所有指向它的指针无法释放。长生命周期进程里会持续增长。注意「逻辑泄漏」——容器只增不减、缓存没有上限——也属于这一类虽然不是new/delete层面的问题。悬垂指针/引用dangling对象已销毁指针仍被使用。delete之后继续访问、返回局部变量引用、std::string::c_str()在字符串修改后继续使用都是这类。访问已销毁对象是 UB。重复释放double free同一块内存被释放两次或两个shared_ptr控制块管理同一裸指针。UB常表现为堆损坏。越界访问out-of-boundsstd::vector::operator[]不检查边界越界是 UBat()会抛std::out_of_range。C 风格数组和strcpy系列函数同样是重灾区。未初始化读取局部变量未初始化就读取值不确定是 UB。new int不加初始化器也不初始化new int()或new int{}才值初始化。分配/释放不匹配new[]配delete、new配free、malloc配delete都是 UB。对象切片与虚析构缺失通过基类指针delete派生类对象若基类析构不是virtual是 UB。这条严格说属于多态使用错误但后果常表现为内存泄漏或堆损坏。五、检测手段没有工具时内存错误往往要等到线上才暴露。可用的手段分几类。第一类是编译器与静态分析开启-Wall -WextraGCC/Clang或/W4MSVC使用-fsanitizeaddress,undefinedGCC/Clang在运行时捕获越界、释放后使用、泄漏等问题。MSVC 有/fsanitizeaddress。静态分析工具能在不运行程序的情况下发现部分问题但会有误报。第二类是运行时检测器AddressSanitizerASan通过插桩检测越界与释放后使用LeakSanitizer 检测泄漏Valgrind 的 Memcheck 在 Linux 上无需重新编译即可检测多类内存错误。它们都有性能开销适合测试环境而非生产。第三类是调试堆MSVC 的 CRT 调试堆提供_CrtDumpMemoryLeaks等接口需要包含crtdbg.h属于平台相关。这类接口不可移植只应在 Windows 调试构建中使用。第四类是代码审查与类型设计把「谁拥有、谁释放」写进类型里比任何工具都有效。看到裸new就问一句「它的所有者是谁、异常路径上谁来释放」能提前消灭大部分问题。六、完整示例一个遵守规则零的缓冲区// 适用于 C17#include algorithm#include cstddef#include memory#include utilityclass Buffer {public:explicit Buffer(std::size_t n): size_(n), data_(std::make_uniqueunsigned char[](n)) {}std::size_t size() const noexcept { return size_; }unsigned char* data() noexcept { return data_.get(); }const unsigned char* data() const noexcept { return data_.get(); }// 规则零不写析构、拷贝、移动全部由成员 unique_ptr 决定Buffer(const Buffer) delete;Buffer operator(const Buffer) delete;Buffer(Buffer) noexcept default;Buffer operator(Buffer) noexcept default;private:std::size_t size_ 0;std::unique_ptrunsigned char[] data_;};void fill(Buffer b) {std::fill(b.data(), b.data() b.size(), static_castunsigned char(0));}这里显式删除了拷贝、默认了移动是因为Buffer表达「独占一块字节缓冲」复制语义不明确。std::make_uniqueunsigned char[](n)创建的是数组形式析构时自动使用delete[]不会与标量版混用。data()返回的指针在Buffer被移动或销毁后失效调用方必须保证生命周期。若把成员换成std::vectorunsigned char连size_都不需要代码会更短这里用unique_ptrT[]是为了展示数组所有权的写法并不是说它优于vector。绝大多数场景下std::vector才是更合适的选择。常见坑点❌new[]分配后用delete释放。✅ 必须用delete[]或者干脆用std::vector/std::unique_ptrT[]管理。❌new出来的指针交给free或malloc出来的指针用delete。✅ 两种分配机制不能交叉new/delete、malloc/free各自配对。❌ 用shared_ptrT(raw)对同一裸指针构造两次。✅ 用make_shared或先让unique_ptr接管再转换否则两个控制块会双重释放。❌ 认为shared_ptr线程安全就等于它指向的对象线程安全。✅ 只有引用计数操作安全对象访问仍需互斥或其他同步手段。❌ 在类里持有指向自身的shared_ptr形成环。✅ 把回边改为std::weak_ptr使用时lock()并判空。❌ 基类析构函数不写virtual却通过基类指针delete派生对象。✅ 多态基类必须声明virtual ~Base() default;否则是 UB。❌delete之后继续使用指针或再次delete。✅ 删除后立即置空置空只能防重复delete不能防「释放后使用」。❌ 认为new int会把值初始化为 0。✅new int不初始化要零初始化写new int()或new int{}。总结主题关键规则常见错误存储期自动/静态/线程/动态四类在栈上分配过大对象new/delete分配 构造、析构 释放严格配对new[]配deletemalloc/free只管理字节不调用构造析构与new/delete混用placement new显式析构不能delete忘记调用析构函数RAII构造获取、析构释放析构函数抛异常智能指针用类型表达所有权shared_ptr循环引用、双控制块错误分类泄漏/悬垂/重复释放/越界/未初始化只关注「忘记 delete」内存管理的关键不是记住多少条规则而是建立两个习惯第一任何资源都用拥有明确析构行为的对象来持有让「释放」自动发生第二任何裸指针出现时先回答「它拥有资源吗、生命周期到哪结束」。前者消灭大部分泄漏后者消灭大部分悬垂与重复释放。工具能帮你发现漏网之鱼但类型设计才是第一道防线。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →