C++内存分布与malloc/free、new/delete对比:从内存泄漏排查到性能优化 去年我接手一个 C 服务的排查压测刚跑十分钟监控就提示进程 RSS 从 300MB 一路涨到了 1.2GB。第一反应当然是查内存泄漏可把 valgrind 和 ASan 都拉出来跑了一圈干干净净。最后定位下来问题不出在“漏”而出在我对 C/C 内存分布的一个根深蒂固的假设我以为 vector 扩容后旧内存会被系统立刻回收。那次之后我就决定把这一块系统性聊透——从代码区、常量区、全局数据区、堆、栈到贯穿其中的 new/delete 与 malloc/free正好是这类排查最需要的完整知识拼图。这篇内容适合刚学完 C 语法但总在指针和对象生命周期上卡壳的人也适合写了几年代码却一直停留在“new 一下就能用”“malloc 一下就能存”层面的老战友。读完之后你能画出程序运行时的内存地图能说清 malloc 内部大致怎么工作能答明白面试里“new/delete 和 malloc/free 有什么区别”这个经典问题更重要的是当线上出现各种诡异崩溃时心里有了一条清晰的排查方向。1. 先把地图摊开代码区、数据区、堆、栈分别由谁掌管1.1 为什么先搞懂“谁分配、谁回收”比背区域名字更重要很多教学材料喜欢上来就扔一张内存分布图让大家记住低地址是高地址、栈向低增长、堆向高增长。这些当然没错但实际排错时真正派上用场的不是这张图本身而是“每个区域的分配和回收到底由谁负责”。比如栈上的变量函数一结束就自动销毁你用不着管堆上的内存谁 new 了就得谁 delete没人管就是泄漏。知道“谁负责”之后很多莫名其妙的崩溃和内存增长问题就已经能排除掉一半了。我把这个思路叫“所有权思维”。程序里每一块内存在任意时刻都应该有一个明确的拥有者。栈内存的所有者是编译器生成的函数调用机制全局区的所有者是程序运行环境堆内存的所有者是你自己。搞清楚这一点后面聊 new/delete 和 malloc/free 的差异以及混用陷阱时你会有一种“串起来了”的感觉。1.2 五个区域的关键参数对照表内存区域的划分在不同平台上有细节差异但宏观上基本一致。我用下面这张表把常见区域的关键参数整理出来这是整篇文章的地基区域存放内容生命周期分配方回收方典型误用代码区编译后的机器指令程序启动到结束编译器/链接器系统尝试修改代码段常量区字符串字面量、const 常量程序启动到结束编译器系统通过指针篡改只读数据数据段已初始化全局变量、static 变量程序启动到结束加载器系统过度使用全局状态堆new/malloc 动态分配的对象手动管理用户代码用户代码泄漏、double free栈局部变量、函数参数、返回地址函数调用期间编译器插入代码编译器插入代码返回局部变量地址、栈溢出代码区和常量区对于日常开发来说几乎见不到但有两个别名特别容易把人带沟里一个是“字符串字面量并非存在栈上”另一个是“同一个字符串字面量在多个地方出现时编译器可能合并成同一份”。这两点如果理解不到位后面很容易出现“我明明改的是自己的数组为什么程序崩了”的困惑。1.3 一个局部变量、一个字符串字面量、一个 new 出来的对象分别去哪了看一段典型代码我们来逐个认领每块内存的住处int global_count 42; // 数据段程序启动就已存在退出才销毁 static int static_local 7; // 同样是数据段作用域只在函数内但生命周期是整个进程 const char* name hello; // 指针变量在栈上指针指向的字符串字面量在常量区 char buffer[] hello; // 这个数组整体在栈上内容是运行时从常量区拷贝过来的 void func() { int local 0; // 栈上func 执行完就没了 int* p new int(5); // 指针 p 在栈上它指向的 int 在堆上 std::string s world; // s 本身在栈上但字符串数据在堆上 delete p; // 堆上的 int 被回收栈上的 p 仍然存在 }这里最容易被新手绕晕的是const char* name hello和char buffer[] hello的区别。前者只有指针在栈上字符串本体是只读的放在常量区后者是编译器把字符串内容拷贝到栈上所以你可以安全地修改 buffer 的内容但如果尝试name[0] x大概率直接段错误。类似的还有char* p hello;这种老式写法本质指向常量区千万别去写。我经常用一个生活化的类比栈是办公室的临时桌面函数调用时把要用的文件摊开函数返回时全部收走速度极快但空间有限堆是你的私人储物柜随时可以放东西进去但你不关门就永远占着全局数据区是办公室墙上的公告栏全楼的人都能看程序结束才撕掉常量区是会议室墙上的规章制度牌只能读不能改。理解了这张地图之后我们再看堆的管理者——malloc 到底是怎么干活的。2. malloc 不是找系统要内存而是先批发再零售2.1 那个隐藏的“冰箱”堆管理器的批发与零售逻辑很多人以为每次 malloc 就是直接向操作系统申请一块内存free 就是马上还给系统这个直觉只对了一半。真实情况是 malloc 会先通过系统调用一次性申请一大块内存然后在用户态把它切成小块一块块分给你。这就像你不可能每次做饭都去地里种菜而是周末去菜市场批量采购一周的菜放冰箱需要时再从冰箱里取。这个“冰箱”在进程里就是堆管理器。它的核心数据结构是一套按大小分桶管理的自由链表小块释放后放进对应的桶里下次 malloc 同样大小直接捡现成的开销极小大块释放后可能被合并成更大的空闲块方便后续大请求。这也是为什么 malloc 不是一个“函数级”的简单封装而是一套相当复杂的分配策略。理解这个批发零售逻辑之后一个很重要的推论就出来了malloc 的性能取决于分配器内部状态而不是“内核分配有多快”。实际项目中如果频繁分配大量小对象即使每个 malloc 只要几百纳秒累积起来也可能吃掉大量 CPU。所以我通常建议对小对象高频场景直接上对象池而不是裸调 malloc。2.2 brk 和 mmap小块小堆大块走映射malloc 在 Linux 上通常有两条路向内核拿内存小块分配走brk把堆区顶部向上推扩展进程堆空间成本低释放时一般只会把内存还给堆管理器而不是操作系统。大块分配走mmap直接在进程地址空间中映射一整块匿名内存释放时通过munmap归还给系统。这两条路径的分界线在 glibc 里默认约为 128KB但它是可调的。为什么大块要走 mmap因为超过一定阈值后大块内存归还系统带来的收益更大而且不会阻碍堆顶收缩可以显著减少堆内存碎片。反过来小块分配如果频繁 mmap一次系统调用加一次页表操作的成本远高于从自由链表里取一块。这个细节和文章开头那个“vector 扩容后 RSS 不降”的场景直接相关vector 的旧缓冲区通常是小块内存走的是 brk 路径即使被释放了也只是回到堆管理器的自由链表里RSS 自然居高不下。当然这里不是说真的泄漏而是分配器还在替进程“囤货”。2.3 free 之后内存去哪儿了RSS 不降不是泄漏这是排查内存问题时最容易误判的一条。进程占用的物理内存RSS由三部分组成代码段、数据段、堆和栈的物理页。当你 free 一块内存时分配器只是把这块内存标记为空闲放回自由链表。操作系统看到的是一整块仍在进程地址空间中的堆区域那些页并没有被回收于是 RSS 不会下降。那什么时候 RSS 会降只有当那些页被系统回收或者大块 mmap 的区域被 munmap 时。你可以调用malloc_trim尝试把堆顶的空闲空间还给系统但注意它并不能回收堆中间的碎片。也就是说RSS 长期不降只能说明“分配器持有的总虚拟内存变大了”并不能直接说明“你的程序泄漏了”。要判断是否泄漏更可靠的是看进程堆的“活跃部分”是否持续增长比如连续申请和释放相同大小的对象后RSS 是否仍然逐轮抬升。我把这个检查方法用在压测场景里记录 RSS 的同时把/proc/self/status里的VmRSS与VmSize一块记录再观察 malloc 的可复用空闲块增长趋势。如果只是 RSS 高而 VmSize 稳定多半是分配器囤货或少量碎片不是泄漏如果 VmSize 同步上涨而且堆里活跃对象越来越多那才需要认真查泄漏。2.4 内存碎片是怎么来的以及实际项目里的应对策略碎片分为两类内部碎片和外部碎片。内部碎片是分配粒度造成的比如你要 13 字节分配器为了保证对齐很可能给你 24 字节的块多出来的 11 字节就是内部碎片。这类碎片没法完全避免但可以通过尽量对齐请求大小来减少。外部碎片则是多次 malloc/free 后堆上出现很多不连续的小空闲块导致总空闲量够大却分配不出一块连续的较长区域。外部碎片最常见的制造方式就是“大小交错”先 malloc 一堆小对象全部释放一部分留一部分再来申请大对象。频繁这么做堆中间会被大大小小的空闲块隔开。我在实际项目里见过一个模块只是因为一次请求里分配了十几个大小差异极大的 buffer压测一晚上堆碎片率升到 30%部分请求的内存分配时间暴涨。应对碎片的经验按优先级排序高频同尺寸小对象统一走对象池用数组加自由链表实现分配释放都是 O(1)。大对象尽量走 mmap 路径避免在堆上制造碎块。明确生命周期避免大量对象长时间“半死不活”地穿插在堆中。框架层统一使用 allocator替换为更先进的内存分配策略。后面讲 new/delete 和对象池的时候会有具体落地思路。3. new/delete 在 malloc/free 之外多做了什么3.1 new 的三大隐藏动作分配、失败处理、构造面试里问“new 和 malloc 有什么区别”很多人张口就是“new 会调用构造函数malloc 不会”只对了一半。new 表达式实际上做了三件事调用operator new分配裸内存内部通常封装了 malloc。如果分配失败调用 new_handler 或直接抛std::bad_alloc而不是返回空指针。在分配好的内存上调用构造函数完成对象初始化。关键在第三步的失败处理如果构造函数抛异常C 运行时会自动调用对应的operator delete释放内存保证不会同时泄漏内存和异常。这一点很多人没意识到也是 new 相比 malloc 更安全的核心原因。malloc 失败返回 NULL你还需要自己检查容易漏掉new 则把失败处理融入语言机制。还有一个反直觉的点new 并不比 malloc 更慢。因为 new 内部就走 malloc额外的函数调用开销微不足道真正影响性能差距的是“分配策略”和“构造/析构是否被调用”。如果你把一个类设计得所有字段都是简单数值构造和析构几乎是空的那么 new 和 malloc 的性能差距可以忽略不计。但如果你每次 new 都触发一次深拷贝或复杂析构那肯定比裸 malloc/free 要重得多。3.2 delete 的逆序析构、释放、以及数组形式的坑delete 和 new 是相反的顺序先调用析构函数再调用operator delete释放内存。问题在于编译器如何知道这个指针到底指向什么类型因为 delete 的语法已经限定了指针类型所以delete p会直接调用~T()再 free。真正的坑在于数组形式。new[]分配数组时很多实现会在数组元素前面隐藏一个“数组长度”字段方便delete[]知道要调用多少次析构函数。因此new[]必须配delete[]new必须配delete混在一起是未定义行为虽然有时候不崩但那纯粹是运气好。我自己踩过一次很经典的坑写了一个内部字符串类重载了operator new[]但没有重载operator delete[]结果创建数组对象时一切正常释放时内存局部性突然变差隔三差五崩一次。后来用 ASan 一跑定位到是数组长度字段读取错乱。那之后我给自己立了个规矩所有自定义分配相关操作new 和 delete、new[] 和 delete[] 必须成对实现绝不偷懒。3.3 placement new在有内存的地方造对象placement new 是 C 里一个很小众但极其实用的技巧它只负责在指定地址上构造对象不负责分配内存。典型场景是对象池、共享内存、以及自定义容器重新分配缓冲区。#include new void* raw malloc(sizeof(Foo)); // 只拿内存不构造 Foo* p new (raw) Foo(args); // 在 raw 上构造 Foo不分配 // 使用 p... p-~Foo(); // 显式调用析构 free(raw); // 释放裸内存不能用 delete p使用 placement new 的核心约束是不能用delete p去释放它因为 delete 会调用operator delete去释放指针指向的内存而这内存不是通过 new 分配的。你只能手动调用析构函数再用最初分配这块内存的方式释放。这个“一对三”的平衡关系必须写进代码注释里不然几个月后接手的人很容易犯混。3.4 operator new 重载统计、接管与对象池实现C 允许全局重载operator new和operator delete也允许在类内部重载。全局重载适合做内存统计和调试类内重载则很适合对象池场景。class Widget { public: void* operator new(size_t size) { std::cout Widget allocate: size std::endl; return malloc(size); } void operator delete(void* ptr) noexcept { std::cout Widget deallocate std::endl; free(ptr); } };要小心的是重载operator new之后所有该类的 new 表达式都会走你的分配函数因此它的性能和正确性直接决定业务稳定性。做对象池时我通常会用固定大小的数组存储对象并用一个自由链表来维护空闲节点。分配时从链头取一个节点用 placement new 构造释放时调用析构函数把节点重新挂回链表。这样 malloc 只在池初始化时发生一次业务运行期间零系统调用极大降低了内存分配开销和碎片风险。这张对照表可以帮大家快速回顾 new/delete 与 malloc/free 的本质差异维度malloc/freenew/delete是否调用构造函数/析构函数不调用调用失败行为返回 NULL抛 bad_alloc可替换 new_handler返回类型void*需强转类型安全指针数组分配需自己算总字节数new[] 会维护元素个数是否可重载否operator new/delete 可重载是否涉及语言机制纯函数调用表达式与运算符机制释放方式free 一个指针delete 一个指针编译器完成析构4. 混用现场double free、悬垂指针和所有权混乱的排查链路4.1 混用 new/delete 与 malloc/free 会怎样很多刚接触 C 的人会写这种代码用 malloc 分配内存但用 delete 释放或者用 new 分配后给 free。严格说这是未定义行为但具体后果并不一样。如果delete一个 malloc 出来的内存编译器首先会调用析构函数而这份内存上根本没有构造好的对象可能读到垃圾数据然后operator delete内部再 free垃圾数据和真正的分配器元数据搅在一起轻则泄漏重则崩溃。如果free一个 new 出来的对象析构函数永远不会被调用有析构逻辑的对象会直接资源泄漏比如 std::string 的动态缓冲区就永远没人清理。最离谱的情况是new[]的内存交给free。new[] 在前面藏了长度字段free 会把这个长度字段当作堆块的一部分处理可能直接破坏分配器元数据出现那种“看起来只错了一点点但数据随机被踩”的诡异 bug。这类 bug 在 ASan 里通常会报malloc-free mismatch或invalid free。4.2 浅拷贝问题一份内存两个“主人”的经典事故另一个非常高频的崩溃来源是浅拷贝。默认的拷贝构造函数和赋值运算符是逐成员拷贝对指针成员来说就是单纯复制地址不会复制它指向的数据。于是两个对象像两个孩子抢同一个玩具析构时谁都不让谁double free。我用一个最简化的例子说明class IntBuffer { public: IntBuffer(size_t size) : data_(new int[size]) {} ~IntBuffer() { delete[] data_; } private: int* data_; }; void foo() { IntBuffer a(100); IntBuffer b a; // 编译通过但 b.data_ 和 a.data_ 指向同一块内存 } // 离开作用域时同一块内存被释放两次 - double free解决办法本质只有一条明确“一块内存只有一个所有者”。要么实现深拷贝真正复制一份数据要么使用移动语义把所有权转移不再让旧对象持有要么干脆用std::unique_ptr这类智能指针让编译器帮我们维护所有权。选型取决于业务需求但原则不变。4.3 完整排查链路ASan 如何定位一次 delete 崩溃有一次同事反馈某个服务偶尔在 delete 处崩溃概率很低release 版本根本复现不了。我给的排查思路是这样的大家可以直接照搬先稳定复现用相同的输入反复压测或者在 crash 点附近加上日志。复现不了的问题无法调试。用 ASan 重新编译加上-fsanitizeaddress -g -O1这一步会把堆越界、use-after-free、double free 全部打回原形。跑同样的用例让崩溃自然发生。ASan 会在真正出错的代码位置停下并输出一段详细报告比如ERROR: AddressSanitizer: heap-use-after-free on address 0x... READ of size 8 at 0x... thread T0 #0 ... my_class.cpp:35 freed by thread T0 here: #0 operator delete #1 ... my_class.cpp:28读报告的“allocated by”和“freed by”两段找到内存被释放和再次被使用的两个现场答案基本就出来了。修复后不要急着关 ASan用原来触发崩溃的 case 再跑一遍确认 0 error再跑几轮压测看内存曲线。那次最终定位到的问题就是前面说的浅拷贝 double free第一个对象析构后立刻释放了缓冲区第二个对象的析构又在 delete 一块已经被释放的内存。ASan 报告里 alloc、free、use 三个时间点清清楚楚比人眼盯着代码猜高效太多了。4.4 生命周期管理的实用建议智能指针与所有权约定排查过几次之后我在团队里定了一条强制约定裸new只能出现在构造函数和工厂函数里而且必须立刻交给智能指针或容器管理。业务代码里禁止裸 delete 裸指针的散落统一用std::unique_ptr表达独占所有权用std::shared_ptr表达共享所有权。真的需要裸指针做性能关键路径时必须标明“这个指针是谁分配的、谁负责释放”并配套对象池或内存池方案。智能指针之外另一个被低估的规则是“谁 new 谁 delete”。如果构造函数里写p_ new int()析构函数里就必须释放p_如果p_是通过工厂传入的那所有权就得明确转移到类外部。每块内存至少且仅有一个 owner这是我从所有内存崩溃里提炼出来的唯一结论。5. 从布局到性能对齐、虚表和缓存友好性5.1 sizeof 为什么不是字段之和结构体内存对齐的规则搞明白了堆栈映射再来看看对象在内存里到底怎么摆放。一个常见问题是struct { char c; int i; }的 sizeof 为什么是 8 而不是 5答案是对齐。现代 CPU 加载内存不是一字节一字节来而是按机器字宽取。如果 int 的地址落在 4 的倍数上一次就能读完整如果落在奇数地址可能需要读两次再拼接。所以编译器会在char c后面填充 3 个字节让int i对齐到 4 字节边界。几个常见结构的尺寸直观展示如下struct A { char c; int i; }; // sizeof 8 struct B { char c; double d; }; // sizeof 16c 后垫 7 字节 struct C { char c; int i; double d; }; // sizeof 16 struct D { char c; double d; int i; }; // sizeof 24末尾再补 4 字节D 为什么是 24因为结构体整体要对齐到最大成员对齐值 8double 8 字节 int 4 字节后还剩 4 字节必须垫满到 16 的倍数实际是 884424。这类对齐问题对性能的影响比很多人想象的大如果结构体经常放在数组里每个对象多加 4 字节填充数组一长缓存行命中率就会明显下降。5.2 对象里到底还有什么虚表指针、RTTI 和布局细节如果类里有虚函数对象的开头通常会藏一个虚表指针vptr指向一张虚函数表vtable。这张表里存的是这个类所有虚函数的地址编译期生成一般放在只读数据段或数据段。也就是说虚函数表并不在堆里而是所有同类对象共享堆上每个对象付出 8 字节左右的代价保存一个指向 vptr 的指针。这也是为什么带虚函数的类不能和普通类互相转来转去甚至不能 memset 整个对象。你要是把 vptr 覆盖了虚函数调用就直接跳到非法地址。RTTI 信息同理typeid 需要访问对象里的元数据指针破坏布局就会得到完全错误的类型信息。我记得有次练习写序列化库时一个同学给对象做了内存拷贝拷贝然后在新对象上调用虚函数结果跳到了完全无关的地址。查了半天才发现默认拷贝把 vptr 原样复制过来本来没问题但他在拷贝后又手动按字节做了一遍序列化把 vptr 当成了普通数据写死于是彻底破坏了类型信息。这类问题常规编译器警告很难发现最好在自定义拷贝和序列化时显式区分“对象数据”和“语言元数据”。5.3 SoA 与 AoS堆上连续内存的缓存友好性堆上的对象如果频繁访问布局方式直接决定性能。传统写法喜欢定义结构体数组 AoS比如struct Particle { float x, y, z; float velocity; }; std::vectorParticle particles; // AoS如果你要循环计算所有 x 坐标之和缓存会把 Particle 的其他字段也一起加载进来但你并不需要它们。数据量大的时候这种“读一个 x 却拉进 4 条字段”的模式非常浪费内存带宽。反过来把结构拆成数组就是结构体数组 SoAstruct ParticleCloud { std::vectorfloat x, y, z; std::vectorfloat velocity; };计算 x 之和时只顺序读取 float 数组缓存命中率明显提高。我在一个模拟渲染项目里把粒子系统从 AoS 改成 SoA 后遍历时间降了一倍多原因就是这个。对新手而言这条优化通常优先级很高实现成本低效果立竿见影。5.4 最后一个小技巧用 /proc/self/maps 观察真实内存地图讲了很多理论最后给大家一个可以亲手验证的工具。在 Linux 上写一个简单程序打印它自己的内存映射#include unistd.h #include fcntl.h #include iostream int main() { int fd open(/proc/self/maps, O_RDONLY); char buf[4096]; ssize_t n; while ((n read(fd, buf, sizeof(buf))) 0) { write(STDOUT_FILENO, buf, n); } close(fd); return 0; }输出里能看到若干段地址范围标注了[heap]、[stack]以及各种共享库的映射。你会发现堆通常从低地址开始向上长栈则在高地址区域向下长两者的中间还隔着动态库。这和文章开头那张“内存地图”完全对上了。以后进程再出现内存诡异先看这个文件判断是堆区虚高、栈溢出踩了别的东西还是接受了碎片化的连续四次延迟都比一头扎进代码里瞎猜有效率得多。这篇的内容到这里就差不多了。整篇文章讲的是地图、分配器、触发词和布局但我不希望它只停在概念层面。实际做项目时内存性能问题的百分之七十根源都在“访问模式”而不是“分配速度”。把高频对象放进连续数组、减少每帧的裸 new再固定用 ASan 定期扫一遍内存错误这套方法论比任何单个技巧都稳。后面我大概率还会写一篇对象池/内存池的完整实现把这里提到的 placement new 和 operator new 重载真正落到代码里。到时候你们会发现很多看起来很难调的线上问题归根到底都只是“内存地图”没画清楚而已。