
“new”和“delete”这两个关键字几乎是每个C程序员入门第一天就会碰到的老朋友。但真到了生产环境里你有没有遇到过这样的场景程序跑个两三天内存占用一路飙升直到被运维kill或者明明很简单的逻辑一释放内存就崩溃又或者数组new出来之后用delete回收编译器不报错、运行结果却奇奇怪怪。我做过一段时间服务端底层开发也带过新人发现一个很扎心的事实能真正把new和delete讲清楚、用对的人并不算多。这不是因为这两个关键字本身有多难而是它们背后的内存生命周期、对象构造析构、编译器的隐藏处理、异常安全这些问题很少有人系统梳理过。这篇文章打算把这套东西完整拆开讲一遍。适合正在学习C的初学者也适合写过几年C但遇到内存问题总靠猜的工程师。文章会覆盖new/delete的核心原理、常见的几种形态、配对规则、智能指针的现代替代以及我用Valgrind和ASan排查过的真实内存问题案例。最后还会整理一份避坑清单都是我实际踩过、在代码评审里抓过的典型错误。1. 为什么new和delete这么难掌握1.1 从一次事故说起内存泄漏的代价之前我负责过一个常驻网关服务功能本身不复杂接收请求、做转发、记录日志。这个服务上线后稳定跑了大概三天内存从最初的300M一路涨到2G多逼近容器限制之后被系统反复OOM kill。当时第一反应是哪个第三方库或者缓存模块写疯了查了半天没结果。最后用Valgrind一跑才发现问题出在一段很不起眼的代码里某个函数里new了一个临时对象中间有一个提前return的分支把delete漏掉了。一次泄漏不过几百字节但每秒钟这个函数被调用几百次三个工作日下来内存就像漏水的桶一样迅速见底。后来我在代码评审里养成了一个习惯只要看到new就一定追问对应的delete在哪、在什么路径上、会不会被异常打断。如果一个函数里超过两层if或者有心跳、重试、回调这类逻辑new出来的对象生命周期就会变得很难掌控。这其实是C内存管理最麻烦的地方——内存的释放时机完全靠程序员自觉编译器帮不了你。1.2 new/delete与malloc/free的本质区别很多从C语言转过来的朋友会有个疑问既然C里面已经有malloc和free了为什么C还要搞一套新的new和delete这两者的本质区别在于new不仅仅是分配内存它还会调用构造函数delete不仅仅是释放内存它还会调用析构函数。用C语言的方式去理解malloc做的事情就是在堆上划一块指定大小的空间返回一个void指针仅此而已。而new int(42)在底层干了三件事调用operator new分配一块sizeof(int)大小的内存在这块内存上执行int的构造函数把值初始化为42最后把这块内存的地址返回给你。delete则反过来先调用int的析构函数对内置类型来说这一步是空的但如果是自定义类就很关键再调用operator delete释放内存。正因为new和delete绑定了构造和析构所以它们不能和malloc/free混用。一个用new创建的对象如果交给free去释放构造函数会被执行但析构函数永远不会被调用。如果这个对象里管理着文件句柄、网络连接、其他的堆内存那么资源就会全部泄漏。反过来用malloc分配的内存交给delete释放同样不对因为delete会把这个内存当成一个合法对象来析构对着一块没有构造过的原始内存调用析构函数行为是完全未定义的。1.3 一套自己的规约谁分配谁释放内存问题之所以难排查很多时候不是因为某个API不会用而是代码里没有一个统一的规约。我见过一个模块里有的地方用new分配有的地方用malloc分配有的地方用shared_ptr管理有的地方用裸指针到处传。到了释放的时候谁都说不清这块内存到底是谁的结果就是double free和泄漏齐飞。我在团队里定的第一个硬性规则就是“谁分配谁释放”。这个规则听起来特别简单但真正在代码里落实需要很强的自律。一个类如果在其构造函数中new了成员析构函数就必须delete一个函数如果new了对象并返回给调用方那函数注释里必须写清楚“调用方负责释放”。这条规则不能靠记忆力要在代码评审时逐行核对。另一个进阶的规则是超过两个共享点的裸指针就应该考虑用智能指针替代。裸指针没有所有权语义光靠注释和约定来约束在人员流动大的团队里迟早要出事。2. new的几种形态与背后逻辑2.1 普通new与隐式构造日常写代码时最常见的形态就是int *p new int(10);以及更加常用的MyClass *obj new MyClass();。这里的括号其实是C一个历史遗留问题不加括号内置类型只分配内存不初始化值是不确定的加了括号会做值初始化内置类型会被置零。对自定义类型来说无论加不加括号都会调用默认构造函数区别不大。但对于int、double这类基础类型我强烈建议一律写成new int(0)这样带初始化的形式避免拿到一个随机初值还浑然不觉。另一个不能忽略的点是new表达式中构造函数的隐式调用。当类里有用户定义的构造函数时对象的内存分配和构造是打包在一起的。编译器先调用operator new拿到内存然后在这个内存地址上以placement new的方式调用构造函数。如果构造函数内部抛出了异常编译器会自动释放已经分配的内存保证不会泄漏。这一块我后面讲异常安全时再展开。2.2 new[]数组分配背后的隐藏元数据数组形式int *arr new int[100];有个非常隐蔽的机制编译器实际分配的内存会比100 * sizeof(int)更大。多出来的那一小段空间用来记录数组长度这样delete[]时才知道需要调用多少次析构函数。这个额外的cookie信息通常紧挨着返回给你的指针之前。也就是说你拿到的arr指针并不是操作系统实际分配的那块内存的起点起点在arr偏移若干字节的地方。正因为如此new[]出来的内存必须用delete[]释放而不能用delete。如果错误地使用了delete编译器会把这个指针当作单个对象来析构和释放释放的起点就错了轻则析构次数错误重则堆结构被破坏程序崩溃得毫无规律。我见过一个最刁钻的例子是new[]之后用delete去释放因为元素是内置类型没有析构逻辑程序竟然“正常”跑了好几个月直到换了一个动态库版本堆分配策略变化后突然崩溃。这种不报错的错误是最可怕的因为它给了你一种虚假的安全感。2.3 nothrow new与placement newnew在内存分配失败时默认会抛出std::bad_alloc异常而不是返回空指针。但是有些老代码、嵌入式环境、或者对异常不友好的模块会期望new返回nullptr。为了兼容这种需求标准库提供了nothrow版本写作int *p new (std::nothrow) int(42);。这个版本在分配失败时不会抛异常而是返回nullptr需要你手动检查指针。placement new则完全是另一回事它不在堆上分配内存而是在一块已有的内存上构造对象语法是new (buffer) MyClass(args)。它的典型使用场景是内存池和容器实现。比如std::vector在扩容时就是在分配好的原始内存上逐个构造对象而不是反复调用new。使用placement new时有一条铁律不要也不能对返回值调用delete因为这块内存不是它分配的。正确的销毁方式很简单——先显式调用析构函数p-~MyClass()再由内存池或原始缓冲区的所有者负责回收内存。很多第一次接触的朋友在这里翻车把placement new的结果delete掉直接把堆信息破坏掉了。2.4 构造失败与异常安全当new表达式中的构造函数抛出异常时会发生什么标准的规定是编译器会自动回收已完成分配的内存然后让异常继续向上传播。也就是说你不需要、也不应该在这个场景里手动释放内存因为构造函数根本没有返回一个可用的对象指针。但有一种模式会破坏这种安全性我称之为“裸new裸delete式初始化”。很多老代码把资源获取写在成员初始化列表里比如class Handler { public: Handler() : conn_(new Connection), log_(new Logger) {} private: Connection* conn_; Logger* log_; };如果new Logger抛异常了那么new Connection已经分配的内存就不会被释放因为Handler的析构函数不会被调用——对象还没构造完成。这种问题在初始化列表里尤其隐蔽。我更推荐的方式是用智能指针成员或者退一步把多个new拆到构造函数体里逐步获取确保每个资源都有对应的承接者。这一节本质上想提醒的只有一件事new不是一个孤立的动作它和构造函数、异常机制是一个整体只看分配不看失败路径迟早会付出代价。3. delete的正确姿势配对是唯一的安全法则3.1 三种delete形态与配对规则与new对应的delete也有三种形态普通delete对应普通new、delete[]对应new[]、显式析构调用对应placement new。配对规则看起来非常简单但实际项目里总是能见到各种混搭。我把规则整理成一张表分配方式正确的释放方式错误释放的后果newdelete用delete[]会读错cookie堆崩溃new[]delete[]用delete会少析构一批对象且释放起点错误new (nothrow)delete判断非空后用delete[]同上行为未定义placement new显式析构 由缓冲区所有者回收直接delete会把不属于它的内存释放掉为什么这里要如此强调配对因为C运行时在释放内存时并不认识你的指针是数组还是单个对象。它只知道new[]时存储了一个“有多少个元素”的元数据且这个元数据的位置和分配起点绑定在一起。只有编译器生成对应的销毁循环代码和释放逻辑才能正确地访问那块元数据。delete和delete[]对应的代码路径本来就不同混用后底层就是未定义行为可能立刻崩也可能缓刑很久才崩。3.2 为什么delete[]不能替代delete单从语法上编译器并不禁止你用delete[]去释放一个new出来的单个对象反之亦然。但运行时行为很可能是灾难。一个实际的例子如果你new了一个包含100个自定义对象的数组然后误用了delete编译器只会在“数组首元素地址”上调用一次析构函数后面99个对象的析构函数全被跳过。如果这些对象的析构函数里在做资源释放关闭文件、删除临时文件、归还连接那么99份资源就全部泄漏了。更严重的是delete认为这个地址是一个普通对象释放时会从指针当前位置发起堆释放操作而真正要释放的块起点在cookie之后这直接触发了堆块的头信息错乱。我在一次代码评审里见过一段为了“省事”而写的代码某个字符串数组用new[]创建释放时统一写成了delete。当时代码能跑纯粹因为字符串内部管理的内存由string自己的析构函数处理而string数组如果跳过析构内部的缓冲区就全泄漏了。后来把这段代码放到压力测试环境内存像流水一样消失。3.3 基类析构函数必须virtual这个问题和delete的关系极其紧密我觉得有必要单独拎出来讲。当基类指针指向派生类对象时如果基类的析构函数不是virtual那么通过基类指针delete这个对象时编译器只会调用基类的析构函数派生类的析构函数被完全跳过。这意味着派生类中通过new申请的资源没有机会被释放。class Base { public: ~Base() {} // 非virtual灾难 }; class Derived : public Base { private: char* buffer_; public: Derived() : buffer_(new char[1024]) {} ~Derived() { delete[] buffer_; } }; Base* p new Derived(); delete p; // 只有Base::~Base()被调用buffer_泄漏这个问题的可怕之处在于它不会立刻暴露——析构函数没有错误返回值泄漏只是默默发生。要等到内存吃紧的时候你才会意识到问题那时候再回头看这段继承体系排查范围就大了。所以我的习惯是一个类只要准备被继承析构函数就写成virtual哪怕析构函数为空也要写。这个习惯对性能的影响微乎其微只是类里多了一个虚表指针但能从根本上杜绝这种泄漏。3.4 智能指针与delete的现代替代既然裸指针的delete这么容易出错现代C给出的答案是智能指针。unique_ptr是一个独占所有权的指针它会在离开作用域时自动调用deleteshared_ptr通过引用计数管理多个所有者最后一个持有者析构时会释放对象。这两个东西的最大价值不是帮你省了几行代码而是把“谁释放”的问题从人的记忆变成了系统机制。我在团队里推荐的核心策略是默认情况下动态对象的创建一律使用make_unique和make_shared不要直接new。有朋友问我shared_ptr的引用计数本身也是堆分配make_shared还能把对象和计数块合并成一次分配性能更高那岂不是完美大部分情况下是的。但要注意shared_ptr会使对象在最后一个引用被清除时才析构如果你在对象内部存了一个指向自己的shared_ptr循环引用就会导致内存永远无法释放。这种情况应该改用weak_ptr打破循环或者重新审视你的所有权模型。一个经常被忽略的细节是unique_ptr默认使用delete释放对象而它无法正确处理new[]分配的数组。如果你非要拿unique_ptr管理数组比如保存原生缓冲区应该写成unique_ptrT[]或者直接改用std::vector 。很多资深的同行现在写代码已经很少手动写delete了裸new只出现在性能极敏感的底层模块里。这不是说delete过时了而是说把delete交给RAII管理是C社区付出了大量教训之后换来的共识。你完全可以继续使用裸指针但需要意识到每一次手写delete都是在为潜在的内存问题留一道口子。4. 实操从泄漏到崩溃一次完整排查实录4.1 环境准备与最小复现理论讲了再多彩蛋不如实际动手排查一次。下面这个案例来自我本地编写的一个简单图像处理工具它把一批图片的像素数据从磁盘加载到内存做一个简单的高斯模糊然后输出结果。这个工具刚开始跑在小图集上没问题后来换到一个大目录运行到第几百张图片时直接段错误而且每次崩的位置都不固定一会是模糊函数一会是文件保存阶段。我先写了一段最小复现代码把所有业务逻辑去掉只保留核心循环读文件路径、用new创建像素缓冲区、处理、用delete释放。崩溃神奇地消失了。接着我把真实代码里的图像对象加进来崩溃又出现了。这说明问题和图片对象内部的内存管理方式高度相关。这个阶段最重要的成果是我把崩溃范围从“整个程序”缩小到了“单个图像对象的构造析构过程”。如果你也遇到不确定的崩溃一定要先做这种减法把所有无关的IO、多线程、业务分支全部剪掉才好定位。否则你在几百个文件里瞎猜效率太低。4.2 使用Valgrind定位内存泄漏排查内存泄漏我第一个用的工具是Valgrind。它的原理是模拟CPU执行你的程序对每一次内存读写做检查所以程序会慢十几倍但准确率非常高。我的使用方式是valgrind --leak-checkfull --show-leak-kindsall --error-limitno ./image_tool输出的内容里最关键的是“definitely lost”和“indirectly lost”这两类。前者表示确实有内存块没有任何指针能引用到了后者表示这组泄漏是跟随前者的内部块。我在这份报告里看到了一个清晰的记录每个图像对象加载数据时调用了new unsigned char[width * height * channels]但在某个尺寸判断分支里因为图像格式不符合预期直接continue了delete[]被跳过。这个分支平均每两百张图触发一次所以小批量测试时根本看不出来跑全量数据才暴露。Valgrind还能检测“invalid read/write”也就是读写已释放或越界的内存。如果你的程序在正常结束时没有输出这类错误说明没有明显的越界访问但要注意Valgrind检测不到所有堆越界它只对在堆块边界上的非法访问敏感。更精细的检测还需要配合下文的ASan。4.3 使用AddressSanitizer定位越界与double freeValgrind可以抓泄漏但处理越界和double free这类问题我更推荐AddressSanitizerASan。ASan是一个编译器插桩工具它在你编译时往代码里植入检查逻辑运行时空闲内存周围会被填充特殊的“毒药”标记一旦越界读写碰到这些区域立刻报警并且给出非常精确的堆栈信息。使用方式非常直接g -fsanitizeaddress -g -O1 image_tool.cpp -o image_tool_asan ./image_tool_asan在我这个案例里ASan报了一个堆缓冲区溢出指向了模糊算法写像素数据的那个for循环。循环边界用了原始图像的宽高但当源图像尺寸不是奇数时边界处理出了偏差多写了几个字节。这几个字节恰好越过缓冲区末尾破坏了相邻内存块的头信息导致该块在释放时发生abort。ASan的直接好处是它把崩溃时间点从“下一次随机崩溃”提前到“越界行为发生的瞬间”配合栈回溯一眼就能定位到具体行号。ASan也有代价内存占用比平时高很多运行速度慢约两倍因此不适合直接上生产但作为开发阶段的守门员价值极大。从这以后我把ASan塞进了本地构建的默认开关每个MR在合入前都会跑一遍。新人一开始可能会抱怨编译太慢但遇到几次真实bug之后都会真香。4.4 修复与回归验证定位到问题后修复往往是几行代码的事。我这个图像工具的修复思路是用std::vector 替代裸指针数组让析构函数在异常和提前返回时都能安全释放内存。具体改法很简单// 修复前 unsigned char* data new unsigned char[width * height * channels]; if (format ! expected) { delete[] data; // 容易漏 continue; } process(data); delete[] data; // 修复后 std::vectorunsigned char data(width * height * channels); if (format ! expected) { continue; // vector自动释放 } process(data.data());改完之后我把整个大目录重新跑了一遍Valgrind报告里“definitely lost”归零ASan也没有再输出关键错误。为了彻底放心我做了连续多轮全量测试在启用ASan的版本下跑相同的任务并通过修改-验证的循环确认没有回归。这里想重点说一下验证思路只用“程序不崩”这一个指标是不够的因为很多内存问题表现得极其善意你不带检测工具根本看不出来。要养成习惯修复一个问题后至少用ASan或Valgrind回归一次确认这条路径上没有其他暗雷。5. 常见问题与背后的原理5.1 常见问题速查表我把这些年遇到的新手问题、代码评审中发现的典型错误整理成了一张速查表方便你遇到类似情况时快速对照排查现象可能原因排查方向程序内存持续增长delete被跳过或所有指针都指向内存却没有清理Valgrind --leak-checkfull偶发段错误堆栈不稳定越界写破坏邻块或使用已释放内存ASan编译运行释放时崩溃double free、delete和delete[]混用检查所有new/delete配对通过基类指针delete派生类对象但析构不完整基类析构函数不是virtual给基类析构函数加上virtual数组对象析构次数不对new[]没有配合delete[]改用vector或unique_ptrT[]构造函数抛异常导致资源泄漏初始化列表中有裸new改用智能指针成员程序退出时报堆损坏内存越界发生已久释放时才暴露ASan定位首次越界位置这张表覆盖了我认为日常开发中最常见的八种情况。但有两点要说透第一表格只是入门指南真正困难的是定位那些“不会立即报错”的错误比如函数错把局部指针的值当作长度去delete这在某些环境下可以潜伏几年第二不要试图用经验记忆替代工具机器检查永远比人脑可靠。5.2 我踩过的几个坑第一个坑发生在多年前我给某个网络库写缓冲池。为了省时间我直接new了一块大的char数组然后自己划分成小块给上层用最后统一用delete[]释放。听着还行但我犯了两个错误一是池的某些小块被上层程序员delete掉了而他们以为自己在释放业务对象二是池管理器析构时又把这整块数组释放了一遍结果double free。修复的方式是把池的接口设计成完全不暴露裸指针上层拿到的都是带引用计数的句柄生命周期由池统一管理。这件事让我明白一个道理凡是跨越模块边界传递原始内存就要约定绝对清晰的所有权如果约定太复杂就改设计不要用文档去对抗人的健忘。第二个坑是和异常相关的。某个模块在try块里new了一个对象处理完逻辑后delete看起来没什么问题。可如果这段代码中间的某个函数抛了异常delete语句根本不会执行对象就泄漏了。我当时用最笨的办法修复——把每个可能抛异常的函数都用try-catch包起来逻辑堆了一层又一层丑得不行。后来改成在函数入口处就声明一个unique_ptr后面无论走哪条路径析构都会自动执行。你从这两次经历能明显感觉到工具和规范是可以把人从泥潭里拉出来的。5.3 团队规范建议最后聊一聊怎么在团队里落地这些经验。我的建议是不要只停留在“我们要小心使用new/delete”这种口号上而是把原则落成代码评审的检查项代码中不允许出现裸的new除非有充分的性能理由否则一律用make_unique或make_shared。任何new/delete必须在同一个文件、同一个类内可见不允许一个模块new、另一个模块delete除非这两个模块之间有明确的所有权文档。基类析构函数必须是virtual这是铁律没有例外。使用组合时优先成员对象对象直接作为类成员而不是成员指针。只有生命周期与外部同步的对象才考虑动态分配。在CI流程中加入ASan编译任务哪怕只跑核心用例也能挡住大多数低级的越界和释放错误。这些规则对老代码会有些苛刻推行时可以分批整改先从新增代码抓起。拿新建的模块做样板跑顺了再逐步改造历史代码。等团队所有人都习惯了这种写法内存问题的排查工作量会下降一个量级。6. 一个容易被忽略的细节new和delete的性能代价聊性能似乎有点超纲但我始终认为一个不关心性能的C工程师不可能真正理解new和delete为什么会被设计成现在的模样。new和delete的代价主要来自两方面一是堆分配器在并发环境下为了线程安全要做锁或者原子操作二是每次分配释放都可能引发操作系统层面的内存管理动作。高频小对象的创建销毁往往构成一个系统里最贵的公共成本。举个例子我在一个实时渲染测试项目里简单计时过每秒创建和销毁一万个小的向量对象如果每个对象都走new和delete总耗时比基于对象池复用高出几倍。原因很明显每一次new都可能触发堆分配器的全局锁竞争多个线程同时分配时等待尤其明显。这个场景里正确的优化思路不是自己写一个神奇的快速allocator而是减少分配次数——比如把临时对象放到栈上或者用一个循环复用的池。还有一个容易被忽略的点是内存碎片。频繁new一个4字节的小对象和delete会让堆上形成大量细碎的空隙导致后续的大块分配失败或者变慢。即使没有内存泄漏长期运行的进程也可能因为碎片率过高而出现性能下降。解决办法同样是尽量复用对象避免频繁小字节分配。踩过这个坑后我在设计接口时会更倾向于传引用而不是每次都new一个临时对象返回给调用方。7. 项目实战里的new和delete一个服务器的完整生命周期前面讲了很多理论、技巧和排查手段最后我想用一个完整的视角看看一个典型服务器程序里new和delete应该如何被组织和托底。我参与过的一个推送服务核心架构是连接管理器负责维护海量TCP连接每个连接对应一个Session对象消息队列负责暂存待推送的消息工作线程从队列取消息找到目标Session并下发。在这个系统里Session对象要么在建立连接时创建、在断开时销毁要么被连接管理器统一持有。如果直接在每个Socket事件处理函数里new和delete会引发一个很严重的问题工作线程在处理某个Session时连接刚好断开了另一个线程把Session给delete了这边还在用——这叫作悬空指针访问程序不一定立刻崩但时机到了就是个深坑。后来我们换了方案Session由连接池统一管理用shared_ptr引用断开连接只是从活动集合中移除不会立即销毁。只有最后一个需要它的对象也不再持有时内存才真正释放。消息队列里的消息对象也做过一轮优化。最初每条消息都靠new创建发送完就delete这导致高并发时堆分配竞争激烈。后来我们用了一个预分配好的内存池容器消息对象从池里申请、发送完归还不触发系统的堆分配。线程间用无锁队列传递的是池里对象的下标而不是裸指针彻底避开了所有权混乱的问题。这套设计跑下来后我的体会是new和delete在系统里的角色并不是让你小心翼翼地管理每一块内存而是让你在更高层面去思考所有权和生命周期。当你把对象的所有权边界划清楚把哪些对象需要动态创建、哪些可以复用想明白new和delete才会回归它们本来该有的位置——只是C对象生命周期机制里的两个普通操作而不应该成为你每天提心吊胆的源头。我在实际带项目的过程中最常对团队说的一句话是“new和delete不是bug本身它们只是让bug显形的工具。真正的问题永远是你没有把生命周期交给合适的机制去管理。”想清楚这一点比记住一百条内存规则都管用。