前言
new失败时会发生什么?多数人的回答是「抛出std::bad_alloc」。这只说对了一半。在抛出异常之前,operator new会先调用一个由用户安装的新处理器(new-handler),给它一次「抢救」的机会:释放缓存、把预留内存交出来、记录日志,或者干脆换一个处理器。只有处理器不存在,或者处理器自己决定放弃时,异常才会抛到调用方。
这个机制在嵌入式、长时间运行的服务、内存受限的容器里很有用,但它在日常代码里几乎不可见,导致很多误解。有人以为处理器是「内存耗尽时的回调,返回就代表问题解决了」;实际上处理器返回只表示「再试一次」,如果它没有真正释放出内存,operator new会再次调用它,可能陷入循环。也有人以为每个线程、每个分配器可以有各自的处理器;实际上它是全进程唯一的一个函数指针。还有人把new (std::nothrow)当成「不触发处理器」的写法,这一点要分实现看。
本文讲清五件事:处理器是什么、operator new的失败循环如何工作、处理器可以做什么、如何安装与恢复、以及哪些分配形式根本不经过它。示例以 C++17 为基准,代码中的失败路径做了显式模拟,不依赖真实的物理内存耗尽。本机没有 C++ 工具链,所有行为按标准语义描述,未做编译与运行验证。
一、接口:三个名字
<new>头文件提供了三个相关名字:
// 适用于 C++17
namespace std {
using new_handler = void (*)(); // 函数指针类型
new_handler set_new_handler(new_handler) noexcept; // 设置并返回旧的
new_handler get_new_handler() noexcept; // C++11 起,查询当前的
}set_new_handler接收一个「无参数、无返回值」的函数指针,把它设为当前处理器,并返回之前的那一个。传入nullptr表示卸载处理器。程序启动时处理器是空指针,所以默认行为就是直接抛std::bad_alloc。
处理器是全局状态,不是线程局部的。多线程同时调用set_new_handler没有同步保证,应该在程序启动、其他线程运行之前设置一次;如果确实需要在运行期更换,必须自己加锁,并注意更换期间其他线程的分配行为。
二、operator new的失败循环
标准描述的分配流程可以概括为下面的循环:
- 尝试分配请求的字节数;成功则返回指针。
- 失败时读取当前 new-handler。
- 如果处理器为空,抛出
std::bad_alloc。 - 否则调用处理器。
- 处理器返回后,回到第 1 步重试。
这意味着处理器有四种「正确结局」:
| 处理器的动作 | 结果 |
|---|
| 释放了缓存或预留内存,然后返回 | 重试可能成功 |
| 安装另一个处理器(或卸载),然后返回 | 用新处理器继续重试 |
抛出std::bad_alloc或其派生类 | 异常传播给new的调用方 |
直接终止程序(如std::terminate、std::abort) | 进程结束 |
如果处理器什么都没做就返回,循环会再次进入,处理器被再次调用。标准没有规定重试次数上限,实现可能在若干次之后自行抛出bad_alloc,因此不能假设「处理器只会被调用一次」,更不能写一个空处理器来「忽略」失败。
处理器抛出的异常应当派生自std::bad_alloc。抛其他类型在语法上可行,但调用方通常只捕获std::bad_alloc,会导致异常逃逸到意料之外的地方。
三、处理器里能做什么、不能做什么
处理器的价值在于「在放弃之前释放一些东西」。典型做法是维护一块预留内存,或者清空一层对象缓存:
// 适用于 C++17;仅示意处理器的结构
#include <cstddef>
#include <new>
namespace {
void* reserve_block = nullptr;
bool reserve_ready = false;
void release_reserve() {
if (reserve_ready && reserve_block != nullptr) {
::operator delete(reserve_block);
reserve_block = nullptr;
reserve_ready = false;
return; // 已释放,返回让 operator new 重试
}
throw std::bad_alloc{}; // 没有可释放的,放弃
}
}处理器里最忌讳的是再次分配内存。用std::string拼接日志、用std::cout输出、用容器保存状态,都可能触发新的new,而此时内存正处于紧张状态,轻则递归调用处理器,重则死锁或栈溢出。需要记录信息时,用预先分配好的定长缓冲区配合std::fputs之类的接口,或者只设置一个标志,等恢复正常后再输出。
同样要避免在处理器里做复杂的同步。如果某个线程正持有堆相关的内部锁,处理器再去申请同一把锁就会死锁;标准库的分配器内部锁不可见,所以最稳妥的策略是「处理器只做简单的指针交换和释放」。
四、完整示例:模拟一次失败与恢复
真实触发内存耗尽既不可控也不安全,下面的例子用一个「预算」模拟分配失败,完整演示安装、重试、放弃三种路径。类专属的operator new按标准流程实现,std::get_new_handler用来取出当前处理器。
// 适用于 C++17
#include <cstddef>
#include <cstdlib>
#include <iostream>
#include <new>
class Widget {
public:
static void* operator new(std::size_t size) {
for (;;) {
if (budget_ > 0) {
--budget_;
if (void* p = std::malloc(size)) {
return p;
}
}
std::new_handler handler = std::get_new_handler();
if (handler == nullptr) {
throw std::bad_alloc{};
}
handler(); // 返回则重试,抛出则离开循环
}
}
static void operator delete(void* p) noexcept {
std::free(p);
}
static int budget_;
int value = 0;
};
int Widget::budget_ = 0;
namespace {
int reserve_blocks = 0;
void release_handler() {
if (reserve_blocks > 0) {
--reserve_blocks;
Widget::budget_ += 1; // 模拟「释放一块预留内存」
return;
}
throw std::bad_alloc{}; // 没有可释放的,结束重试
}
}
int main() {
std::new_handler previous = std::set_new_handler(release_handler);
// 场景一:预算为 0,但处理器能释放一块预留内存,重试成功
Widget::budget_ = 0;
reserve_blocks = 1;
Widget* first = new Widget;
std::cout << "first 分配成功,地址 " << first << '\n';
delete first;
// 场景二:预算为 0 且没有预留内存,处理器抛出 bad_alloc
Widget::budget_ = 0;
reserve_blocks = 0;
try {
Widget* second = new Widget;
delete second;
} catch (const std::bad_alloc&) {
std::cout << "second 分配失败,捕获 bad_alloc\n";
}
std::set_new_handler(previous); // 恢复原来的处理器
return 0;
}推演一下场景一:budget_为 0,进入循环后跳过分配,取到处理器并调用;处理器把reserve_blocks减到 0,把预算加 1 后返回;循环重来,预算大于 0,malloc成功,返回指针。场景二:处理器发现没有预留可释放,抛出bad_alloc,异常穿过operator new到达try块。两条路径都符合标准描述的协议。
把这段逻辑搬到全局operator new时,还要注意 C++17 的对齐分配重载:
// 适用于 C++17;仅列出需要一并考虑的重载
void* operator new(std::size_t size);
void* operator new(std::size_t size, std::align_val_t alignment);
void operator delete(void* ptr) noexcept;
void operator delete(void* ptr, std::align_val_t alignment) noexcept;如果只替换了不带对齐参数的那一个,使用alignas(64)的类型仍会走标准库的默认实现,替换就不完整。C++14 起还有带大小的operator delete(void*, std::size_t),C++17 起有带大小与对齐的版本;是否定义它们会影响编译器选择哪条释放路径。
五、哪些分配不经过处理器
并非所有「分配失败」都会走 new-handler:
| 形式 | 失败时的行为 | 是否调用 new-handler |
|---|
new T | 抛std::bad_alloc | 是 |
new T[n] | 抛std::bad_alloc | 是(走operator new[]) |
new (std::nothrow) T | 返回空指针 | 通常仍会经过常规分配路径,因而可能调用,具体以实现为准 |
new (ptr) T(placement new) | 不分配内存 | 否 |
std::malloc | 返回空指针 | 否,与处理器无关 |
std::vector扩容 | 通过分配器调用operator new | 是(除非分配器自定义) |
new (std::nothrow)常常被误解为「失败时不抛异常,所以不会有处理器」。标准只保证它失败时返回空指针;它内部通常调用会抛异常的版本并捕获bad_alloc,因此处理器仍可能被调用。若希望完全不触碰处理器,只能使用不经过operator new的分配方式,例如自己管理的内存池。
另外,std::allocator在 C++17 及以后直接调用::operator new,所以容器的扩容也会触发处理器;但容器本身在扩容失败时通常提供强异常保证,push_back抛出后原容器保持不变。
常见坑点
坑点 1:处理器什么都不做就返回。
❌void handler() {},分配失败后无限重试。
✅ 要么释放真实内存后返回,要么抛出std::bad_alloc,要么终止程序。
坑点 2:在处理器里分配内存。
❌std::string msg = "oom"; std::cerr << msg;,日志本身触发新的new。
✅ 使用预分配的定长缓冲区和低层输出函数,或只置一个标志。
坑点 3:以为处理器是线程局部的。
❌ 在工作线程里set_new_handler,期望只影响本线程。
✅ 它是全进程唯一的;在启动阶段设置,运行期更换要自行同步。
坑点 4:安装后不恢复。
❌ 在某个模块里设置处理器,模块卸载后函数指针悬空。
✅ 保存set_new_handler的返回值,在作用域结束时恢复;用 RAII 包装更稳妥。
坑点 5:处理器抛出非bad_alloc类型。
❌throw std::runtime_error("oom");,调用方的catch (const std::bad_alloc&)接不住。
✅ 抛出std::bad_alloc或其派生类。
坑点 6:只替换了operator new,忘了operator new[]与delete。
❌ 自定义分配用malloc,释放却走默认delete,堆被破坏。
✅ 成对替换所有相关重载,并保持分配与释放的实现一致。
坑点 7:忽略 C++17 的对齐重载。
❌ 只定义operator new(std::size_t),alignas(64)类型仍走默认实现。
✅ 同时提供带std::align_val_t的版本与对应的delete。
坑点 8:把nothrow当成「不调用处理器」。
❌ 依赖new (std::nothrow)绕过处理器做特殊处理。
✅ 明确它只保证返回空指针,处理器是否被调用以实现为准。
总结
| 概念 | 内容 |
|---|
| 类型 | std::new_handler,即void(*)() |
| 默认值 | 空指针,失败直接抛std::bad_alloc |
| 设置接口 | std::set_new_handler,返回旧处理器 |
| 查询接口 | std::get_new_handler(C++11) |
| 调用时机 | operator new分配失败、抛异常之前 |
| 合法结局 | 释放内存后返回、换处理器、抛bad_alloc、终止 |
| 作用域 | 全进程唯一,非线程局部 |
new-handler 的本质是一个「失败重试钩子」,不是「内存不足的万能解药」。它能做的只有释放已有资源、换一个策略,或者干净地放弃;它不能凭空变出内存。使用它的正确姿势是:在程序启动时安装一个只做简单释放的处理器,在其中避免任何分配与复杂同步,并确保它在放弃时抛出std::bad_alloc。把这几条守住,它就是一个可控的兜底机制;否则它只会把一次分配失败变成难以定位的循环或死锁。