35. 内存泄漏常见模式:placement new使用不当
placement new 是个好东西,但也是个容易踩坑的东西。
我记得刚用 C++ 那会儿,第一次看到 placement new 时觉得特别酷——能在指定内存上构造对象,不用堆分配,多高效啊!结果呢?第一次用就漏了内存。嗯,准确地说,不是漏了内存,而是漏了析构。
什么是 placement new?
先简单回顾一下。普通的 new 做两件事:分配内存 + 调用构造函数。而 placement new 只做第二件事——它在已经分配好的内存上构造对象。
// 普通 new
MyClass* p1 = new MyClass(); // 分配 + 构造
// placement new
void* buf = malloc(sizeof(MyClass));
MyClass* p2 = new(buf) MyClass(); // 只构造,不分配
看起来很简单对吧?但问题就出在「只构造,不分配」这六个字上。
最常见的错误:只 new 不 delete
很多人以为用了 placement new,就得用 placement delete 来释放。这是个误解。
placement new 根本没有对应的 placement delete 操作符。为什么?因为 placement new 不拥有内存,它只是「借住」在别人家里。你想想看,你借住在朋友家,走的时候需要把朋友家的房子拆了吗?当然不用。你只需要把自己的东西收拾干净就行了。
- placement new 构造的对象,必须显式调用析构函数
- 原始内存的释放,由内存的拥有者负责
// ❌ 错误用法
void* buf = malloc(sizeof(MyClass));
MyClass* p = new(buf) MyClass();
// 忘记调用析构函数
free(buf); // 内存释放了,但对象没析构!资源泄漏!
// ✅ 正确用法
void* buf = malloc(sizeof(MyClass));
MyClass* p = new(buf) MyClass();
p->~MyClass(); // 显式析构
free(buf); // 释放内存
场景一:自定义内存池中的 placement new
我在项目中遇到过一个大坑。当时我们做游戏服务器,为了减少频繁的堆分配,自己实现了一个对象池。代码大概长这样:
class ObjectPool {
char* pool;
size_t poolSize;
size_t offset;
public:
ObjectPool(size_t size) : poolSize(size), offset(0) {
pool = new char[size];
}
template<typename T>
T* allocate() {
if (offset + sizeof(T) > poolSize) return nullptr;
T* obj = new(pool + offset) T();
offset += sizeof(T);
return obj;
}
~ObjectPool() {
delete[] pool; // ❌ 只释放了内存,没有析构对象!
}
};
看出来了吗?这个对象池在析构时只释放了底层 char 数组,但池子里所有通过 placement new 构造的对象,它们的析构函数一个都没调用。如果这些对象持有文件句柄、网络连接、动态内存……那就是一场灾难。
如果你写对象池,要么在析构时遍历所有对象并显式调用析构函数,要么要求池中对象必须是 trivially destructible 的类型。我后来选择了后者,用 SFINAE 做了静态检查。
场景二:placement new 与异常安全
这个更隐蔽。看代码:
void process() {
void* buf = malloc(sizeof(ComplexObj));
ComplexObj* p = new(buf) ComplexObj();
// 这里如果抛出异常……
do_something_risky(p);
p->~ComplexObj();
free(buf);
}
如果 do_something_risky() 抛异常了,析构和 free 都不会执行。内存泄漏 + 资源泄漏,双杀。
我曾经在一个网络库里见过类似的 bug。当时有个连接对象用 placement new 构造在共享内存上,结果中间有个校验步骤抛了异常,连接对象没析构,导致那个共享内存段里的连接计数永远减不回来。最后整个服务挂了,因为共享内存用满了。
class PlacementGuard {
void* buf;
void* obj;
void (*dtor)(void*);
public:
PlacementGuard(void* b, void* o, void (*d)(void*))
: buf(b), obj(o), dtor(d) {}
~PlacementGuard() {
if (obj) dtor(obj);
if (buf) free(buf);
}
void release() { obj = nullptr; buf = nullptr; }
};
场景三:数组的 placement new
嗯,这里要注意。对数组使用 placement new 时,事情变得更复杂了。
// 你以为这样是对的?
void* buf = malloc(sizeof(MyClass) * 10);
MyClass* arr = new(buf) MyClass[10]; // 可能有问题!
// 实际上,编译器可能额外存储数组大小信息
// 导致 placement new 写入的内存布局和预期不一致
标准并没有规定数组 placement new 的内存布局。有些编译器会在数组头部存储元素个数,这样你传入的 buf 指针和实际对象起始位置就差了几个字节。如果你用 free(buf) 释放,没问题。但如果你用 placement new 返回的指针去析构……
我个人建议:尽量避免对数组使用 placement new。如果非要用,就逐个元素构造:
void* buf = malloc(sizeof(MyClass) * 10);
MyClass** arr = static_cast<MyClass**>(buf);
for (int i = 0; i < 10; ++i) {
arr[i] = new(buf + sizeof(MyClass) * i) MyClass();
}
// 析构时逐个调用
for (int i = 0; i < 10; ++i) {
arr[i]->~MyClass();
}
free(buf);
避坑指南
我曾经踩过的坑,总结成几条规则:
- 谁分配谁释放——placement new 不分配内存,所以不要用 delete 释放它。内存的分配者负责释放内存。
- 显式调用析构——placement new 构造的对象,必须显式调用析构函数。没有自动析构这回事。
- 异常安全——用 RAII 包装 placement new 的生命周期,确保异常发生时也能正确清理。
- 别用数组 placement new——除非你完全清楚编译器的实现细节,否则逐个构造更安全。
- 对齐问题——placement new 要求内存地址满足对象的对齐要求。用 malloc 一般没问题,但如果你从字节数组里取偏移量,要小心。
知识结构图
总结
placement new 本身不是坏东西,它是个强大的底层工具。但强大意味着需要你更小心。说白了,它把「内存生命周期」和「对象生命周期」拆开了,你就得分别管理好这两件事。
我现在的习惯是:只要用了 placement new,立刻写对应的清理代码。先写析构和释放,再写构造。顺序反过来,能避免很多遗忘的情况。
另外,如果你用的是 C++17 及以上,可以考虑用 std::construct_at 和 std::destroy_at 替代裸的 placement new。它们语义更清晰,而且配合 std::optional 或 std::variant 用起来更安全。
嗯,placement new 的坑就这些。记住一条:谁的孩子谁抱走——谁分配的内存谁释放,谁构造的对象谁析构。各司其职,就不会漏。
公众号:蓝海资料掘金营,微信 deep3321