Linux虚拟地址空间与物理内存管理:从页表到OOM排查实战
2026/9/24 21:36:52 网站建设 项目流程

做Linux环境运维和C/C++开发久了,几乎每个人都会遇到一个绕不开的坎:虚拟地址空间和物理内存。这两个词看着基础,真到了排查内存泄漏、OOM、进程崩在诡异地址上的时候,很多人还是会被绕晕。我最早也以为学会 free 和 top 就够了,直到有一次线上进程莫名其妙被干掉,才老老实实把虚拟内存这整套机制翻了个底朝天。这篇内容不聊教科书式的理论堆砌,尽量用实际场景把进程地址空间、页表、伙伴系统、slab 这些概念串起来,也把平时群里被反复问到的“磁盘满和内存满有什么区别”“rm 删除的文件能不能恢复”这类问题一起说清楚。适合刚开始接触 Linux 系统编程的同学,也适合想补一补内存管理短板的开发和运维朋友。

1. 虚拟地址空间为什么存在:先把“隔离”和“翻译”想明白

1.1 如果程序直接操作物理内存会怎样

首先想一个问题:如果没有虚拟地址空间,程序直接读物理地址会怎样?假设同时跑三个进程,大家都往物理地址 0x1000 写数据,结果必然互相踩踏;一个进程里数组越界,很可能直接改写另一个进程的数据,更不要说恶意程序可以非常轻松地把整个物理内存翻一遍。没有虚拟内存,每个进程还必须被加载到一块足够大的连续物理区域里,物理内存早就碎成一片,想找到一个能塞下大程序的连续空档都难,换页、按需加载更无从谈起。所以虚拟地址空间存在的第一价值是隔离,第二价值是让每个进程都以为自己拥有一整段连续、独立、从低到高排列的地址空间,这是程序不必关心物理布局的基础。

这里可以用生活场景打个比方:物理内存像一家酒店的房间,程序像客人。不能直接让客人拿着备用钥匙满楼跑,而是给每家发一张房卡,这张卡上写的是虚拟房号,前台有一张表记录虚拟房号对应到哪个真实房间。这张表就是页表,前台处理请求的过程叫地址翻译。历史上有过程序直接用物理地址的时期,代价是系统只要跑两个程序,就很容易出乱子,而且硬件也不允许随意访问任意物理地址,否则内核权限模型全线崩掉。正是这套“隔离+翻译”的架构,让现代操作系统能同时跑几百个进程而不互相干扰。

1.2 一个进程的地址空间长什么样

Linux 下每个进程都有独立地址空间,内核用一个 mm_struct 描述它。经典的 x86 32 位布局里,用户态占 0x00000000~0xBFFFFFFF(3G),内核态占 0xC0000000~0xFFFFFFFF(1G);64 位则大得多,x86_64 目前常用 48 位地址,用户态一般到 0x00007fffffffffff,内核态地址则在 0xffff800000000000 以上。用户态空间从低地址到高地址大致是这样:代码段、已初始化数据段、未初始化数据段、堆、mmap 映射区、栈、环境变量和参数区。堆向上增长,栈向下增长,中间的 mmap 区放动态库、共享内存和大型 malloc 分配,这也是随机化(ASLR)保护的重点区域。

想直观看到某个进程的完整布局,最简单的方式是读 /proc/ /maps。我自己排查问题时几乎每次都先看这个文件:

cat /proc/1234/maps | head -20

输出里每一行对应一个虚拟内存区域,格式是“起始地址-结束地址 权限 偏移 设备 inode 路径”,权限里的 r/x 决定了这块区域是否可读可执行。曾经有个客户说程序运行一段时间后“崩在了一个奇怪地址”,我用 maps 一对比,发现是栈被压到 mmap 区域里去了,越界写把某个库的代码覆盖了。地址空间布局看着像一条直线,实际上每一段都有严格边界,越界本身不会被硬件立刻发现,只有访问到没有映射的地址时才触发段错误。

1.3 从虚拟地址到物理地址:页表、TLB 与缺页异常

