从线上内存泄漏到malloc与OOM:内存管理排查实战笔记
2026/9/20 4:11:46 网站建设 项目流程

之前有个服务的告警我盯了一整天:内存从 800MB 一路爬到 2.7GB,重启后回落,过了两天又爬回来。第一反应是代码里哪里没 delete,但翻遍缓存、连接池、线程池,都没找到明显的持续增长点。后来用 valgrind 跑了一个压测副本,才发现问题出在一个常驻对象上:每处理一批任务就 new 一个辅助器,塞进一个全局 map,却没有对应的 delete。修复只有三行代码,但真正让我花时间的,是把“内存管理”这个章节重新补了一遍——分配器、栈堆、虚拟内存、OOM、泄漏工具,一层一层串起来。

这篇文章相当于我补课之后的一份实战笔记。适合写过 C/C++ 也写过 Go/Java 的后端开发者,尤其是被线上内存问题坑过、只靠重启解决问题的朋友。内容不会停留在“栈和堆的区别”,会把内存真正的链路拆开:malloc 背后发生了什么、为什么进程能被 OOM Killer 直接杀掉、泄漏排查时该用哪几个工具、以及工程上怎么提前规避。

1. 一次线上内存上涨,把内存管理逼成了必修课

1.1 那个内存只涨不跌的服务

先还原一下当时的现场。服务是一个常驻的消费者进程,每收到一批外部消息就做解析和落库。上线一周后,监控平台上 RSS 曲线开始呈锯齿状:每次发布重启后会掉到 600MB 左右,之后以大约每 12 小时 300MB 的速度往上走,到 2.7GB 后可能出现单机内存用量过高告警。因为我们当时没有给容器设置硬性 memory limit,所以它还能“苟延残喘”,但再跑几天很可能会被节点负载拖垮。

刚开始排查,我按习惯先检查三类东西:

  • 本地缓存有没有设置最大条数或过期时间。
  • 连接池、线程池、HTTP Client 是否在每次请求后正确关闭。
  • 数据表订阅和消息队列消费有没有积压,会不会是重试逻辑把大量数据挂在内存里。

这三类全部查过,没有异常。线程数稳定在 20 左右,CPU 也只是正常波动。于是我开始怀疑是某个场景下的对象泄漏。但代码量不小,靠人眼一个个 new 找 delete 效率太低。后来我用 valgrind 的--leak-check=full在一个低并发副本上跑了一轮,报告里清清楚楚列出了一处Direct leak of 72 byte(s) in 1 object(s),分配栈指向消息解析器里的辅助对象。问题就这样定位到了。

这个案例本身很简单,但复盘时有价值的是另一件事:为什么这段代码在性能测试时没有暴露问题?因为压测没有跑足够多的轮次,也没有设置“内存超过基线的淘汰阈值”。这让我意识到,内存管理不能只靠“出了问题再修”,更需要知道它在底层是怎么运作的,否则连监控曲线都读不懂。

1.2 从问题里拆出的三个基本问题

排查过程中我不断问自己:内存到底是从哪里来的?谁会负责还?系统在什么情况下会介入回收?这三个问题分别落在不同的抽象层。

第一层是分配器,也就是malloc/freenew/delete以及各类内存池。第二层是语言运行时的生命周期模型,比如 C++ 的 RAII、Java/Go 的垃圾回收器、Rust 的所有权机制。第三层是操作系统,包括虚拟内存、页表、换页、OOM 决策。我们平时读到的“内存管理”教科书章节,通常把这几个层次拆开讲;但线上排错时它们会叠加出现。比如 RSS 一直涨,可能是分配器把空闲内存攥在手里不还给系统,也可能是 GC 还没触发,还可能是真泄漏。如果只凭一个指标去猜,很容易走弯路。

所以我把这篇文章的结构设计成一条从底层到上层的链路:先讲分配器的行为,再讲栈和堆的生命周期规则,然后剖析虚拟内存和 OOM 的真相,最后回到具体的泄漏工具和工程手段。

2. 从 malloc 到内核:分配器如何管理你的内存

2.1 malloc 返回一块内存,但未必真的从内核取到内存

