2. new/delete 运算符深度剖析:operator new/delete 的重载、placement new、内存池原理

好,咱们今天来聊聊 C++ 里最基础、也最容易踩坑的东西——newdelete

很多人觉得这俩不就是分配和释放内存吗?有什么好讲的?

嗯,我当年也是这么想的。直到有一次,我在一个高并发的服务器项目里,发现程序跑着跑着就卡死了,查了三天,最后定位到是 new 抛异常导致的内存泄漏。从那以后,我就把 operator newplacement new 翻来覆去看了好几遍。

今天咱们就把这层窗户纸捅破。

2.1 new 和 delete 到底干了什么?

先问个问题:newmalloc 有什么区别?

你可能会说:new 会调用构造函数,malloc 不会。

没错,但这只是表象。实际上,new 干了三件事:

  1. 调用 operator new 分配原始内存
  2. 在分配的内存上调用构造函数
  3. 返回指向对象的指针

delete 则反过来:

  1. 调用析构函数
  2. 调用 operator delete 释放内存

说白了,new 就是 malloc + 构造函数的组合。但这里有个关键点:operator newoperator delete 是可以被重载的。

核心理解: new 是语言关键字,operator new 是函数。前者负责调用后者,再加上构造逻辑。重载的是函数,不是关键字。

2.2 重载 operator new / operator delete

为什么要重载?

我举个例子。你在写一个游戏引擎,每帧要创建几千个小对象。默认的 new 每次都要向操作系统申请内存,这开销太大了。你想想看,系统调用一次就要几十微秒,几千次下来,帧率直接崩了。

这时候,重载 operator new 就派上用场了。

class MyClass {
public:
    void* operator new(size_t size) {
        std::cout << "自定义分配: " << size << " 字节" << std::endl;
        return malloc(size);
    }

    void operator delete(void* ptr) {
        std::cout << "自定义释放" << std::endl;
        free(ptr);
    }
};

注意,operator new 的第一个参数永远是 size_t,表示要分配的内存大小。这个参数由编译器自动传入,你不需要手动传。

我个人的习惯是,在重载时加上 std::nothrow 版本,这样分配失败时返回 nullptr 而不是抛异常:

void* operator new(size_t size, const std::nothrow_t&) noexcept {
    return malloc(size);
}

小技巧: 如果你重载了全局的 operator new,记得也要重载对应的 operator delete,否则链接时会报错。我吃过这个亏。

2.3 placement new:在指定内存上构造对象

这个就更有意思了。

placement new 不分配内存,它只负责在已经分配好的内存上调用构造函数。语法长这样:

#include <new>

void* buffer = malloc(sizeof(MyClass));
MyClass* obj = new (buffer) MyClass(42);  // placement new

为什么要用这个?

场景一:你有一个大块的内存池,想在上面反复创建和销毁对象,避免频繁的系统调用。

场景二:你需要在共享内存或 mmap 映射的区域上构造对象。

场景三:你写了一个自定义的内存分配器,想控制对象在特定地址上创建。

我曾经在一个嵌入式项目里,用 placement new 在固定地址上构造设备驱动对象。那会儿内存只有 64KB,每一字节都得精打细算。

注意,用 placement new 创建的对象,不能直接用 delete 释放。你得手动调用析构函数:

obj->~MyClass();  // 手动析构
free(buffer);      // 释放原始内存

警告: 千万不要对 placement new 出来的对象调用 delete!那会调用 operator delete 去释放内存,但你的内存可能来自池子或共享内存,不是堆上分配的。后果就是——崩溃。

2.4 内存池原理:从零开始搭一个

好了,前面铺垫了这么多,终于到重头戏了。

内存池,说白了就是预先申请一大块内存,然后自己管理里面的小块分配。这样就不需要每次都跟操作系统打交道了。

最简单的内存池,就是一个固定大小的对象池。比如你只分配 MyClass 对象,每个对象大小固定,那就好办了。