地址翻译的核心是分页。Linux 默认 4K 一页,一个地址就可以拆成“页号 + 页内偏移”。页号不能直接查一张大表,现代 x86_64 用四级页表:PML4 -> PUD -> PMD -> PTE,每一级存下一级的物理页帧号,最后一级才指向真正的物理页。这个查找过程很像去大型仓库找货物:先看哪个库房,再找哪个货架,再找哪一层,最后定位到第几号储物格。指令执行时 CPU 的 MMU 自动完成翻译,命中 TLB 就很快,TLB 不命中就要多层查表,开销大很多。

这里有个非常容易踩的坑:malloc 返回一个指针后,你以为真的分到了物理内存?其实没有。malloc 只是在进程地址空间里“挂了一个虚拟区域”,第一次写入这个地址时才会触发缺页异常,内核才真正分配物理页并填页表。这就是 Linux 按需调页(demand paging)的基本逻辑。曾经有同事分配 2GB 缓冲区,程序基本只写前几十 MB,free 一看实际占用远没到 2GB,他怀疑内存统计出错了,其实就是这个机制在起作用。理解这一条对于看 free、top 时解释“VIRT 大不代表 RES 大”非常重要,也是后面调 OOM 问题的前提。

2. Linux 物理内存管理核心机制:从页帧到 slab 再到 NUMA

2.1 页帧与伙伴系统:Linux 如何分配物理内存

虚拟地址最终要映射到物理页。物理内存会被内核切分成大小统一的页帧(page frame),谁负责分配和回收这些页帧?答案是伙伴系统(buddy system)。伙伴系统把空闲物理页按大小挂到不同链表里,order 为 0 表示单页(4K),order 为 1 表示 2 页连续(8K),order 为 10 表示 1024 页连续(4M)。内核需要某一大小的连续内存时,就从小到大找,找不到就把更大的块一分为二,一半返回给调用者,另一半留在低 order 链表里;回收时如果两半满足伙伴关系,就合并成大块。这就是“外部碎片管理”的基本思路。

看伙伴分布直接读 /proc/buddyinfo。这个文件平时用得少,但在排查大页分配失败、DMA 缓冲区申请失败时特别有用:

cat /proc/buddyinfo

从左到右是 order 0 到 order 10 的空闲页块数量。如果数字集中在低位 order,表示内存已经碎得厉害,想要 4M 的连续物理页很可能失败。曾经有个压测环境,只要过一段时间业务就跑不动,dmesg 里全是分配连续页失败,free 完全正常,顺着 buddyinfo 才发现是碎片化问题,顺带把原因指向某个疯狂 malloc/free 的模块。所以别一听内存问题就怀疑泄漏,碎片也是原因之一。

2.2 slab/slub:给内核对象“开小灶”

伙伴系统按页管理,但内核经常创建、销毁大量小对象,比如进程的 task_struct、文件系统的 inode、dentry 缓存。如果每个对象都直接向伙伴系统申请一整页,效率低得可怕。所以内核引入了 slab(新版内核主要是 slub)分配器:先从伙伴系统拿一批页,切成大小统一的对象,之后对象分配和释放只在这个对象池里完成,既减少了对伙伴系统的打扰,也降低了对象初始化的重复成本。对频繁使用的结构体,内核还会建立专用缓存,比如用 kmem_cache_create 创建自己的对象池。

观察 slab 占用有两个实用入口:cat /proc/slabinfo 和 slabtop。如果你发现内存“不知道被谁吃掉了”,top 里看不到用户进程占用,但 free 显示 used 很高,那十有八九是 slab 或 page cache。特别留意 SReclaimable 和 SUnreclaim 两个值,前者可以回收,后者回收不掉。我遇到过一例诡异现象:系统跑几天后内存一直上涨,用户进程全部杀掉后 used 依然很高,最后用 slabtop 发现是某个文件系统驱动的 kmalloc-64 对象无限累积,问题定位到驱动层面的对象未释放,跟应用毫无关系。这类坑没有 slab 知识很难查。

2.3 内存节点、区域与 NUMA