很多同学的第一反应是:调用malloc(1024),就是向操作系统申请了 1024 字节。从逻辑上确实是这样,但工程实现上完全不是。malloc是用户态分配器的入口函数,Linux 下通常指 glibc 的 ptmalloc 实现。它自身维护着一批内存块缓存,先看有没有合适大小的空闲块,有就直接从缓存里切一块返回,整个过程发生在用户态,不触发系统调用。只有当分配器手里没有可用内存块时,才会通过brk调整堆顶,或者通过mmap向内核申请新的内存段。

glibc malloc 的内存管理粒度也很有意思。小块内存会放进 fastbin 或 tcache,这些缓存可以做到非常快的分配和释放;中等大小的块会进入 unsorted bin、small bin 或 large bin,分配器在其中做合并和拆分;而超过MMAP_THRESHOLD(默认通常是 128KB)的大块分配,会直接走mmap单独分配一块地址空间,释放时也更容易还给内核。所以“每次 malloc 都直接跟内核要内存”是最大的误解。

C++ 的new在 Linux 上底层多半也是调用malloc,区别在于它会先分配未初始化内存,再调用构造函数;delete会先调用析构函数,再调用free。这里有一个经常被忽略的规则:如果用了new[]分配数组,就必须用delete[]来释放,因为数组版本会在内存块前面记录元素数量,只有delete[]才知道要调用多少次析构函数。混用new[]delete属于未定义行为,可能当时不出错,换编译器或运行库就崩。

2.2 free 之后 RSS 为什么没有立即下降

我在复盘那次线上问题时,专门写了一个小测试程序:循环 malloc 又 free 几十万次,同时观察 RSS。结果发现程序运行结束后,RSS 依然比启动前高出一大截。这个现象非常容易让人误判成“内存泄漏”,但它其实只是分配器的滞后行为。

原因有几个。第一,free通常只是把内存块归还给分配器的缓存堆,比如放回 tcache、fastbin 或 unsorted bin,并没有真正把这一块内存交还给操作系统。第二,即便brk把堆顶往下调整,内核也未必立即回收对应的物理页,因为内核认为你刚刚释放完,很可能紧接着又要分配,提前回收反而增加缺页开销。第三,mmap大块区域释放后,地址空间确实会还给内核,但相应页表的清理也存在异步因素。

所以看到 RSS 没降,不要急着“确诊”。真正泄漏的特征是:反复执行同样的分配/释放周期后,内存水位持续抬高,而且永远不会回到低点;分配器缓存则表现为“上升一截后趋于平坦”。如果你确实需要“释放后尽快归还系统”,可以调M_TRIM_THRESHOLD或调用malloc_trim(0),但这是以牺牲后续分配性能为代价的,不建议作为通用操作。

2.3 换用 tcmalloc/jemalloc 是一种取舍

既然 glibc malloc 有这么多缓存策略,是不是高并发服务应该直接换 jemalloc 或 tcmalloc?不一定。这两个主流分配器的核心思路是:给每个线程一个本地缓存,让线程在同一尺寸的小对象分配上尽量不碰全局锁。主线以 page 为粒度管理大块区域,再用空闲链表切分成小对象。这样做对于大量线程各自的高频小分配,收益非常明显。

但代价也实实在在。由于线程缓存是私有的,空闲内存不会被其他线程复用,也更不容易主动归还给操作系统,结果常常是进程的 RSS 水位比 glibc malloc 更高。我见过一个服务,切到 jemalloc 后 P99 延迟降了约 15%,但 RSS 涨了 20%,原因就是它每个请求都会创建一批临时小对象,线程缓存把这些对象全攥住了。后来通过调整background_thread和缓存上限才把水位压回来。所以换分配器必须配合监控和压测做决策,不能拿“性能更好”一句话就上线。

3. 栈与堆的生命周期:为什么局部变量不泄漏,而 new 出来的对象会

3.1 栈帧按作用域天然回收

栈内存的分配本质就是移动栈指针。调用函数时,CPU 会把返回地址压栈,然后通过修改栈指针为局部变量腾出空间;函数返回时,栈指针直接恢复,所有局部变量的内存自动失去意义。这个过程没有“释放”操作,也不会有碎片,速度极快。

