C++ 内存管理:分区、自定义分配器、常见问题与检测工具

张开发
2026/4/9 20:27:08 15 分钟阅读

分享文章

C++ 内存管理:分区、自定义分配器、常见问题与检测工具
目录内存基础1. 内存分区2. 栈 vs 堆3. 什么时候用堆4. 现代 C 的救赎智能指针自定义内存管理1. 重载 operator new / operator delete2. 定位 new3. 显式析构和定位 new 是黄金搭档4. 自定义分配器5. 内存池6. 总结一下常见内存问题与检测工具1. 内存泄漏2. 悬垂指针3. 缓冲区溢出4. 重复释放5. 内存碎片6. 最后想说的结尾都说 C 内存管理难难在哪Java 程序员我有 GC。Go 程序员我也 GC而且并发快。C 程序员看了一眼代码默默写了个 delete然后忘了写 new。对这就是 C 内存管理最朴素的悲剧我们亲手 new 出来的小可爱最后没人 delete。所以今天我想聊聊 C 中内存管理的问题。内存基础我们先看看 C 的内存分区以及栈和堆这两玩意。1. 内存分区C 程序运行时的内存就像一套‘豪华大别野’(д´)代码区只读放 CPU 要执行的机器指令。谁敢乱改程序直接崩溃。数据区放全局变量、静态变量这些家伙从程序启动活到程序结束。栈区函数调用时局部变量、函数参数放上去用用完自动清理。非常快但地方小放太多就容易栈溢出。堆区我们需要大块内存、或者对象要活过函数返回就去堆上申请new / malloc。自己申请的用完必须亲手 delete / free否则就会内存泄漏。另外还有常量区只读数据段比如字符串字面量 hello 放那儿。你敢改它未定义行为大概率崩溃。放一张流程图2. 栈 vs 堆对比项栈堆管理方式编译器自动压栈/弹栈我们只管声明变量手动申请new、手动释放delete分配速度极快——移动一下栈指针比眨眼还快慢——需要找空闲内存块可能触发系统调用大小限制很小看编译器/平台大比如 2 GB 在 32 位下更大在 64 位生命周期随作用域自动结束从 new 到 delete跨函数存活碎片问题无碎片LIFO 完美整齐有外部碎片申请释放顺序乱产生空洞出错后果栈溢出递归忘终止、超大局部数组内存泄漏、野指针、double free举个栗子void fun() { int a 21; // 栈上a 在函数结束时自动消失 int* p new int(100); // 堆上分配一个 intp 本身在栈上 // ... 忘了 delete p; } // 这里 p 被销毁但堆上的 int(100) 永远没人管然后内存泄漏3. 什么时候用堆对象体积很大。对象需要比创建它的函数活得更久比如工厂函数返回一个对象的指针。需要动态大小比如输入决定数组长度栈上不能放变长数组堆上可以 new int[n]。需要多态基类指针指向派生类对象通常放堆上配合智能指针。但记住能用栈就别用堆。栈又快又安全自动析构不会泄漏。堆每次使用都需要亲手释放。4. 现代 C 的救赎智能指针能不写 new / delete 就别写。用 std::unique_ptr 和 std::shared_ptr它们遵循 RAII资源获取即初始化。void good() { auto p std::make_uniqueint(21); // 不用 delete函数结束自动释放堆内存 }堆还是那个堆但我们再也不用自己扫地了。自定义内存管理默认的 new/delete 虽然大多时候够用了但我们要是遇到高频创建小对象、实时系统、或者需要把对象放在共享内存里那就得自己动手搓了。1. 重载 operator new / operator delete注意一下我们重载的是分配/释放内存的底层函数operator new而不是 new 表达式它先调 operator new 再调构造函数。全局重载不推荐容易冲突void* operator new(size_t size) { std::cout Global new: size bytes std::endl; void* p malloc(size); if (!p) throw std::bad_alloc(); return p; } void operator delete(void* p) noexcept { std::cout Global delete std::endl; free(p); }所有地方包括标准库都会用我们这个版本容易出诡异 bug。一般只在特定调试或内存追踪工具里用。类专属重载常见做法class Widget { public: static void* operator new(size_t size) { std::cout Widget new std::endl; return ::operator new(size); // 仍用全局分配 } static void operator delete(void* ptr) { std::cout Widget delete std::endl; ::operator delete(ptr); } };我们想统计某个类的分配次数、对齐到特殊边界、或者从内存池里分配都可以用这种方式。当然别以为重载了 new 就万事大吉new[] / delete[] 也要重载否则可能不对称。而且 size 参数有时候比 sizeof(Widget) 大因为数组需要额外记录元素个数我们忘了处理就会崩。2. 定位 new假如我们已经有一块内存栈上、堆上、共享内存、内存池里想在上面构造对象。这时候普通 new 不行它总是自己分配内存。定位 new 就是答案。#include new int main() { char buffer[sizeof(Widget)]; // 栈上原始内存 Widget* pw new (buffer) Widget(); // 在 buffer 上构造 Widget /* 使用 pw... */ pw-~Widget(); // 必须显式析构否则资源泄漏 }buffer 不需要 delete因为是栈上的。经典使用场景内存池从池子里拿一块空闲内存用 placement new 构造对象。共享内存进程间通信把对象构造在映射的共享内存区域上。避免重复分配比如容器预留容量后在已分配的内存上构造新元素。三大铁律定位 new不分配内存只调用构造函数。必须手动调用析构函数pw-~Widget()否则对象内部可能泄漏资源比如它自己又 new 了堆内存。不能直接用 delete pw因为 delete 会尝试释放那块内存而那块内存可能不在堆上。正确做法先析构再根据原始内存的来源释放如果是堆上 malloc 的就 free。3. 显式析构和定位 new 是黄金搭档显式调用析构函数是 C 为数不多的“允许我们手动调用像 . 操作符一样的东西”。语法很简单ptr-~T()。为什么需要普通栈对象离开作用域自动析构堆对象 delete 时先析构再释放内存。但定位 new 构造的对象编译器不会自动调用析构必须我们亲自来。示例结合内存池简单雏形int main() { int i 10; char* pool static_castchar*(malloc(1000 * sizeof(Widget))); Widget* pw new (pool i * sizeof(Widget)) Widget(); /* ... 使用 */ pw-~Widget(); // 析构 // 最后别忘了释放整个 pool: free(pool); }显式析构之后那块内存上不再有活动对象但内存本身仍然有效可以再次用构造新对象。这叫内存重用。4. 自定义分配器因为标准库容器支持分配器参数。所以我们可以写一个分配器类提供 allocate、deallocate、construct、destroy 等接口。然后 std::vectorint, MyAllocatorint v; 就会用我们的策略分配内存。一个极简固定大小内存池分配器示意只讲思路templatetypename T class PoolAllocator { // 内部维护一个链表每个空闲块指向下一个 // allocate: 从链表头部取一个块返回指针 // deallocate: 把块插回链表头部 };为什么要自己写分配器避免频繁调用系统 malloc。减少内存碎片。满足特定对齐要求。我们要是想写一个符合标准库要求的分配器非常繁琐。C17 之后可以用 std::pmr::memory_resource 作为更现代的接口但底层原理还是类似。感兴趣的可以了解一下绝不是因为我懒。5. 内存池核心思想一次性向系统申请一大块内存比如 1 MB然后自己用小尺子切成等长或不等长的块快速分配/释放。释放时不是还给系统而是放回池子的空闲链表。实现思路固定大小池适合同一类对象如游戏中的子弹、粒子。每个块大小相同用单向链表串起空闲块。分配取头结点释放头插。变长池复杂需要处理合并相邻空闲块一般直接用现成的库如 tcmalloc、jemalloc或者 boost::pool。它的优点极快O(1) 分配/释放无外碎片固定大小池减少系统调用。缺点内碎片如果对象小于块大小浪费空间自己管理内存生命周期容易踩坑。简单实现固定大小对象池class ObjectPool { struct Block { Block* next; }; Block* freeList nullptr; char* pool nullptr; size_t blockSize, capacity; public: ObjectPool(size_t size, size_t count) : blockSize(size), capacity(count) { pool static_castchar*(malloc(size * count)); // 初始化空闲链表 for (size_t i 0; i count; i) { Block* b reinterpret_castBlock*(pool i * size); b-next freeList; freeList b; } } void* allocate() { if (!freeList) return nullptr; void* p freeList; freeList freeList-next; return p; } void deallocate(void* p) { Block* b static_castBlock*(p); b-next freeList; freeList b; } ~ObjectPool() { free(pool); } };我们可以在 allocate 返回的内存上构造对象用完先析构再 deallocate。6. 总结一下技术场景重载 operator new调试统计、对齐控制等定位 new 显式析构内存池、共享内存、避免默认构造函数自定义分配器高性能容器、特殊内存区域如 GPU 内存内存池游戏/高频小对象分配且测量证明 malloc 是瓶颈我们还是先写清晰、安全的代码用 std::vector、智能指针、RAII。不要一上来就自定义内存管理。等我们的 operator new 占了 30% 的时间或者我们需要在共享内存里放一个哈希表再来自己实现这些玩意。现代 C 是给了我们这些工具但也给了我们足够的绳索把自己吊起来。所以用好 RAII 和智能指针比什么都强。常见内存问题与检测工具有时候我们写的 C 程序跑起来像喝醉了一样偶尔崩溃、偶尔输出乱码、偶尔吃掉所有内存。导致我们一脸懵想知道到底哪儿出事了。所以我把常见的内存问题分成五大恶人每个都有自己的作案手法和痕迹。然后推荐几件工具帮你快速定位。1. 内存泄漏症状程序运行越久越卡内存占用持续上升最后被系统 OOM Killer(Out Of Memory killer) 干掉。或者我们写了个服务器跑了三天后 new 抛出 std::bad_alloc。典型示例void leaky() { int* p new int[1000]; // 忘了 delete[] p; } // p 离开作用域但堆上那点东西永远没人管检测工具Valgrind (memcheck)经典工具运行我们的程序退出时报告哪些内存没释放。慢程序慢 20 倍但可靠。假设我们有一个程序叫 test使用 Valgrind 时可以这么用valgrind --leak-checkyes test--leak-check选项会开启详细的内存泄漏检测。我就作简单介绍其它的命令自己探索吧(・ε・)。AddressSanitizerASan编译器插桩Clang/GCC 加 -fsanitizeaddress运行时检测更快慢 2 倍而且能同时抓很多其他问题。使用 Clang 示例clang -fsanitizeaddress -g test.c -o test使用 GCC 示例gcc -fsanitizeaddress -g test.c -o test-fsanitizeaddress启用ASan检测-g生成调试符号2. 悬垂指针症状程序偶发崩溃崩溃的地方看起来不可能比如访问一个已经释放的对象。有时能运行有时崩多线程下尤其邪门。典型示例int* dangling() { int x 21; return x; // 返回局部变量地址 } // x 已销毁外部拿到一个指向过去的指针 void use() { int* p dangling(); *p 100; // 未定义行为可能崩可能改掉别的变量可能没反应 }另一个更隐蔽的int* p new int(10); int* q p; delete p; *q 20; // q 现在是悬垂指针检测工具ASan释放后将内存标记为已释放下次访问立即报错use-after-free。Valgrind同样能检测对已释放内存的读写。我们释放指针后应该立刻置为 nullptr虽然不能完全解决问题但能避免二次释放。或者用智能指针让生命周期自动管理。3. 缓冲区溢出症状程序崩溃崩溃点离写入代码很远因为溢出破坏的是相邻内存的控制信息比如堆块头、栈上的返回地址。有时候表现为奇怪的逻辑错误或安全漏洞。典型示例// 栈溢出 void stack_overflow() { char buf[4]; strcpy(buf, hello); // 写了 6 个字符包括结尾 \0buf 只有 4 // 破坏栈上相邻的变量或返回地址 } // 堆溢出 void heap_overflow() { int* arr new int[10]; arr[10] 21; // 越界破坏堆元数据 delete[] arr; }检测工具ASan在栈和堆的缓冲区周围放红区poisoned memory访问到就报错。Valgrind也能检测越界但比 ASan 慢。Compiler 选项-D_FORTIFY_SOURCE2GCC在编译时对一些 strcpy 等函数加边界检查。4. 重复释放症状程序崩溃在 delete 或 free 内部报错类似 “double free or corruption”。典型示例int* p new int(21); delete p; delete p; // 第二次释放同一块内存或者两个指针指向同一块堆内存各自释放int* p new int(21); int* q p; delete p; delete q; // double free检测工具ASan分配内存时记录释放后标记第二次释放立即报错。Valgrind同样能检测到。Debug 堆Windows会触发断点。5. 内存碎片症状程序运行一段时间后new 一个大对象失败抛出 bad_alloc但 mallinfo() 或任务管理器显示还有大量空闲内存。或者性能逐渐下降。我们分成两类外部碎片空闲内存被分割成许多小片没有一块足够大的连续空间。内部碎片分配的内存块比实际需要的大比如固定大小内存池对象只有 8 字节块大小 16 字节浪费 8 字节。典型示例// 不断交错分配不同大小的对象 for (int i 0; i 100000; i) { int* p1 new int[1]; // 4 字节 int* p2 new int[100]; // 400 字节 delete p1; // 留下 4 字节空洞 } // 多次后空闲内存全是 4 字节的洞没法分配 400 字节连续块检测工具没有直接‘碎片检测’的现成工具但可以用自定义分配器记录分配大小分布或者用 malloc_info / mallinfoLinux看空闲块数量。HeapprofGoogle 工具能可视化堆布局。Valgrind 的 massif 可以生成堆使用时间线图看出碎片趋势。缓解方法使用内存池固定大小消除外部碎片。避免频繁分配不同大小的小对象用 std::vector 代替大量独立 new。6. 最后想说的内存问题排查起来很痛苦但请记住RAII 智能指针让析构自动发生消灭大多数泄漏和悬垂。用容器代替裸数组std::vector、std::array、std::string 自带边界检查和安全生命周期。善用ASan能让我们多点摸鱼时间。结尾不想写结尾完~

更多文章