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 构造在共享内存上,结果中间有个校验步骤抛了异常,连接对象没析构,导致那个共享内存段里的连接计数永远减不回来。最后整个服务挂了,因为共享内存用满了。

✅ 正确做法:RAII 包装
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);

避坑指南

我曾经踩过的坑,总结成几条规则:

  1. 谁分配谁释放——placement new 不分配内存,所以不要用 delete 释放它。内存的分配者负责释放内存。
  2. 显式调用析构——placement new 构造的对象,必须显式调用析构函数。没有自动析构这回事。
  3. 异常安全——用 RAII 包装 placement new 的生命周期,确保异常发生时也能正确清理。
  4. 别用数组 placement new——除非你完全清楚编译器的实现细节,否则逐个构造更安全。
  5. 对齐问题——placement new 要求内存地址满足对象的对齐要求。用 malloc 一般没问题,但如果你从字节数组里取偏移量,要小心。

知识结构图

placement new 内存泄漏常见模式 placement new 忘记调用析构函数 异常导致清理跳过 数组 placement new 对象持有资源未释放 文件句柄/网络连接泄漏 中间步骤抛异常 析构和free都被跳过 编译器额外存储数组大小 内存布局不一致 解决方案:RAII包装 + 显式析构 + 避免数组

总结

placement new 本身不是坏东西,它是个强大的底层工具。但强大意味着需要你更小心。说白了,它把「内存生命周期」和「对象生命周期」拆开了,你就得分别管理好这两件事。

我现在的习惯是:只要用了 placement new,立刻写对应的清理代码。先写析构和释放,再写构造。顺序反过来,能避免很多遗忘的情况。

另外,如果你用的是 C++17 及以上,可以考虑用 std::construct_atstd::destroy_at 替代裸的 placement new。它们语义更清晰,而且配合 std::optionalstd::variant 用起来更安全。

嗯,placement new 的坑就这些。记住一条:谁的孩子谁抱走——谁分配的内存谁释放,谁构造的对象谁析构。各司其职,就不会漏。


公众号:蓝海资料掘金营,微信 deep3321