这也解释了为什么局部变量不会导致内存泄漏。只要函数返回,它的栈帧就失效了,哪怕你在这个函数里创建了一个巨大的数组,只要不把数组地址传到外部,内存都会被马上复用。C 语言里“返回局部变量地址”是危险行为,因为栈帧已经没了,但指针还悬在调用方手里。Go 语言则不同,编译器会把那些“泄漏到堆上的局部变量”自动分配到堆上,所以 Go 允许函数返回局部变量的指针,这是逃逸分析带来的差异。

3.2 堆对象没有“离场时间”,所以要人为或靠运行时兜底

堆内存则没有“函数结束就失效”的天然规则。通过newmalloc得到的对象,生命周期和调用栈完全解耦。你可以把一个new出来的对象传入任何函数、存到全局变量、塞进异步队列,它都会一直存在,直到有人显式delete/free

这就把问题抛给了程序员:谁来负责释放?什么时候释放?如果负责释放的地方有两个,可能 double free;如果没人释放,就是泄漏;如果释放得太早,而其他线程还在用,就变成悬垂指针。我在处理线上问题时,发现一个很普遍的现象:真正的内存泄漏很少是“整块代码忘了写 delete”,而是所有权混乱——对象创建者以为别人会释放,使用者以为宿主对象会释放,最后没有归属,谁都不清。所以现代 C++ 才会反复强调 RAII、智能指针,把释放动作绑定到一个栈对象的析构函数上,让编译器在离开作用域时自动调用析构函数,从而借栈的生命周期来管理堆的生命周期。

3.3 语言机制给出了三种答案:手动、GC、所有权

不同语言对“堆对象生命周期归谁管”给出了不同的制度设计:

  • C 靠程序员手动malloc/free,完全自由,也完全危险。
  • C++ 提供 RAII 和unique_ptr/shared_ptr,让释放时机跟着对象自动走。
  • Java/Go 使用垃圾回收器,通过可达性分析找到那些“已经无法从根集访问的对象”,再在某个时机统一回收。
  • Rust 引入所有权和借用检查,在编译期计算谁拥有某个对象,并在作用域结束时自动插入释放代码。

这三种制度的取舍非常明显。手动管理,性能和可控性最高,但心智负担最大;GC 写起来最省心,但存在暂停和内存水位问题;所有权模型安全又高效,但会让部分代码写法变复杂。理解这些差异后,你再看内存指标时会有不同视角:Java 进程 RSS 高,不一定是代码泄漏,可能是 GC 堆的容量规划;Go 进程内存上涨但平稳,可能是本质上没达到触发 GC 的阈值;C++ 的 RSS 下降得慢,可能是分配器缓存导致的假象。每一步都对应着一个明确的排查方向。

4. 虚拟内存、overcommit 和 OOM Killer:进程为什么能被直接杀掉

4.1 虚拟地址空间让分配变得“过于大方”

你在ps里看到的 VSZ 和 RSS 其实是两码事。RSS 是常驻物理内存,VSZ 是虚拟地址空间大小。每个 64 位进程都有一套独立的虚拟地址空间,由内核通过页表映射到物理内存。malloc申请大内存时,内核可能只在进程地址空间里划了一堆虚拟页,真正物理页要等你写入数据、触发缺页异常才分配。

这个机制带来了巨大的灵活性,但也让“内存分配成功”变得很不可靠。你malloc(2GB),系统可能立刻返回非空,但实际上物理内存并没有 2GB 可用,只是先记账了。这种“先承诺、后兑现”的方式就叫 overcommit。

4.2 overcommit 是 OOM 的根源之一

Linux 默认允许一定程度的 overcommit(/proc/sys/vm/overcommit_memory=0),内核会根据当前空闲内存、swap 和可回收 page cache 估算一个“乐观”的承诺上限。好处是很多稀疏分配的应用能用最小物理内存跑起来,坏处是当所有进程真的都去写这些页面时,物理内存会瞬间见底,内核只能启动 OOM Killer,挑一个进程杀掉来缓解压力。

所以你会看到一种奇怪的现象:程序 malloc 成功了,运行到某个写入点却突然死掉,dmesg里出现 “Out of memory: Kill process” 或 cgroup 的 OOM 记录。这不是代码访问越界,而是整个机器或容器的内存“账面超支”。如果容器设置了 memory limit,超过限制后进程同样会被 cgroup OOM 杀掉,但你在进程本身的日志里通常看不到任何异常。

