
有个同事前几天跑过来说一个跑在服务器上的进程突然报内存分配失败可我看机器内存明明很充裕top 一看还有好几十G空闲。后来查下去发现他自己没注意进程是32位编译的虚拟地址空间被撑爆了。类似的场景我相信很多人遇到过——你对Linux虚拟地址空间的理解决定了排查内存问题时能不能一眼看到真相。这篇文章我来好好聊聊Linux虚拟地址空间这回事。不只讲概念还会带你看实际进程的内存布局、讲清楚MMU和页表的协作过程再给出一套我自己排查内存问题时的常用路数。搞开发、做运维、或者在准备Linux面试的朋友都可以看把这块补扎实了很多“玄学内存问题”就不再玄学了。1. 虚拟地址空间到底是什么——先搞懂它解决的核心痛点1.1 一个很直观的类比酒店房间号和真实房间我在给别人讲虚拟地址空间的时候最喜欢用的类比是酒店。你去住酒店前台给你一张房卡房卡上写着“8216房”你按照走廊里的指引牌走到8216房刷卡进去住。你全程不需要知道这个房间在整栋楼的哪个角落、隔壁是谁、楼下是什么你只需要保证“8216”这个房号在当前酒店是唯一的就行了。进程和物理内存的关系就是这样。每个进程手里都有一张自己的“房卡”也就是虚拟地址空间。它以为自己从地址0x400000开始有一整段连续的大内存实际上这段地址可能映射到了物理内存的各个零散角落有些甚至根本不在内存里而是在磁盘的交换分区上。但因为有了虚拟地址空间这层“房卡机制”进程根本不需要关心物理内存到底长什么样。如果没有虚拟地址空间会怎样那进程就要直接操办物理内存的每一块地盘。比如两个进程都把自己编译到0x1000这个地址一运行就会互相踩踏程序里写错了指针可能一不小心就覆盖了别人的内存物理内存碎片化严重时明明总内存够多但找不到一块足够大的连续区域给程序用。这一堆问题靠虚拟地址空间基本都能化解。1.2 进程的“我在用内存”和物理内存的真实占用虚拟地址空间其实是操作系统的“谎言”每个进程都以为自己在独占整台机器的内存。你在Linux里写一个简单的C程序哪怕什么都不干它的虚拟地址空间也有几十个映射区域。默认情况下一个64位进程的虚拟地址空间会有128TB那么大但真正映射到物理内存的、可能就那几MB的代码和数据。关键点在于“映射”这两个字。虚拟地址空间里铺满了各种映射区域但只有你真正去读写那些地址时物理内存才会实际分配。这就像你在酒店拿了一堆房卡但只有走到那个房间刷开门那个房间才真正“属于你”。这个按需分配的机制让大量进程可以共享同一台机器的物理内存而不会互相挤爆。当然虚拟地址空间也不是无限资源。32位系统下整个地址空间只有4GB一个进程最多用3GB用户空间。64位下理论空间很大但依然有边界而且地址空间本身还会受进程限制、文件大小限制等因素约束。这个我后面细讲。1.3 说点理论门槛地址空间和物理内存是两个维度很多人刚开始接触这块时喜欢把虚拟地址空间和物理内存混在一起看这就会造成误解。比如看到程序占用虚拟内存很高就以为物理内存快爆了或者看到物理内存还剩很多就觉得程序不可能申请不到内存。实际上它们之间是“映射”关系不是“等于”关系。我用一个更贴近实际的方式来说。你在Linux里用free -m看内存会看到total、used、free这些指标但你看不到每个进程的地址空间有多大、有多少映射是文件映射、有多少是匿名映射、有多少页被换出。想看这些得去/proc/pid/maps、/proc/pid/status或者用pmap命令。地址空间是一个进程“能看到的世界”物理内存是整台机器“真实的家底”两者之间的桥梁是页表这是后面要重点展开的内容。2. 32位与64位地址空间布局到底差在哪2.1 经典3G/1G布局为什么不够用了老一代搞Linux的人对“3G/1G”这个词肯定不陌生。32位系统地址空间总共4GBLinux沿用了常规做法用户态进程使用较低的3GB0x00000000到0xBFFFFFFF内核使用最高的1GB0xC0000000到0xFFFFFFFF。用户进程即使物理内存再大自己能用的一般也就不到3GB剩下的还得给堆、栈、动态库预留各种空隙。这带来一个很实际的问题你的机器哪怕装了8GB内存一个32位进程能用的虚拟地址空间上限也就3GB左右超过这个数malloc就直接返回NULL或者mmap直接失败。这个限制是体系结构级的用户态程序很难绕过去。我见过不少老程序就是因为这个原因迁移到64位后内存问题一下子消失了问题的本质根本不是物理内存不够而是虚拟地址空间被“封顶”了。3/1G布局还有一个麻烦就是物理内存超过1GB之后内核那1GB地址空间没法直接映射全部物理内存于是搞出个HIGHMEM高端内存机制给内核访问高地址物理内存开了个后门。这个机制很复杂性能也有损耗。这也是为什么服务器领域很早就抛弃32位内核全面转向64位。2.2 x86-64的划分用户态和内核态各管一边到了64位时代地址空间宽敞得多。以x86-64为例当前Linux使用的其实只有48位有效虚拟地址。用户空间从0x0000000000000000到0x00007fffffffffff一共128TB内核空间从0xffff800000000000往上也是128TB。中间从0x0000800000000000到0xffff7fffffffffff是一大段非规范地址区普通程序碰到这段地址基本就是非法访问。64位下用户空间有128TB对绝大多数程序来说虚拟地址空间不再是瓶颈了。但这不代表你可以乱来。如果程序里写了个死循环不断申请内存但不释放虚拟地址空间照样会被耗尽而且因为空间大这种问题更容易隐藏等到爆发时往往很难收拾。内核地址空间放在最高地址段用户进程在用户态时根本访问不到那块区域。这种设计保证了用户程序即使恶意或者写错了指针也不会直接篡改内核内存。这里顺便说一句很多人在学内核模块、学提权的时候真正要动脑筋的地方就是如何在这套地址隔离机制下找到可乘之机。2.3 一个64位进程从上到下都有什么在64位Linux上一个正常启动的进程地址空间从低到高大体是这样的代码段text程序机器码只读可执行一般从0x400000开始数据段data已初始化的全局变量、静态变量BSS段bss未初始化的全局变量不占磁盘但占内存堆heap动态分配的内存向高地址增长共享库映射区域libc等动态库映射到的一块随机地址范围栈stack局部变量、函数调用帧从高地址向低地址增长vDSO和vsyscall内核提供的一些快速系统调用跳板也在高地址处内核空间用户进程只能看到地址范围不能访问这些区域不是连续铺开的中间会有很多空白区也就是未映射区域。这些空白和随机化的偏移一部分是安全考虑ASLR一部分是为了让越界访问更容易触发段错误而不是悄悄写坏别的数据。3. 从用户视角看真实内存布局——自己动手查3.1 读懂/proc/ /maps一页纸看穿进程内存地图如果你想知道某个进程的虚拟地址空间长什么样不需要写代码也不需要gdb直接看/proc/pid/maps就行。我先拿一个正在运行的bash举个例子cat /proc/$$/maps输出是一行一行“起始地址-结束地址 权限 偏移 设备 inode 路径”。其中权限部分有r、w、x、p/s这几个位p表示私有映射s表示共享映射。这一行行的映射拼起来就是一个进程的完整虚拟地址空间地图。读懂了它你就知道进程的堆在哪、栈在哪、libc被加载到哪个地址、有没有映射奇怪的文件。注意maps里的地址是虚拟地址不是物理地址。你在这个文件里看到7f...这种高位地址就是动态库和堆栈常见的分布范围。很多内存排查工作第一步就是通过maps来确认问题进程的地址空间被什么给吃掉了。3.2 pmap命令更适合人看/proc/pid/maps的信息量很大但不够直观。我更喜欢先用pmap命令。pmap -x pid会显示每个映射区域的起始地址、大小、RSS、脏页数量以及映射内容。RSS表示这个映射实际占用了多少物理内存这个比虚拟大小有意义得多。pmap -x 12345输出里能看到每个库占了多少RSS堆占了多少RSS栈占了多少RSS。这比单纯看ps aux里的%MEM要精细得多。我排查内存泄漏的时候第一眼就会看这里的RSS变化如果某个映射区域RSS持续增长且不回落基本上就是泄漏点。还可以用gdb的info proc mappings来查看效果类似。不过日常命令行快速查看pmap足够了。3.3 实验malloc之后maps里到底发生了什么为了更直观理解“虚拟地址空间会随分配变化”我写一段极简的C代码来演示#include stdio.h #include stdlib.h #include unistd.h int main() { printf(PID%d\n, getpid()); getchar(); char *p malloc(64 * 1024 * 1024); printf(malloc 64MB done, address%p\n, p); getchar(); for (int i 0; i 64 * 1024 * 1024; i 4096) { p[i] 1; } printf(touch all pages done\n); getchar(); return 0; }编译运行后第一次按回车前后观察/proc/pid/maps和/proc/pid/status里的VmSize和VmRSS。你很快会发现malloc(64MB)之后地址空间VmSize涨了但物理内存VmRSS没怎么动只有当程序真的去逐页写入时VmRSS才会涨上去。这就是前面讲的按需分页。这段小实验建议你自己跑一遍。它会帮你建立起“虚拟地址申请”和“物理内存实际占用”是两件事的直觉。很多人问为什么程序内存占用那么高问的就是VmRSS而报错说“内存不够”有时候却是VmSize顶到了上限两者完全不同。4. 地址怎么映射MMU、页表与缺页中断的协作4.1 一次完整的内存访问CPU到底经历了什么好奇的同学会问进程访问一个虚拟地址比如0x7ffd12345678计算机是怎么知道对应哪个物理内存单元的这里面的核心角色是MMU内存管理单元和页表。流程大致是CPU执行指令发现要访问一个虚拟地址于是把这个虚拟地址发给MMU。MMU先查TLB快表TLB是页表项的高速缓存命中的话直接在硬件层面翻译成物理地址访问内存。TLB未命中MMU就得去内存里查多级页表找到对应的物理页帧号完成翻译同时把结果填进TLB方便下次快速命中。如果页表里根本没有这个虚拟地址对应的映射或者权限不对就会触发缺页异常或保护异常操作系统接管决定是给这个进程分配一个物理页、从磁盘加载文件页、还是直接判定程序违规并杀掉。整个过程高速且低调你很难直接感知到但它每时每刻都在发生。4.2 多级页表为什么能省这么多内存页表如果只做一层一个48位地址空间、4KB页面需要的页表项数量会是天文数字。所以x86-64用的是四层页表从PGD、PUD、PMD到PTE每一级只索引一部分地址位。这种层级结构最大的好处是稀疏进程的地址空间哪怕再大只有真正用到的那些区域才需要分配页表页。初次接触多级页表的人总觉得这岂不是每次访问内存都要查四五次表其实TLB就是干这个的命中率通常高达99%以上。再加上CPU的硬件预取机制多级页表带来的性能代价大部分都被掩盖掉了。4.3 缺页中断不只是“内存不够了”缺页中断听起来像错误其实它是Linux内存管理里最忙碌的正常流程。一个新进程启动它的代码段并没有全部加载进物理内存是执行到哪一页才通过缺页中断把对应页从磁盘加载进来。一个文件刚刚被mmap你没有访问它时它可以在磁盘上待着只有读它时才一页页进内存。这是读写文件高性能的重要支撑。缺页还分好几种匿名页缺页内核直接从伙伴系统分配物理页并清零文件映射缺页内核把对应文件内容读进页缓存写时复制缺页比如fork之后父子进程共享同一个只读物理页一旦有人要写就会触发缺页复制出一份实现内存隔离。理解了缺页机制你再去看那些“运行得很慢但CPU占用不高”的程序思路会清晰很多。5. 开发中最常遇到的内存问题和排查办法5.1 程序报错内存不够但这台机器明明还有空闲内存这种场景很多人遇到过。程序执行malloc或mmap失败返回NULL但用free一查物理内存还有大把。原因往往是这几个方向第一进程的虚拟地址空间用完了比如32位进程第二进程的RLIMIT_AS被设了限制你可以用ulimit -v看一下如果输出不是unlimited虚拟内存上限就被限制了第三系统启用了vm.overcommit_memory2这种严格模式物理内存加上交换空间不足以覆盖所有进程承诺的虚拟内存时分配也会失败。排查这类问题不要停在“内存是否够”的层面要多问几个为什么。先用cat /proc/pid/status看VmSize和VmRSS再看ulimit -a然后看/proc/meminfo里的CommitLimit和Committed_AS。大多数“还有内存却分配失败”的问题都能在这里找到答案。5.2 段错误不一定是空指针也可能是访问了“不属于你的地址”段错误segmentation fault最常见的直接原因是指针访问了没有映射的虚拟地址或者映射了但权限不够。它不一定是解引用了NULL也可能是栈溢出碰到未映射的守卫区或者写了只读的代码段或者用越界指针访问了映射区域之间的空洞。用gdb调试时你可以在崩溃点用info registers看指令地址和访问地址再对照info proc mappings看它落到了哪个区间。如果落在未映射区域基本就是野指针或者越界访问如果落在只读区域就是写入了不该写的地方。通过这种方式定位段错误比单纯看报错日志高效得多。5.3 排查内存问题要用到的小工具集我日常排查内存问题会交替使用这几样strace追踪系统调用看mmap、brk是否有异常pmap看RSS分布/proc/pid/status看整体指标gdb看调用栈和变量valgrind查内存泄漏和越界访问。内存泄漏类问题用valgrind最稳但性能开销极大不适合生产环境长时间跑生产环境更适合用pmap持续采集RSS做趋势分析。这里分享一个我自己用得比较多的排查手法连续采集某个进程的/proc/pid/maps和/proc/pid/status每三秒一次观察哪个区域的总大小在持续增长。如果堆在涨说明是malloc没有配对free如果某个共享库映射区域在涨可能动态加载了什么东西如果栈在涨要小心是不是递归没有终结条件。这比凭空猜测高效得多。5.4 面试中常见的几个坑面试时聊到虚拟地址空间考察点通常集中在用户态和内核态的划分、虚拟地址和物理地址的映射关系、页表结构、缺页中断、以及为什么64位下虚拟地址空间依然可能成为瓶颈。有几个容易踩的坑我提一下不要把free的空闲内存理解成“一定可以给进程用”因为还有个overcommit策略在把关也不要把程序的RSS理解成进程占用的全部内存因为共享库和共享内存可能有多个进程共摊更不要以为页表可以无限膨胀一个进程如果映射了大量区域页表本身也会吃掉不少物理内存。5.5 生产环境里的“虚拟内存大但RSS小”正常吗经常有人问为什么我的JVM或Nginx显示虚拟内存占用好几十GB但RSS只有几百MB是不是内存泄漏大多时候不是。这部分差异主要来自两类映射一类是mmap了文件但不全部访问另一类是内存预分配但没触碰。比如JVM用-Xmx配置堆大小经常预留大块地址空间但只提交部分物理页。这种情况应该看RSS趋势而不是死盯虚拟内存大小。但也不要放松警惕。有些程序确实会不断增长虚拟地址空间且不回收比如反复mmap不munmap。这种问题在64位系统上更容易积累因为地址空间很大一时半会不会崩溃等到真的没有地址可映射时游戏就结束了。生产环境还是要建立监控不只盯物理内存也要盯虚拟地址空间的增长速度。6. 常见问题速查表与实操心得问题现象可能原因排查思路进程malloc返回NULL物理内存还有空余32位进程地址空间耗尽或ulimit -v限制或overcommit策略过严查看/proc/pid/status的VmSize执行ulimit -v检查/proc/meminfo的CommitLimit程序崩溃gdb显示访问0xffff...地址访问了未映射的内核地址段或非规范地址结合info proc mappings确认访问区域范围检查野指针和栈溢出VmSize持续暴涨但RSS不怎么变大量mmap映射未释放或预留大块虚拟内存批量采样/proc/pid/maps找出持续新增的映射区域在代码里定位对应操作RSS持续增长内存最终耗尽堆内存泄漏或缓存/文件映射异常增长用pmap -x看RSS来源用valgrind辅助定位泄漏点栈溢出后程序并无明显崩溃点递归过深、局部数组过大访问落到未映射的栈守卫区gdb查看调用栈关注info proc mappings中栈段地址附近的保护页多进程共享内存后RSS偏高共享内存映射在多个进程的页表中都有映射用pmap观察共享库和共享匿名映射的实际RSS确认是否异常增长这里再分享几条实操心得。第一给进程配置内存时先确认它跑在32位还是64位环境。很多老程序迁移到64位后把编译参数带上-m64整个内存问题就豁然开朗。第二监控进程内存时建议同时记录VmSize、VmRSS和RSS从smaps里累加算出更精确的值。只看其中一个指标很容易被误导。地址空间和物理内存是两码事谁先耗尽都会要命。第三刚学虚拟地址空间时一定要亲手跑一遍malloc实验观察maps和status的变化。有了这个直观认知后面所有关于内存隔离、按需分配、缺页中断的讨论你才能接得住。虚拟地址空间这块内容理解到一定程度之后你再看Linux内核、KVM虚拟化、容器内存隔离这些方向都会顺很多。它不是单独的一个知识点而是把进程管理、内存管理、文件系统、设备驱动串起来的一条暗线。反复读这两三遍再结合实际排查几次内存问题算是彻底入门了。