都说 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 |
举个栗子:
c++
void fun()
{
int a = 21; // 栈上:a 在函数结束时自动消失
int* p = new int(100); // 堆上:分配一个 int,p 本身在栈上
// ... 忘了 delete p;
} // 这里 p 被销毁,但堆上的 int(100) 永远没人管,然后内存泄漏
3. 什么时候用堆?
- 对象体积很大。
- 对象需要比创建它的函数活得更久(比如工厂函数返回一个对象的指针)。
- 需要动态大小(比如输入决定数组长度,栈上不能放变长数组,堆上可以 new int[n])。
- 需要多态(基类指针指向派生类对象,通常放堆上,配合智能指针)。
但记住:能用栈就别用堆。栈又快又安全,自动析构,不会泄漏。
堆每次使用都需要亲手释放。
4. 现代 C++ 的救赎:智能指针
能不写 new / delete 就别写。
用 std::unique_ptr 和 std::shared_ptr,它们遵循 RAII(资源获取即初始化)。
c++
void good()
{
auto p = std::make_unique<int>(21);
// 不用 delete,函数结束自动释放堆内存
}
堆还是那个堆,但我们再也不用自己扫地了。
自定义内存管理
默认的 new/delete 虽然大多时候够用了,但我们要是遇到高频创建小对象、实时系统、或者需要把对象放在共享内存里,那就得自己动手搓了。
1. 重载 operator new / operator delete
注意一下:我们重载的是 分配/释放内存 的底层函数(operator new),而不是 new 表达式(它先调 operator new 再调构造函数)。
全局重载(不推荐,容易冲突)
c++
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。
一般只在特定调试或内存追踪工具里用。
类专属重载(常见做法)
c++
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 就是答案。
c++
#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 构造的对象,编译器不会自动调用析构,必须我们亲自来。
示例(结合内存池简单雏形):
c++
int main()
{
int i = 10;
char* pool = static_cast<char*>(malloc(1000 * sizeof(Widget)));
Widget* pw = new (pool + i * sizeof(Widget)) Widget();
/* ... 使用 */
pw->~Widget(); // 析构
// 最后别忘了释放整个 pool: free(pool);
}
显式析构之后,那块内存上不再有活动对象,但内存本身仍然有效(可以再次用构造新对象)。
这叫内存重用。
4. 自定义分配器
因为标准库容器支持分配器参数。
所以我们可以写一个分配器类,提供 allocate、deallocate、construct、destroy 等接口。
然后 std::vector<int, MyAllocator<int>> v; 就会用我们的策略分配内存。
一个极简固定大小内存池分配器(示意,只讲思路):
c++
template<typename T>
class PoolAllocator
{
// 内部维护一个链表:每个空闲块指向下一个
// allocate: 从链表头部取一个块,返回指针
// deallocate: 把块插回链表头部
};
为什么要自己写分配器:
- 避免频繁调用系统 malloc。
- 减少内存碎片。
- 满足特定对齐要求。
我们要是想写一个符合标准库要求的分配器非常繁琐。
C++17 之后可以用 std::pmr::memory_resource 作为更现代的接口,但底层原理还是类似。
感兴趣的可以了解一下,绝不是因为我懒。
5. 内存池
核心思想:一次性向系统申请一大块内存(比如 1 MB),然后自己用小尺子切成等长或不等长的块,快速分配/释放。
释放时不是还给系统,而是放回池子的空闲链表。
实现思路:
- 固定大小池:适合同一类对象(如游戏中的子弹、粒子)。每个块大小相同,用单向链表串起空闲块。分配:取头结点;释放:头插。
- 变长池:复杂,需要处理合并相邻空闲块,一般直接用现成的库(如 tcmalloc、jemalloc)或者 boost::pool。
它的优点:极快(O(1) 分配/释放),无外碎片(固定大小池),减少系统调用。
缺点:内碎片(如果对象小于块大小,浪费空间);自己管理内存生命周期,容易踩坑。
简单实现(固定大小对象池):
c++
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_cast<char*>(malloc(size * count));
// 初始化空闲链表
for (size_t i = 0; i < count; ++i)
{
Block* b = reinterpret_cast<Block*>(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_cast<Block*>(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。
典型示例:
c++
void leaky()
{
int* p = new int[1000];
// 忘了 delete[] p;
} // p 离开作用域,但堆上那点东西永远没人管
检测工具:
- Valgrind (memcheck):经典工具,运行我们的程序,退出时报告哪些内存没释放。慢(程序慢 20 倍),但可靠。
假设我们有一个程序叫 test,使用 Valgrind 时可以这么用:
bash
valgrind --leak-check=yes test
--leak-check选项会开启详细的内存泄漏检测。
我就作简单介绍,其它的命令自己探索吧(・ε・)。
- AddressSanitizer(ASan):编译器插桩(Clang/GCC 加 -fsanitize=address),运行时检测,更快(慢 2 倍),而且能同时抓很多其他问题。
使用 Clang 示例:
bash
clang -fsanitize=address -g test.c -o test
使用 GCC 示例:
bash
gcc -fsanitize=address -g test.c -o test
- -fsanitize=address:启用ASan检测
- -g:生成调试符号
2. 悬垂指针
症状:程序偶发崩溃,崩溃的地方看起来不可能(比如访问一个已经释放的对象)。
有时能运行,有时崩,多线程下尤其邪门。
典型示例:
c++
int* dangling()
{
int x = 21;
return &x; // 返回局部变量地址
} // x 已销毁,外部拿到一个指向过去的指针
void use()
{
int* p = dangling();
*p = 100; // 未定义行为:可能崩,可能改掉别的变量,可能没反应
}
另一个更隐蔽的:
c++
int* p = new int(10);
int* q = p;
delete p;
*q = 20; // q 现在是悬垂指针
检测工具:
- ASan:释放后将内存标记为已释放,下次访问立即报错(use-after-free)。
- Valgrind:同样能检测对已释放内存的读写。
我们释放指针后应该立刻置为 nullptr(虽然不能完全解决问题,但能避免二次释放)。
或者用智能指针,让生命周期自动管理。
3. 缓冲区溢出
症状:程序崩溃,崩溃点离写入代码很远,因为溢出破坏的是相邻内存的控制信息(比如堆块头、栈上的返回地址)。
有时候表现为奇怪的逻辑错误或安全漏洞。
典型示例:
c++
// 栈溢出
void stack_overflow()
{
char buf[4];
strcpy(buf, "hello"); // 写了 6 个字符(包括结尾 '\0'),buf 只有 4
// 破坏栈上相邻的变量或返回地址
}
// 堆溢出
void heap_overflow()
{
int* arr = new int[10];
arr[10] = 21; // 越界,破坏堆元数据
delete[] arr;
}
检测工具:
- ASan:在栈和堆的缓冲区周围放红区(poisoned memory),访问到就报错。
- Valgrind:也能检测越界,但比 ASan 慢。
- Compiler 选项:-D_FORTIFY_SOURCE=2(GCC)在编译时对一些 strcpy 等函数加边界检查。
4. 重复释放
症状:程序崩溃在 delete 或 free 内部,报错类似 "double free or corruption"。
典型示例:
c++
int* p = new int(21);
delete p;
delete p; // 第二次释放同一块内存
或者两个指针指向同一块堆内存,各自释放:
c++
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 字节)。
典型示例:
c++
// 不断交错分配不同大小的对象
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 / mallinfo(Linux)看空闲块数量。
- Heapprof(Google 工具)能可视化堆布局。
- Valgrind 的 massif 可以生成堆使用时间线图,看出碎片趋势。
缓解方法:
- 使用内存池(固定大小)消除外部碎片。
- 避免频繁分配不同大小的小对象,用 std::vector 代替大量独立 new。
6. 最后想说的
内存问题排查起来很痛苦,但请记住:
- RAII + 智能指针:让析构自动发生,消灭大多数泄漏和悬垂。
- 用容器代替裸数组:std::vector、std::array、std::string 自带边界检查和安全生命周期。
- 善用 ASan 能让我们多点摸鱼时间。
结尾
今天不想写结尾,完~