提示:线上应用被杀时,第一件事不是去看代码逻辑,而是先确认dmesg或节点事件里有没有 OOM。否则你很可能在排查一个不存在的 use-after-free,而真正的答案是内存配置问题。

4.3 页表、缺页与 cgroup 限流下的现实

虚拟内存还有一个影响性能的重要概念:缺页中断。第一次访问一个刚映射的页面,会触发 minor fault,内核建立页表项、分配物理页;如果页面被换出到 swap,需要读盘,则触发 major fault,性能损耗巨大。在容器环境里,如果多个进程共享同一物理节点,内核会在内存吃紧时优先回收 page cache,导致应用的磁盘读请求频繁落盘,反应在业务上就是延迟抖动。这种现象和内存泄漏没有关系,纯粹是缓存生命周期被压缩了。

排查这类问题时,我会同时看三个指标:节点内存使用率、容器 RSS、page cache 命中率。如果节点内存高、容器 RSS 并不高、但业务变慢,很可能是页面置换和 cache 回收造成的。优化手段不是写代码,而是调大容器的 memory limit、减少同节点的超卖比例,或者把不重要的缓存去掉,让热数据尽量留在 page cache 里。

5. 内存泄漏排查的完整链路:从监控图到定位到具体行

5.1 先回答:这是不是真的泄漏

真正开始用工具之前,我会花十分钟做“是不是泄漏”的判断。判断对象不是进程,而是“内存为什么收不回来”。我常用的方式是:

  1. 把服务稳定流量跑起来,每 1 分钟记录一次 RSS、堆内存和 GC/分配器统计。
  2. 连续观察至少 2 轮完整的业务洪峰,看每一轮结束后内存是否回到同一水位。
  3. 如果水位逐轮抬高,下一步看代码中“常驻集合”的规模:全局容器、缓存、channel、线程池任务队列。

这一步能把问题范围缩小很多。比如 Go 服务遇到 RSS 持续上升,先看runtime.MemStats里的HeapInuseHeapReleased,如果HeapInuse稳定但HeapReleased越来越小,说明运行时持有内存不释放,不一定是业务泄漏。C++ 服务则可以用pmap -x <pid>看堆段大小和映射数量,再通过/proc/<pid>/smaps里的Private_Dirty估算真实物理占用。

5.2 用 ASan/valgrind/pprof 一步步锁到行号

如果判断为“像泄漏”或“疑似越界”,我会先用编译期插桩工具,因为它能直接给出代码栈。

对于 C/C++ 小规模复现,valgrind 最直接:

valgrind --leak-check=full --show-leak-kinds=definite ./your_app

它慢,但报告精确。缺点是没法跑高吞吐压测,适合在低并发副本或单测里用。

更好的选择是 AddressSanitizer。编译时加上:

g++ -fsanitize=address,leak -g -O1 -fno-omit-frame-pointer your_code.cpp

运行后如果出现泄漏,它会直接告诉你“直接泄漏了多少字节,分配来自哪个调用栈”。ASan 的排查效率比 valgrind 高不少,代价是性能下降,不适合直接跑线上,但适合在回归环境里开启。

Go 服务则简单很多,直接用 pprof 采样堆内存:

go tool pprof -top http://your-service:6060/debug/pprof/heap

inuse_space可以定位当前还存活的对象,看alloc_space可以定位分配热点。这两者经常被混淆,排查泄漏时重点看前者,因为泄漏是“生存对象只增不减”。

5.3 我的二分法:切代码、看 smaps、复现

工具给出的栈如果直接命中业务代码,那皆大欢喜。但很多时候栈会指向库函数、框架代码,或者因为异步调用链太深,指向一个很通用的入口。这时候我习惯用“二分法”缩小嫌疑。

具体做法是:在压力环境里,先记录Private_Dirty总量的基线,然后用配置开关关闭某个功能模块,重新跑一轮相同的场景,观察内存增长率有没有明显变化。每关一个模块,就近似得到一个排除项。配合/proc/<pid>/smaps看具体哪段地址空间增长最快,比凭感觉翻代码有效得多。

复现不只是“能复现就好”,要尽量让复现路径稳定。如果是异步任务,需要把消息批量重放;如果是周期性任务,需要固定触发时间。我见过很多人卡在一处原因是“复现出来了但每次栈都不一样”,后来发现是因为线上并发比本地高好几倍,某些对象是在并发竞争下才创建失败的,进而导致泄漏分支进入。所以复现时别图省事,先加大并发,再逐渐降低,直到能稳定复现。

