【C++ 内存申请】从 C++ new 到内核:虚拟内存、VMA 与内存泄漏的全链路解析
目录标题
- 1. 从 C++ `new` 到物理内存:堆、虚拟内存和 VMA 究竟发生了什么
- 2. 销毁与并发:`free` / `munmap`、线程和页表更新
- 3. 内存泄漏与系统稳定性:进程级泄漏 vs 内核级泄漏
- 结语

1. 从 C++ new 到物理内存:堆、虚拟内存和 VMA 究竟发生了什么
很多人第一次听到“new 只是申请虚拟内存、物理内存在首次访问时才分配”都会有点抽象感。正如一些认知心理学研究常说的那样,我们的大脑很容易把“调用时刻”和“真正发生的时刻”混成一件事,这恰好就是理解内存管理的第一层障碍。
本章从三个视角拆开:C++ 运行库 → 操作系统 → 硬件 MMU,把“第一次访问新堆区时系统到底做了什么”讲清楚。
1.1 C++ 视角:new / malloc 并不等于系统调用
在 C++ 代码里,你看到的是:
int* p =newint[1000];直觉会以为“马上向操作系统申请了一块物理内存”,但实际上更接近:
- C++ 运行库(
malloc实现)维护一个 堆池:- 已经向 OS 申请过的一些 大块虚拟地址区间;
- 内部有 free list、元数据等管理结构。
- 小额分配:
- 只是在堆池里切一小块,更新元数据;
- 不一定发生任何系统调用。
- 只有堆池不够时:
- 才会通过
brk/sbrk或mmap向 OS 要新的虚拟地址范围。
- 才会通过
所以从 C++ 层面记住一句话就够:
new/malloc返回的只是“一块属于这个进程的虚拟地址”,
至于物理内存什么时候真正到位,还得看 OS 的策略(通常是按需分配)。
1.2 OS 视角:VMA、页表和按需分配(demand paging)
操作系统不会一页一页地“记账”,而是按区间管理地址空间,这就是 VMA(Virtual Memory Area,虚拟内存区域) 的概念:
- 每个 VMA 描述一段连续虚拟地址:
- 起止地址:
start ~ end - 权限:读/写/执行
- 类型:匿名内存(堆、栈)、文件映射(
mmap文件)等
- 起止地址:
- 进程的所有 VMA 构成其虚拟地址空间布局:
- 代码段、数据段、堆、栈、共享库映射区等。
当 C 运行库向 OS 申请堆空间时(例如通过 mmap):
- 通常不会当场为每一页分配物理页:
- 页表项可以是“未驻留”状态;
- 真正访问时再触发缺页处理(demand paging)。
内核创建/扩展一个 VMA,表示:
“从 X 到 Y 这一段虚拟地址,可以读写,属于这个进程的匿名内存(堆)”
这就解释了为什么“只申请不访问再立即释放,有可能没有任何物理页被真正分配”:
你只是多了一条 VMA 记录,然后又删掉了。
1.3 硬件视角:第一次访问堆区、page fault 和 MMU 流程
当你第一次写入一个新堆区:
int* p =newint[1000]; p[0]=42;// 很可能在这里触发该页的第一次 page fault可能发生的流程是:
- CPU 执行
p[0] = 42:- 生成一个虚拟地址 VA。
- MMU 查 TLB:
- 没有这个 VA 的缓存 → 继续查页表。
- 查页表:
- 发现虚拟页还没有对应物理页(标记为“未驻留”或根本没有 PTE);
- 触发 page fault 异常,陷入内核。
- 内核 page fault handler:
- 查 VMA:确认 VA 落在某个合法、可写的 VMA 中;
- 分配一个物理页;
- 建立 VA → PA 的页表映射;
- 更新 TLB。
- 返回用户态,重新执行这条写操作:
- 这次变成一次普通的内存写入。
如果在这之后代码因为其他 bug 崩溃(除 0、别的非法访问、abort 等),
也只是这个进程被终止,OS 会在回收阶段把为它分配的物理页连同页表一并清理掉,不会“污染系统内存管理”。
1.4 难点对比:VMA / 页表 / 虚拟地址 / 物理页
这些概念容易混,所以用一张表来多角度对比一下:
| 概念 | 粒度 | 谁管理的? | 作用 | 典型数量级 |
|---|---|---|---|---|
| 虚拟地址 | 字节/指令级别 | 硬件 + OS | 程序看到的“地址”;cpu发出的地址 | 每条 load/store 都用 |
| 页(虚拟页) | 通常 4KB/2MB | 硬件 + OS | 页表映射的基本单位 | 每进程成千上万 |
| VMA | 多页组成的区间 | OS(内核) | 描述一段连续虚拟地址的属性 | 每进程几十到几百 |
| 页表项(PTE) | 针对某一虚拟页 | OS 填、MMU 使用 | 将虚拟页映射到物理页或磁盘 | 每页一个,数量巨大 |
| 物理页 | 物理内存中的页 | OS | 真正存放数据、指令的地方 | 受物理内存大小限制 |
记忆窍门:VMA 是“段”的描述,页表是“页”的映射,而程序只看到“地址”。
2. 销毁与并发:free / munmap、线程和页表更新
在理解了“分配”的故事之后,另一个很自然的问题是:“如果在第一次访问时,另一个线程刚好释放了这块内存,会不会把 OS 搞乱?”
这里需要严格区分三个层次:
- C++ 对象生命周期(
new/delete) - 分配器行为(
malloc/free与内部堆池) - OS 地址空间操作(
munmap、缩堆、VMA/页表/TLB)
心理学上有个有趣的说法:我们对“同时发生”的事往往会脑补出各种诡异的中间状态,其实在操作系统里,大部分关键操作都被设计成“要么成功、要么失败”的原子效果,不给你“半截状态”的机会。
2.1 C++ 语义:一旦 free / delete,并发访问就是 UB
先看标准层面:
int* p =newint[100]; std::thread t1([&]{ p[0]=1;});// 线程 A std::thread t2([&]{delete[] p;});// 线程 B没有任何同步的前提下:
- 线程 A 读写
p指向的对象; - 线程 B 调用
delete[] p结束对象生命周期。
标准立刻把这归为未定义行为(Undefined Behavior):
- 不关心 OS 会不会已经回收物理页;
- 不关心 page fault 先发生还是
delete先发生; - 只要对象已经被销毁,任何读写都是非法的。
也就是说,从 C++ 的世界观里,这个问题已经判死刑了,你不能靠“OS 恰好怎么做”来指望它“刚好工作”。
2.2 分配器视角:free 不等于 munmap
现实里,多数 free 做的是:
- 更新用户态堆管理元数据;
- 把这块区域挂回 free list;
- 通常不立刻调用
munmap或缩堆(除非这块非常大,或实现有特殊策略)。
这意味着:
- 虚拟地址映射(VMA + 页表)还在;
- 物理页还在;
- OS 完全不知道这块内存已经“逻辑上被 C++ 回收”。
如果此时另一个线程继续访问这块地址:
- 从 OS / 硬件视角看:就是普通合法内存访问;
- 真正的危险来自 C++ 层面:
- 这些数据可能已经被分配器复用了;
- 下一次
malloc可能会把同一块区域分配给别的对象; - 于是你在用一块“已被重用、类型不匹配”的内存——典型 use-after-free/双重释放风险。
总结一句:大部分内存 bug,其实死在用户态逻辑上,而不是 OS 地址空间管理上。
2.3 OS 视角:munmap、VMA 更新和 TLB shootdown
只有在以下情况时,才真正会影响 VMA / 页表:
free内部针对大块分配调用munmap;- 手动调用
munmap(addr, len); - 进程退出时内核清理整个地址空间。
以 munmap 为例,内核大致会做:
- 获取 mmap 相关锁(保证 VMA/页表修改的互斥性)。
- 在该进程的 VMA 树中查找并删除/拆分对应区间。
- 遍历对应页表项,释放物理页,清理 PTE。
- 对相关虚拟地址范围做 TLB shootdown:
- 当前 CPU 清空对应 TLB 条目;
- 向运行该进程的其他 CPU 发送 IPI,令其同步清空;
- 等确认刷新完成后才继续。
- 系统调用返回。
因此:
一旦munmap返回,对该进程所有线程而言,
这一段地址就不再有有效的映射——任何访问要么 SIGSEGV,要么已经被别的映射重用,不会存在“某个线程还在看旧页表”的合法状态。
2.4 场景总结:访问 vs 释放 vs 进程退出
难点其实在于很多行为混在一起,我们用表总结一下不同层面的“销毁”会有什么效果:
| 行为 | 发生在谁那一层 | 是否改变 VMA/页表 | 是否回收物理页 | 是否影响其他进程 |
|---|---|---|---|---|
delete / free 小块 | C++ 运行库(用户态) | 否 | 否 | 否 |
free 导致大块 munmap | C++ 运行库 + OS | 是(删 VMA、清 PTE) | 是 | 否 |
munmap 文件映射/匿名区 | OS(内核) | 是 | 视情况回收或写回磁盘 | 否 |
| 进程正常退出/崩溃 | OS(内核) | 是(全清) | 是(全部回收) | 只释放资源,不破坏其他 |
记忆要点:
- 只要进程还活着,内存泄漏/并发错误会影响这个进程及系统负载;
- 一旦进程退出,OS 会把它的地址空间“整块切掉”,不会留残骸给其它进程。
3. 内存泄漏与系统稳定性:进程级泄漏 vs 内核级泄漏
谈完分配、销毁和并发后,最后一个常见问题就是你刚问的:
“进程内存泄漏是不是关闭进程就能恢复,会不会对系统有长期影响?”
这就进入了错误分类与实际危害的层面。就像心理学里常说的那句:“真正危险的不是你看到的伤口,而是你没意识到的感染”,很多人把“进程内堆泄漏”和“系统长期吃掉内存”混在一起,其实它们是两类问题。
3.1 普通进程内存泄漏:进程退出后资源都会回收
典型代码:
voidfoo(){int* p =newint[1000];// 忘了 delete[]}// p 丢失,这 1000 个 int 永远不会再被这个进程使用如果进程持续运行:
- 堆会不断膨胀,消耗物理内存/交换空间;
- 系统可能变慢(频繁换页)、甚至触发 OOM Killer。
但只要进程退出(无论正常退出、崩溃,还是被 kill):
- OS 回收该进程所有 VMA;
- 释放所有物理页、页表等数据结构;
- 文件描述符、socket、管道等也会被内核关闭。
所以结论非常明确:
✅ 普通意义上的“进程内堆泄漏”,在进程退出后不会长期占用系统内存,
影响只存在于进程存活期间。
但这并不意味着可以“不管泄漏”,因为:
- 长时间运行的服务进程可能永远不重启;
- 泄漏会逐渐拖垮系统,影响其它服务;
- 线上环境需要的是稳定长期运行而非“崩了再说”。
3.2 泄漏真的有多危险:性能、OOM 与系统级影响
在进程存活期间,泄漏的危害包括:
- 自己变慢:
- 堆越来越大,缓存命中率下降;
- GC 型语言(如果用的话)扫描成本上升。
- 拖累全系统:
- 占据大量物理内存,其他进程被迫频繁换页;
- page cache 被挤掉,I/O 性能变差。
- 触发 OOM:
- 当物理内存和 swap 都吃紧,内核可能启动 OOM Killer;
- 挑选“看起来占内存多/优先级低”的进程杀掉;
- 可能是你,也可能是完全不相干的另一个服务。
因此,工程实践中 查泄漏 的目标不是“释放退出后的内存”,而是:
- 避免长时间运行的服务压力积累;
- 减少尾部延迟、系统抖动;
- 提升整体可靠性。
3.3 真正会“关了进程还占内存”的:内核泄漏与驱动问题
只有一种情况会导致“进程死了很久,系统可用内存却越来越少”:
🧨 内核态/驱动代码泄漏了内存或其他非进程关联资源。
一些可能的例子:
- 内核模块在处理系统调用时分配了内存,却没有在错误路径释放;
- 驱动为某个设备分配 DMA 缓冲区、pinned 内存,没有按 refcount 归还;
- 文件系统、网络栈的内核缓存管理有 bug。
这类问题的特征是:
- 某个动作持续发生(例如频繁打开/关闭设备、发请求);
- 即使发起请求的用户进程退出,内核里仍有资源悬挂;
- 系统
free内存持续下降,只能靠重启或卸载模块才能恢复。
它们实质上是 OS/驱动的 bug,而不是普通应用开发者能通过“写对 delete”解决的。
下面用一张表,区分几种“泄漏”:
| 类型 | 发生层次 | 进程退出后是否释放 | 对系统长期影响 | 谁负责修? |
|---|---|---|---|---|
C++ 堆泄漏(忘 delete) | 用户态 | 是 | 只在进程存活时影响 | 应用开发者 |
| 文件描述符泄漏 | 用户态 + OS | 是(进程退出即关闭) | 存活期间耗尽 fd / 句柄 | 应用开发者 |
| shared memory 不释放 | 用户态 + OS | 视具体 API/标志而定 | 可能需要手动清理 | 应用+系统管理员 |
| 内核内存泄漏(驱动 bug) | 内核态 | 否 | 重启前系统内存不断被吃掉 | OS/驱动开发者 |
| GPU/设备驱动泄漏 | 内核态/驱动 | 通常否 | 导致显存不足/设备不可用 | 设备厂商/驱动开发者 |
3.4 收束:从“进程视角”到“系统视角”的内存观
把全文压缩成几句方便写在博客结尾的要点:
- 分配:
- C++
new/malloc→ 先是拿到一块虚拟地址(堆池里的子区间); - OS 只是登记了 VMA,不一定立刻分配物理页;
- 第一次访问时才通过 page fault 分配物理页并建立页表映射。
- C++
- 销毁与并发:
free/delete决定的是“对象生命周期”和“堆池逻辑”,不是直接对 VMA/页表动刀;- 真正改变地址空间的是
munmap、缩堆和进程退出; - 任何“释放后再访问”的并发都属于 C++ 层面的 UB,不能指望 OS 帮你兜底。
- 泄漏与系统稳定性:
- 进程内堆泄漏会拖垮自身和系统性能,但进程一退出,OS 会回收所有资源;
- 关掉进程还能持续吃内存的,往往是内核/驱动泄漏,属于系统级问题;
- 工程实践中既要用工具(ASan、Valgrind、各种 profiler)盯住用户态泄漏,也要对系统异常行为保持警觉。
结语
在我们的编程学习之旅中,理解是我们迈向更高层次的重要一步。然而,掌握新技能、新理念,始终需要时间和坚持。从心理学的角度看,学习往往伴随着不断的试错和调整,这就像是我们的大脑在逐渐优化其解决问题的“算法”。
这就是为什么当我们遇到错误,我们应该将其视为学习和进步的机会,而不仅仅是困扰。通过理解和解决这些问题,我们不仅可以修复当前的代码,更可以提升我们的编程能力,防止在未来的项目中犯相同的错误。
我鼓励大家积极参与进来,不断提升自己的编程技术。无论你是初学者还是有经验的开发者,我希望我的博客能对你的学习之路有所帮助。如果你觉得这篇文章有用,不妨点击收藏,或者留下你的评论分享你的见解和经验,也欢迎你对我博客的内容提出建议和问题。每一次的点赞、评论、分享和关注都是对我的最大支持,也是对我持续分享和创作的动力。
最后,想特别推荐一下我出版的书籍——《C++编程之禅:从理论到实践》。这是对博主C++ 系列博客内容的系统整理与升华,无论你是初学者还是有经验的开发者,都能在书中找到适合自己的成长路径。从C语言基础到C++20前沿特性,从设计哲学到实际案例,内容全面且兼具深度,更加入了心理学和禅宗哲理,帮助你用更好的心态面对编程挑战。
本书目前已在京东、当当等平台发售,推荐前往“清华大学出版社京东自营官方旗舰店”选购,支持纸质与电子书双版本。希望这本书能陪伴你在C++学习和成长的路上,不断精进,探索更多可能!感谢大家一路以来的支持和关注,期待与你在书中相见。
阅读我的ZEEKLOG主页,解锁更多精彩内容:泡沫的ZEEKLOG主页