22. new/delete与operator new:深入内存分配底层

说实话,很多C++程序员用了好几年new和delete,却不知道它们背后还有一层「隐藏的机制」。我当年刚入行时也是这样——直到有一次线上服务频繁崩溃,排查了三天才发现是自定义内存分配器出了问题。嗯,从那天起,我就把operator new和operator delete彻底搞明白了。

今天咱们就来扒一扒,new表达式和delete表达式到底干了些什么事。

22.1 new表达式到底干了什么?

你写下一行代码:

MyClass* obj = new MyClass(42);

编译器看到这行代码,其实帮你做了两件事:

  1. 调用operator new分配原始内存 —— 注意,这时候还没构造对象
  2. 在分配的内存上调用构造函数 —— 这才真正创建对象

说白了,new表达式 = operator new + 构造函数调用。

我习惯把operator new看作「内存批发商」,它只负责给你一块足够大的、未初始化的内存。至于这块内存怎么用,那是构造函数的事。

核心理解:new表达式不可重载,但operator new可以。你无法改变new的「两步走」语义,但可以控制第一步——内存怎么来。

22.2 delete表达式又干了什么?

对应的,delete表达式也分两步:

  1. 调用析构函数 —— 清理对象资源
  2. 调用operator delete释放内存 —— 归还内存
delete obj;  // 先析构,再释放内存

这里有个坑,我曾经踩过:如果你用 new[] 分配了数组,必须用 delete[] 来释放。为什么?因为 new[] 会在内存头部额外存储数组长度信息,delete[] 知道怎么读取这个信息并依次调用每个元素的析构函数。用错了,就是未定义行为。

警告:new/delete 和 malloc/free 绝对不能混用!new分配的内存用free释放,或者malloc分配的内存用delete释放,都是未定义行为。我见过有人这么干,结果程序在客户现场崩溃,排查了整整一周。

22.3 operator new 的六种形式

标准库提供了六种operator new的重载形式,我整理成了表格:

形式 签名 说明
普通分配 void* operator new(std::size_t size) 最常用的,分配size字节,失败抛std::bad_alloc
nothrow分配 void* operator new(std::size_t size, const std::nothrow_t&) 分配失败返回nullptr,不抛异常
placement new void* operator new(std::size_t size, void* ptr) 不分配内存,直接在ptr指向的位置构造对象
对齐分配(C++17) void* operator new(std::size_t size, std::align_val_t al) 按指定对齐方式分配内存
类专属 void* MyClass::operator new(std::size_t size) 为特定类定制内存分配
带额外参数 void* operator new(std::size_t size, Args... args) 用户自定义参数,比如传入分配器标签

你想想看,这六种形式给了我们极大的灵活性。尤其是placement new,在嵌入式开发和内存池设计中几乎是必备技能。

22.4 自己动手:重载operator new

我项目中就遇到过需要统计内存分配次数的场景。当时线上服务频繁Full GC,我想看看C++层面到底分配了多少内存。于是写了个简单的内存追踪器:

#include <iostream>
#include <cstdlib>

// 全局重载operator new
void* operator new(std::size_t size) {
    std::cout << "[Alloc] " << size << " bytes" << std::endl;
    void* p = std::malloc(size);
    if (!p) throw std::bad_alloc();
    return p;
}

void operator delete(void* p) noexcept {
    std::cout << "[Free]  pointer: " << p << std::endl;
    std::free(p);
}

// 类专属重载
class Widget {
public:
    void* operator new(std::size_t size) {
        std::cout << "[Widget Alloc] " << size << " bytes" << std::endl;
        return std::malloc(size);
    }
    void operator delete(void* p) noexcept {
        std::cout << "[Widget Free]" << std::endl;
        std::free(p);
    }
};

int main() {
    int* p = new int(42);   // 触发全局operator new
    delete p;

    Widget* w = new Widget(); // 触发Widget::operator new
    delete w;
    return 0;
}

运行这段代码,你会看到每次分配和释放都有日志输出。这在调试内存泄漏时特别有用。

小技巧:重载全局operator new时要非常小心。它会影响到所有使用new的代码,包括标准库内部。我建议只在调试阶段使用,或者用LD_PRELOAD注入的方式做内存分析。

22.5 placement new:在已有内存上构造对象

placement new是operator new的一种特殊形式。它不分配内存,只调用构造函数。语法是这样的:

#include <new>  // 必须包含这个头文件

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

// 使用完毕后,手动调用析构函数
obj->~MyClass();
std::free(buffer);

为什么要用placement new?我举两个场景:

  • 内存池:预先分配一大块内存,然后反复在上面构造/析构对象,避免频繁的系统调用
  • 共享内存:在进程间共享的内存区域中构造对象

注意,placement new分配的对象不能用delete释放。因为delete会调用operator delete去释放内存,但placement new并没有「拥有」这块内存。正确的做法是:手动调用析构函数,然后由你负责释放底层内存。

重要:placement new返回的指针,和传入的buffer指针是同一个地址。不要对同一个buffer多次调用placement new而不先析构,否则会构造两次,造成资源泄漏。

22.6 知识体系总览

下面这张图把new/delete和operator new/delete的关系梳理清楚了:

new/delete 与 operator new/delete 关系图 new 表达式 delete 表达式 第一步 operator new 分配原始内存 第二步 调用构造函数 第一步 调用析构函数 第二步 operator delete 释放内存 operator new/delete 可重载,new/delete 表达式语义不可改变 placement new 跳过第一步,直接在已有内存上构造对象

22.7 避坑指南

最后,我把自己这些年踩过的坑总结一下:

  • 不要重载全局operator new除非你很清楚后果 —— 我曾经在项目中重载了全局operator new用于内存统计,结果导致第三方库的内存分配行为异常,排查了两天才发现是重载影响了库内部的new调用
  • placement new必须配对显式析构 —— 不能用delete,只能手动调用析构函数
  • new[]和delete[]必须成对使用 —— 数组形式会在头部存储元素个数,混用会导致内存损坏
  • operator new失败默认抛异常 —— 如果你不想处理异常,可以用nothrow版本
  • 类专属operator new会被继承 —— 如果派生类没有重写,会使用基类的operator new,但传入的size可能是派生类的大小

嗯,关于new/delete和operator new的底层机制,今天就讲到这里。理解了这些,你再看智能指针的源码时,会发现一切都豁然开朗。


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