2024 年,我负责的一个 Java 服务,跑在一台 32G 内存的服务器上。这服务平时一切正常,可有一阵子,它开始出现一种很难复现的卡顿:大多数请求几十毫秒就回了,但每隔一段时间,某个请求会突然慢到几百毫秒甚至一秒多。我先查 GC,GC 日志里确实偶尔有长一点的停顿,可对不上这个频率。我又查 CPU,CPU 一直闲着;查内存,free 一看,32G 里还有 8G 多是空闲的——内存明明够用。我当时就想:内存这么宽裕,肯定不是内存的事。可我盯着 free 的输出又看了一会儿,目光落在了一个我平时从来不看的字段上:Swap。那一行写着,swap 已经用掉了 2.3G。我心里咯噔一下,可随即又说服了自己:内存还剩 8G 呢,系统怎么会去用 swap?用了 swap,不就是内存不够的意思吗?这两件事——"内存还剩 8G"和"swap 用了 2.3G"——在我当时的认知里,是直接矛盾的。我盯着这个矛盾想了很久,最后才反应过来:我对 swap 的理解,从根上就错了。我一直以为 swap 是"内存的备用油箱",油箱空了才会去用它。可事实根本不是这样。这件事逼着我把 Linux 的 swap、swappiness、内存回收、缺页中断这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,32G 内存,一个 Java 服务
事故现象:
- 服务大多数请求几十毫秒,偶尔某个请求卡几百毫秒~1 秒多
- CPU 一直闲着;free 显示还有 8G 多空闲内存
- ★ 内存看着很宽裕,但 swap 已经用掉了 2.3G
现场排查:
# 1. 看内存和 swap
$ free -h
total used free shared buff/cache available
Mem: 31Gi 18Gi 2.1Gi 0.5Gi 11Gi 12Gi
Swap: 8.0Gi 2.3Gi 5.7Gi # ★ swap 用了 2.3G
# 2. ★ 看 swappiness 设的多少
$ cat /proc/sys/vm/swappiness
60 # ★ 默认值 60,偏积极
# 3. ★ 看是不是真有"换入换出"在持续发生
$ vmstat 2 5
... si so ...
0 0
120 0 # ★ si 不为 0 = 正在从 swap 换【入】
88 0 # 换入就意味着:有进程在等磁盘
# 4. ★ 看是谁的内存被换进了 swap
$ for f in /proc/*/status; do
awk '/VmSwap|Name/{printf $2" "}END{print ""}' $f
done | sort -k2 -rn | head
java 1980000 kB # ★ Java 进程被换出了近 2G
根因(后来想清楚的):
1. ★ swap 不是"内存的备用油箱"。它不是"内存满了
才用"。内核【内存还有空闲时,就会主动】把很久
没被访问的【冷内存页】换到 swap 上去。
2. 决定它"多积极地换"的,是 vm.swappiness。默认 60,
是一个【相当积极】的值 —— 它倾向于"宁可换出一些
冷页,也要多留点内存给文件缓存"。
3. 我的 Java 服务,堆里有些区域确实长时间没访问
(冷页),于是被内核【挑出来换到 swap】了 ——
尽管当时内存根本没紧张。
4. ★ 麻烦在"换回来"那一下:被换出的页,下次一旦
被访问,必须先从【磁盘】把它读回内存(major
page fault)。磁盘比内存慢几个数量级。
5. 对延迟敏感的服务,一次"换回"就是几十到几百毫秒
的停顿 —— 这就是那些偶发的、对不上 GC 的卡顿。
swap 被用,不代表内存不够;但被换出的关键页,会带来延迟。
修复 1:内存还有空闲,为什么用了 swap
# === ★ 先纠正最核心的误解:swap 不是"备用油箱" ===
# === 我以为的 swap vs 真实的 swap ===
# 我以为:swap 是内存的"备用油箱",物理内存【用光了】,
# 系统走投无路,才会去借用 swap。所以"用了 swap"
# 就等于"内存不够"。
# ★ 真相:swap 是内核【主动的内存管理手段】,不是
# 走投无路的应急。内核在内存【还很宽裕】时,就会
# 主动把一些页换到 swap 上去。
# === ★ 内核为什么"内存够也要换出" ===
# 内核的逻辑不是"省内存",而是"让内存用得更值"。
# 内存里的页,大致分两类:
# - 匿名页:进程的堆、栈 —— 没有对应的磁盘文件,
# 要腾走它,只能写到 swap。
# - 文件页:从文件读进来的缓存(buff/cache)——
# 它本来就有磁盘上的源文件。
# ★ 内核会盘算:有些进程的匿名页【很久没碰过】(冷),
# 与其让这些冷页白占着宝贵内存,不如把它们换到
# swap,腾出来的内存拿去做【文件缓存】,让文件读写
# 更快。这是一笔"内核认为划算"的买卖。
# === ★ 所以那个"矛盾"根本不矛盾 ===
# "内存还剩 8G" 和 "swap 用了 2.3G" 一点都不冲突:
# 内核不是因为不够才换,是因为它【判断换了更划算】。
# ★ "用了 swap" ≠ "内存不够"。这是两件事。
# === 怎么判断"用了 swap"到底要不要紧 ===
# 关键不是看 swap used 这个【存量】数字,
# 而是看有没有持续的【换入换出流量】:
$ vmstat 2
... si so ...
0 0 # ★ si/so 都是 0 = 换过一次后就静止了,问题不大
120 60 # ★ si/so 持续不为 0 = 正在频繁换,这才要命
# si = swap in(换入) so = swap out(换出)
# ★ swap 有占用、但 si/so 长期是 0 —— 通常无害。
# si/so 持续跳动 —— 才是真有进程在被磁盘拖慢。
修复 2:swappiness——内核"换出冷页"的积极程度
# === ★ 控制"换得多积极"的旋钮:vm.swappiness ===
# === swappiness 是什么 ===
$ cat /proc/sys/vm/swappiness
60
# swappiness 是一个 0~100 的值。它调的是内核回收内存
# 时的【倾向】:
# - 回收内存有两条路:换出匿名页(动 swap),
# 或者丢掉文件缓存页(buff/cache)。
# - ★ swappiness 【越高】,内核越倾向于【换出匿名页】;
# 越低,越倾向于【先丢文件缓存】、尽量不动 swap。
# === ★ 几个关键取值 ===
# - swappiness = 0 :最大限度【避免】换出匿名页。
# (注意:不是"绝对不 swap",
# 内存真不够时还是会换)
# - swappiness = 1 :极力避免 swap,但留一条活路。
# - swappiness = 10 :★ 服务器常用值 —— 很不情愿换,
# 只在比较有必要时才动 swap。
# - swappiness = 60 :★ 默认值。相当积极,桌面合理,
# 但对延迟敏感的服务【偏高了】。
# - swappiness = 100 :最积极地换出匿名页。
# === ★ 为什么默认 60 对我的服务不合适 ===
# 60 意味着内核相当乐意"为了多留文件缓存而换出冷页"。
# 对一个【文件 IO 不多、但对延迟极敏感】的 Java 服务,
# 这笔买卖是亏的:省下来的文件缓存我用不太上,
# 可被换出的堆页一旦被访问,就是实打实的卡顿。
# ★ 这种服务,该把 swappiness 调低(10,甚至 1)。
# === 临时改(立即生效,重启失效)===
$ sysctl vm.swappiness=10
$ cat /proc/sys/vm/swappiness
10
# === ★ 永久改(写进配置)===
$ vi /etc/sysctl.conf
vm.swappiness = 10
$ sysctl -p # 让配置立即生效
# ★ 改完是"以后内核换得不那么积极了",
# 已经在 swap 里的页,不会自己跑回来(见修复 5)。
# === 一句话 ===
# ★ swappiness 不决定"用不用 swap",它决定内核回收
# 内存时"多愿意动 swap"。延迟敏感服务,调低它。
修复 3:被换出的页慢在哪——major page fault
# === ★ 真正造成卡顿的,是"换回来"那一下 ===
# === 换出 vs 换入,慢的是换入 ===
# - 换出(swap out):内核挑个冷页写到 swap。这事在
# 后台干,进程基本无感。
# - ★ 换入(swap in):进程要访问一个【已经被换到
# swap 上】的页 —— 这个页此刻【不在内存里】,
# 必须先从磁盘 swap 区把它读回内存,进程才能继续。
# 这一读,进程就【卡在那等磁盘】。
# === ★ 这个"等磁盘"有个名字:major page fault ===
# 进程访问一个不在内存的页,触发"缺页中断"。
# - minor fault:页其实在内存,只是没建好映射 —— 极快。
# - ★ major fault:页在磁盘上(比如 swap),必须发起
# 一次【磁盘 IO】把它读回来 —— 这就慢了。
# 内存访问:纳秒级。磁盘 IO:机械盘毫秒级,
# ★ 慢了【百万倍】量级。一次 major fault,
# 就够让一个请求多花几十上百毫秒。
# === 看进程的 major fault 多不多 ===
$ ps -o pid,maj_flt,cmd -p 9400
PID MAJFLT CMD
9400 82000 java ... # ★ major fault 累计很高
# 隔一段时间再看一次,看 MAJFLT 涨得快不快 ——
# ★ 持续猛涨 = 进程一直在等 swap 换入。
# === ★ 为什么"对不上 GC" ===
# 我一开始查 GC,因为卡顿像 STW。但 swap 换入造成的
# 停顿,和 GC 是【两个独立的源头】:
# - GC 停顿:在 GC 日志里有记录。
# - ★ swap 换入停顿:GC 日志里【啥也没有】 ——
# 它是进程在"执行普通代码"时,碰到一个被换出的页,
# 被磁盘 IO 卡住的。日志当然不会记。
# 这就是为什么卡顿频率和 GC 日志对不上 —— 找错地方了。
# === 实时看换入换出流量 ===
$ vmstat 1
... si so ...
140 0 # ★ si 持续不为 0 = 一直在从 swap 换入
# si(swap in)只要持续跳动,就有进程在被磁盘拖慢。
# === ★ 认知 ===
# swap 本身不慢,慢的是"被换出去的页又被用到"。
# 对一个其实一直在被访问的"热"数据,把它换出去,
# 就是给它的每次访问都埋了一颗"磁盘 IO"的雷。
修复 4:怎么看是谁被换进了 swap
# === ★ 定位:到底是哪个进程的内存进了 swap ===
# === 第一步:确认 swap 总量和趋势 ===
$ free -h
Swap: 8.0Gi 2.3Gi 5.7Gi
$ vmstat 2 5 # 看 si/so 是不是持续不为 0
# === ★ 第二步:逐进程看 VmSwap —— 谁被换得最多 ===
# /proc//status 里的 VmSwap,就是这个进程
# 有多少内存被换进了 swap。
$ for f in /proc/[0-9]*/status; do
pid=$(awk '/^Pid:/{print $2}' $f)
name=$(awk '/^Name:/{print $2}' $f)
sw=$(awk '/^VmSwap:/{print $2}' $f)
[ -n "$sw" ] && [ "$sw" -gt 0 ] && echo "$sw kB pid=$pid $name"
done | sort -rn | head
1980000 kB pid=9400 java # ★ 罪魁:Java 被换出近 2G
45000 kB pid=1203 mysqld
# ★ 这一段:把所有进程按"被换进 swap 的量"排序,
# 排第一的,就是受 swap 影响最大的进程。
# === 第三步:用 smem 看得更直观(若装了)===
$ smem -s swap -r | head
# smem 能直接按 swap 占用排序列出进程,比上面的脚本省事。
# === ★ 第四步:看这个进程是不是"延迟敏感" ===
# 被换出最多的是 java —— 一个在线服务。
# 在线服务被换出 = 它的某些堆页被访问时会卡。
# ★ 如果被换出最多的是一个【离线批处理/很少跑的
# 后台进程】,那其实无所谓 —— 它的冷页被换出去,
# 省下内存给别人用,反而是好事。
# 关键看:【被换出的,是不是一个延迟敏感的进程】。
# === 第五步:核对它的 major fault ===
$ ps -o pid,maj_flt,cmd -p 9400
# ★ VmSwap 大 + maj_flt 持续涨 = 这进程正实实在在
# 地被 swap 拖慢,必须处理。
# 只是 VmSwap 大、但 maj_flt 不涨 = 换出的是真冷页,
# 暂时没被访问,影响没那么急。
修复 5:正确解法——调 swappiness、清 swap、按需关 swap
# === ★ 解法:按"服务是否延迟敏感"分情况处理 ===
# === ★ 第一步(根治):把 swappiness 调低 ===
# 对延迟敏感的服务器,默认 60 太积极。调到 10:
$ vi /etc/sysctl.conf
vm.swappiness = 10
$ sysctl -p
# ★ 这一步是治本:让内核【以后】不再那么爱换出冷页。
# 但它【不会】把已经在 swap 里的页弄回来。
# === ★ 第二步:把已经换出去的页"清"回内存 ===
# 改完 swappiness,之前换出的 2.3G 还躺在 swap 里。
# 要让它们回到内存,最干脆的办法是 swap 关了再开:
$ swapoff -a # ★ 关闭所有 swap:会把 swap 里的
# 页【全部强制换回内存】
$ swapon -a # 再重新启用 swap
# ★ 重要前提:执行 swapoff 前,必须确认【物理内存
# 装得下】swap 里那些页(free 看 available 够不够)。
# 内存不够时 swapoff 会很慢甚至触发 OOM —— 别硬来。
# === 第三步:确认清干净了 ===
$ free -h
Swap: 8.0Gi 0Gi 8.0Gi # ★ swap used 归零
$ vmstat 2 # si/so 应该回到 0
# === ★ 要不要"彻底关掉 swap" ===
# 对【极度延迟敏感】的服务(Redis 官方就建议关,
# 或某些数据库场景),可以彻底禁用 swap:
$ swapoff -a
$ vi /etc/fstab # ★ 把 swap 那行注释掉
# /dev/mapper/swap swap swap defaults 0 0
# ★ 注意权衡:彻底关 swap 后,内存一旦真的不够,
# 就没有 swap 兜底,会【直接触发 OOM Killer】杀进程。
# 关不关,取决于你更怕"偶发卡顿"还是"OOM"。
# 折中做法:不关 swap,但 swappiness 设 1,
# 让 swap 只在内存真的告急时才作为"最后兜底"。
# === ★ 第四步:针对单个服务用 cgroup 精细控制 ===
# 不想全局动 swappiness,可以只给某个服务设:
# (systemd service 里)
# MemorySwapMax=0 # ★ 这个服务不许用 swap
# ★ 这样只保护关键服务,其他进程该换还换 —— 更精细。
# === ★ 解法选择小结 ===
# 1. 延迟敏感服务器:swappiness 调 10,几乎都适用
# 2. 改完用 swapoff -a; swapon -a 把旧页清回来
# 3. 极致低延迟服务:cgroup 给它单独 MemorySwapMax=0
# 4. 彻底关 swap 要想清楚:换来的是 OOM 风险
修复 6:swap 排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ "用了 swap" 不等于 "内存不够" ===
# 内核内存够时也会主动换出冷页,这是正常管理手段。
# === 2. ★ 看 swap 要不要紧,看 si/so 流量,不看 used 存量 ===
$ vmstat 2 # si/so 持续不为 0 才是真问题
# === 3. 真正的卡顿源是 major page fault(从 swap 换回)===
$ ps -o pid,maj_flt,cmd -p PID # maj_flt 持续涨 = 被 swap 拖
# === 4. ★ swap 造成的停顿,GC 日志里查不到 ===
# 卡顿频率对不上 GC,就该往 swap / IO 这边查。
# === 5. 延迟敏感的服务器,swappiness 默认 60 偏高,调 10 ===
$ vi /etc/sysctl.conf 设 vm.swappiness=10; sysctl -p
# === 6. ★ 改完 swappiness,要 swapoff -a;swapon -a 清旧页 ===
# 前提:确认物理内存装得下,否则别执行。
# === 7. 彻底关 swap 要权衡:没了兜底,内存不够会直接 OOM ===
# === 8. 排查"偶发卡顿、疑似 swap"的步骤链 ===
$ free -h # ① swap 有没有被占用
$ vmstat 2 # ② si/so 是否持续不为 0
$ 逐进程看 VmSwap # ③ 谁被换进了 swap
$ ps -o maj_flt 看那个进程 # ④ 它在不在被换入拖慢
$ 调低 swappiness + swapoff/swapon # ⑤ 根治 + 清旧页
# 按这个顺序,swap 引起的卡顿基本能定位、能根治。
命令速查
需求 命令
=============================================================
看内存和 swap 用量 free -h
看换入换出流量 si/so vmstat 2
看当前 swappiness cat /proc/sys/vm/swappiness
临时改 swappiness sysctl vm.swappiness=10
永久改 swappiness 改 /etc/sysctl.conf 后 sysctl -p
看某进程被换出多少 grep VmSwap /proc/PID/status
看某进程的 major fault ps -o pid,maj_flt,cmd -p PID
把 swap 里的页清回内存 swapoff -a 然后 swapon -a
彻底关闭 swap swapoff -a + 注释 /etc/fstab 里 swap 行
给单个服务禁 swap systemd service 设 MemorySwapMax=0
口诀:用了 swap 不等于内存不够,看 si/so 流量不看 used 存量
卡顿源是换回页的 major fault,延迟敏感服务把 swappiness 调到 10
避坑清单
- swap 不是内存的备用油箱,不是内存满了才用,内核内存够时也会主动换出冷页
- 用了 swap 不等于内存不够,这是内核为多留文件缓存而做的主动内存管理
- 判断 swap 要不要紧看 vmstat 的 si/so 流量,而不是看 free 里 used 的存量
- swappiness 0-100 控制内核换出匿名页的积极程度,默认 60 对延迟敏感服务偏高
- 真正造成卡顿的是被换出的页又被访问,触发 major page fault 要等磁盘 IO
- swap 换入造成的停顿在 GC 日志里查不到,卡顿对不上 GC 就该往 swap/IO 查
- 延迟敏感的服务器把 swappiness 调到 10,临时用 sysctl 永久写 sysctl.conf
- 改完 swappiness 旧页还在 swap 里,swapoff -a 再 swapon -a 可把它们清回内存
- 执行 swapoff 前必须确认物理内存装得下 swap 里的页,否则会很慢甚至触发 OOM
- 彻底关 swap 要权衡,没了兜底内存一旦真不够会直接触发 OOM Killer 杀进程
总结
这次"内存明明宽裕、服务却偶发卡顿"的事故,纠正了我一个对 swap 的、几乎是望文生义的误解。在我的脑子里,swap 这个东西的形象一直特别简单,简单到像一个常识:它是内存的"备用油箱"。物理内存是主油箱,平时就烧它;只有当主油箱见了底、车子快开不动了,系统才会迫不得已,去启用那个备用的、慢吞吞的 swap。这个"备用油箱"的比喻,推导出一个在我看来天经地义的结论:系统一旦动用了 swap,就等于在向我宣告——"内存不够了"。正因为我把这两件事画上了等号,所以当 free 同时告诉我"还剩 8G 内存"和"swap 用了 2.3G"时,我的大脑里产生了一个短路般的矛盾:还剩着 8G 呢,主油箱明明还小半箱油,你启用备用油箱干什么?面对这个我自己制造出来的矛盾,我选择了一个最省事的处理方式——我说服自己"swap 用了点没关系,内存还够",然后把目光移开了。我没有去深究它,因为在我那个"备用油箱"的模型里,这个现象是无法被解释的,而一个无法被解释的现象,人是很容易选择视而不见的。复盘到根上,我才明白,swap 根本不是什么走投无路时才用的"备用油箱"。它是内核手里一个主动的、日常的内存管理工具。内核管理内存,想的从来不是"省着点用",而是"让每一页内存都用在最值的地方"。它会冷眼打量内存里所有的页:你这个进程的这块堆内存,已经好久好久没人碰过你了,你是一块"冷"的内存;而把你腾走以后空出来的这块地方,我拿去给文件读写做缓存,能实实在在地提速。于是它做了一笔在它看来稳赚的买卖:把那块冷内存写到 swap 上去,把腾出来的空间改作他用。它做这件事的时候,内存根本没有紧张——它换出冷页,不是因为"不得不",而是因为它"算过觉得划算"。而它有多热衷于做这种买卖,由一个我从来不知道的旋钮 swappiness 决定,这个旋钮的出厂默认值是 60,是一个相当热衷于换出的姿态。我的服务的卡顿,就是这笔"内核觉得划算"的买卖,转嫁到我头上的成本:它判断为"冷"的那些堆页,其实并不是真的永远不用,它们只是暂时安静;一旦某个请求又需要用到它们,这些页此刻已经不在内存、而是躺在慢吞吞的磁盘 swap 区里了,进程就只能停下来,死死地等着磁盘把它们一页一页读回来——一次内存访问,本该是纳秒级的,现在变成了毫秒级的磁盘 IO,慢了百万倍。这就是那些幽灵般的、和 GC 日志怎么都对不上的卡顿的真相。这次最大的收获,是我意识到,我用来理解一个系统组件的那个"比喻",本身就可能是整个误解的源头。"备用油箱"这个比喻,生动、好记、符合直觉,可它从根上就是错的——它把一个"内核主动的、日常的优化手段",描绘成了一个"被动的、应急的最后手段"。我不是被某个具体的技术细节绊倒的,我是被自己脑子里那个错误的比喻绊倒的。这个错误的比喻像一副有色眼镜,它不仅没帮我看清 swap,反而让我对着 free 输出里那个清清楚楚的事实,硬生生地视而不见。所以下一次,当我面对一个系统组件,发现它的真实行为和我的预期对不上、甚至像我这次一样"自相矛盾"时,我会先停下来,做一件事:我会去检查,我脑子里用来理解它的那个比喻、那个心智模型,究竟是从哪里来的?它是我真正搞懂这个东西之后提炼出来的,还是仅仅因为它的名字、它的样子,让我想当然地套上去的?当一个事实和一个模型冲突时,需要被怀疑、被推翻的,永远应该是那个模型,而不是那个事实。
—— 别看了 · 2026