【C++ 面试真题】27. 聊聊 C++ 的内存泄漏与内存布局

【C++ 面试真题】聊聊 C++ 的内存泄漏与内存布局

"线上进程内存一直涨,怎么查?"------这是工程里最真实的内存问题。查泄漏绕不开进程的内存布局:数据在哪个区、泄漏发生在哪、工具看见的是什么。背得出"new 了没 delete"只是及格,真考你的是"一张完整的布局图、泄漏的判定标准、AddressSanitizer 和 valgrind 怎么用、free 之后内存去哪了"。本文把泄漏排查和内存布局合起来讲透。


一、开场:先画进程的内存布局

❓ 介绍一下 C++ 进程的内存布局?

✅ 从低地址到高地址,一张图讲全:

text 复制代码
高地址
┌────────────────┐
│   栈 stack     │ ← 向下增长
│   ↓            │
│   (空洞)      │
│   ↑            │
│   堆 heap      │ ← 向上增长
├────────────────┤
│ 共享库/mmap 区  │
├────────────────┤
│ BSS 段         │ 未初始化静态数据
├────────────────┤
│ 数据段 data    │ 已初始化静态数据
├────────────────┤
│ 代码段 text    │ 机器指令、字面量
└────────────────┘
低地址

回答思路:按"代码段 → 数据段 → BSS → 堆 → 栈"从低到高报,每段一句放什么,最后补"堆栈相向增长、中间是空洞"。

存放 谁管理
text 机器指令、字符串字面量(只读) 系统
data 已初始化的全局/静态变量 编译期
BSS 未初始化全局/静态(清零) 加载时
heap new/malloc 的动态内存 程序员
stack 局部变量、参数、返回地址 编译器

💡 经典追问点:int* p = new int;p 在栈上,它指向的 int 在堆上------"变量在哪"和"资源在哪"是两回事。泄漏,就发生在堆这一区。


二、堆与栈:为什么泄漏只发生在堆

❓ 为什么栈不会泄漏、堆会?

✅ 因为两者的回收机制完全不同:

维度
回收 作用域结束自动回收 手动释放(或智能指针)
顺序 严格后进先出,无碎片 任意顺序,有碎片
分配 移栈指针,极快 分配器找块,慢
大小 有限(Linux 默认 8MB)
忘了释放 不可能(不存在这事) 泄漏

栈的一切由编译器接管,"忘记回收"在栈上根本不可能发生;堆把释放责任交给了程序员------责任转移正是泄漏的温床

💡 new 的幕后三层:newoperator newmalloc(分配器) ;分配器在用户态维护空闲块池,池子不够才用 brk/mmap 向内核要内存。这层结构直接决定了后面"free 之后内存去哪"的答案。


三、什么是内存泄漏?怎么判定?

❓ 到底什么样的现象叫内存泄漏?

✅ 严格定义:申请了堆内存、失去了所有访问它的途径、又没释放------这块内存在进程结束前永远无法回收。

三个关键词对应三种形态:

cpp 复制代码
// ① 丢了指针:最直白的泄漏
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(首选)

text 复制代码
# 编译时加 sanitizers
g++ -fsanitize=address
    -g main.cpp

泄漏(LeakSanitizer 默认附带)在进程退出时报告 :每块泄漏内存的分配调用栈 直接打出来,精确到行。还有个隐藏好处:ASan 顺带抓 use-after-free、double free、越界------一次插桩全家桶。原理是把每块内存的"可达性"在退出时做图遍历(从栈/全局出发标记可达堆块),扫不着的分配即泄漏。

第三层:外挂式------valgrind memcheck

text 复制代码
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------把资源生命周期绑到对象:

cpp 复制代码
// ❌ 四条路径都要记得 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 不降"不等于泄漏。

cpp 复制代码
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 怎么改造、综合应用与真实问题排查怎么做,敬请关注 👋

相关推荐
PPPPickup2 小时前
金证秋招笔试
java·开发语言
不正经学生2 小时前
C语言结构体:自定义数据类型,让变量打包出行
java·c语言·开发语言·数据结构·算法
ycjunhua2 小时前
Spring AI 2.0 GA:ToolCallingAdvisor 重构与 Java Agent 新范式
java·人工智能·spring
开源必胜2 小时前
Future的异常处理以及CompletableFuture传播链、异常处理
java
加油码3 小时前
动静态库的链接底层原理(进阶版)
linux·c++
一木 之林3 小时前
三、内存管理、指针与堆栈
c语言·jvm·c++
林森lsjs3 小时前
队列 FIFO 原理 & 手撕两种队列实现(链表 + 循环数组)—数据结构陆
java·开发语言·数据结构·队列
初夏睡觉4 小时前
原创题目 - 空壳恋爱
c++·算法·dp·oi·信息学·原创题目·值得挑战的题目
lisin-lee-cooper4 小时前
简单聊聊 OpenFeign 框架源码
java·微服务