我画了一张图,帮你理解内存池的核心逻辑:

内存池核心原理 预分配大块内存(例如 1MB) 块1 (空闲) 块2 (已用) 块3 (空闲) 块4 (已用) ... 空闲链表(Free List) 块1 → 块3 → 块5 → nullptr

你看,核心思路就是:

  1. 预先分配一大块连续内存
  2. 按固定大小切成小块
  3. 用链表把空闲块串起来
  4. 分配时从链表头取一块,释放时放回链表头

代码实现其实很简单:

class FixedPool {
    struct Node {
        Node* next;
    };
    Node* freeList = nullptr;
    char* pool = nullptr;
    size_t blockSize;
    size_t poolSize;

public:
    FixedPool(size_t blockSize, size_t blockCount)
        : blockSize(blockSize), poolSize(blockSize * blockCount) {
        pool = new char[poolSize];
        // 初始化空闲链表
        for (size_t i = 0; i < blockCount; ++i) {
            Node* node = reinterpret_cast<Node*>(pool + i * blockSize);
            node->next = freeList;
            freeList = node;
        }
    }

    void* allocate() {
        if (!freeList) return nullptr;  // 池子空了
        Node* node = freeList;
        freeList = node->next;
        return node;
    }

    void deallocate(void* ptr) {
        Node* node = static_cast<Node*>(ptr);
        node->next = freeList;
        freeList = node;
    }

    ~FixedPool() { delete[] pool; }
};

然后配合 placement new 使用:

FixedPool pool(sizeof(MyClass), 1000);

void* mem = pool.allocate();
MyClass* obj = new (mem) MyClass();

// 使用 obj...

obj->~MyClass();
pool.deallocate(obj);

我的经验: 这种固定大小的内存池,在对象频繁创建销毁的场景下,性能提升非常明显。我在一个网络库中用过,分配速度比 malloc 快了将近 10 倍。但要注意,它只适合固定大小的对象,如果对象大小不一,就得用更复杂的伙伴系统或 slab 分配器了。

2.5 重载 operator new 与内存池结合

最后,咱们把内存池和 operator new 重载结合起来。这样,你的类就可以透明地使用内存池了:

class MyClass {
    static FixedPool pool;

public:
    void* operator new(size_t size) {
        return pool.allocate();
    }

    void operator delete(void* ptr) {
        pool.deallocate(ptr);
    }
};

FixedPool MyClass::pool(sizeof(MyClass), 1000);

现在,你写 new MyClass() 时,分配的内存就来自池子了。用户完全感知不到底层的变化。

嗯,这里要注意一点:如果你的类有继承关系,子类的大小可能和父类不同。这时候 operator new 收到的 size 参数会变化,你得根据 size 决定从哪个池子分配。

我建议的做法是,用多个不同大小的池子,根据 size 参数选择对应的池子。或者干脆用 malloc 兜底,只对特定大小的对象走池子。

避坑指南: 我曾经在重载 operator new 时忘了处理 size == 0 的情况。标准规定,即使分配 0 字节,也要返回一个合法的非空指针。所以记得加个判断:if (size == 0) size = 1;

2.6 总结一下

今天咱们聊了:

  • newdelete 的底层机制——分配内存 + 构造/析构
  • 如何重载 operator newoperator delete 来自定义内存管理
  • placement new 的用法和注意事项
  • 内存池的原理和实现,以及如何与 operator new 重载结合

说白了,这些技术都是为了一个目的:更高效、更可控地管理内存

你想想看,默认的 new 就像去超市买东西,每次都要排队结账。而内存池就像你提前囤了一冰箱的菜,随用随取,省去了排队的功夫。

在实际项目中,我一般不会一上来就用内存池。先分析性能瓶颈,确认是内存分配导致的,再动手优化。过早优化是万恶之源,这句话不是白说的。

好了,今天就到这儿。希望下次你在代码里看到 new 的时候,能多想一想它背后发生了什么。


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