ARTICLE DETAIL

资讯详情

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

Linux虚拟内存与进程地址空间:从页表映射到按需分配

Linux虚拟内存与进程地址空间:从页表映射到按需分配 我见过太多人栽在这上面写C的、写C的、甚至写Java的面试时被问“进程的地址空间到底有多大”“malloc之后内存真的分配了吗”一句话都答不上来。还有一次组里新来的同事盯着top里的VIRT值吓得不行说服务占了50GB内存我一看RES才几百MB他愣是不信。这背后的门道就是Linux的进程地址空间与虚拟内存机制。这篇文章我打算彻底讲透这件事从为什么要有虚拟内存到地址空间长什么样再到虚拟地址怎么翻译成物理地址、物理内存什么时候真正分配最后落到命令行实战和面试题。整个链路讲清楚后你会发现很多“诡异”的内存现象其实都能解释清楚。适合所有在Linux下做开发、运维以及准备面试的读者。1. 为什么Linux要把内存“虚拟化”1.1 没有虚拟内存的年代直接操作物理地址的灾难早期的操作系统远没有现在的保护能力。如果你写过一个简单的C程序指针里存的地址就是真实的物理内存地址。当时甚至有种玩法往0xB8000这个固定物理地址写数据可以直接改屏幕显示因为那是显卡的显存映射区。这种模式下后果是灾难性的多进程根本没法安全共存任何一个野指针都可能把另一个进程正在执行的代码写坏大家一起崩溃。程序加载进内存时还要求一大块连续空间内存碎片一多明明总内存够用但就是加载不了新程序。我举个例子方便理解物理内存就像一个巨大的集体宿舍每个床位都有固定编号物理地址。没有虚拟内存的年代每个程序必须真的搬进这个宿舍还得住在一整排相邻的床位。一旦宿舍里住了几个程序腾挪空间就很小新人很难找到一整排空位。而虚拟内存做的就是引入一层“门牌号映射”程序看到的门牌号虚拟地址是连续的、宽敞的但实际上床位是散的有的甚至根本还没安排。1.2 虚拟内存的三大价值隔离、连续、按需分配虚拟内存本质上是在进程和物理内存之间加了一个中间层这个中间层解决了三个核心问题。第一是隔离。每个进程都有自己独立的地址空间进程A里的0x1000000和进程B里的0x1000000是两码事经过页表翻译后指向完全不同的物理页。A进程怎么踩内存都影响不到B更碰不到内核的地址。这才是现代操作系统能同时跑几百个进程还不互相干扰的基础。第二是连续。进程的虚拟地址空间是一段从低到高的线性空间代码段、数据段、堆、栈一个接一个排列整整齐齐。而底层物理内存可以是一堆零散的页帧映射关系已经建好了程序根本不用感知这种凌乱。对程序员来说“内存是连续的一整块”这个幻觉是虚拟内存给我们的最大福利。第三是按需分配。这是很多人最容易忽略的一点虚拟地址空间和物理内存是分离的。程序可以申请巨大的虚拟地址空间比如1TB但物理内存可能一个页都没给它。只有代码执行到某个地址真正读写数据时内核才临时从物理内存中抠一个页出来填上。这种“拖延症”设计让进程的虚拟地址空间可以远超物理内存大小也让程序启动速度飞快——因为启动时根本不用把整个可执行文件读进内存。2. 进程地址空间到底长什么样2.1 从ELF可执行文件到内存布局每次启动一个程序内核做的第一件事是解析可执行文件Linux下是ELF格式然后按里面的段布局信息在进程的虚拟地址空间里“圈地”。ELF里最关键的是几个段代码段text、数据段data、BSS段bss。代码段存放指令权限是r-x可读可执行但不可写。数据段存放已初始化的全局变量和静态变量权限是rw-。BSS段有些特殊它存放未初始化的全局变量ELF文件本身并不为BSS段存储数据只是在文件头里登记了这段空间有多大。程序加载时内核会为BSS段映射一个全零的匿名页面这样那些“未初始化”的全局变量运行时自动就是0。这里有个关键点加载可执行文件不等于把文件内容全部读进内存。内核只是建立虚拟地址到文件页的映射关系而已。真正执行到某个代码页时才触发缺页中断从磁盘把那一块读进物理内存。你可以理解为“先画个饼饿了才真的去烙”这就是虚拟内存按需分配的经典应用。2.2 一张图看懂地址空间各个区域一个典型的Linux用户态进程地址空间从低地址到高地址大概是这样的高地址 ------------------------------------ | 内核空间用户态不可直接访问 | ------------------------------------ 0x00007fffffffffff用户空间顶点 | 栈区向下增长 | ------------------------------------ | 共享库 / mmap匿名映射区 | ------------------------------------ | 堆区向上增长 | ------------------------------------ | BSS段未初始化全局变量 | | 数据段已初始化全局变量 | | 代码段只读指令 | ------------------------------------ 低地址代码段在最下面往上依次是数据段、BSS段再往上就是堆区。堆区通过brk或mmap系统调用扩展向高地址方向增长。然后是mmap映射区共享库libc、ld.so等以及大块内存映射都在这块区域。栈区放在用户地址空间的最高处向下增长。再往上就是内核空间了用户态代码没有任何权限去访问。这里值得多解释一句的是栈。栈之所以放在高地址并向下增长跟堆正好形成“相向而行”的局面堆向上长栈往下长中间的mmap区域是活动区三层互不侵占把地址空间利用得很紧凑。但要注意每个线程其实都有自己的独立栈那些栈也都分布在mmap区域附近而不是在进程主栈的位置。栈的大小受RLIMIT_STACK限制默认8MB用ulimit -s可以看到改太大反而容易把mmap区域挤爆。2.3 64位系统的地址空间真的那么大吗很多资料会说“64位系统地址空间是2^64”这个说法在Linux上并不准确。目前x86-64处理器一般只实现48位虚拟地址新一代支持5级页表后是57位所以Linux用户空间实际可用范围是0x0000000000000000到0x00007fffffffffff总共128TB。内核空间在0xffff800000000000以上的高地址区域。中间从0x0000800000000000到0xffff800000000000是一大片巨大的空洞任何进程只要去访问这片“无人区”直接段错误。还有一个细节ASLR地址空间布局随机化。现代内核每次启动进程时都会随机调整栈、堆、mmap映射区的起始地址这样攻击者无法提前预测缓冲区溢出后要跳转的准确地址。但副作用是不同次运行同一个程序你看到的内存地址都不一样这给调试带来了一些麻烦。写代码时千万不要假设地址固定。2.4 堆和栈为什么会相向而行堆向上增长栈向下增长这个设计背后有实际考量。两个区域都是从可用区向外扩展如果都往一个方向长总有一方会很快撞到边界。相向而行可以最大化利用中间的自由区域同时堆和栈扩展时只需要改一下边界指针成本非常低。不过现实中现代glibc的malloc在分配超过128KB的大块内存时已经不再用堆区了而是直接调用mmap创建一块匿名映射。所以malloc一个100MB的内存块你大概率会在/proc/pid/maps里看到一个独立的映射区域而不是在堆区里。这也是为什么很多人的地址空间图上堆区看起来特别小但实际虚拟内存很大。3. 从虚拟地址到物理地址的翻译过程3.1 页表一张决定生死存亡的映射表虚拟内存这套机制能跑起来靠的核心数据结构是页表。物理内存和虚拟内存都被切成固定大小的页典型是4KB虚拟地址的高位是虚拟页号低位是页内偏移。页表保存着“虚拟页号到物理页帧号”的映射每个页表项PTE除了物理页帧号还有一堆标志位存在位、读写权限位、用户态可访问位、脏位、访问位等。翻译过程说起来并不复杂CPU拿到一个虚拟地址后先提取虚拟页号和页内偏移然后去查页表找到对应的物理页帧号最后把物理页帧号加上页内偏移就得到物理地址。因为页大小固定是4KB低12位2^12 4096是不需要翻译的直接拼接过去就行。我打个比方页表就像一本“门牌号对照册”进程以为自己在访问地址0x7f001000内核借助这本册子一看哦实际床位是第1024号页帧然后把访存请求转过去。整个过程对用户程序完全透明程序永远不知道自己的数据真实住哪。3.2 多级页表到底省了多少内存如果只用一张庞大的扁平页表问题马上就来了。以32位系统为例4GB地址空间分成4KB页有2^20个页表项每项4字节一张表就要4MB。每个进程都要一张独立的页表100个进程就是400MB这还没算物理内存本身的开销。而且一个刚启动的进程可能就用了几百KB地址空间却不得不为整张4MB页表买单浪费极其严重。Linux的做法是引入多级页表典型的是4级分页PML4、PDPT、PD、PT每级索引9位页内偏移12位总共是99991248位。每个层级正好512项每项8字节一张表正好是一个4KB页面。多级页表的核心优势是只在有映射的地址区域才创建下一级页表。空的内存区域顶多在PML4层占一个无效项不需要一路向底层插入。这样算下来一个只用了几百MB地址空间的进程页表总开销可能只有几十KB跟单级页表的4MB形成鲜明对比。这也是为什么现代CPU和操作系统能支撑那么多进程同时运行的重要原因之一。3.3 MMU与TLB硬件加速的两板斧页表查询不能完全靠软件否则每次访问内存都要走好几层页表性能早就崩了。现代CPU里有一个叫MMU内存管理单元的硬件模块专门负责地址翻译。MMU的工作流程是收到虚拟地址去查页表取出物理地址然后继续访问物理内存。但MMU查页表还是得读内存多级页表还要读几次内存代价依然很高。所以CPU里又加了一个高速缓存叫TLBTranslation Lookaside Buffer专门缓存“虚拟页号到物理页帧号”的最近映射关系。打个比方TLB是MMU的“通迅录快捷键”上一次查过10号虚拟页对应500号物理页这个对应关系先记在通迅录里下次再访问10号页时不用翻页表了直接命中。TLB命中率对性能影响巨大特别是追求低延迟的高并发服务。历史上每次进程切换都要清空TLB因为不同进程的地址映射不同老条目留着也没用但这导致进程切换后的短时间内TLB命中率很低。后来的CPU引入PCID进程上下文标识符让不同进程的TLB条目可以共存配合软件指定的非全局页极大优化了进程切换时的TLB表现。4. 内存分配的“拖延症”按需分配与缺页中断4.1 malloc之后内存真的分配了吗这是最常见的误解。程序调用malloc申请一块100MB的内存绝大多数情况下内核并不会立刻分配100MB物理内存它只是把一段虚拟地址空间标记为“可用”甚至页表项都懒得建全。等到程序真的往这块内存里写数据时CPU发现页表项是空的或无效的触发缺页异常内核才在此刻分配一个物理页把页表项补上。所以malloc成功返回仅仅代表虚拟地址空间申请成功和物理内存是两回事。用top看进程状态VIRT列是虚拟内存大小RES列才是实际占用的物理内存大小。你写一个malloc(2GB)但不碰它的进程VIRT可能显示2GBRES可能只有几MB。很多人第一次看到这个现象都以为是bug其实这是虚拟内存机制的正常表现。我还记得之前帮人排查过一个Java服务JVM启动参数里-Xmx设了8GBtop里VIRT一度冲到12GB但RES只有1GB多。那就是JVM申请了大量的虚拟地址空间但真正热数据很少完全依赖物理内存的按需分配。这种情况一点都不用慌可一旦RES真的逼近物理内存上限那就真要警惕了。4.2 缺页异常minor fault与major fault缺页异常按严重程度分几类搞懂它们对排查性能问题很有帮助。minor fault轻微缺页页表项不存在但物理页其实已经存在了。最典型的是加载共享库第一个进程把libc.so的页面读进物理内存第二个进程启动时这些页面已经在物理内存里了只需要在页表里补一条映射不用去磁盘读。这种缺页很快对性能影响很小。major fault严重缺页物理页既不存在数据也不在内存里必须从磁盘的Swap分区或文件里读进来。这涉及一次真正的磁盘IO代价非常高堪称性能杀手。如果系统频繁出现major fault说明物理内存严重不足或者程序在频繁访问被换出的内存页整体性能会断崖式下跌。在命令行里可以用ps -o minflt,majflt,cmd -p PID查看进程的缺页统计。也可以用time -v ./你的程序运行命令结束后能看到Minor和Major缺页次数。如果你发现某个程序major fault特别多先怀疑内存不足或者文件映射的随机读取别急着加并发。4.3 写时复制fork为什么这么快Linux的fork()系统调用以快著称一个几百MB的进程fork出一个子进程耗时可能只有几毫秒。原理是fork时并不会复制父进程的物理内存而是复制一份页表同时把所有私有页面标记为只读并打上COWCopy-On-Write写时复制标记。父子进程此刻共享同一批物理页谁都没有真正拥有它们。等到父进程或子进程想要写某个共享页时CPU发现这个页是只读的触发缺页异常内核在异常处理里检查发现这其实是COW页于是分配一个新的物理页把原页内容复制过去更新页表指向新页并恢复可写状态。整个过程对其他线程不可见写哪个页就复制哪个页没写的页就一直共享。COW机制不只是fork在用文件映射、一些安全加固方案也用到类似思路。它付出的代价是额外的缺页异常和潜在复制开销所以如果你发现fork后父子进程大量写入内存系统的缺页数会明显上升。这也是为什么有人fork后马上exec新程序反而比直接复制内存省事得多。5. 命令行实操把虚拟内存机制“看”出来5.1 每个进程的地址空间地图/proc/pid/maps纸上谈兵没意思Linux给了我们一个观察虚拟地址空间的绝佳窗口/proc/pid/maps。随便找个进程看一下$ cat /proc/self/maps 55e6f8b69000-55e6f8b6a000 r--p 00000000 08:01 2134216 /usr/bin/cat 55e6f8b6a000-55e6f8b75000 r-xp 00001000 08:01 2134216 /usr/bin/cat 55e6f8b75000-55e6f8b77000 r--p 0000c000 08:01 2134216 /usr/bin/cat 55e6f8b77000-55e6f8b78000 rw-p 0000e000 08:01 2134216 /usr/bin/cat 7f0f19d41000-7f0f19d6a000 r--p 00000000 08:01 2634819 /usr/lib/x86_64-linux-gnu/libc.so.6 7f0f19d6a000-7f0f19d76000 r-xp 00029000 08:01 2634819 /usr/lib/x86_64-linux-gnu/libc.so.6 7f0f19d76000-7f0f19d7a000 r--p 00035000 08:01 2634819 /usr/lib/x86_64-linux-gnu/libc.so.6 7f0f19d7a000-7f0f19d7c000 r--p 00039000 08:01 2634819 /usr/lib/x86_64-linux-gnu/libc.so.6 7f0f19d7c000-7f0f19d7f000 rw-p 0003b000 08:01 2634819 /usr/lib/x86_64-linux-gnu/libc.so.6 7fff98463000-7fff98485000 rw-p 00000000 00:00 0 [stack]每行的格式是起始地址-结束地址权限位文件偏移设备号inode映射对象。权限位最后一列不是单纯的r/w/x还附加了p或sp代表私有映射s代表共享映射。排错时这个文件很有用。程序崩溃时coredump日志里可能会给出类似“Address 0x7f0f19d6a123 is at offset 0x123 in mapping libc.so.6”的提示这时你可以去maps里找这个地址落在哪个区域如果落在[heap]里说明堆地址被写坏了如果落在某个.so的映射里说明问题大概率跟该库的数据有关。这是追查崩溃的经典手段。5.2 看物理内存占用更准确/proc/pid/smapsmaps只告诉你有这块区域但每块区域真实占了多少物理内存得看smaps。这个文件比maps详细得多每个映射区域下面附着一堆统计字段Rss该区域占用的物理内存总大小包括私有和共享部分Pss把共享页按引用进程数平均分摊后的物理内存比Rss更真实Private_Clean私有且未修改过的页Private_Dirty私有且已修改过的页这部分才是“只属于这个进程且写脏了”的内存Shared_Dirty共享且被修改过的页比如libc在多个进程间共享但被写过的部分我排查内存泄漏时第一眼看的是所有映射的Private_Dirty总和。如果这个数值随着服务运行不断上涨且从不下降那才是真正泄漏的迹象。只看Rss有一个致命问题把共享库的内存重复计算了。比如libc.so占10MB200个进程引用它每个进程的Rss里都算一遍看起来每进程多10MB但物理内存里只有一份。5.3 组合工具pmap、top、free怎么配合命令行排查内存问题我常用的组合是这三样pmap -x PID输出和maps/smaps类似但可读性更好一眼能看到某个进程底下哪个映射占了大头。比如发现一个进程虚拟内存2GB用pmap一查发现是某个线程栈或者mmap的匿名块占了1.8GB排查方向马上就明确了。top里主要关注VIRT、RES、SHR三列。VIRT是虚拟内存RES是物理内存SHR是共享内存部分。命令行模式下按E可以切换内存显示单位。很多新手一看VIRT那么高就紧张记住一句口诀VIRT高不一定是坏事RES高才是真占了物理内存。free -h看的是系统全局内存。真正现代的查看方式是看available列它代表“在不触发明显swap的情况下还能给新进程分配多少内存”。buff/cache里的page cache是可以被内核回收的所以虽然used可能很高但available依然健康。我有个经验之谈排查线上问题不要只看某一个指标要组合看。比如进程RES在涨同时free的available在跌这是内存压力的信号如果进程VIRT在涨而RES稳定多半是逻辑问题不是真正的物理内存耗尽。5.4 实战案例一个服务虚拟内存飙高到50GB一次线上排查某服务top里VIRT显示50GB但RES只有500MB运维紧张得不行以为内存泄漏。我上去一看这个服务起了300个线程每个线程默认栈大小8MB仅线程栈就是300乘以8MB等于2.4GB的虚拟内存再加上一个对象池预分配了40GB的虚拟地址空间但几乎没写入。两个因素叠加VIRT瞬间冲到50GB不稀奇。这种情况需要处理吗如果物理内存充足、RES稳定可以先不管但要留意是否触发了ulimit -v限制。有些环境为了防失控给进程设置了虚拟内存上限一旦进程请求的虚拟地址空间超过这个上限malloc会直接返回失败程序可能崩溃。真到这一步你就得优化线程栈大小或者精简预分配逻辑了。另一个容易踩的坑是32位进程。32位进程用户空间只有3GB虚拟地址不是无限的。如果程序预分配大块虚拟内存哪怕不写地址空间也可能耗尽malloc返回NULL程序挂掉。这在64位时代听起来很荒谬但如果你维护的是一些老旧的嵌入式系统这个坑依然存在。6. 经典面试题背后的底层逻辑6.1 为什么空指针解引用一定是段错误C/C程序员都知道空指针不能碰但要说清楚背后的机制很多人讲不透。虚拟地址0到0xfff这一段是保留区Linux默认不会为任何进程映射这页。当你解引用NULL指针时CPU拿着地址0去查页表发现页表项不存在立刻触发缺页异常。内核在缺页处理中检查这个地址是不是合法映射发现0地址根本不属于任何映射区域于是给进程发送SIGSEGV信号默认动作就是杀死进程。所以空指针崩溃的本质是“访问了一个没有映射关系的虚拟地址”而不是“访问了物理内存的0号位置”。这也能解释为什么用mmap可以把0地址映射上从而“绕过”空指针崩溃——有些老式的安全机制或者特殊场景会这么干但正常应用不应该做这种事。6.2 64位下虚拟地址空间上限是多少面试官爱问“64位系统进程地址空间多大”直接回答2^64是不够准确的。Linux x86-64下用户空间通常是128TB47位地址空间内核空间在最高的128TB区域。中间的广袤空洞是未映射区访问就会段错误。如果开启了5级页表57位用户空间能扩展到64PB但至今不是所有服务器默认开启。这个问题的意义在于理解虚拟地址不是无限的虽然128TB听起来很大但某些场景下依然会撞墙。比如极端多线程、极端大内存映射以及前面提到的32位兼容模式虚拟地址耗尽完全可能。程序员如果能明白用户空间和内核空间的界限很多内存相关的诡异问题都能从根子上想通。6.3 malloc成功为什么还会被OOM杀掉最后一个高频面试题是关于OOM内存不足的明明malloc成功了系统为什么还会杀进程这要从Linux的overcommit机制说起。Linux默认允许进程申请超过物理内存加Swap总和的虚拟内存也就是“超卖”。malloc成功只代表内核在账面上答应给你这些虚拟内存真正的物理内存是在你访问时按需分配的。如果多个进程都这么干实际运行到一定程度物理内存真的不够了内核会启动OOM Killer根据评分挑一个或几个进程杀掉释放内存。这解释了为什么malloc、new、甚至Java的new对象都成功了进程却可能在一瞬间被系统杀掉。判断标准很简单凡是RES不断上涨直到物理内存见底的才有OOM风险只是VIRT大通常不会触发OOM。最后再分享一个我自己的习惯每次在Linux上排查内存问题我会先做一个几十MB的malloc测试程序写一下再睡眠通过/proc/pid/status里的VmRSS变化直观感受虚拟内存和物理内存的差别。这个习惯帮我带新人的时候少费了无数口舌也建议你自己动手试一次比看十篇文章都管用。
返回列表