真实修复后,验证也得分两步:第一步,编译修正版,在本地压力下运行同样的场景,确认 ASan 或 valgrind 报告不再出现泄漏;第二步,部署到预发或单台线上机器,盯至少一个完整业务周期,观察 RSS 是否恢复到“高点到高点不再递增”的形态。等一个周期还不够,至少两个周期才敢说根因被解决。

6. 设计上提前规避:智能指针、内存池和可观测性

6.1 所有权从编码规范一开始就要定

排查问题始终是事后的体力活,我更希望从设计层面让内存问题少发生。最有效的一件事,是把“所有权”写进代码规范,而不是等到 review 时靠人侠义地判断。

在 C++ 里,规范可以这样定:

  • 裸的new只能出现在构造函数或工厂函数里,不允许散落在业务逻辑中。
  • 返回一个堆对象时,必须明确返回unique_ptrshared_ptr,禁止返回裸指针“暗示”别人去 delete。
  • 跨线程传递对象时,只传智能指针或值拷贝,不传裸指针 + 生命周期承诺。
  • 任何容纳对象的容器,统一用值类型或unique_ptr,避免手写“指针容器 + 析构循环”。

在 Go 里,虽然不需要手动释放,但也要警惕隐性持有。比如把一个大对象放进全局 map,map 本身不清理,GC 永远认为它存活;再比如 goroutine 泄漏会导致其引用的整个上下文都不释放。所以规范里要写明:全局 map 必须有淘汰策略,channel 必须有退出信号,协程不允许无期限阻塞。

6.2 对象池与内存池很适合高频小对象

分配器再好,也不如“不分配”快。高频小对象场景,比如网络协议解析、RPC 编解码、字符串拼接,可以考虑对象池或内存池。思路是预先从系统申请一块较大的内存,按请求处理流程“借用”和“归还”,而不是每来一个请求就new一个临时对象又马上释放。

对象池的实现并不复杂,但有三个容易踩的坑:

  • 池子要区分“归还”和“仍然在借”,否则同一个对象会被两个请求同时写,造成数据互相污染。
  • 对象池的内存不会自动低于峰值,所以池子不能无限增长,要有上限或在空闲时主动清理。
  • 一旦你把对象借出去,就必须保证调用链上的所有分支都能归还。如果中间有异常提前返回,很容易漏掉 return,造成池子越用越少,形成新的“逻辑泄漏”。

所以在使用对象池前,先问自己:这个对象的分配频率高到 CPU profile 里已经占比较大吗?如果不是,引入池子反而增加代码复杂度和追踪难度。性能优化里的第一条永远是先测量,再决定要不要池化。

6.3 可观测性:内存管理需要持续跟踪

最后是工程侧的“把内存问题挡在发布前”。我在团队里推动的一套做法,主要包含四件事:

  • CI 里增加内存安全检测步骤,C++ 用 ASan,对核心模块跑针对性单测和短压。
  • 对每个服务设置内存 limit,让内存超支在容器编排层直接暴露,而不是等机器 OOM 后失控。
  • 监控里同时看“分配量”和“驻留量”两类指标,比如 Go 的heap_allocheap_objects与 RSS 对照,C++ 的分配器统计与 RSS 对照。
  • 每个业务版本上线前留基线,运行两天后和上一个版本的同期内存曲线比对,出现趋势偏离就自动告警。

这套做法的核心逻辑,是把“事后排查”变成“版本回归的一部分”。内存问题不会因为代码 review 认真就完全消失,但只要每次发布都能观察到内存水位变化,很多泄漏甚至等不到上生产就被发现了。我后来处理类似问题时,已经不再靠盯着监控图猜,而是先把基线数据拉出来对比,几分钟内就能判断是哪次变更引起的。

我一直觉得,内存管理是最值得花时间补的“基础章节”。它不像某个框架的新特性那样按月更新,但几乎是所有卡顿、崩溃、被杀之前必踩的坑。把 malloc 的缓存逻辑、栈和堆的生命周期规则、虚拟内存和 OOM 的运作方式串起来之后,再看线上内存问题,思路会清晰很多。希望这套从底层到排查的链路,能让你下次遇到内存告警时少走一点弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询