在服务器上跑过几个 Java 或数据库实例的人,多半都见过“内存不够”的瞬间。Linux 虚拟内存不是一个让你能假装内存很大的魔法,而是一套让每个进程都拥有独立、隔离、看起来连续完整地址空间的核心机制。我最早被“虚拟地址转物理地址”这套图绕得有点晕,后来在线上动手排查内存泄漏、swap 抖动和 OOM 时,才慢慢把整条链路串起来。这篇文章适合刚入门 Linux 的同学,也适合那些已经会用 free 但遇到内存异常仍然抓瞎的开发者。读完你能理解虚拟内存为什么存在、系统如何回收内存,以及排查内存故障时怎么少走弯路。
1. 虚拟内存到底管的是什么
1.1 “虚拟”不是让你多一块内存
很多人第一次见到 free 命令的输出时,第一反应是“我物理内存明明只有 16G,为什么每个进程加起来都 100 多 G 了?”这里的关键就在于虚拟内存概念:进程看到的是虚拟地址空间,不是物理地址空间。Linux 给每个进程画了一张很大的“地图”,64 位系统下通常可以达到 128 TiB 左右,但这张地图本身不占物理内存。
你可以把虚拟内存理解成酒店的房间编号:每个房间都有独立门牌号,但酒店实际能同时住的人数取决于楼层和房间数量,而不是门牌号的数量。进程通过门牌号(虚拟地址)去找自己的房间,真正住进去(分配物理内存)时,操作系统才根据当前资源情况安排。门牌号可以很多,物理房间是有限的,但门牌号之间绝对不允许串门,否则进程 A 就能看到进程 B 的隐私数据。
所以“虚拟”二字的实际含义是:逻辑上隔离、物理上共享、按需映射。进程 malloc 一段内存时,操作系统往往不会立刻给它分配物理页,而是先在地图上记一笔“这块区域可以用”,等进程真的写入数据时,才触发缺页异常,把对应的物理页面分配出来。这种懒加载机制让内存分配变得非常廉价,也让很多程序跑起来比想象中轻快。
1.2 地址空间、页表与 MMU 的角色
虚拟地址不会自己变成物理地址,中间的核心角色是 MMU(内存管理单元)和页表。MMU 是 CPU 里的硬件单元,负责把虚拟地址翻译成物理地址;页表是操作系统维护的数据结构,记录虚拟页面到物理页框的映射关系。
以 x86-64 为例,一个虚拟地址会被拆成多段偏移,分别作为多级页表的索引。通常有四层:PML4、PDPT、PD、PT,最后再加上页内偏移。这就意味着每次访问内存都可能要经过三四次查表,性能开销很大。硬件上的 TLB(块表)就是专门缓存最近用过的地址转换结果的,避免每次都从内存读页表。
页表里每一行(页表项)也不只是存物理地址,还带了一堆标志位。比较常见的有 present(页面是否在内存里)、RW(是否可写)、User(是否允许用户态访问)、Accessed(是否被访问过)、Dirty(是否被写过)。这些标志位是内核做回收、换出、写时复制等操作的依据。
提示:理解这些标志位的价值在于排障。当你去看 /proc/PID/smaps 时,里面很多字段比如 Referenced、Dirty 都是从这些硬件标志位汇总出来的。不要把它们当摆设。
为什么不用一个大数组当页表?因为虚拟地址空间太大,光是覆盖全部地址的单一页表就会吃掉海量内存。多级页表的好处是:进程真正用到的地址范围往往很小,对应的中间层级为空时,可以直接让上层页表项指向空,节省大量内存。这就是“按需构建”的典型案例。
1.3 为什么说虚拟内存是操作系统稳定的地基
虚拟内存带来的不只是“看起来内存很大”的错觉,它更核心的价值是隔离性和保护性。每个进程运行在自己的地址空间里,无法直接访问其他进程的地址,除非通过内核提供的共享内存、mmap 等机制明确共享。这样任何程序都能保证自己的指针不会莫名其妙访问到别人的数据结构。
另一个重要的设计是写时复制(Copy-on-Write)。fork() 创建子进程时,父进程的地址空间并不需要真的复制一份。Linux 让父子进程共享同一批物理页,但把这些页表项都标记为只读。当任何一个进程尝试写入时,CPU 会触发缺页异常,内核这时才真正复制物理页。这个机制让 fork 的成本大大降低,也是很多服务端程序频繁 fork 还能撑住的根本原因。
虚拟内存还统一了“内存”和“文件”的视角。mmap 可以把一个文件直接映射到进程地址空间,读取文件就像访问内存一样;同时,page cache 也是建立在文件页基础之上的。这让文件 IO、内核缓存、共享库加载都走同一套内存管理逻辑,整个系统的代码路径变得非常统一和干净。我一直觉得,能把虚拟内存这条路想明白,再看 Linux 的文件系统、网络缓存、容器隔离体系,会突然有一种“原来都是同一套底子”的通透感。
2. 内存管理的关键机制:页、缺页、换入换出
2.1 物理页、虚拟页和页表的关系
进程看到的是虚拟页,物理内存按物理页框管理,两者通过页表对应起来。虚拟页和物理页的默认大小都是 4KB,这是几十年的老传统。现在服务器上常见 HugeTLB 或 THP(透明大页),页大小可能是 2MB 甚至 1GB,目的就是减少页表项数量、降低 TLB miss 概率。
页表项维护的关键在于“什么时候建立映射”:进程访问一个还没有映射的虚拟地址时,CPU 会产生 page fault(缺页异常),随后进入内核的缺页异常处理程序,由内核分配物理页并建立映射,再让程序继续执行。这个动作通常非常快,但如果你碰上频繁的 major fault,性能就会明显下降,因为内核可能要从磁盘读数据进来。
这里要区分两个指标:minor fault 和 major fault。minor fault 是物理页面已经在内存中,只是当前进程的页表里还没有建立映射,补一个映射就能继续;major fault 则是目标数据不在物理内存里,必须从磁盘读入,比如换出页被重新读回、mmap 文件映射首次加载。用 time 命令可以很方便地看到程序运行期间这两个数字,time -v your_command输出里的 Minor/Major page faults 就是。
2.2 缺页异常的几种路径
缺页异常不完全是“内存不够”的信号,它其实是虚拟内存机制正常工作的入口。常见的缺页路径至少可以分三类:
- 首次访问缺页:进程 malloc 了一整块区域,但物理页还没分配。第一次写这块内存时触发缺页,内核立刻分配物理页并清零。这也是为什么申请大内存不一定慢,但触碰大内存一定慢(建议分配大块内存时考虑 mmap,加上 MADV_HUGEPAGE 之类提示,能优化大页分配行为)。
- 写时复制缺页:fork 之后父子进程共享的是只读页,任何一方要写,MMU 就抛一个 protection fault,内核复制原物理页,再更新页表为可写。这类缺页频率高、单次耗时短,很多系统性能分析里的 fault 计数就是它。
- 换出/文件缺页:物理页被回收并写到了 swap 或文件缓存需要重新读入时,页表项标记为不存在,访问时从 swap 分区或磁盘加载数据。这类缺页最慢,一旦频繁出现,系统基本已经处于内存压力很大的状态。
排障中如果看到每秒大量 major fault,不要只盯着“是不是代码有 bug”。先确认可用内存还剩多少、swap 是否被大量使用,再看是不是某些进程反复读文件映射。我见过很多挂着“大量缺页”表象的问题,最后排查到其实是 page cache 被反复淘汰,应用读文件每次都落盘。
2.3 swap 与回收机制:只是备胎但不是摆设
Linux 的内存回收机制根本目标是“尽量让物理页处于可用状态”。当系统内存不足时,内核会从几个方向收回页面:文件页(已同步到磁盘或可重新读取的)直接丢弃,脏文件页先写回再丢弃,匿名页(进程堆栈等没有磁盘备份的)则写到 swap 换出。
swap 分区或 swap 文件存在的意义就在这里:它是匿名页的“救生圈”。只要物理内存紧张,内核会先回收文件页,因为文件页丢了大不了重新从磁盘读;如果还不够,才会换出匿名页。很多人以为 swap 是内存不够时才用的,这个说法不算错,但更准确的表述是:swap 是内核回收匿名页的必要出口。就算内存还很充裕,某些场景下内核也可能因为需要缓存更多文件数据而换出少量匿名页。
内存水位也很关键。内核把空闲内存分成 min、low、high 三个水位。当可用内存跌到 low 以下,kswapd 内核线程会被唤醒开始回收;如果直接跌到 min 以下,说明回收速度跟不上,内存分配就会进入直接回收路径,调用方会阻塞在回收过程中。高水位与低水位的差留给突发分配和后台回收足够的时间,所以有时候你看到 MemFree 还剩几百兆,但系统已经开始做内存回收,这并不一定是故障,可能是水位线设置导致的正常提前量。
实操心得:千万别一看到 swap 有使用量就急着加大内存。swap 使用量持续增长时,先看是被谁换出、换出的是文件页还是匿名页、si/so 高不高。如果只是偶尔换出一部分冷匿名页,系统性能几乎没有影响;如果 si/so 持续高位,通常说明物理内存已经不足,要么增加内存,要么调整应用的缓存策略。
3. 日常排查思路:从现象反推内存问题
3.1 先看 /proc/meminfo 而不是只看 free
free -h 是大家最常用的命令,但只看它容易误判。free 里的 used 包含 page cache 吗?其实 free 把 kernel 认为的可用内存拆成了 used、buff/cache、available,不同发行版对 used 展示口径还不完全一致。更可靠的起点是直接看 /proc/meminfo,它就是 free 等命令的上游数据源。
关键字段值得背下来:
- MemTotal / MemFree:物理内存总量和完全空闲页总量。MemFree 很低不意味着有问题,因为可作为缓存回收的页很多。
- MemAvailable:内核估算的“在不触发换出、不牺牲太多缓存的前提下,还能分给新进程的内存”。日常判断内存够不够,优先看这个。
- Buffers / Cached:buffers 主要指块设备、文件系统元数据占用的页;Cached 大部分是文件页缓存,包含 tmpfs 等共享内存页。它们都是可回收的。
- AnonPages:匿名页,进程堆栈、私有数据映射占用的内存,不含文件映射。换出主要针对这部分。
- Mapped:进程实际映射到地址空间的文件页数量。Cached 减掉 Mapped 之外的,才是纯碎缓存。
- Shmem:tmpfs、共享内存等。它既算 Cached 也算 AnonPages,排查共享内存占用时很容易被漏掉。
- Slab / SReclaimable / SUnreclaim:内核自身数据结构占用的内存。SReclaimable 可以回收,SUnreclaim 不可回收。如果你发现 Slab 特别高,先看是不是某个文件系统、网络模块或者驱动把内核对象堆满了。
我自己的习惯是先看 MemAvailable 占总内存的比例,低于 5% 就得警惕;再看 Cached 和 AnonPages 的占比关系,判断是文件缓存过多还是进程内存过高;最后看 Slab 是否异常增长,很多“内存泄漏”最后发现是内核态对象堆积。
3.2 vmstat、sar、top 怎么看才不抓瞎
vmstat 1 每秒刷新一组数据,其中最有价值的是 si、so、r、b、cs 几个字段。si 和 so 是 swap 的换入换出页数;r 是正在运行的进程数(不含等待 IO 的);b 是阻塞在 IO 等资源的进程数;cs 是上下文切换次数。如果 si/so 持续非零且数值很大,几乎可以确定内存压力导致的换页已经拖慢了系统;这时配合看 r 和 b,能判断是 CPU 排队还是 IO 等待。
top 里几个内存相关指标容易被误读。VIRT 是进程虚拟内存大小,并不等于实际占用,Java 进程 VIRT 动辄几十 GB 是常态,因为 JVM 会保留大块地址空间;RES 是常驻物理内存部分,也就是进程实际占用的物理页数;SHR 是共享内存部分,包括共享库、mmap 文件页等。三个进程的 RES 不能简单相加再乘以进程数来估算总内存占用,因为共享部分重复计算了。
sar 在排查长期内存趋势时很管用。sar -r看物理内存使用率、缓冲区和缓存大小;sar -B看 page fault 和 swap 活动,其中 pgmajfault/s 就是一个很有力的压力指标。如果你能提前部署好 sar 采集,往往能在故障复盘时一眼看出内存是什么时候开始恶化的,这个时间点对定位事件非常关键。
注意:top 里的 %MEM 是按 RES 算的,不代表进程的“真实内存成本”。有些共享库被八百个进程用到,每个进程 %MEM 都显示 0.1%,可你如果误以为它们各自占用了同样数量的物理内存,算出来的总量会高出实际很多。真要看进程级内存成本,可以看 PSS(比例份额),smem 这类工具专门补这个视角。
3.3 用匿名页和文件页判断内存去向
很多时候你只想知道“内存到底被谁吃了”。方法不是直接看某个进程,而是先判断是进程私有内存多,还是文件缓存多,还是内核态 Slab 多。下面三个方向可以帮助缩小范围。
先分大类,再看具体进程:
- 如果 AnonPages 持续高涨,说明主要是进程堆内存占大头,结合 top 里的 RES 找进程,再用 smem 看 PSS。
- 如果 Cached 高、AnonPages 不高,说明 page cache 占用大,可能是频繁读写大文件。这时看系统里有没有大量 IO 活动,必要时可以调整 vm.vfs_cache_pressure 或脏页比例来优化。
- 如果 Shmem 很高,别漏了排查 /dev/shm、tmpfs 挂载点和 docker 容器内共享内存。
定位进程细节时,可以看 /proc/PID/status 里的 VmPeak、VmSize、VmRSS、VmSwap,或者 /proc/PID/smaps_rollup 一次拿到汇总。smaps 文件可以看每个映射段的具体占用情况,对分析匿名映射大小、文件映射大小、私有脏页非常有效。
实操心得:别把 vmRSS 当作全部。进程的匿名映射只要是私有的,退出后内存会一次性还给系统;但文件映射占用的是 page cache,进程退出后这些页不会被立刻释放,而是作为缓存继续留在系统里。所以你会遇到“进程都死了,free 显示也没变多”的情况。这不是泄漏,是缓存在起作用;只要 MemAvailable 在合理范围,就不用过度担心。
4. 调优、限制与避坑经验
4.1 vm.swappiness 该怎么设置
vm.swappiness 是内核控制“回收文件页和匿名页相对倾向”的参数,默认值在多数发行版是 60,范围 0–200。很多人以为设置成 0 就彻底禁用 swap,这是误解。swappiness=0 只表示内核尽量少换出匿名页,但物理内存严重紧张时一样会换出。
这个参数本质是一个权重:值越大,越倾向于回收匿名页并把它换出到 swap;值越小,越倾向于回收文件页和缓存。文件页的回收成本通常低于匿名页,所以就算你想为应用保留更多缓存,也不需要把 swappiness 调到特别高。
我的经验建议:
- 普通 Web/Java 服务,内存还算充裕,可以设为 10 左右,让系统尽量少做无谓的匿名页换出。
- 数据库主机,尤其是对延迟敏感的场景,很多人喜欢直接设为 0,但要注意这只是降低换出概率,不是隔离内存压力;真到 OOM 边缘,该换还是会换。
- 一些内存很紧、文件缓存又很重要的场景,可以保留默认值甚至调到 80 以上,让系统大胆用缓存和文件页换性能,适当换出冷匿名页。
在 cgroup v2 环境下,每个容器的 memory.swappiness 也可单独控制,这比全局调优更精准。我建议容器场景优先用 cgroup 层级的配置,而不是对所有进程一刀切。
注意:新版内核引入了一些类似
PSI的指标,能让系统更精准判断内存压力,但 swappiness 依然是日常调优里能直接感知的工具。调完后一定要看实际回收行为,而不是只盯着数字。
4.2 overcommit 到底怎么选
Linux 在分配内存时,内核允许进程申请比实际物理内存更多的虚拟内存,这就是 overcommit。如果不允许,那么 malloc 一个几百 MB 的大缓冲区可能直接失败,这对于现代应用来说无法接受。内核通过 /proc/sys/vm/overcommit_memory 控制策略:
| 值 | 策略 | 适用场景 |
|---|---|---|
| 0 | 启发式:根据内存和虚拟空间大小估算是否允许 | 默认值,绝大多数场景没问题 |
| 1 | 无条件允许 overcommit | 测试环境图省事时可用,但 OOM 风险高 |
| 2 | 禁止明显过大的 overcommit,按比例限制 | 稳定性优先、数据库类服务可考虑 |
overcommit_memory=2 时,系统允许的虚拟地址空间总量由CommitLimit决定:通常等于 swap 大小 + overcommit_ratio * 物理内存大小。你可以在 /proc/meminfo 里看到 Committed_AS 和 CommitLimit 两个字段。Committed_AS 是所有进程已经承诺的虚拟内存总量,如果超过 CommitLimit,新的大分配就会被拒绝。
很多数据库的调优指南会建议把 overcommit_memory 设为 2,理由是防止某些应用一次性申请巨量内存把系统直接拖垮。但我见过不少反例:设成 2 后,JVM 或某些框架启动时申请大块虚拟内存失败,应用直接起不来,排查半天才发现是它。所以这个参数一定要根据应用动态判断:如果应用里明显有“一次性故意申请超大缓冲区”的代码,建议保留默认 0;如果整套环境非常稳定、服务类型单一,设成 2 确实能提升整体安全性。
实际建议:不要看单个参数就做决定。先看 Committed_AS 和 CommitLimit 的差距,再评估应用对“分配失败”的容忍程度。稳妥做法是保持默认 0,靠 MemAvailable 监控和监控告警来预警,而不是靠杀掉所有大分配来保平安。
4.3 cgroup 内存限制使用中踩过的坑
容器时代里,内存限制大多交给 cgroup 来做。cgroup v2 相关文件路径是 /sys/fs/cgroup/。核心文件有两个:memory.max(硬上限,超过可能触发 OOM kill)和 memory.high(软上限,超过后会加强回收、压缩内存,但不会立刻杀进程)。
坑点一:只设 memory.max 不设 memory.high,会让容器在接近上限时突然进入直接回收,产生明显的延迟尖刺。memory.high 相当于提前预警,让内核在压力较小时就开始回收,避免攒到最后一刻集中干活。但 memory.high 设置太低也会造成频繁回收,反而浪费 CPU。
坑点二:page cache 也计入 cgroup 内存统计。容器里读了大量文件后,page cache 会增长,即使进程没占多少内存,memory.max 也可能被 cache 顶满。这时内核第一反应是回收 cache,但如果回收来不及,就会引发 OOM。很多容器“内存不够”的告警其实根本不是进程内存多,而是文件缓存太多。
坑点三:memory.swap.max 默认是单向的。需要明确设置的还有匿名页换出的比例。cgroup v2 里设置了 memory.swap.max 后,容器内进程可以换出到 swap;如果没设置,则按全局 swap 策略执行。建议容器场景先把 memory.high 和 memory.max 配好,再考虑是否给单独容器开放 swap 能力。
避坑思路:用 cgroup 限制内存时,先观察容器当前的 cache/anon 占比。如果是文件密集型应用,memory.high 可以稍微收紧,memory.max 可以适当保留缓冲,再加一个只读根文件系统,避免大量脏页反复写回。
4.4 OOM killer 的一些细节
OOM killer 可能是大家对虚拟内存最熟悉又最恐慌的部分。它并不是“内存满了一瞬间就杀”,而是当系统无法通过回收、换出或拒绝分配来满足内存请求时,选择牺牲一个进程来释放空间。
OOM 的评分依据是 oom_score,分数越高越容易被杀。内核会综合考虑进程占用的内存量、CPU 时间、运行时长、优先级等因素。程序员可以调整 /proc/PID/oom_score_adj,范围是 -1000 到 1000。设置为 -1000 表示 OOM 时完全免疫,该设计通常只给内核自身使用;设置为正数则更可能被杀。许多服务主进程会把自己的 oom_score_adj 调到较低值,同时把需要保护的数据线程显示隔离开,防止监控进程被误杀。
另一个容易踩的坑是:OOM killer 是“挑一个进程杀”,所以即使某个容器只是短暂分配了一大批内存,被杀的也可能是这个容器内的守护进程,而不是触发内存暴涨的那个线程。cgroup v2 下可以设置 memory.oom.group,让 cgroup 内所有进程一起被处理,能减少这种误杀。
OOM 日志可以从 dmesg 或内核日志里看到,里面通常包含大量进程内存快照。排障时别只盯着“谁被杀了”,要看那一时刻的整体内存分布:是匿名页暴涨,还是 page cache 占太多,还是 slab 不可回收。只有回答了这个根因,调参和重启才有意义。
个人经验:生产环境里,我从不把 oom_score_adj=-1000 用在业务进程上。业务进程如果完全免疫 OOM,内核就只能杀别的进程,那才是真正的灾难。合理做法是让内核优先杀“不重要但吃内存”的进程,同时靠监控快速感知并人工介入。OOM 是最后一道防线,不是第一道退路。
我在实际运维中最大的体会是:Linux 虚拟内存这套体系,只有把“地址转换、缺页异常、回收换出、cgroup 限制”这四个层面串起来,才能真正做出正确的调优决策。单独调一个 swappiness 或 overcommit 参数,往往只是头痛医头。建议你现在就把 /proc/meminfo 翻出来,对照本文把每项字段都过一遍,结合自己服务的实际负载做一次内存画像——这个动作,比任何优化命令都值钱。