ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

进程地址空间深度拆解:虚拟内存疯涨,物理内存为何稳如泰山

进程地址空间深度拆解:虚拟内存疯涨,物理内存为何稳如泰山 一个安静的下午我盯着监控面板上那个不断上涨的内存数值头一次意识到自己对进程地址空间的理解有多浅。那是一个常驻服务刚启动时只占 200MB跑了两天居然涨到了 1.2GB几乎翻了三倍。当时我的第一反应是内存泄漏于是排查了很久的未释放指针却一无所获。后来才搞清楚不是代码泄漏了而是我对进程地址空间在运行期间的动态扩展规律缺乏足够的体感——这种虚拟内存疯涨但物理内存稳定的现象恰恰是理解地址空间本质的最佳入口。如果你是一名做服务端开发、嵌入式开发或系统编程的工程师或者正在准备操作系统相关的内容那么这篇内容你大概率用得着。它不会停留在地址空间就是你程序能看到的所有地址这种教科书写法而是把进程地址空间当作一个活生生的运行现场来拆解虚拟内存和物理内存的真实关系、可执行文件如何一步步膨胀成进程、栈和堆的边界规则、mmap 的隐藏玩法以及如何用 /proc 接口和 pmap 工具做实际体检。读完你能建立起一张完整的进程底账以后再看崩溃日志和性能问题思路会清晰很多。1. 从一个段错误说起进程地址空间的稀缺感与真实面貌先回想一下你第一次被段错误支配的经历。空指针解引用、越界写数组、栈溢出程序啪一下崩了coredump 里留下一串十六进制地址。你当时有没有想过一个问题为什么地址空间访问不对系统会直接杀掉你的进程而不是像文件系统那样返回一个错误码因为内存访问是 CPU 亲自动手的操作一旦出格没有缓冲区可以兜底。进程地址空间定义了你的程序能合法触碰的所有内存位置你触碰的范围之外就是禁区。这个观念我会在整篇内容里反复强调——你手里的进程始终活在一个被精心规划的地址地图里而不是一坨可以随便挥霍的 RAM。1.1 你有 2^64 个地址却依然不够用我们干脆把底账算清楚。64 位系统下进程地址空间理论上可以寻址的范围是 2^64也就是 18446744073709551616 个字节。听起来是不是无穷无尽但操作系统为了管理效率和安全性把这块空间劈成了两半用户空间用低一半内核空间用高一半。而 Linux 下用户空间实际可用的通常只有 128TB 左右这取决于架构和内核配置但标准 x86-64 下就是这么分的。128TB 依然大得吓人。但这张虚拟地址地图只是一张产权证不代表你有 128TB 的实际内存。进程 say我拥有地址 0x7f1234567890CPU 会通过页表翻译成物理地址然后才真正去读写那根内存条上的数据。物理内存只有 16GB 的机器上可能同时跑着上百个进程每个进程都拥有自己那一整张 128TB 的地图——这靠的完全是虚拟化和按需分配的机制后面第二章会详细讲。1.2 地址空间的标准化三段式布局我刚入行那会儿画过不少进程内存布局图每个版本都略有差异但骨架就三块文本区代码段、数据区含数据段、BSS、堆、栈区含参数与环境变量区。实际运行中Linux 进程在用户空间的布局大致是一片固定的可执行文件映射区包括只读的代码段、只读数据段、可读写数据段以及 BSS 段未初始化数据运行时动态生成的堆区向上增长一片用于 mmap 映射的区域包括共享库、匿名映射、线程栈往下增长用户栈区从高地址向低地址生长一个几百字节到几 KB 不等的随机空隙是 ASLR 的手笔用于安全防护。我给新手画图的习惯是先画地址 0 在最下面内核地址倒挂在最上面中间夹着代码段 数据段 堆 mmap 区 栈。这里面有两条增长方向不同的动态地带——堆往上栈往下它们在 mmap 区两侧对峙谁膨胀太多都可能碰上谁。1.3 为什么不直接用物理地址编程想象一下如果你代码里直接用物理地址写数据地址 0x0 代表某根内存条的起始、0x1000 代表某个外设寄存器今天你写的程序只能在特定机器、特定内存插法下运行。多开几个进程更麻烦——它们会互相踩脚。地址空间存在的意义就是隔离和抽象。我打过一个比方你住酒店房间号是走廊房号这种逻辑编号你的行李箱放在房间里的样子也是例行登记的。进程地址空间就是这种惯例上统一的房间编号操作系统是前台物理内存是仓库。仓库的东西是不是真的摆在你房间里不是你需要时前台才去仓库搬进来。你的房门钥匙不能开别人的房间这就是地址空间隔离。2. 虚拟内存与物理内存的那层页表滤镜第二章必须回答一个核心疑问每个进程都有 128TB 的虚拟地址空间那物理内存怎么够分答案是大多数虚拟地址从未真正存在过。系统只在进程实际访问某地址时才把一个物理页框挂到页表上这就是按需调页。2.1 页表的最小单位与四级翻译机制虚拟地址在硬件层面是给 CPU 的 MMU内存管理单元翻译的。翻译用的页表不是一个线性大数组而是一个多级索引结构。拿 x86-64 来说通常用四级页表每级索引 9 位最后 12 位是页内偏移。如果每个进程都维护一个覆盖整个 128TB 的扁平页表那光页表本身就要几百 GB 内存显然不现实。于是用稀疏树的思路只给实际映射过的区域建页表项没映射过的区域访问时直接触发缺页异常交给内核判定要不要分配。这也是为什么初始化一个 1GB 的数组和立刻读写这个数组的每个字节在内存占用上完全不同——前者可能只是零页映射后者才真正让物理内存水位上涨。2.2 缺页异常进程真正拿到内存的时刻进程访问一个地址时如果页表项还不存在CPU 会抛一个缺页异常page fault。内核的缺页异常处理程序会分类处置若是合法的按需调页比如首次访问堆区、代码段新调入的页面内核分配物理页框填好页表恢复进程执行若是写一个只读页比如尝试修改代码段则会触发保护错误这可能走向写时复制COW或直接段错误若是完全不存在的地址比如越界访问到未映射的高地址就是真正的 segmentation fault。我遇到不少刚入行的同学调试时一看到 page fault 就紧张其实正常程序跑起来页面错误极其常见只是被内核吞掉了。只有那些在 dmesg 里留下 segfault 报告、在程序里收到 SIGSEGV 的才是真正出了问题。2.3 为什么说页表是滤镜不是字典常见的误解是页表是一条条记录虚拟地址 X 对应物理地址 Y的字典。但实际上它更像一台滤镜它把进程视角下的巨大逻辑空间过滤成一小撮真正有结果的物理集合。滤镜的不同档位就是页表项里的各种标志位——可读、可写、可执行、用户态可访问、已修改、已访问这些标志位决定了你程序的过界行为。举个例子缓冲区溢出攻击为什么难防攻击者想让程序去执行一段注入的代码但栈区域在页表里标记为不可执行NX 位那 CPU 就会在取指阶段直接拒绝。这说明页表标志位不但是地址翻译机制还是安全防线。3. 从 ELF 文件到运行视图地址空间是怎么被盘活的很多学习过操作系统原理的读者知道编译链接生成可执行文件和运行时进程的内存布局但很少把这两者串成一条线。其实进程地址空间的初始内容绝大部分来自 ELF 文件本身。3.1 ELF 段与进程段的映射ELFExecutable and Linkable Format文件里链接器用 program header 描述了多个 segment段每一个段包含若干个 section节。内核在 execve 加载程序时不是逐字节把整个 ELF 文件读入内存而是按 segment 的映射信息建立虚拟地址到文件偏移的映射。典型的映射像一个清单ELF 段信息加载到地址空间的区域权限.text代码低地址的只读执行区域r-x.rodata只读数据紧跟代码段常合并到同一映射r--.data已初始化数据可读写数据段rw-.bss未初始化数据数据段扩展区不占文件大小但占虚拟空间rw-动态链接段.dynamic, .interp 等映射到 mmap 区域附近按需Linux 内核很巧妙地利用文件映射的方式来初始化进程地址空间——文件只被映射进虚拟空间物理页是访问时才真正调入的。这就是为什么一个 200MB 的可执行文件启动时你看到的 RES常驻物理内存可能只有几十 MB。3.2 BSS 段的零的秘密BSS 段是比较好玩的一块。它存的是未初始化的全局变量和静态变量。ELF 文件里 BSS 段几乎不占磁盘空间但它映射到虚拟地址空间后占了一块完整的区域。程序访问这些变量时首次会拿到一个全零的物理页——内核通过一个零页来统一响应后续写入才会触发写时复制分配真实页。我在实际项目里见过一个有趣现象一个进程申请了一个巨大的全局数组并清零RES 内存立刻涨上来但如果你只是声明了它而不碰RES 会稳定在很低的值。很多性能排查场景里这种认知差异能帮你快速分辨到底是虚拟地址空间膨胀还是物理内存真的被消费了。3.3 动态链接器入场之后的地址空间变化现代程序几乎都用动态链接。执行可执行文件时内核先加载可执行文件然后加载动态链接器比如 ld-linux-x86-64.so.2由动态链接器再 mmap 一堆共享库libc、libm、libpthread现在通常在 libc 里等。共享库被映射到进程地址空间的 mmap 区域每个库都有一组自己的段。共享库的地址在每次运行时通常都不一样——这是 ASLR 的随机化成果。为了支持这种随机化现代系统几乎全部使用与位置无关的可执行代码PIE编译时加 -fPIE -pie 选项。如果你在部署时发现同样程序的崩溃地址每次都不一样不要惊讶ASLR 就是故意的。4. 栈和堆两条增长方向相反的动态地带可执行文件的静态映射只是地址空间的骨架真正的血与肉是运行时动态分配的堆和自动增长的栈。它们各自有边界和脾气越过边界的代价也完全不同。4.1 栈从高地址向低地址生长的临时账本函数调用发生在栈上每一次函数调用会压入一个栈帧里面放着局部变量、返回地址、以及一些保存的寄存器。栈指针 rsp 指向栈顶从高地址往低地址走。这是编译器约定出来的结构x86-64 下栈向下生长堆向上生长。栈的大小受限于内核配置——Linux 下可以用 ulimit -s 查看默认通常是 8MB。这不是硬件的只有 8MB 内存而是地址空间里给栈画的虚拟区域上限。一旦栈帧压入超过这个上限向低地址继续增长时会碰到一个不可映射的保护区访问它就会触发段错误——大部分无递归终止条件的死循环最后都是这种死法。我在实际开发中养成的习惯递归深度有上限的算法每一层调用栈里避免放大数组不要在函数里定义超大局部数组改用堆或静态区多线程程序中每个线程默认栈大小可能只有 2MB 到 8MB起线程时明确设置栈大小 pthread_attr_setstacksize能少踩很多雷。4.2 堆brk 与 mmap 两种分配路线堆是进程里专门用于动态内存分配的区域。malloc 的实现在 Linux 上有一个重要的分派逻辑比较大的分配通常超过 MMAP_THRESHOLD默认 128KB会直接使用 mmap 创建一块匿名映射跟堆的主区域分开较小的分配则通过扩展堆顶brk来提供。为什么这样分因为 mmap 分配的块在 free 时能立刻归还操作系统unmap细粒度地减少 RSS而用 brk 扩展的堆区释放后往往只是调整堆顶不能中间任意缩回。很多内存池方案就是看穿了这一点才会在大块分配上选择 mmap 路线追求拿到手能及时还回去。用 brk 扩展堆区在地址空间里的直观效果是边界program break可以用 sbrk 查看向上移动堆区和 mmap 区的距离越来越近直到相遇——如果相遇还继续分配malloc 就返回 NULL。你看到的内存不够了的报错往往不是物理内存耗尽而是堆的虚拟边界撞上了 mmap 区域的缓冲区。4.3 栈和堆之外的匿名映射无处不在的内存功臣除了传统的栈、堆现代进程里还会有很多匿名映射它们也挂在堆和栈之间的 mmap 区域。创建线程会分配线程栈本质是 mmap 出来的匿名映射glibc 的 malloc 大块分配是匿名映射共享内存是命名或匿名映射加载器甚至会用匿名映射来做各种运行时的数据保护区。匿名映射最直接的表现是你用 pmap 查看进程时那一大堆未关联文件的zero映射行。很多面试者会忽略这一点以为进程的内存布局只有代码、数据、栈、堆四格一看到 /proc/pid/maps 里几十个区间就懵了。真实世界的进程地址空间是接近上百段不同性质的映射组成的而不是四块整整齐齐的豆腐。5. 写时复制、共享库与 fork地址空间里的高级协同地址空间不仅服务于单进程的隔离还在多个进程之间承担协同任务。这里最关键的是共享库的全局共享机制以及 fork 时那套精妙的写时复制技术。5.1 多个进程如何共享同一份 libc你会发现机器上跑几十个进程但 pmap 里每个进程都映射了 libc.so物理内存并没有被乘以几十。原因是当多个进程映射同一个文件时内核会让它们指向相同的物理页框。这节省了海量内存也让共享库这种设计成为现代操作系统的基石。但是共享库的 .data 段可写数据怎么办段页表对每个进程独立如果某进程要修改自己的全局变量会让它走写时复制路径把共享物理页复制一份作为私有副本原物理页继续被其他进程共享。这样一来逻辑上是共享的、物理上是按需私有的得以同时成立。5.2 fork 的地址空间快照本质fork 创建一个子进程时传统理解是把父进程地址空间复制一份。但 Linux 实际做的是让子进程完全复用父进程的页表所有页面标记为只读并打上写时复制的标记。父子任何一方尝试写入就触发缺页异常内核复制物理页后标记为可写各自独立。这套机制有几个直接推论fork 非常廉价基本只有页表复制和任务结构体初始化不会复制全部物理内存谁先写入谁先复制不写入的页保持共享大量只有读操作的父子进程可以安全共存在极低的 RSS 水位下。我在设计多进程模型时经常刻意利用 COW 特性父进程初始化大块只读数据比如预加载的配置、词表fork 子进程后子进程只读不改内存占用几乎不会翻倍。但一旦子进程暴力修改这块内存RSS 就会暴涨。你可以在业务代码里用这个特性做性能优化也能用它来排查为什么 fork 后内存用量曲线异常陡峭。5.3 exec 之后的地址空间重置fork 之后通常要 exec 新程序。exec 会丢弃当前进程的大部分用户空间映射重新加载新程序。这也是为什么 fork 是廉价的、exec 是调换整个舞台的过程。你 fork 出来后再调用 exec 替换镜像父子进程从此各走各路。理解这点工作中处理为什么子进程的地址空间和父进程不一样这类问题就会很自然。6. 实战体检用 pmap 和 /proc 接口读出进程的底账知道了原理下一步就是把这些抽象概念落到工具上学会体检自己的进程。Linux 下最直接的两个入口/proc/ /maps 和 /proc/ /statm以及 pmap 命令。6.1 读懂 proc maps 的每一列以某个 PID 为例/proc/ /maps 的每一行长这样7f2b4a400000-7f2b4a401000 r--p 00000000 fd:01 123456 /usr/lib/x86_64-linux-gnu/libc.so.6拆开来看前两段虚拟地址区间的起止r--p权限位r 可读、w 可写、x 可执行、p 私有、s 共享00000000该映射相对于文件起始的偏移fd:01设备号123456文件 inode 号最后是文件路径名如果是匿名映射则不显示或者显示 [heap]、[stack] 这样的名字。我排查问题时第一件事就是看这个地图的分布是否异常。例如堆区 [heap] 区域特别大但同时物理内存不高说明可能是虚拟分配了但没实际写入如果堆区 mmap 区总和逼近地址空间允许值就要考虑是不是有大块内存泄漏。6.2 pmap 的统计视角与 RSS 判断pmap -x 是解读 maps 的友好封装会输出各映射项的 RSS、脏页数和共享/私有属性。我最常用的命令是 pmap -x p pid它能直接看到整个进程的虚拟空间总量Virtual Memory和常驻物理内存总量Resident Memory。实战判断技巧Virtual Memory 大不代表有问题。很多服务启动时预映射了大量虚拟区域比如 JVM 的堆、磁盘缓存映射等VMS 可以轻松突破几十 GB但 RSS 稳定。观察 RSS 和 Shared 列。如果匿名私有映射尤其 [anon] 或 [heap]的 RSS 持续单边上涨大概率是内存泄漏或者缓存未及时回收。对比 Dirty 列。私有脏页才是真正属于该进程、需要写回或保留的物理页脏页增长对内存水位的影响最直接。6.3 定位异常增长的一套排查流程在实际项目的内存异常排查中我通常按这个顺序走先用 top 或 htop 找到 RSS 占比异常升高的进程 PID用 pmap -x 查看各区域 RSS找出增长集中在 heap 还是 anon 区若是 heap 增长考虑是不是频繁 malloc/free造成堆碎片或未归还若是 anon 区增长检查是否有大量 mmap 的大块分配未释放再用 strace/perf 或应用层的内存剖析工具jemalloc heap profiling、gperftools定位调用点。这里有个案例可以分享。一次我排查一个 media server 的内存上涨pmap 显示大量匿名私有页占了接近 1.4GB但 heap 区本身很小。后来用 gdb 给 malloc 打断点发现是频繁创建线程导致的线程栈没有释放——线程退出后 glibc 未必立刻返回全部匿名内存堆积多了就成了看似泄漏其实没泄漏。这不是代码 bug而是地址空间碎片化现象。搞清楚这个机制能救你不少排查时间。7. 地址空间的扩展游戏部署配置与 ASLR 的影响地址空间布局不是铁板一块内核会留一些随机性和伸缩性。这也导致很多线上问题具有不可重现的迷惑性。这块内容不管做运维还是安全开发都绕不开。7.1 ASLR为什么每次启动崩溃调用栈都不同ASLR 的本质是在装载共享库、栈、mmap 基址等位置注入随机偏移让攻击者难以预测目标地址。这从防御角度非常有用但它也带来一个实际困扰依赖固定地址的调试工具、反作弊系统或性能分析器可能在每次运行之间看到不同的地址范围。调试时如果你需要可重复的地址布局可以在运行时关闭 ASLRecho 0 /proc/sys/kernel/randomize_va_space需要 root但生产环境千万别这么干。更推荐的做法是使用支持 PIE 调试的 GDB 插件或者用 /proc/ /maps 动态获取真实的基址写脚本按需匹配符号。7.2 共享库地址冲突与加载器重定位在没有 PIE 的年代共享库使用固定的加载地址多个程序引用同一个库时可能会发生地址冲突于是加载器要执行重定位修改代码里的绝对地址引用。PIE 出现后所有代码都编译成与位置无关的形式加载到哪个地址都能用地址相对寻址加载器的工作量和冲突概率大幅降低。这对开发者的实际意义是编译选项的选择直接影响了运行时的地址空间布局。老式非 PIE 程序可能固定把代码段放在 0x400000PIE 程序则可能在 0x555555554000 附近。看到 pmap 区间分布在低位地址基本可以判断出这是非 PIE 的老程序。7.3 资源限制与容器环境里的地址空间边界运行在高密度容器环境里进程的地址空间还会受到 cgroup 和 ulimit 的双重限制比如RLIMIT_AS 限制进程虚拟地址空间总量设得太小会导致 mmap 失败RLIMIT_STACK 限制栈大小cgroup memory.limit 限制物理内存和 swap 总量与虚拟地址空间不是一个维度。我在拥挤的微服务集群里见过一个案例服务本身内存使用不高但因为代码在启动时 mmap 了一块巨大的保留区而容器限制了虚拟地址空间总量直接启动失败。排查时看到 mmap 返回 ENOMEM一开始以为是物理内存不够后来用 ulimit -v 查看才发现是虚拟地址空间限制。最后谈一点我对地址空间的体感写到这里我更想把文章落回最初那个讲内存上涨的下午。当我真正理解进程地址空间是一个虚拟的舞台时很多原本让人抓狂的问题都变得可预测了。之前执着地找内存泄漏其实 STL 容器在释放后glibc 未必把内存归还内核而 mmap 分配的大块区域如果 munmap 到位内存立刻就能掉下去。你看着 RSS 曲线以为泄漏了可看地址空间的映射才发现不过是堆空闲内存滞留。还有一个小技巧是如果你手上有一个运行中的服务建议偶尔导出它的 /proc/ /maps 留档。等出了诡异的内存问题拿出这个快照对照一眼就能看出是哪个区间出现了异常扩张。这比你对着空白的监控图猜答案要靠谱得多。进程地址空间就是你程序的整个家。家里各种装修好家具摆放都很重要但你真正关心的应该是家里哪个角落的东西悄悄堆积起来了。会看地址空间你就能在它爆掉之前先伸手把那些多余的纸箱子搬走。
返回列表