【内存分配与管理】理解new与placement new
前三篇文章中,我们已经了解了malloc的底层实现以及与我们平时接触最为密切的的堆栈内存分配,本篇文章将从malloc的底层机制到new的面向对象封装,了解new的设计理念、底层实现以及正确用法
new表达式
我们先来从最表层来对比一下malloc与new:
1.概念上的区别
malloc/free是C/C++标准库函数,而new/delete是C++关键字,或者说是new表达式。因此malloc仅仅只分配内存,而不会进行初始化类成员的工作,new不止分配内存,而且还调用类的构造函数。
2.返回类型的不同
new内存分配成功时候,返回的是对象类型的指针,不需要进行类型转换,从这个角度来说比较安全malloc内存分配成功则返回 void类型(泛型指针),必须通过强制类型转换将 void* 转换成需要的类型。
3.分配失败后的返回值不同
malloc失败,会返回空指针。new失败,默认是抛出异常,要捕获异常bad_alloc
4.是否需要指定内存大小
new操作符申请内存的时候不需要指定内存块的大小,编译器会自动根据类型信息来计算malloc需要显示的指出内存的大小。
这只是浅层表象,我们该如何理解new呢?
理解new
new不仅仅是”申请内存“,本质上是一个将”内存分配“和”对象构造“绑定在一起的语法糖。
从底层来看,其首先调用全局的operator new分配原始内存(通常基于malloc实现),然后通过在这块内存上调用构造函数,对象的生命周期开始。相对的,delete则先调用析构函数结束对象的生命周期,再调用opeartor delete释放内存。
这背后反应了C++对对象的一个核心理解:对象由”内存存储“和”生命周期“两部分组成,二者在默认情况下是绑定的,目的是提供简单对象语义。
但是,C++也同样允许我们将二者拆开单独控制,将“内存的生命周期”和”对象的生命周期“解耦,实现更为精细的控制。
比如,在智能指针make_shared的实现中,对象和控制块是统一分配的,因此内存无法单独释放,但是对象的生命周期需要在强引用归零时结束。此时,就需要通过”字符数组+placement new“将“内存的生命周期”和”对象的生命周期“解耦,并通过显式析构结束对象生命周期。这既保证了及时结束对象生命周期,又不会在释放控制块时导致对资源对象的双重析构
所以可以说,new/delete 的设计本质是:在提供简单对象语义的同时,也允许程序员在需要时精细控制内存和对象生命周期,这是 C++ 能实现智能指针、内存池等高级机制的基础。
现在,我们再来对比一下malloc和new,理解为什么有了malloc/free为什么还要new/delete?
从C++面向对象的特点出发: C语言是面向过程的,其关注的是“数据+操作”,并不严格区分“内存”和“对象”,所以只需要通过malloc/free来管理内存的存储期即可。而C++是面向对象的,内存仅仅是对象的一部分,对象还包括构造、析构等生命周期的管理,需要区分“内存生命周期”和“对象的生命周期”。而new/delete将内存分配和对象的生命周期绑定起来,做面向对象的封装,从而支持RAII思想与异常安全,符合C++语义要求
具体来说,RAII要求资源创建即绑定到对象的生命周期中,在构造时申请资源,析构时释放资源,以防止资源的泄露。如果没有new/delete,那么用户需要手动malloc+construct+destruct+free,这一流程既繁琐又容易出错,破坏了面向对象的抽象和封装的核心原则,内存管理负担过重
从异常安全的角度来说: new/delete通过try-catch+回滚析构,保证了在对象构造时能正确调用operator delete来释放申请资源,防止内存泄露。如果没有new/delete,那么用户始终都需要手动try-catch来保证对象构造时的异常安全,这使得资源管理逻辑与业务逻辑深度耦合,代码复杂度急剧上升,且难以在所有可能的分支上保证正确的资源释放。
new底层原理
new表达式在编译器展开后,首先调用operator new分配原始内存(通常基于malloc实现),然后调用构造函数完成对象的初始化。如果构造函数抛出异常,则会被try-catch捕获,并调用对应参数的operator delete,将operator new申请的内存释放,防止内存泄露
T* p = new T(args);
//类似于下面逻辑:
void* mem = operator new(sizeof(T)); // ① 分配
try {
p = static_cast<T*>(mem);
p->T::T(1, 2); // ② 构造
} catch (...) {
operator delete(mem); // ③ 构造失败回滚
throw;
};
operator new是C++提供的可重载内存分配函数,用于为new分配原始内存,其默认调用的就是全局的::operator new
其逻辑是,尝试通过malloc进行内存分配,如果分配失败,则会循环调用new-handler处理函数——如果用户没有手动设置,则会直接抛出std::bad_alloc异常;如果设置了,则会在调用后再次尝试malloc,为一个死循环
// libstdc++-v3/libsupc++/new_op.cc
void* operator new(std::size_t size) _GLIBCXX_THROW (std::bad_alloc) {
if (size == 0)
size = 1;
void* p;
while ((p = malloc(size)) == 0) { // 核心:调用 malloc
// 如果 malloc 失败,尝试调用 new_handler
std::new_handler nh = std::get_new_handler();
if (nh)
nh(); // 用户可能释放一些内存
else
throw std::bad_alloc();
}
return p;
}
operator new重载
为什么需要重载?
用于检测使用上的错误。 比如,对于越界访问new分配的内存的情况,我们可以在内存空间的头部和尾部的额外的空间,填充特定的标志位置,并在operator delete时便可以检查出标志内容是否被修改
void* operator new(size_t size) { void* p = std::malloc(size + extra); // 写 guard bytes return p; }提高效率。 operator new和operator delete针对的是“通用分配”,其需要对各种内存分配需求做适配,所以表现更为均衡。所以,如果程序大部分情况下,对内存的分配要求都是相同的,则可以制定相应的分配策略
统计使用信息
如何写一个正确的operator new?
正确处理size == 0的情况。operator new(0)必须返回一个“可区分”的空指针,常见做法是将size设置为1,并分配一块大小为1的内存块
支持new-handler。在分配失败时,循环调用std::get_new_handler()获取到设定的new_headler函数,让handler决定如何处理,比如释放内存/终止程序/抛异常等,然后重新尝试分配
void* operator new(std::size_t size) { if (size == 0) size = 1; while (true) { if (void* p = std::malloc(size)) { return p; } std::new_handler handler = std::get_new_handler(); if (!handler) { throw std::bad_alloc(); } handler(); // 可能释放内存或终止 } }处理继承下的operator new。 类专属的静态operator new默认会被派生类继承使用,但是其往往只是适用于当前类,因此必须在分配内存前先检查size大小是否和当前类类型大小相同,如果不同,则交给默认的全局operator new处理。
void* Base::operator new(std::size_t size) { if (size != sizeof(Base)) return ::operator new(size); // 交给全局 new // 否则:走 Base 专属逻辑 }
相对的,只要我们自定义了operator new,就必须提供对应的operator delete,否则在构造失败的路径下会导致未定义行为,如下面代码所示:
class Widget {
public:
static void* operator new(std::size_t size) {
// 自定义分配
}
static void operator delete(void* p) noexcept {
// 对应释放
}
//placement new 版本
static void* operator new(std::size_t size, MemoryPool& pool) {
return pool.allocate(size);
}
static void operator delete(void* p, MemoryPool& pool) {
pool.deallocate(p);
}
};
这是因为,我们在new创建对象时,如果构造函数抛出异常,会选择调用对应参数版本的operator delete来回收内存,但是如果找不到相应版本的operator delete,则会回退到全局的operator delete。这就会出问题:new使用的是自定义的策略,而delete使用的却是标准的释放,分配和释放机制并不匹配,可能导致内存泄露、破坏
void* raw = Widget::operator new(sizeof(Widget)); // 成功
try {
new (raw) Widget(); // 构造失败 ❌
} catch (...) {
Widget::operator delete(raw); // ❗回收内存
throw;
}
那如何写一个正确的operator delete呢?
其需要注意的类似,一是要正确处理删除空指针,直接return即可;二是同样要检查size大小,防止大小错误的删除
class Base { //一如以往,但此刻重点在 operator delete
public:
static void* operator new(std::size_t size) throw(std::bad_alloc);
static void operator delete(void* rawMemory, std::size_t size) throw();
...
};
void Base::operator delete(void* rawMemory, std::size_t size) throw()
{
if (rawMemory == 0) return; //检查 null 指针。
if (size != sizeof(Base)) { //如果大小错误,令标准版
::operator delete(rawMemory); //operator delete 处理此一申请。
return;
}
//现在,归还 rawMemory 所指的内存;
return;
}
placement new表达式
【new、placement new、operator new(size_t, void*)重载之间的关系是什么?】
new表达式、placement new表达式更像是一种“语法糖”,而operator new重载是底层实现。new和placement new在语法上都是new表达式,不是相互独立的两种语法,编译器生成的代码结构类似,区别在于二者调用到的operator new重载版本不同,进而影响到在构造异常时调用到的opeartor delete版本及其回滚行为。
C++标准:如果内存分配成功但对象构造失败(构造函数抛出异常),且存在与 placement new 参数匹配的 placement delete 函数,那么编译器应该调用这个 placement delete 来释放内存。
再本质一些,对于普通的new具有资源所有权,由C++保证自动回滚;而placement new需要用户自主决定所有权和回滚行为
// 二者共同的伪代码
void* mem = operator new(...); //调用到的operator new 版本不同
try {
construct(mem); //都会构造
} catch (...) {
operator delete(...); //构造失败时调用对应的operator delete回滚
throw;
}
具体来说,二者的区别在于:
调用的operator new重载版本不同。 new表达式调用的版本是 operator new(std::size_t size),其回调用malloc进行内存分配,具体原理见“new底层原理”;而默认placement new表达式调用的重载版本是 operator new(std::size_t, void __p),其只是单纯地返回指针,不会分配内存,不会调用new-handler,不会抛异常。* (当然,也可以调用你自定义的placement operator new版本,以上讨论都是针对C++默认提供的默认版本的placement new)
// placement new底层调用到的operator new重载,实现简化为: void* operator new(std::size_t, void* ptr) noexcept { return ptr; // 只是原样返回指针,不做任何处理! }二者实际上都是C++在全局作用于下提供的operator new重载版本
void* operator new(std::size_t) throw(std::bad_alloc); //normal new. void* operator new(std::size_t, void*) throw(); //placement new. void* operator new(std::size_t, //nothrow new. const std::nothrow_t&) throw();异常处理机制不同。 new表达式会调用构造函数,在构造函数抛出异常时,会通过try-catch捕获并通过operator delete释放new表达式通过operator new(std::size_t size)申请的内存,自动完成回滚,防止内存泄露
而placement new表达式在面对构造函数抛出异常时,不会做任何事,因为这块内存根本不是它分配的,其不拥有该资源的所有权,所以C++提供相匹配的operator delete重载函数中没有做任何事,需要由用户完成资源的释放。 换句话说,如果placement new的构造出现了异常,需要用户手动兜底
我们该如何使用?
基本语法:new (address) Type (initializer-list) address必须是一个指针,initializer-list是类型的初始化列表
void* memory = operator new(sizeof(MyClass)); // 1.分配原始内存
MyClass* obj = new (memory) MyClass(/* 构造函数参数 */); // 2.在指定内存构造对象
使用注意点:
placement new构造出来的对象,其析构与内存释放必须由用户自己负责,而不能直接delete,以保证内存来源和释放相匹配
从内存管理的角度来说,因为placement new,T* obj = new(buffer) T()所初始化的内存,其可能来自于栈、堆、文件和匿名映射区或是内存池中,即可能对应了不同的operator new来分配内存,而delete无法泛化处理,需要我们手动调用析构,让分配内存的operator new和释放内存的operator delete相匹配
从语义上来说,由于不是通过普通new表达式创建的对象,而是通过operator new(size_t)首先分配内存,再通过placement new表达式调用operator new(size_t, void*)+构造函数来初始化,则代表了需要将“内存管理”和“对象生命周期管理”分离,所以不能直接delete,因为delete的底层实现类似析构+调用对应版本的operator delete,又将“内存管理”和“对象生命周期管理”耦合在了一起,属于未定义行为。
if (obj != nullptr)
{
obj->~MyClass(); // 析构
operator delete(obj); // 释放内存
}
尽管有些场景下混搭使用不会出现问题,比如以下场景,3和4可以合为delete(buffer)。但这种行为的性质就像malloc/delete或new/free混搭一样,也许不会内存泄露,可依旧属于未定义行为
int main() {
// 1. 在堆上分配原始内存(未构造)
void* buffer = operator new(sizeof(MyClass));
// 2. placement new 构造对象
MyClass* obj = new (buffer) MyClass(42);
// 3. 手动调用析构函数
obj->~MyClass();
// 4. 手动释放内存
operator delete(buffer);
}
比如以下场景:对于MyClass类,其分配内存优先从内存池中分配内存,归还内存也要归还到内存池中
class MemoryPool
{
public:
static inline char pool[1024];
static inline bool used = false;
static void* Allocate(size_t size)
{
//从内存池中分配
}
static void Free(void* ptr)
{
//归还到内存池
}
};
class MyClass
{
public:
int x;
MyClass(int v)
: x(v){}
// 普通new走对象池
static void* operator new(size_t size)
{
return MemoryPool::Allocate(size);
}
static void operator delete(void* ptr)
{
MemoryPool::Free(ptr);
}
};
如果我要求某次对象的构造不从对象池分配,而是从全局的堆内存中分配,即调用全局的operator new来分配内存,并通过placement new对其初始化,如果直接使用delete就会导致将全局的堆内存归还到内存池中,出现错误。因为此时delete调用的operator delete是MyClass类中的operator delete,而没有调用到和全局operator new相匹配的operator delete。
int main()
{
// 调用全局的operator new,而不是类中的,会外部分配原始内存
void* buffer = ::operator new(sizeof(MyClass));
// placement new
MyClass* obj = new(buffer) MyClass(42);
// 错误!
delete obj;
}
正确的做法是析构+全局::operator delete,必须让operator new和operator delete配对使用,而不是使用默认的delete
如果正确地自定义实现placement new表达式?
实际上,其和“写一个正确的operator new”的行为是一致的,因为本质上都是在写operator new的重载,只不过说的placement new重载,一般都不会在operator new中分配内存,也不会在operator delete中释放内存
placement new和placement delete必须成对设计。 换句话说,如果提供了operator new(A, B, C)版本的重载,那么也必须提供对应的operator delete(A, B, C)版本的重载,以保证在构造函数抛异常时可以被正确回滚,防止资源泄漏。
在类内重载operator new时,需要避免遮盖了正常版本的operator new。 正确的方法是,创建一个基类,包含所有C++默认提供的operator new,并让需要自定义operator new的类继承该基类
class StandardNewDeleteForms { public: // normal new/delete static void* operator new(std::size_t size) throw(std::bad_alloc) { return ::operator new(size); } static void operator delete(void* pMemory) throw() { ::operator delete(pMemory); } // placement new/delete static void* operator new(std::size_t size, void* ptr) throw() { return ::operator new(size, ptr); } static void operator delete(void* pMemory, void* ptr) throw() { return ::operator delete(pMemory, ptr); } // nothrow new/delete static void* operator new(std::size_t size, const std::nothrow_t& nt) throw() { return ::operator new(size, nt); } static void operator delete(void *pMemory, const std::nothrow_t&) throw() { ::operator delete(pMemory); } }; class Widget: public StandardNewDeleteForms { //继承标准形式 public: using StandardNewDeleteForms::operator new; //让这些形式可见 using StandardNewDeleteForms::operator delete; static void* operator new(std::size_t size, //添加一个自定的 std::ostream& logStream) //placement new throw(std::bad_alloc); static void operator delete(void* pMemory, //添加一个对应的 std::ostream& logStream) //placement delete throw(); ... };
placement new的应用场景?
一般应用在自定义的内存池——避免频繁地调用malloc/free,重复利用同一块内存区域;STL allocator容器的底层实现,consructor函数的本质就是placement new,支持延迟构造,比如vector支持reserve来提前分配内存而不初始化
总结
从malloc的底层机制到new的面向对象封装,我们看到了C++在内存管理哲学上的重大演进。new/delete不仅是简单的语法糖,更是C++面向对象核心思想的体现——将内存分配与对象生命周期管理绑定,为RAII和异常安全提供基础支持。
对于大多数场景,直接使用new/delete(或智能指针)是最安全的选择;但在需要极致性能或特殊内存管理的场合,理解如何正确重载operator new、使用placement new等技术
在后续的文章中,我们将继续探索内存管理的更多话题——智能指针、移动语义等机制如何优化对象生命周期管理