news 2026/10/22 3:02:11

详解C++ new-handler机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
详解C++ new-handler机制

前言


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的失败循环


标准描述的分配流程可以概括为下面的循环:



  1. 尝试分配请求的字节数;成功则返回指针。

  2. 失败时读取当前 new-handler。

  3. 如果处理器为空,抛出std::bad_alloc。

  4. 否则调用处理器。

  5. 处理器返回后,回到第 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。把这几条守住,它就是一个可控的兜底机制;否则它只会把一次分配失败变成难以定位的循环或死锁。




版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 3:02:09

Debian Functions全解析:从维护脚本到自建函数库的融合实践

最近在折腾 Debian 服务器自动化的事&#xff0c;绕来绕去总绕不开“Debian Functions”这个词。可能有人觉得这就是个脚本函数集合&#xff0c;实际上它在 Debian 系系统里承载了两层含义&#xff1a;一层是打包维护脚本&#xff08;postinst、prerm 那些&#xff09;背后官方…

作者头像 李华
网站建设 2026/10/22 3:01:57

基于人脸识别的校园考勤请假选课系统设计与实践

做了几年JavaWeb方向的项目&#xff0c;我越来越觉得很多校园管理系统的问题不在功能少&#xff0c;而在“流程对不上现场”。就拿考勤来说&#xff0c;传统刷卡、点名、钉钉打卡都绕不开一个尴尬场景&#xff1a;学生完全可以“代打”&#xff0c;老师课后还要花时间核对表格。…

作者头像 李华
网站建设 2026/10/22 3:01:49

Agent开发实战:从API调用到智能体工作流搭建

从第5天开始&#xff0c;Agent学习终于进入深水区。前两天还在熟悉基础模型的调用方式&#xff0c;Day05-06直接切换到“怎么让模型自己干活”的核心问题。这两天的学习量很大&#xff0c;但收获也最明显&#xff1a;我把一套完整的Agent工作流从设计到实现完整走了一遍。这篇文…

作者头像 李华
网站建设 2026/10/22 2:59:01

SPPF融合焦点调制:YOLO全局语义增强的轻量即插即用改进

直接说结论&#xff1a;把Focal Modulation焦点调制模块接进SPPF&#xff0c;是我最近几轮YOLO改进实验里收益最稳、改动最少的一步。它不需要换backbone&#xff0c;也不需要重写整个检测头&#xff0c;参数增量可以控制在几十万到一百多万之间&#xff0c;却能把中高层特征长…

作者头像 李华
网站建设 2026/10/22 2:58:51

多元线性回归避坑指南:从共线性诊断到业务落地的工程实践

简介&#xff1a;这份资源围绕多元线性回归模型展开&#xff0c;面向具备一定R语言基础、希望掌握统计建模完整流程的数据分析学习者与研究者。内容以上海人均生产总值为案例&#xff0c;演示从数据导入、探索性分析到模型构建、评估、残差诊断与系数显著性检验的全过程&#x…

作者头像 李华
网站建设 2026/10/22 2:58:47

SpringBoot+Vue+MySQL企业OA系统部署与二次开发实战指南

从接到需求到把系统跑起来&#xff0c;中间隔着的往往不是代码&#xff0c;而是一堆文档没写清楚的环境问题、版本兼容问题和逻辑理解问题。最近帮某家公司的行政部搭建内部管理系统&#xff0c;他们在用Excel表格和微信群里转来转去的审批消息管考勤、管请假、管报销&#xff…

作者头像 李华