物理内存还被分成不同 zone:32 位下常见 ZONE_DMA、ZONE_NORMAL、ZONE_HIGHMEM,64 位下主要用 ZONE_DMA、ZONE_DMA32 和 ZONE_NORMAL。DMA zone 用于外设直接访问的内存,容量一般很小,如果外设需要连续 DMA 缓冲,order 比较大的分配就可能失败。机器上了 NUMA 之后,内存按 CPU 节点分组,每个 CPU 有本地内存和远端内存,访问远端内存要走互联总线,延迟更高。用 numactl --hardware 可以看到节点分布和拓扑。

这里有个真实教训:在 NUMA 机器上跑数据库实例,节点0 内存已经分得差不多,节点1 还有大量空闲,但应用默认优先使用本地内存,于是明明系统总内存充足,某个进程却因为节点0 内存不足被 OOM。后来给配置加了 memory policy 或者 cgroup 均衡,问题才消失。如果你用的是虚拟机,也别忘了宿主机的 NUMA 拓扑会被透传或者隐藏,盲目按物理机经验调参数,很多时候会适得其反。

2.4 观察内存的常用命令与要点

先看 free:

free -m

关键是理解 total、used、free、buff/cache、available。其中 available 才是估出来的“还能用多少”,因为它考虑了 page cache 可回收量。很多人看到 buff/cache 几GB就以为内存不够,其实那是内核在做磁盘缓存,应用程序有需要时会被收回。接下来是 vmstat 1 看 si/so、r、b,si/so 持续非 0 说明系统在反复换入换出,物理内存真的不够用了。更进一步可以看 /proc/meminfo 的 MemTotal、MemFree、MemAvailable、CommitLimit、Committed_AS、Slab 等字段,这是所有内存工具的数据源头,信息比 free 全很多。

ps 里的 VIRT、RES、SHR、DATA 经常被误解。VIRT 是进程的虚拟地址空间总大小,包含代码、数据、栈、堆、共享库、mmap 等,不等于真实占用;RES 才是常驻物理内存;SHR 是共享部分,比如共享库和匿名共享页。一个程序 VIRT 显示 20GB,RES 只有 1GB 是完全正常的,可能只是预声明了大块虚拟地址但没有全部写入。想查更细,读 /proc/ /smaps 或者 /proc/ /status,里面 VmRSS、VmSize、RssAnon、RssFile 非常清楚,适合判断到底哪些页在物理内存里。

3. 实际场景:从热词里挑几个高频内存问题做现场排查

3.1 Linux 系统怎么看磁盘空间:先分清内存和磁盘

很多新手会把“内存不足”和“磁盘满”混在一起。它们都被叫作“空间”,但是完全两种资源:内存是字节存储,掉电就没了,磁盘是持久化存储。linux系统怎么看磁盘空间是搜索量很大的问题,标准答法是 df 和 du:

