【C++ 面试真题】聊聊 C++ 的内存泄漏与内存布局
“线上进程内存一直涨,怎么查?”——这是工程里最真实的内存问题。查泄漏绕不开进程的内存布局:数据在哪个区、泄漏发生在哪、工具看见的是什么。背得出"new 了没 delete"只是及格,真考你的是"一张完整的布局图、泄漏的判定标准、AddressSanitizer 和 valgrind 怎么用、free 之后内存去哪了"。本文把泄漏排查和内存布局合起来讲透。
一、开场:先画进程的内存布局
❓ 介绍一下 C++ 进程的内存布局?
✅ 从低地址到高地址,一张图讲全:
高地址
┌────────────────┐
│ 栈 stack │ ← 向下增长
│ ↓ │
│ (空洞) │
│ ↑ │
│ 堆 heap │ ← 向上增长
├────────────────┤
│ 共享库/mmap 区 │
├────────────────┤
│ BSS 段 │ 未初始化静态数据
├────────────────┤
│ 数据段 data │ 已初始化静态数据
├────────────────┤
│ 代码段 text │ 机器指令、字面量
└────────────────┘
低地址
回答思路:按"代码段 → 数据段 → BSS → 堆 → 栈"从低到高报,每段一句放什么,最后补"堆栈相向增长、中间是空洞"。
| 段 | 存放 | 谁管理 |
|---|---|---|
| text | 机器指令、字符串字面量(只读) | 系统 |
| data | 已初始化的全局/静态变量 | 编译期 |
| BSS | 未初始化全局/静态(清零) | 加载时 |
| heap | new/malloc 的动态内存 | 程序员 |
| stack | 局部变量、参数、返回地址 | 编译器 |
💡 经典追问点:
int* p = new int;里 p 在栈上,它指向的 int 在堆上——"变量在哪"和"资源在哪"是两回事。泄漏,就发生在堆这一区。
二、堆与栈:为什么泄漏只发生在堆
❓ 为什么栈不会泄漏、堆会?
✅ 因为两者的回收机制完全不同:
| 维度 | 栈 | 堆 |
|---|---|---|
| 回收 | 作用域结束自动回收 | 手动释放(或智能指针) |
| 顺序 | 严格后进先出,无碎片 | 任意顺序,有碎片 |
| 分配 | 移栈指针,极快 | 分配器找块,慢 |
| 大小 | 有限(Linux 默认 8MB) | 大 |
| 忘了释放 | 不可能(不存在这事) | 泄漏 |
栈的一切由编译器接管,"忘记回收"在栈上根本不可能发生;堆把释放责任交给了程序员——责任转移正是泄漏的温床。
💡 new 的幕后三层:
new→operator new→ malloc(分配器);分配器在用户态维护空闲块池,池子不够才用brk/mmap向内核要内存。这层结构直接决定了后面"free 之后内存去哪"的答案。
三、什么是内存泄漏?怎么判定?
❓ 到底什么样的现象叫内存泄漏?
✅ 严格定义:申请了堆内存、失去了所有访问它的途径、又没释放——这块内存在进程结束前永远无法回收。
三个关键词对应三种形态:
// ① 丢了指针:最直白的泄漏
void f() {
int* p = new int[1000];
if (err) return; // p 出作用域
delete[] p; // 到不了
}
// ② 该释放没释放:逻辑遗漏
struct C {
C() { data = new char[64]; }
~C() { /* 忘了 delete[] */ }
char* data;
};
// ③ 间接泄漏:强引用清不掉
// 循环引用的 shared_ptr(上期详讲)
⚠️ 别把"内存占用高"直接等同泄漏:分配器缓存空闲块(RSS 不降是常态)、真在用的缓存、内存池预分配——都不是泄漏。判定标准是**“不可达 + 未释放”**,最终裁判是工具(下一节),不是眼瞪任务管理器。
四、排查工具:三层火力
❓ 实际排查内存泄漏用什么工具?
✅ 由轻到重三层:
第一层:编译期/静态——clang-tidy、编译器警告:new 后没有对应 delete、unique_ptr 能替代的模式直接报出来。成本最低,掐灭于摇篮。
第二层:运行期插桩——AddressSanitizer(首选):
# 编译时加 sanitizers
g++ -fsanitize=address
-g main.cpp
泄漏(LeakSanitizer 默认附带)在进程退出时报告:每块泄漏内存的分配调用栈直接打出来,精确到行。还有个隐藏好处:ASan 顺带抓 use-after-free、double free、越界——一次插桩全家桶。原理是把每块内存的"可达性"在退出时做图遍历(从栈/全局出发标记可达堆块),扫不着的分配即泄漏。
第三层:外挂式——valgrind memcheck:
valgrind --leak-check=full
--show-leak-kinds=all
./app
不改编译、逐条指令翻译执行,报告"definitely lost / indirectly lost / still reachable"分级。代价是程序慢 10~30 倍,不适合压测场景;好处是零侵入,适合拿现成二进制查。
🎯 实战路径:CI 里挂 clang-tidy → 开发/测试构建开 ASan → 交付前或排查线上疑难点用 valgrind 复核。Windows 下 ASan(MSVC 已支持)之外还有 VLD(Visual Leak Detector)。
五、泄漏的工程形态与预防
❓ 真实项目里泄漏都藏在哪?怎么防?
✅ 高发区四类 + 一条铁律:
- 异常路径:正常分支 delete 了,抛异常的分支漏了——try/catch 救不过来的复杂控制流最危险;
- 提前 return:函数中段 return/continue 跳过清理;
- 接口契约不清:谁分配谁释放没写明白,双方都以为对方管(C API 交接重灾区);
- 回调/异步:注册了资源,忘了注销;异步任务持有缓冲,任务丢了缓冲悬空或泄漏。
铁律就是 RAII——把资源生命周期绑到对象:
// ❌ 四条路径都要记得 delete
char* buf = new char[n];
if (a) return;
if (b) throw;
delete[] buf;
// ✅ 一条路径都不用管
auto buf = make_unique<char[]>(n);
if (a) return; // 自动释放
if (b) throw; // 自动释放
💡 预防优先级:智能指针 > 容器 > 手写 RAII 包装 > 裸 new/delete。能不裸 new 就不裸 new——上一期讲的 malloc/new 底层机制,是帮你理解原理,不是鼓励使用。
六、free 之后,内存去哪了?
❓ delete/free 之后,内存马上还给操作系统吗?
✅ 未必——分配器通常把块挂回空闲池等复用;只有堆顶大块收缩(brk)或 mmap 的独立映射才真正还给内核。所以"进程 RSS 不降"不等于泄漏。
void* p = malloc(1 << 20); // 1MB
free(p);
// RSS 通常不变:块进空闲池
void* q = malloc(1 << 20); // 大概率复用
// 同样的内存,没有新向内核要
💡 这个知识点连接三件事:布局(堆的边界由 brk/mmap 扩)、判定(工具看可达性不是 RSS)、优化(内存池本质就是把空闲池管理做成定制版)。mallopt(M_TRIM_THRESHOLD) 这类参数还能调"归还阈值",面试当深水区加分项。
七、面试高频追问
❓ Q1:栈溢出和内存泄漏是一回事吗?
✅ 不是。栈溢出是栈空间用完(无界递归、超大局部数组),表现是段错误——它是"崩给你看";泄漏是堆内存丢而未还,进程继续跑但越吃越多——它是"慢慢憋死"。前者查递归和局部大对象(改 vector 进堆),后者查工具报告的分配栈。
❓ Q2:全局对象什么时候构造?和泄漏有关吗?
✅ main 之前构造、main 返回后析构(跨编译单元顺序不定)。相关的是"构造即分配、永不析构的单例泄漏":new 出来故意不 delete 的 static 单例,工具会报 still reachable——通常无害(进程退出统一回收),但报告里要有本事把它和真泄漏区分开。
❓ Q3:ASan 和 valgrind 怎么选?
✅ 开发自测/CI 用 ASan——快(约慢 2 倍)、报告直接、顺带抓越界和 UAF;拿不到源码、或不能改构建时用 valgrind——零侵入但慢 10~30 倍。性能压测中两者都别开。
❓ Q4:valgrind 报告的 definitely lost 和 still reachable 什么区别?
✅ definitely lost:没有任何指针指着的泄漏块——真泄漏,必修;still reachable:退出时仍有指针(常见 static 缓存、单例)——进程结束由 OS 回收,一般无害。间接丢失(indirectly lost)是只被"丢失的块"引用着的块。
❓ Q5:怎么写一个最简单的泄漏检测?
✅ 思路是重载全局 operator new/delete:分配时记录"指针 → 调用栈"进注册表,释放时移除,atexit 时打印残留项——一个下午能写玩具版,工业版就是 VLD/LeakSanitizer 的雏形。能讲出"注册表 + atexit 汇报"结构,这题就过了。
❓ Q6:内存碎片算泄漏吗?
✅ 不算。碎片是空闲但用不上(小块散落,凑不出大块),内存还在分配器手里;泄漏是根本回不来。碎片的表现是"空闲很多却分配失败",解法是内存池/slab 或换 jemalloc 这类分配器——和治理泄漏是两条路线。
八、总结速查表
| 考点 | 一句话结论 |
|---|---|
| 布局顺序 | text → data → BSS → 堆 → 栈 |
| 泄漏定义 | 不可达 + 未释放 |
| 泄漏只发生在堆 | 栈自动回收,无"忘放"一说 |
| 高发区 | 异常路径、提前 return、契约不清 |
| ASan | 运行期插桩,退出报告分配栈 |
| valgrind | 零侵入,慢 10~30 倍 |
| 报告分级 | definitely lost 才必修 |
| free 之后 | 进空闲池,RSS 不降 ≠ 泄漏 |
| 预防铁律 | RAII / 智能指针 |
| 碎片 ≠ 泄漏 | 空闲但凑不出大块 |
一句话回顾
泄漏是**“堆上不可达又未释放”**,布局里只有堆把回收交给了人;排查三层火力——clang-tidy 静态 → ASan 插桩(附分配栈)→ valgrind 零侵入;free 之后内存多半躺在分配器空闲池里,RSS 不降不是泄漏;而预防只有一条铁律——RAII:能智能指针就别裸 new。
如果您觉得本篇内容对你有帮助,欢迎点赞 👍、收藏 ⭐、转发 📢。下期是内存管理篇收官——聊聊裸指针拯救:散落的 free/delete 怎么改造、综合应用与真实问题排查怎么做,敬请关注 👋

2万+

被折叠的 条评论
为什么被折叠?



