22. new/delete与operator new:深入内存分配底层
说实话,很多C++程序员用了好几年new和delete,却不知道它们背后还有一层「隐藏的机制」。我当年刚入行时也是这样——直到有一次线上服务频繁崩溃,排查了三天才发现是自定义内存分配器出了问题。嗯,从那天起,我就把operator new和operator delete彻底搞明白了。
今天咱们就来扒一扒,new表达式和delete表达式到底干了些什么事。
22.1 new表达式到底干了什么?
你写下一行代码:
MyClass* obj = new MyClass(42);
编译器看到这行代码,其实帮你做了两件事:
- 调用operator new分配原始内存 —— 注意,这时候还没构造对象
- 在分配的内存上调用构造函数 —— 这才真正创建对象
说白了,new表达式 = operator new + 构造函数调用。
我习惯把operator new看作「内存批发商」,它只负责给你一块足够大的、未初始化的内存。至于这块内存怎么用,那是构造函数的事。
核心理解:new表达式不可重载,但operator new可以。你无法改变new的「两步走」语义,但可以控制第一步——内存怎么来。
22.2 delete表达式又干了什么?
对应的,delete表达式也分两步:
- 调用析构函数 —— 清理对象资源
- 调用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的关系梳理清楚了:
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