df -h df -i du -sh /var/log/*

df -h 看文件系统使用率,df -i 看 inode,du 看某目录实际占用。这里有个高频坑:df 显示磁盘 100%,但 du 统计下来所有文件加起来根本没那么多。原因通常是某个文件被 rm 删除后,进程还持有文件描述符,空间事实上没释放。用 lsof +L1 就能列出这些“已删除但仍被占用”的文件。定位后重启对应进程或者 kill 掉,磁盘空间就会回来。这类问题和内存清理经常被混在一起讨论,但排查思路完全不同,一个是看文件系统,一个是看页表。

3.2 物理内存分配为什么会失败:OOM Killer 与 overcommit

Linux 默认允许内存过量分配(overcommit),也就是说 malloc 时内核不一定真的检查物理内存是否够。vm.overcommit_memory=0 是启发式,malloc 很大一块可能照样成功,真正的物理内存分配发生在写入时。这导致一种很常见的现象:程序写内存写到一半,内核发现物理内存不够了,于是触发 OOM Killer,挑一个进程杀掉。查杀进程记录命令:

dmesg -T | grep -i oom

输出里会写着哪个进程被杀了,当时内存占用多少,以及 oom_score 排行。

OOM 选择有打分机制,oom_score 大致和进程占用物理内存正相关,也会受到 oom_score_adj 影响。系统默认会倾向于杀内存占用最大的进程,所以数据库类大内存进程特别容易中招。想保护某些关键服务,可以调 oom_score_adj 或限制在 cgroup 里。但在调参之前一定先想清楚:是不是虚拟机内存本身就被分配多了?是不是写了太多 cache?我个人踩过的坑是遇到 OOM 就着急关 swap、调 overcommit_memory=2,结果问题没解决,反而把原本稳定的服务搞得更怪。合理做法是先看 dmesg、free、swap、cgroup,再决定策略。

3.3 rm -rf 删除的文件可以恢复吗:一次关于页缓存和误删除的实战说明

rm -rf 删除的文件可以恢复吗?这也是热词。如果删除后立即有其它进程覆盖了数据块,理论恢复希望极小;但如果文件被删除时还有进程打开着,数据就还在,因为内核的文件引用计数没有变成 0。此时可以去 /proc/ /fd 里找到 fd,直接复制出来:

ls -l /proc/1234/fd | grep deleted cp /proc/1234/fd/3 /path/to/rescue

这样做的前提是被删文件对应的 inode 仍然被某个进程持有着。恢复出来的文件大小和内容一般完整,之后立刻备份到其它磁盘。这也是为什么很多运维遇到误删后会先 lsof,而不是马上重装系统或者重建分区,因为操作越多恢复成功率越低。

顺带说一句,网上总有人问 Linux 系统分区能不能用 Ghost 备份?这个说法有一定道理。Ghost 是基于扇区的镜像克隆方案,它对文件系统内部结构理解得很浅,而 Linux 分区里有 ext4 日志、UUID、引导信息,直接用扇区块照搬回来后容易起不来。我不是说 Ghost 完全不能用,而是备份 Linux 更推荐用 rsync、dd、LVM 快照、再生龙(Clonezilla 类)这类能感知文件系统的工具。这个话题和内存没有直接关系,但很能说明“块级操作”和“文件级操作”的思维差异,处理内存页和页表时也是同样道理,千万别只盯着表象。

3.4 长时间运行 sh 脚本没有日志输出,怎么确认脚本正常

“linux系统执行sh脚本长时间窗口无日志输出,怎么查看脚本是否还在正常运行”又是一个高频问题。脚本没输出,不等于没运行,但也不等于内存没问题。一般步骤是:先用 ps 找到脚本和子进程:

ps aux --forest | grep -E "sh|your_script"

确认进程还在后,读 /proc/ /status 里的 State 和 VmRSS,State 是 R/S/D 中的哪一种?D 通常表示不可中断睡眠,比如卡在磁盘 I/O 或 NFS;再配合 top 看 CPU 和 MEM 排序,基本能判断是否在忙。如果怀疑是阻塞在某个系统调用,可以 strace -p 看当前系统调用,或者用 root 权限查 /proc/ /stack 看内核栈。

我踩过的坑是脚本里有一段命令在等一个远端服务响应,整个进程一直处于 S 状态,没日志是因为标准输出被缓冲到内存里没落盘。后来习惯在脚本里加 stdbuf -oL 或者用 logger 直接写 syslog,问题好查很多。从内存角度讲,这里也值得留意:如果脚本申请了大数组又没有释放,VmRSS 持续涨,最终也可能触发 OOM。所以脚本不输出时,除了看进程状态,也别忽略 VmRSS、VmSize、swap 的变化曲线。

4. 常见问题与排查技巧实录

4.1 free 里 buff/cache 占用高,到底要不要清理

不少人在看到 buff/cache 高后会急着 echo 3 > /proc/sys/vm/drop_caches 去清。我的建议是:绝大多数情况下不要。buff/cache 是内核用空闲内存做磁盘和文件缓存,能显著加速读写,不会长期挤占应用内存,应用需要时内核会主动回收。真要在内存压力大时验证缓存是否可以回收,可以先:

sync; echo 1 > /proc/sys/vm/drop_caches

echo 1 只清 page cache,echo 2 清 dentry 和 inode,echo 3 两者都清。这是排障手段,不是日常维护操作。特别对于跑数据库的机器,清 cache 会拉高后续查询的 I/O,反而让性能更差。我实测过,在上千个文件的目录里做 git 操作,清过 cache 之后速度立刻掉一截,过一会儿热缓存回来才恢复。所以看到 cache 大,先看 available,数字够就说明缓存可回收,不用管它。

4.2 进程 RES 为什么降不下来:内存瘦身的观察与取舍

很多人发现某个进程的 RES 一直很高,重启后立刻降,过几天又涨。不一定就是泄漏,常见原因有四个:一是进程持续做 malloc/free,glibc 的 malloc 可能把释放过的一小块内存留在 arena 里,形成碎片,间隙被共享或 mmap 占用;二是文件中被修改的脏页没有写回,一直占物理内存;三是共享库被多个进程映射,RES 里包含共享部分;四是 mmap 已经 unmap 但 TLB 相关缓存未刷新(这个较少见)。真正要看是不是泄漏,可以连续记录 /proc/ /status 里的 VmRSS 和 VmSize,看趋势。

如果确认是持有太多不用的页,可以调用 mallopt 或 malloc_trim(0),不过对 glibc 碎片的效果有限,最稳妥的办法是重构分配策略或者定期重启进程。还有个小技巧:用 madvise(MADV_DONTNEED) 主动释放一段明确不再使用的地址范围,比重启进程要优雅。我在调试一个批量任务进程时,就是靠这个函数把峰值 RSS 从 6GB 压到 3GB 以内的。不过这类优化要先分析哪个区域的内存分布占比高,盲目 madvise 可能会误伤热数据。

4.3 swap 使用与物理内存的关系,以及虚拟机的“内存”误区

swap 经常被误解成“虚拟内存”,其实它是一个磁盘上的交换区,用来在物理内存不够时把内存页换出来。判断系统是否真的在频繁交换,重点看 vmstat 里的 si/so,如果 si/so 长时间非 0,说明物理内存已经紧张到内核在不停换页了。有的人一看到 swap 用了很多就赶紧 swapoff -a,这非常危险,因为 swapoff 必须把所有交换页读回物理内存,如果内存不够,系统可能直接 OOM。交换之前应该先确认 MemAvailable 足够大,或者先加内存。

虚拟机的场景也有类似误区。比如在宿主机上装 Linux 虚机,看到虚机里 free 正常就以为宿主机内存够用,实际上虚机的内存页全映射在宿主机的物理页上,宿主机 OOM 时虚机体会异常卡顿甚至被杀。而 qcow2 是磁盘镜像文件,不是内存镜像,磁盘占用跟内存占用是两回事。换句话说,虚机里“磁盘空间满”不代表“内存不够”;宿主机“内存不够”很可能表现为虚机里的程序随机死掉。理解这一层,才能正确诊断混合环境下的内存问题。

4.4 快速记忆:排查内存问题先看这四组数据

我把自己习惯的排查顺序整理成了一张速查表,遇到内存相关故障时按这个顺序来,基本能覆盖八成问题:

数据源命令主要看什么
内存总览free -hMemTotal、MemAvailable、buff/cache 是否正常
内核统计cat /proc/meminfoSlab、CommitLimit、Committed_AS 是否逼近
页分配cat /proc/buddyinfo连续大块页是否不足
进程详情cat /proc/ /statusVmRSS、VmSize、State、RssAnon/RssFile

这个表格不是万能的,像 cgroup 内存限制、NUMA 策略、驱动泄漏等问题还需要额外工具,但它能帮你快速确定大方向。记住一个原则:物理内存分配失败、虚拟地址映射缺失、磁盘空间耗尽,在这三者之间做区分,是排障的第一步。很多人一上来就 top、free 乱试,不如先花十秒确认问题到底发生在哪一层。

最后再分享一点个人体会:这几年来我实际处理过的内存问题里,最后发现真相往往是“概念混淆”。把 page cache 当泄漏,把虚拟地址空间大小当实际占用的物理内存,把 swap 当虚拟内存,把磁盘满当内存满。每次排查之前先问自己三句话:我看的是虚拟内存还是物理内存?谁在占用它?这次故障是映射缺失、分配失败还是回收不及时?把这三句话过一遍,大多数问题已经能定位到七八分。想理解 Linux 的内存管理,不需要买多少书,直接把 free -m、vmstat 1 10、cat /proc/ /maps、cat /proc/buddyinfo 这些命令轮着看,配合 dmesg 里的 OOM 日志来回对照,几十次之后,你对虚拟地址空间与物理内存的理解会远超面试标准,因为那些工具不会骗人。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询