首页 / 资讯中心 / 文章详情

Linux进程与虚拟内存的关系:从原理到排查实践

Linux进程与虚拟内存的关系:从原理到排查实践 ★ FEATURED ARTICLE
【Linux基础】操作系统下的进程与虚拟内存的关系经常有人问我同样是写着“内存占用500MB”为什么有的机器卡成狗有的机器稳如老狗或者在排查问题时看到进程的虚拟内存高达好几个GB而实际物理内存才几百MB就一脸迷茫地来求助。这背后的关键就是Linux的进程与虚拟内存之间的关系。搞懂这一层关系很多“莫名其妙”的问题都会变得顺理成章。这篇内容不是什么高深的内核源码分析而是从一个长期在Linux环境下折腾的开发者的视角把进程、虚拟内存、物理内存这三者之间是怎么协同工作的它们之间到底是什么逻辑关系掰开了讲清楚。内容适合刚接触Linux的初学者也适合那些已经用了一段时间Linux、但在内存相关问题上老觉得隔了一层窗户纸的开发者。在动手写代码、处理线上问题之前先把内存这层窗户纸捅破之后你会发现像是top、free、pmap这些命令的输出不再是冰冷数字而是每个数字背后都有明确的含义和来龙去脉。1. 先搞清楚一个认知进程看到的“内存”其实是假象1.1 物理内存是公共资源进程凭什么独享地址空间每台机器都有物理内存比如8GB、16GB。但如果你在一个进程里声明了一个指针对它做算术运算这个指针的值就是所谓的“地址”。问题是这个地址是物理地址吗答案是绝大多数情况下不是。操作系统给每个进程安排了一套独立的地址空间这个地址空间不是物理内存的真实地址而是一套“虚拟地址”。进程在运行过程中访问的所有内存地址都是虚拟地址。内核通过一套地址转换机制把这些虚拟地址翻译成真实的物理地址再去物理内存上读写数据。每个进程都以为自己在独占整台机器的内存空间实际上它们都在共享同一块物理内存只是每个进程看不到别人的地址空间而已。这个设计是革命性的它让进程之间的隔离成为可能。A进程错误地写了一个非法地址影响的只是A进程自己的空间不会弄坏B进程的数据。同时每个进程的地程空间可以比物理内存大得多因为并不是所有虚拟地址都在物理内存里放着只有真正被访问到的部分才会被映射到物理内存。理解了这一点你再看top里面那个VIRT列就不会觉得奇怪了。VIRT列显示的进程虚拟内存大小代表这个进程地址空间的全貌而不是它实际占用的物理内存。两者差别很大这也是很多初学者最容易困惑的地方。1.2 虚拟空间的地址转换MMU和页表完成了什么虚拟地址到物理地址的转换靠的是硬件MMUMemory Management Unit内存管理单元和内核维护的页表。页表本身是一棵多级索引树内核为每个进程维护一套完整的页表记录着每个虚拟页映射到哪个物理页、权限是什么、是否在物理内存里。当你访问一个虚拟地址时MMU会去查页表。如果发现这个虚拟页还没有对应的物理页就会触发缺页异常然后内核介入处理可能是分配一个新物理页可能是从磁盘换入数据也可能是直接报错比如访问了非法地址触发段错误。整个流程对进程完全是透明的进程感知不到页表的存在它只知道自己访问了一个地址结果可能正常返回数据也可能直接崩溃。用生活类比的话物理内存就像酒店的实体房间虚拟地址空间就像每个客人手里的房卡号。客人刷卡访问虚拟地址前台MMU页表查一下这张卡对应哪个实体房间然后引导客人过去。客人之间互相不知道对方的房号也不知道这个房间是不是真的被另一个客人占用了一部分。酒店的实体房间数就是物理内存而客人手里的房卡号范围往往比实际房间数大得多。2. 进程地址空间里到底装了什么2.1 一张图理解进程虚拟内存的完整布局每个进程的虚拟地址空间从低位到高位大致是这样一个布局代码段、数据段、堆、内存映射区共享库、mmap文件、栈以及内核空间。不同体系结构和配置下细节有差异但大体结构是一致的。这些分段各有各的用途。代码段放指令只读数据段放全局变量和静态变量堆是动态分配内存的地方地址向上增长栈存放函数调用相关的局部变量、参数和返回地址地址向下增长映射区用于加载动态库、映射文件以及mmap分配的大块内存。它们之间由内核负责维护边界互不侵犯。进程的虚拟地址空间不是一次性建好的。程序启动时内核按照可执行文件的布局把代码段和数据段映射进来然后分配栈的起始区域再预留堆的空间。之后随着程序运行通过malloc、mmap、栈的增长等操作逐步扩展地址空间。所以一个进程的虚拟内存大小反映的是它建立过的所有映射的总和而不是它此刻真正使用的物理内存的总和。2.2 堆、栈、映射区三种典型内存的分配路径进程运行过程中最常打交道的三个内存区域各自背后的机制完全不同。堆上的内存通过malloc分配。在glibc的实现里小块内存通过brk系统调用扩展堆的边界大块内存超过阈值通常是128KB则走mmap在内核的地址空间里找一块空闲区域直接映射。用brk的好处是连续、效率高缺点是随着分配释放的交替容易产生碎片。用mmap的好处是分配和释放干净利落坏处是每次分配释放都要进内核而且会导致页表更新频繁。栈上的内存则不需要任何系统调用。函数调用时栈指针向下移动预留出局部变量需要的空间。只要访问的地址没有超出当前栈映射的范围一切都在用户态完成。一旦访问的地址超出了已映射的栈区域触发缺页中断内核会为栈增长一个新的页。反复往深处访问栈栈就会不断增长直到遇到其他映射区域比如堆的边界此时程序就会收到段错误。映射区是承上启下的角色。动态库加载时内核把磁盘上的.so文件按页映射进来读写数据时才真正把物理页加载进来。你手动调mmap读文件也一样映射完成不等于文件内容已经在内存里只有访问到对应页时才会触发缺页读取。这带来一个非常实用的好处映射一个大文件即使文件有几个GB只要不全部访问物理内存的占用就很小。2.3 虚拟内存和常驻内存的区别为什么VIRT这么大top第一行里的VIRT和RES是我见过被误解最多的两个指标。VIRT是进程虚拟地址空间的总大小等于进程建立过的所有映射区域加起来。它包含共享库的映射空间、mmap的文件空间、堆的空间、栈的空间、代码段数据段以及那些被标记为保留但从未访问过的区域。只要进程建立了这些映射不管有没有真正访问都会计入VIRT。RES是常驻内存即进程当前真实占用物理内存的量。它只包含当前已经被访问过、并且有物理页支撑的那些虚拟页。所以一个进程的VIRT动不动超过10GB但RES可能只有500MB这完全正常不代表有内存泄漏也不代表程序有问题。要不要把VIRT当作有问题这里面有经验之谈。正常情况下的确不用紧张但有一种场景需要注意如果VIRT在持续且快速地上涨同时RES也在涨那就说明进程确实在持续申请并占用物理内存这才是真正需要警惕的信号。3. 进程与虚拟内存的关系在实操中的全貌3.1 用pmap命令看清虚拟内存布局理论说了一大堆最终还是得落实到实操。在Linux下查看进程虚拟地址空间最直观的命令就是pmap。拿一个进程举例子比如一个常驻后台的Java服务pmap -x pid的输出大致如下$ pmap -x 12345 12345: java -jar service.jar Address Kbytes RSS Dirty Mode Mapping 0000000000400000 4 4 0 r-x-- service.jar 0000000000600000 4 4 4 r---- service.jar 0000000000601000 4 4 4 rw--- service.jar 00007f0a0c000000 65488 0 0 ----- [ anon ] 00007f0a10000000 65536 5248 5248 rw--- [ anon ] 00007f0a14000000 132 132 132 r-x-- libjava.so ... ---------------- ------- ------- ------- total kB 4245280 586236 521420看到这个输出你应该解读出如下信息第一列是虚拟地址的起始地址第二列Kbytes是这个映射区域的虚拟大小第三列RSS是这块映射当前实际占用的物理内存第四列Dirty是脏页大小最后一列Mapping描述了这个区域映射的是什么内容。对照之前讲的理论你能在输出里看到三类典型的区域以r-x--开头的代码段区域可读可执行不可写以rw---结尾的数据段和堆栈区域以及动态库的映射区域。如果某个区域Kbytes很大而RSS是0说明这是一个建立过映射但至今没有被访问过的区域。比如上面输出里那个65488KB的匿名区域RSS是0说明这个区域只是预留了地址空间还没有触发实际的物理内存分配。这是pmap输出里非常常见、也非常正常的一种现象绝不是内存泄漏。3.2 从/proc伪文件系统读取进程内存细节pmap的信息来源是内核暴露的/proc/pid/伪文件系统。如果想要更原始的视角可以直接看这几个文件。首先是/proc/pid/maps它是进程虚拟地址空间的完整地图每一行代表一个已建立的映射区域。格式是起始地址-结束地址、权限位、文件偏移、设备号、inode号、路径名。权限位里的r、w、x、p或s分别代表读、写、执行、私有映射、共享映射。这里有个细节值得注意p和s的区别。私有映射p意味着这个区域是写时复制的进程对这个区域的修改不会写回到原文件也不会被其他进程看到。共享映射s则相反多个进程映射同一个文件区域时一个进程的修改会被其他进程观察到。使用上mmap系统调用如果不拉MAP_PRIVATE就是私有映射拉MAP_SHARED就是共享映射。动态库加载用的就是私有映射而进程间共享内存通信用的则是共享映射。另一个重要的文件是/proc/pid/status里面有一个VmPeak字段表示进程历史峰值虚拟内存大小VmSize是当前虚拟内存大小VmRSS是当前常驻内存大小RssAnon是匿名页主要是堆和栈的常驻内存RssFile是文件映射页的常驻内存。排查内存泄漏时这几个字段比任何GUI工具都管用如果VmSize持续增长而VmRSS也同步增长且主要是RssAnon在涨那么大概率是进程内部在不断地分配堆上内存且没有释放。3.3 观察虚拟内存和物理内存的换算关系看系统总体内存free命令是最常用的。拿一个运行中的云服务器举例$ free -h total used free shared buff/cache available Mem: 15Gi 6.1Gi 1.2Gi 256Mi 7.8Gi 8.7Gi Swap: 0B 0B 0Btotal是物理内存总量。used一般看着不大但别被它骗了。真正的可用内存要看available因为buff/cache这部分内存虽然被内核占用了但一旦进程需要它可以被回收再利用。free显示的free列反而意义不大。buff/cache这里面包含了很多文件页缓存以及各种内核数据结构。文件页缓存是读文件时产生的作用是加速后续读取。它们都属于物理内存里的一部分但不属于任何进程的常驻内存。这也是为什么你看到进程RES加到一起和used对不上因为buff/cache的大量内存挂在内核名下而不是某个进程的名下。判断是否需要排查内存问题我习惯看两个指标一是available是否持续萎缩到接近零二是进程的VmRSS之和是否逼近物理内存总量。前者代表系统整体内存压力后者决定究竟是谁在吃内存。4. 深入理解进程生命周期中的虚拟内存4.1 写时复制为什么fork出来的进程几乎不费内存进程与虚拟内存关系里最精彩的部分发生在fork系统调用时。fork产生子进程传统印象里是“复制一份进程”如果真的是全量复制地址空间那fork的成本会高到无法接受——一个占用2GB内存的父进程每次fork都要真正的复制2GB数据这在现实场景里几乎不可用。内核的做法是写时复制Copy-on-Write。fork时子进程的页表直接复用父进程的页表并且把所有这些页的权限标记为只读同时标记为“私有且仅此一份”。父进程和子进程此时共享同一批物理页。一旦父进程或子进程尝试写某个共享页就会触发写保护异常内核申请一个新的物理页把原页的内容复制过去再把权限改回可写然后修改对应进程的页表指向新页。这个过程对用户程序完全透明代价只发生在真正写入的那一刻。用类比来说这就像图书馆里的同一本书一个人借走了一份手抄本父进程另一个人又要借管理员没有重新抄一遍而是直接把同一个手抄本指给两个人看约定谁都不许在书上写。如果有人非要在上面做笔记管理员才另抄一份给他。大多数进程fork之后立刻调用exec执行新程序根本不会写父进程的内存页所以写时复制机制帮操作系统省下了海量的内存和时间。实操中验证这一点也容易。父进程占着1GB内存fork一个子进程后用free和top看物理内存几乎不会增加。只有子进程开始写入大量内存时物理内存占用才会上升。这也是容器技术在Linux上能够高效运行的重要基石——容器底层就是多个进程共享同一套镜像页缓存。4.2 exec与进程地址空间的重构fork之后通常会调用exec。exec系统调用做的事情非常彻底把当前进程的地址空间整个废弃掉按照新可执行文件重新建立一套完整的虚拟地址空间来替换它。代码段换成新程序的指令数据段换成新程序的全局变量原来堆栈里的内容全部丢弃。虚拟地址空间被重新初始化以后旧进程的内存页引用全部被释放——写时复制如果有的话也一起消失。但这并不意味着所有物理内存都立刻被回收。exec后的程序重新读入自己的代码段和数据段时内核会优先复用文件页缓存里已有的对应页。如果一个进程反复地exec同一个可执行文件这个文件在页缓存里命中的概率非常高整体启动会很快。这也是为什么进程启动速度在第二次以后明显快于第一次。理解exec对地址空间的重构有一个常见场景就很好解释为什么fork之后不exec而是让子和父各跑各的逻辑容易出问题。因为子进程的地址空间在fork后依然是父进程的镜像任何全局变量、堆上对象、文件描述符状态都被完整复制了一份在写时复制的机制下如果父子进程各自操作了同一份句柄或状态结果很容易乱套。很多经典的生产事故根子都在这个环节上。4.3 线程的虚拟内存视角为什么线程是“轻量级进程”在实际部署中一个进程往往有几十个甚至上百个线程。从虚拟内存的角度看同属一个进程的所有线程共享同一个虚拟地址空间它们看到的代码段、数据段、堆、映射区完全一致。唯一的例外是每个线程有自己独立的栈区域因为函数调用链是线程各自独有的。线程之间共享地址空间这一特性是编程模型的基石。全局变量在多个线程之间天然可见无需额外机制。但这也意味着如果一个线程写坏了某个全局对象整个进程所有线程都会受影响甚至直接崩溃。虚拟内存视角下线程并不持有独立的地址空间所以它“轻量”“共享”带来的便利和风险也在这里。在排查时一个进程下的多线程top默认显示的是进程汇总的虚拟内存和常驻内存如果想看每个线程的资源占用需要进入top后按H键切换到线程视图。不过线程的虚拟内存总量和进程本身是重叠的不能简单相加这一点容易误导新手。5. 内存问题的实战排查与避坑指南5.1 内存泄漏排查三板斧观察、定位、采样排查内存泄漏刚开始接触时容易病急乱投医直接上各种性能分析工具。我的习惯是先做观察再做定位最后才用工具采样。第一步观察趋势。用pidstat -r 1或者写个简单的循环脚本持续记录目标进程的RES值变化。如果RES持续增长且不回落到基线就可以初步判断存在泄漏。注意有一些程序本身内存使用模式就有周期例如缓存预热后内存保持高位平稳如果你只看短暂时间窗口未达到平稳期之前会误判成泄漏。通常观察时长至少持续数小时覆盖程序的完整运行周期。第二步定位区域。读取/proc/pid/smaps这是maps文件的增强版每个映射区域都带上了Rss、Pss、Shared_Clean、Private_Dirty这些明细指标。逐个区域对比两次采样之间的Private_Dirty增长情况增长最猛的区域就是泄漏的高发区。绝大多数情况下增长集中在堆上即某个标记为[heap]或匿名rw---的区间。第三步工具采样。到了这一步才考虑用valgrind的massif或jemalloc的heap profiling。以massif为例valgrind --toolmassif --pages-as-heapyes ./你的程序运行一段时间后用ms_print查看堆内存分配的热点函数。线上环境不能轻易停服务的时候更推荐用jemalloc的prof功能动态开启采样代价比valgrind小得多。5.2 OOM Killer把系统搞死的最后一根稻草当物理内存加上Swap都不够用时内核的OOM Killer就会启动。它选一个进程杀掉以释放物理内存。这个机制本意是保护系统整体但在实际运维中经常造成不愉快的意外——你精心维护的服务可能因为其他进程吃内存太多而被误杀。OOM Killer何时启动取决于内存的紧急程度具体逻辑比较复杂但核心是没有可回收的内存页且分配内存的请求必须立刻满足时内核进入OOM流程。它会根据每个进程的oom_score来挑选牺牲者。这个分数综合了进程占用内存大小、oom_score_adj参数等。用/proc/pid/oom_adj或/proc/pid/oom_score_adj可以手动调整让不太重要的进程优先被选为牺牲者保护关键进程。实操中给重要服务降低“被选中的优先级”也就是把oom_score_adj设成一个负数比如-500它会降低这个进程的oom_score。给那些可有可无的辅助任务设成正数比如300让它优先被选中。注意这个调整要写入到对应进程的/proc文件里进程重启后就失效。想持久化的话需要在服务的启动脚本中设置。对容器化的服务有些编排平台已经提供了对应的配置选项本质上也走同样的机制。另一个和OOM相关但容易被忽略的点overcommit策略。/proc/sys/vm/overcommit_memory的值如果是0默认内核会基于启发式算法判断一个内存申请是否合理允许临时超额承诺取1则总是允许超额承诺取2则完全根据CommitLimit来限制超额。在极端情况下比如把一个重要参数从0改成2内存申请突然大量失败应用层可能出现malloc返回NULL导致的连环错误。改这类的内核参数前一定要理清楚你的应用对内存申请失败的容忍度。5.3 Swap的正确使用与两个高频误区Swap是大家既爱又恨的东西。它给系统提供了一个“溢出保护”但交换行为本身极慢远快不起来。这里有两个高频误区几乎每位新手都踩过。误区一只要还有物理内存空闲Swap就不会被使用。实际操作中内核有一个swappiness参数默认通常是60。它代表内核倾向于把匿名页换出到Swap的积极程度并不需要物理内存满了才会触发。你可以通过/proc/sys/vm/swappiness临时调整或者写入/etc/sysctl.conf持久化。想要延迟Swap使用降低这个值某些应用场景比如数据库往往建议设成10甚至0让物理内存尽量优先分配。误区二看到Swap使用量增长就认为系统出了问题。Swap增长只能说明内核在回收某些进程的冷内存这些内存访问频率很低放在物理内存里不仅浪费还会挤压文件页缓存的空间降低整个系统的工作效率。合理的Swap使用反而是资源调优的体现。真正需要警惕的是Swap持续增长且伴随持续的siswap in和soswap out数值说明物理内存严重不足系统正在吞吐量上苦苦挣扎。看Swap的实时情况vmstat 1会输出si和so两列。这两列同时活跃时系统已经很难受了此时与其继续运行不如优先排查哪个进程在使用Swap——看/proc/pid/status里的VmSwap字段或者用工具把影响面拉出来尽早处理。5.4 一个值得收藏的常见问题排查速查表日常排查中高频问题基本就那几类把这些现象、可能原因、排查路径整理成速查表能省不少时间。我根据实操经验整理了一个表供参考现象可能原因排查路径进程RES持续上涨且不回落堆上内存泄漏长期监控RES趋势定位crash区工具采样VIRT极高但RES正常高水位虚拟内存映射正常现象对照pmap确认大块匿名映射未被访问即可系统可用内存趋近零但进程RES不高文件页缓存路径可能兜底检查回收时机free观察buff/cachesar -B看回收活动程序收到“Cannot allocate memory”overcommit限制或物理内存Swap总量不足检查overcommit_memory设置观察free总量进程突然被终止dmesg有OOM记录OOM Killer选中该进程查看oom_score_adjust结合趋势判断原因top里Swap使用量大且si/so持续非零物理内存严重不足系统交换频繁优先定位占用大内存的进程评估扩容或优化malloc返回NULL进程地址空间耗尽或系统内存受限看VmSize是否接近地址空间上限检查ulimit -v这张表的思路是先区分问题发生在虚拟内存还是物理内存层面再决定排查深度。很多内存问题之所以显得“难”并不是问题真有那么难而是定位错了层导致绕了很大的弯路。虚拟内存层面要看到地址空间布局和映射关系物理内存层面要看到页缓存回收和进程常驻内存的真实占比两者用到的工具和思路几乎是两套。6. 几个冷门但能救场的实用经验6.1 追踪进程到底映射了哪些文件线上服务偶发卡顿怀疑是某个进程在大量读文件但又不知道它访问了哪些文件这时候lsof -p pid能列出所有打开的文件但未必能看到映射信息。要想看进程映射了哪些文件页用/proc/pid/maps里的路径名信息就可以了。每个映射区域如果来自文件会显示完整路径匿名区域则标记为[anon]。配合/proc/pid/smaps里的Shared_Clean和Shared_Dirty字段可以进一步判断这些文件页是被多个进程共享的例如动态库页、还是只属于当前进程。排查诸如“为什么我的进程RES比预期高”这类问题这个方法能很快把虚拟内存层面的“账面账”和物理内存层面的“实际账”理清。6.2 模拟内存压力验证应用的抗压能力验证测试想验证某服务在内存紧张时的表现不一定要真的把物理内存用完。可以用ulimit -v配合一个故意占用内存的测试进程快速模拟分配不到内存的场景。比如$ ulimit -v 1048576 # 限制虚拟内存为1GB $ ./你的程序 # 在这个限制下运行这样进程最多只能建立约1GB的虚拟地址空间一旦超过分配请求就会失败。日常开发中验证程序对malloc失败的处理逻辑、确认是否有充足的错误检查这个法子简单又直接。注意ulimit只在当前shell生效对子进程生效退出shell即恢复所以不用担心影响系统其他部分。另一个角度验证系统的OOM体验可以用cgroup限内存的方式模拟。创建一个内存上限为256MB的cgroup把测试进程放进去然后看着它在内存压力下的表现。这样做的优势是不影响主机整体还能精确控制受影响的进程范围。这在容器环境大行其道的当下几乎成为排查内存问题的标配技能。6.3 从误解到理解我在这条路上积攒过的最深体会刚接触Linux那会儿我一度以为VIRT就是“内存占用”看到自己跑的程序VIRT到了好几个GB吓得以为内存泄漏了。后来才知道JVM的堆维护方式、以及各种框架未访问时的映射预留都会让VIRT飙升真实物理内存压力要看RES和系统available。这种“第一印象误导”其实是普遍现象很多用了很久Linux的开发者也未必能说出虚拟地址空间和物理内存之间的换算逻辑。另一件让我印象很深的事情是排查某个线上服务频繁被OOM杀掉的经历。当时第一反应是机器内存扩容结果扩容后问题依旧。后来仔细用上面提到的几个工具排查才发现罪魁祸首是另一个辅助进程它加载了一个巨型的索引文件并且每次都把整个文件mmap后连续扫读导致页缓存膨胀又把swappiness调高最终把内存水位打到危险区。找准方向以后不扩机器内存、改一行配置就解决问题。这个过程让我彻底体会到理解进程与虚拟内存的真正关系比盲目调参或扩容要重要得多。7. 后面还能往哪里深入探索进程与虚拟内存的关系可以说是整个操作系统的核心枢纽。这篇内容讲清楚了虚拟地址空间的布局、物理映射的实现、写时复制的机制、以及排查内存问题的基本方法但在它下游还挂着好多值得深入的话题。比如想了解更底层的实现可以去读内核源码里关于页表管理和缺页异常处理的部分。这些代码实现本身并不要求你成为内核开发者但浏览一遍能让人对文章里提到的概念有更具体的画面感。再比如想深入了解内存性能调优可以关注NUMA架构下内存访问的差异。虚拟机内存访问要走虚拟机监控层的地址转换和普通进程的地址转换类似但多了一层这些在大型应用里会有实际影响。如果愿意继续在实操层面积累建议自己动手写几个小实验程序用mmap映射一个大文件然后观察pmap的变化写一个分配内存但不访问的程序观察VIRT和RES的差异再写一个持续写入内存的程序观察它如何逼出OOM Killer的行为。这样的“动手玩”比读十篇理论文章都更让人记忆深刻。我个人在实际操作中的体会是Linux内核把虚拟内存这个复杂问题封装得很隐蔽很多时候从应用层看一切都顺顺畅畅只有遇到性能瓶颈和稳定性问题才意识到这层关系的重要性。把这些概念和工具一点点吃透排查此类问题的速度会有质的提升。即便是多年以后回头看这个领域的每一个细节仍然会反复出现在你日常的开发和运维工作里。
阅读完成 · 觉得有帮助?
咨询建站