很多做运维和后端的朋友应该都有过这种经历:登上一台服务器,free -h一看,内存用了 95%,top里翻来翻去也没找到哪个进程吃了这么多。然后你开始怀疑是不是被挖矿了,是不是有进程退出后没释放,折腾半天才发现,大部分都被 page cache 占用了,应用一有需要,系统会立刻吐出来。
这篇文章我想把 Linux 内存管理这块彻底讲透——从最底层的虚拟内存与分页机制,到进程地址空间怎么排布,再到物理内存的分配与回收,最后落到性能观测和线上问题排查。无论你是在准备面试,还是在运维一线被内存问题折磨过,这篇都值得存一份慢慢对照着看。内容偏底层但我会尽量说人话,保证每个概念都有例子、有原理、有实战意义。
1. 虚拟内存:先搞清楚 Linux 为什么非要搞这么一层
很多刚接触 Linux 内存管理的人,最难理解的就是“虚拟内存”这个概念。明明物理内存就那么大,为什么进程看到的地址空间和物理内存对不上?要回答这个问题,得先从“如果没有虚拟内存会怎样”说起。
1.1 进程隔离与地址连续性:虚拟内存解决的两个核心问题
如果没有虚拟内存,进程 A 和进程 B 直接操作物理地址,会出现两个问题。
第一是隔离。进程 A 不小心写了一个非法地址,可能直接把进程 B 的内存改掉,甚至把内核数据改掉,整个系统直接崩溃。服务器上几十个进程,谁都不能保证自己不越界。
第二是连续性。进程加载一个程序,代码、数据、堆、栈需要摆在一块连续的物理内存里。物理内存被多个进程分来分去后,连续的大块内存很快就没有了,新进程根本加载不进来。
虚拟内存的引入,相当于给每个进程发了一张独立的、看似连续的地址地图。进程以为自己拥有一整块从头到尾连续的地址空间,实际上这些地址会被内核一层层翻译成真正的物理地址。翻译错了或者访问未被映射的地址,就会触发段错误。
1.2 内核态与用户态的 3:1 分割
Linux 在 32 位时代经典的地址空间划分是 0~3GB 给用户态进程用,3GB~4GB 留给内核态。用户态代码不能直接访问内核态区域,只有通过系统调用时 CPU 切到内核态,才能操作那 1GB 空间。
到了 64 位时代,地址空间大得多。x86-64 下用户空间默认有 128TB,内核空间还有另外一大块。用户态进程平时能看到的也就是那 128TB,但实际上它只会用到其中很小一部分。
这里有个很反直觉的点:虚拟内存大不代表物理内存需求就大。进程可以声明一块很大的虚拟地址空间,但只要不实际读写,就不会占用物理内存。这也是为什么你在top里看到一个进程 VIRT 有几十 GB,RES 可能才几百 MB——VIRT 是它申请的虚拟地址空间总量,RES 才是真真切切占着物理内存的页。
1.3 按需加载与共享:虚拟内存带来的额外红利
虚拟内存除了隔离和连续性,还带来两个非常实用的能力。
一个是按需加载。程序启动时,内核并不需要把整个可执行文件全部读进内存。它会建立好地址映射,等进程真的访问到某一段代码时,再触发缺页异常,从磁盘把对应页面加载进来。启动一个大程序因此变得非常快。
另一个是内存共享。动态库.so在物理内存里通常只保留一份,不同进程的虚拟地址通过页表映射到同一块物理页上。比如 libc 这个库,系统上几百个进程都在用,但物理内存里只有一份代码段副本。这也是free命令里 shared 那一列存在的原因。
2. 分页机制与内存管理单元:虚拟地址是怎么翻译成物理地址的
虚拟地址翻译成物理地址,靠的是 CPU 里的内存管理单元(MMU)和内核维护的页表。操作系统里的“内存管理单元包含页号页框号”这句话,说的就是页表项的核心内容:虚拟页号映射到哪个物理页框号。
2.1 页、页框、页表项的基本概念
Linux 默认页大小是 4KB。进程的虚拟地址空间被切成一个个等长的块,这些块叫“页”(Page);物理内存也被切成同样大小的块,叫“页框”或“页帧”(Page Frame)。虚拟页和物理页框之间的对应关系,记录在页表项(PTE)里。
一个 PTE 里除了物理页框号,还包含很多标志位。比如可读、可写、可执行、是否被访问过(Accessed)、是否被写过(Dirty)、是否用户态可访问等。这些标志位不仅是权限检查用的,也是内核内存回收和 swap 换出的重要依据。缺页异常发生时,内核先看 PTE 里的 present 位——如果页不在内存里,就得分情况处理。
2.2 多级页表:为什么不用一张巨大的线性表
如果只用一个线性页表,32 位下虚拟地址空间 4GB、每页 4KB,需要 100 万个页表项;每个 PTE 占 4 字节,就需要 4MB。每个进程备一份 4MB 的表,100 个进程就是 400MB,这还不算 64 位的 128TB 地址空间——线性表根本不可能实现。
所以 x86-64 架构用了四级页表:PML4 → PDPT → PD → PT → Offset。虚拟地址被拆成这几段,每一段作为对应层级页表的索引。中间任意一级页表为空,就表示这一大块地址空间完全没有映射,不需要为它分配下级页表。这样,进程即使声明了巨大的虚拟地址空间,只要没用到的部分,页表开销也极小。
2.3 TLB 与缺页中断:性能的关键路径
每次访问内存都走一遍四级页表,性能会非常差。所以 CPU 里有一个叫 TLB(Translation Lookaside Buffer)的缓存,专门缓存最近用过的虚拟页到物理页框的映射。TLB 命中就跳过页表遍历,直接拿到物理地址。
但 TLB 的条目数有限,而且要管所有进程的映射。所以内核每次切换进程时,都要把 TLB 里的旧进程映射清掉,这也是进程切换开销的一部分。后来 MMU 引入了地址空间标识符(ASID),允许不同进程的 TLB 条目并存,减少了切换时的刷 TLB 开销。
缺页中断则是整个按需分页机制的核心触发点。进程访问一个虚拟地址,如果页表项 present 位为 0,CPU 会触发缺页异常,进入内核的缺页处理流程。内核先判断这次访问是不是合法的——如果地址压根不在进程的虚拟地址空间范围里,直接送 SIGSEGV;如果是合法但物理页尚未分配,就分配一页并建立映射;如果页被 swap 换出了,就从交换分区读回来。整条路径跑完,进程恢复执行,完全感知不到刚才发生了什么。
3. 进程地址空间布局:堆、栈、mmap 区域的选址逻辑
理解了虚拟地址怎么映射到物理内存,再来看一个进程的虚拟地址空间内部是怎么组织的。这一块对排查线上问题非常关键,比如为什么malloc之后 RSS 没涨,为什么栈溢出一般不会立刻崩。
3.1 从低地址到高地址:text、data、bss、堆、mmap、栈
一个典型的 Linux 进程态地址空间,从低到高大致是这么排的:
- text 段:代码段,只读可执行。
- data 段:已初始化且有非零初始值的全局变量。
- bss 段:未初始化或初始值为 0 的全局变量,不占磁盘空间,加载时清零。
- heap:堆,向上增长,
malloc分配的动态内存主要在这里。 - mmap 区域:共享库、
mmap映射的文件、线程栈、匿名映射等。 - stack:栈,向下增长,局部变量、函数调用信息在这里。
你可以对自己的进程跑一下cat /proc/self/maps,看到的就是一张完整的地址分布图。每一行格式是“地址范围 权限 偏移 设备 inode 路径”。分析内存泄漏时,这个文件比top好用得多,因为你能精确看到每一块匿名映射从哪里到哪里。
3.2 为什么堆向上、栈向下,中间留那么大空隙
堆从低地址向上增长,栈从高地址向下增长,中间留着一大块未映射区域。这样设计的目的是让堆和栈可以相对独立地增长,不至于一个进程稍微多用点内存两个区域就撞上。
栈向下增长意味着一个进程的调用栈深度是有限的。默认栈大小通常是 8MB(ulimit -s可查)。一旦递归过深或者栈上声明了超大数组,栈指针就会进入它下方的不可访问区域,触发段错误,这就是栈溢出。注意,内核不会提前检查栈空间够不够,而是等真正越界的瞬间才通过缺页异常发现非法访问。
堆和 mmap 区域的配合也很有讲究。glibc 的malloc分配小块内存时走brk系统调用,直接在堆顶扩展;分配大块内存时走mmap,在 mmap 区域单独映射一块匿名内存。实测下来这个分水岭大约是 128KB。brk分配的内存放在堆里,释放后不一定还给操作系统;而mmap分配的内存在munmap后会立刻归还,RSS 马上降下来。这就是为什么有些进程反复 malloc/free 后 RSS 居高不下,因为大量 small block 走的是 brk,内存碎片留在了堆里。
3.3 ASLR 与随机化:地址布局不只是为了内存效率
现代内核默认开启地址空间布局随机化(ASLR),每次启动进程时,栈、mmap 区域、动态库加载地址都会随机偏移。这样做的主要目的是安全——防止攻击者利用固定地址进行 ROP 攻击。
但 ASLR 也给性能分析带来一点小麻烦。比如你每次cat /proc/pid/maps,看到的动态库地址都不同,如果写脚本对比进程内存布局,就要注意这种随机性。排查问题时,我一般看的是相对变化趋势,而不是绝对地址。
4. 物理内存的分配与回收:伙伴系统、slab、page cache 与 swap 的协作
虚拟地址空间只是地图,真正要运行程序,必须把物理内存分配给这些虚拟页。物理内存的分配、回收、缓存与交换,是 Linux 内存管理中最“兵荒马乱”的部分,也是线上故障的重灾区。
4.1 伙伴系统:为什么物理页按 2 的幂拆分
内核管理物理内存的核心算法是伙伴系统(Buddy System)。它把空闲物理页按连续的 2 的幂次分组:order 0 是一页(4KB),order 1 是两页(8KB),order 2 是四页(16KB),依此类推。分配内存时,从对应的 order 链表里取一个块;如果没有,就向上找更大的块,把它不断拆半,直到满足需求。
释放时做的是逆操作——把相邻的空闲块合并回更大的块。这种“分裂+合并”的机制,能很好避免外部碎片。但是伙伴系统分配的内存至少是一页,内核里大量的小对象(比如 task_struct、inode)如果每次都要一页页分配,浪费会非常可观。
4.2 slab/slub 分配器:内核小对象的缓存池
为了解决内核小对象分配浪费的问题,Linux 引入了 slab 分配器(现在主流内核用 slub 实现)。它针对每一种内核对象建立一个缓存池,比如task_struct_cachep专门管理进程描述符。对象释放后不归还给伙伴系统,而是留在缓存里复用,下一次分配直接拿走,省去了频繁的页分配和页表操作。
从运维角度看,slab 占用的内存会在free的 used 里体现,但top按进程看不到,因为它是内核自己的缓存。如果发现系统内存被大量占用但查不到是哪个进程,记得看一眼/proc/meminfo里的Slab字段,以及cat /proc/slabinfo找具体是哪些对象。
4.3 page cache:文件读写的隐形加速器
page cache 可能是最容易让运维误判内存问题的元凶。Linux 读写文件时,并不直接读写磁盘设备,而是先经过 page cache。读文件时,如果 page cache 命中,直接从内存返回;没命中才从磁盘读入。写文件时也是先写到 page cache,标记为脏页,再由内核的 flusher 线程异步刷回磁盘。
这种设计让重复读文件非常快,对文件系统性能至关重要。代价是 page cache 会在内存充足时不断膨胀,吃掉大量空闲内存。很多新手看到free的 buff/cache 占了十几个 GB,就觉得内存不够了,其实完全不是这么回事。内核在应用申请内存时,第一选择就是回收 page cache 来满足需求,正常情况下根本不需要手动清理。
4.4 LRU 链表与 swap:内存压力下谁先被扫地出门
当内存紧张时,内核要释放一些物理页。它根据 LRU(最近最少使用)算法维护了几条链表:活跃文件页、不活跃文件页、活跃匿名页、不活跃匿名页。
回收顺序有讲究。优先回收不活跃的文件页,因为干净文件页直接丢弃,之后要用了重新读磁盘即可,几乎没有额外代价。匿名页(进程堆栈、匿名 mmap)不能直接丢弃,要回收就必须先写入 swap 分区,代价更高。所以只有在内存压力持续增大、文件页已经回收得差不多时,内核才会去换出匿名页。
vm.swappiness参数控制的是回收匿名页的激进程度,默认 60。它不是一个百分比阈值,而是一个权重——值越大越倾向回收匿名页,值越小越倾向回收文件页。线上数据库或 Java 服务一般建议调低到 10 左右,因为 swap 抖动带来的延迟对这类应用影响很大。但完全不设 swap 也危险,内存瞬时飙高时没有兜底机制,容易直接被 OOM Killer 干掉。
4.5 OOM Killer:内存耗尽时的最终裁决
当系统内存严重不足,回收也救不回来时,内核会触发 OOM Killer,选择一个进程杀掉释放内存。选谁杀不是随机的,内核根据每个进程的 oom_score 打分来决定,分数越高越容易被杀。
打分主要看进程的 RSS 大小、运行时间、nice 值、硬件架构等。如果你希望一个关键进程永远不被杀,可以把/proc/pid/oom_score_adj设为 -1000;反过来,某些计算任务想让它优先被杀,可以设一个正数。在容器环境里,cgroup 的 OOM 逻辑也遵循类似机制,但是限制在 cgroup 内部,不会影响其他容器。
5. 从 free 到 cgroup:内存观测与线上排查的实用套路
原理讲了一堆,真正上了生产环境,你手里能用的还是那么几个命令。但很多人不会用。比如free输出里 buff/cache 和 available 的区别,比如top里 VIRT 和 RES 的差异,比如容器里看到的内存为什么和宿主机对不上。这一节把观测和排查串一遍。
5.1 free 命令的正确解读
先看一个典型的free -h输出:
total used free shared buff/cache available Mem: 15Gi 2.1Gi 10Gi 12Mi 3.0Gi 12Gi Swap: 4.0Gi 0B 4.0Gi四个关键字段:used是系统实际使用的内存,包含进程内存和内核 slab;free是完全没有用过的物理页;buff/cache是 page cache 和块设备缓冲;available是估算的“还能分给新进程的内存”。
注意available不是free + buff/cache,因为 buff/cache 里有一部分正在被占用,无法回收。内核在计算时会考虑当前回收压力下能安全回收多少缓存。判断一台机器内存够不够,第一眼看available,而不是看free。我见过很多同事一看到free只有几百 MB 就紧张,实际上 available 还有十几个 GB,内存完全没问题。
5.2 vmstat 与 sar:判断内存压力的动态指标
静态看一眼不够,要判断内存是否持续紧张,用vmstat 1 5看连续输出。重点关注 si(swap in)和 so(swap out)两列。如果这两列持续非零,说明系统已经在一页页地和磁盘交换内存,这是一个非常糟糕的信号,说明物理内存确实不足了。
sar -r可以看历史内存使用趋势,特别适合复盘“昨晚服务为什么挂”。看历史记录时,重点观察kbcommit和%commit——这是内核承诺给所有进程的虚拟内存总量占物理内存加 swap 的比例。如果这个比例长期超过 100%,说明 overcommit 压力很大,随时可能触发 OOM。
5.3 top/ps 里的 VIRT、RES、SHR 到底看哪个
进程视角看内存,top里最常用的三个字段是 VIRT、RES、SHR。
- VIRT:进程申请的虚拟地址空间总大小,包括没有实际映射的部分,参考意义不大。
- RES:实际驻留在物理内存中的部分,数值得看。
- SHR:共享内存大小,比如动态库代码、共享内存段。
- 真正接近“独占物理内存”的指标是 RES 减去 SHR,但这只是近似。
排查单个进程的内存占用,直接用pmap -x PID看每一段映射的 RSS。再精确一点,进/proc/PID/smaps看每一段映射的 PSS(按共享比例分摊),比如多个进程共享同一个动态库时,PSS 会把物理页按进程数均摊。分析 Java 或 Python 这类多进程/多线程服务时,PSS 能帮你搞清楚一个进程到底“独吞”了多少内存。
5.4 内存泄漏排查链路:从现象到根因
内存泄漏最常见的现象是:进程 RES 持续增长,重启后回落,过几天又涨上来。排查经验可以总结成一条链路。
第一步,先用top或ps aux --sort=-rss定位哪个进程在涨。第二步,用pmap -x PID看是堆、mmap 还是栈在涨。第三步,对 Java 服务用jmap -heap、jstat -gc看堆内还是堆外;对 C/C++ 服务考虑用 valgrind 或 AddressSanitizer 找具体的泄漏点。第四步,如果pmap显示 mmap 区域增加很多,用strace跟踪是否有mmap/munmap调用对没匹配上。
这里有个很容易踩的坑:RSS 高不等于内存泄漏。glibc 的 malloc 分配的大部分内存并不会在 free 时归还操作系统,而是留在堆里复用;C++ 里 vector 扩容之后缩容,内存也不会立刻返还。所以“RSS 只升不降”不一定是 bug,可能是内存池策略导致。要结合业务特征判断,如果 RSS 在高峰期很高但低谷期能降回来,通常不是泄漏。
5.5 page cache 到底该不该手动清
很多人会问:page cache 占太多了,要不要定期echo 3 > /proc/sys/vm/drop_caches清一下。我的答案是:一般情况下不要。
drop_caches 会清除页缓存、目录项和 inode 缓存,确实能立刻让free的数值变好看。但它把本来可以加速文件读写的缓存清空了,后续访问同一批文件还得重新读磁盘,实际上是拿性能换一个“内存充足”的幻觉。如果系统 available 充足、业务没有感受到内存压力,就不用管 page cache。只有在你准备分配超大块连续内存、又等不及内核自动回收时,才值得手动清理一次。
还有一个小技巧:有时候你 drop_caches 之后发现释放效果不明显,因为 cache 里混着脏页,回写还没完成。可以先sync强制刷盘,再一次 drop_caches。但生产服务器不建议频繁这么操作。
5.6 cgroup 与容器:别忘了容器内看到的内存不是全部
现在大多数服务跑在容器里,容器内执行free看到的往往是宿主机的内存总量,而不是容器限额。这是一个非常坑的认知偏差。判断容器内存是否吃紧,应该看 cgroup 的统计文件。
cgroup v1 里看/sys/fs/cgroup/memory/memory.usage_in_bytes和memory.limit_in_bytes;cgroup v2 里看memory.current和memory.max。容器触发 OOM 时,内核会在 cgroup 视角下杀掉超限容器里的进程。排查时先看dmesg里有没有Killed process,再结合 cgroup 文件确认是否触发了限额。
6. 面试高频问题与真实踩坑:这些坑我都在生产环境遇到过
最后一块,聊几个和 Linux 内存管理相关的面试经典题,再分享几个我实际碰到的案例。把这些串起来,你会发现前面讲的所有原理,最终都能在某个故障里对上号。
6.1 为什么 malloc 很大的内存不一定失败
malloc(1GB)在 Linux 上很可能成功,即使物理内存根本不够。因为 glibc 走的是虚拟机机制——内核在 overcommit 策略允许的范围内,先承诺给你这块地址空间,但不会立即分配物理页。真正把内存坐实的是你逐页写入的时候,此时才触发缺页分配物理内存。
系统的 overcommit 策略由/proc/sys/vm/overcommit_memory控制。默认是 0,内核会做启发式检查,拒绝明显不合理的超大申请;设为 1 表示永远允许;设为 2 表示严格按照“物理内存 + swap × 比例”来限制。生产环境很多人喜欢设成 2,防止某个进程一次性申请过多虚拟内存把系统拖垮,但设成 2 后一些依赖大虚拟地址空间的程序会启动失败,需要权衡。
6.2 Java 进程 RSS 虚高:Metaspace、线程栈和 glibc arena
之前排查过一个 Java 应用,堆设置就 4GB,但 RSS 冲到 12GB 还不停。当时第一反应是堆外内存泄漏,后来用pmap -x和各种工具逐层看,发现问题其实是几部分组成的。
一部分是 Metaspace(类元数据)没有上限或者上线调太高;一部分是大量线程各占 8MB 栈的虚拟空间,实际物理占用虽然不多但页表也要开销;最大的一部分来自 glibc 的 malloc arena 扩展。现代 glibc 为了多线程性能,会为每个线程维护独立的分配区,每个 arena 默认最大 64MB,线程一多,堆外内存就失控了。这类问题不是“内存泄漏”,而是内存分配策略和 JVM 配置叠加的结果。
6.3 容器内频繁 OOM:不是宿主机内存不够,是限额太低
另一个案例更常见。容器跑着跑着进程被杀,dmesg里能看到Killed process,但宿主机free显示 memory 还有很多。原因就是容器 cgroup 的 memory.limit 设得太低,进程内存一涨就触顶。
排查这类问题要养成看 cgroup 文件的习惯,而不是只盯宿主机free。同时要理解,容器 OOM 和宿主机 OOM 的“兜底手段”不一样——宿主机 OOM 可以考虑加 swap、加内存;容器 OOM 先要看限额是否合理,再看进程本身有没有内存增长异常。
6.4 服务器 swap 狂飙:一次糟糕的内核参数调优
还有一次,一台数据库服务器 Swap usage 持续很高,业务响应变慢。一开始大家建议直接关 swap。但关掉之后,内存瞬时尖峰直接导致 OOM,数据库主进程被杀。后来改成保留 swap 但把vm.swappiness从默认 60 调低到 1,同时分析出是某个批量查询在短时间内申请了大量内存,导致内核频繁换页。调低 swappiness 后,内核优先回收文件缓存来满足内存需求,匿名页不再频繁换出,Swap 用量慢慢降下来,数据库也稳定了。
这个案例给我的教训是:swap 不是洪水猛兽,它是一个兜底机制。核心问题是要让内核明白“优先回收文件缓存,尽量减少对匿名页的交换”,而不是一刀切关闭。
6.5 排查内存问题的小习惯
踩了几次坑之后,我现在排查内存问题基本固定一套流程。第一步看free -h的 available;第二步看vmstat 1 5确认有没有持续 swap;第三步top按 RSS 排序定位进程;第四步pmap -x或/proc/PID/smaps看具体映射;第五步结合 cgroup 文件和dmesg判断是容量不足还是进程异常。这套流程跑下来,大部分内存问题都能定位到 80% 的程度。剩下那 20% 就需要对具体应用做深入分析了,比如 Java 的 GC 日志、C/C++ 的 Valgrind 报告,这些就得按项目来学。
Linux 内存管理的内容远不止这些,但把虚拟内存、分页、地址空间、伙伴系统、page cache、swap、OOM Killer 和观测命令串在一起理解,你面对大多数内存问题和面试题都会从容很多。希望这篇总结能帮你把碎片化的知识焊成一个整体,以后不管是排查故障还是做容量评估,都能有自己的判断。