Linux-10 内存管理
内存问题是后端最常遇到也最容易误判的一类。典型的困惑:free 显示只剩 200MB 是不是要 OOM 了、进程 RSS 明明只有 2GB 为什么被 OOM Killer 杀了、容器限制 4GB 但 JVM 堆才设了 2GB 为什么还是被杀。
这一篇把这些问题背后的机制讲清楚。注意本篇讲的是Linux 内核视角的内存管理,Go runtime 自己的堆管理和 GC 是另一套东西(在 golang 系列里)。
1. 虚拟内存:每个进程都以为自己独占内存
一句话:进程看到的所有地址都是假的,是内核和 MMU 硬件联手编造出来的。
进程 A 的虚拟地址 0x400000 -+
+- 页表翻译 -> 完全不同的物理地址
进程 B 的虚拟地址 0x400000 -+
两个进程用同一个虚拟地址,指向不同的物理内存,互不干扰
这个抽象带来四个好处,每一个都很关键:
| 好处 | 说明 |
|---|---|
| 隔离 | 进程无法访问别人的内存(访问越界 → SIGSEGV) |
| 超额分配 | 可以「分配」比物理内存更多的空间,只在真正使用时才给物理页 |
| 共享 | 多个进程映射同一份物理页(共享库、fork 后的 COW) |
| 连续假象 | 虚拟地址连续,物理页可以东一块西一块 |
1.1 进程的地址空间布局
cat /proc/self/maps
# 555555554000-555555556000 r-xp ... /usr/bin/cat 代码段(只读可执行)
# 555555756000-555555757000 rw-p ... /usr/bin/cat 数据段
# 555555757000-555555778000 rw-p ... [heap] 堆,malloc 从这里拿
# 7ffff7dc0000-7ffff7de5000 r-xp ... /lib/libc.so.6 共享库
# 7ffff7ff9000-7ffff7ffd000 rw-p ... 匿名映射(mmap)
# 7ffffffde000-7ffffffff000 rw-p ... [stack] 栈,向下增长
# 7ffff7ffd000-7ffff7ffe000 r-xp ... [vdso] 第 9 篇讲过
权限字段的含义:r读 w写 x执行 p私有 / s共享。
x86_64 的虚拟地址空间是 128TB 用户 + 128TB 内核(48 位有效地址)。所以进程的虚拟地址空间几乎用不完——这就是 VSZ 可以非常大而完全正常的原因。
1.2 页表与 TLB
内存以页为单位管理,x86_64 的标准页大小是 4KB。
getconf PAGE_SIZE
# 4096
虚拟地址到物理地址的翻译靠多级页表(x86_64 是 4 级,5.x 内核支持 5 级):
虚拟地址 48 位被切成 5 段:
[47:39] [38:30] [29:21] [20:12] [11:0]
PGD PUD PMD PTE 页内偏移
每级查一次内存 -> 一次翻译最多要访问 4 次内存!
这个开销无法接受,所以 CPU 有 TLB(Translation Lookaside Buffer)——专门缓存「虚拟页 → 物理页」映射的硬件缓存。
TLB 命中 -> 约 1 个时钟周期(几乎免费)
TLB 未命中 -> 走页表,100 ~ 300 ns
这就是第 7 篇说「进程上下文切换比线程贵」的根本原因:切换进程要换页表(写 CR3 寄存器),TLB 大面积失效,之后的每次内存访问都可能要走一遍页表。
1.3 大页:减少 TLB 压力
TLB 的条目数是有限的(通常几百到几千条)。用 4KB 页时,1000 个 TLB 条目只能覆盖 4MB 内存——对于占用 32GB 的数据库来说,TLB 命中率极低。
大页(Huge Page)把页大小提到 2MB 或 1GB,同样的 TLB 条目数能覆盖 500 倍的内存:
# 查看大页配置
cat /proc/meminfo | grep -i huge
# AnonHugePages: 524288 kB <- 透明大页实际使用量
# HugePages_Total: 0 <- 预留的静态大页数
# Hugepagesize: 2048 kB <- 大页大小
# 透明大页(THP)状态
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never
坑:**透明大页(THP)对数据库是有害的,几乎所有数据库都建议关闭它。**因为 THP 由内核在后台自动合并页面,会产生不可预测的延迟毛刺(内存规整时可能卡住几百毫秒),而且对于随机访问的工作负载,2MB 的粒度会造成大量内存浪费。MongoDB、Redis、Oracle、PostgreSQL 的官方文档都明确要求关闭:
# 临时关闭
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag
# 永久关闭:加到内核启动参数
# GRUB_CMDLINE_LINUX="transparent_hugepage=never"
注意区分:THP(自动、有害)和静态预留的 HugePages(手动配置、对数据库有益)是两回事。关闭的是 THP。
2. 缺页中断:内存是「用到才给」的
进程 malloc 一块内存时,内核只是记账,并不真的分配物理页。
malloc(1GB)
v
内核在 vm_area_struct 里记一笔「这段虚拟地址归你了」
v
返回指针,VSZ 增加 1GB,RSS 增加 0
v
进程第一次写入某个地址
v
MMU 发现页表里没有映射 -> 触发【缺页中断】(Page Fault)
v
内核分配一个物理页,填进页表,RSS 增加 4KB
v
恢复执行那条被中断的指令
这就是「超额分配」(overcommit)的基础——所有进程 malloc 的总量可以远超物理内存,因为大部分从未被真正触碰。
2.1 两种缺页:次要和主要
这个区分直接决定了性能影响:
| 类型 | 英文 | 含义 | 开销 |
|---|---|---|---|
| 次要缺页 | minor fault | 物理页已在内存中,只是页表没建立映射 | 约 1 μs |
| 主要缺页 | major fault | 需要从磁盘读入(换入 swap、或首次读文件) | 50μs ~ 10ms |
# 查看进程的缺页统计
ps -eo pid,min_flt,maj_flt,comm | head -5
# PID MINFL MAJFL COMMAND
# 1234 892340 123 myapp
# ^^^^^^ 次要缺页,正常,数值大也没关系
# ^^^ 主要缺页,【这个数持续增长才是问题】
# 实时观察
pidstat -r 1 3 -p 1234
# 时间 PID minflt/s majflt/s VSZ RSS %MEM Command
# 22:15:01 1234 1234.00 45.00 8234012 4123400 25.1 myapp
# ^^^^^ 每秒 45 次主要缺页 -> 在频繁读磁盘或换页
规律:majflt/s 持续大于 0 是危险信号——说明进程在从磁盘换入内存,性能会断崖式下降。常见原因是内存不足导致 swap,或者工作集远大于可用内存。
2.2 overcommit 策略
内核允许「超额分配」到什么程度,由 overcommit_memory 控制:
cat /proc/sys/vm/overcommit_memory
# 0
# 0 = 启发式(默认):内核估算,明显不合理的大额申请会被拒绝
# 1 = 总是允许:不做任何检查,来者不拒 <- Redis 要求这个
# 2 = 严格模式:总分配量不得超过 swap + 物理内存 × overcommit_ratio
Redis 为什么要求 vm.overcommit_memory=1:Redis 做 RDB 持久化时要 fork 子进程(第 6 篇讲过 COW)。在模式 0 下,内核的启发式检查可能认为「这个进程要复制 20GB 地址空间,风险太高」而拒绝 fork,导致持久化失败。设为 1 后 fork 总能成功,而实际上因为 COW,真正消耗的物理内存远小于 20GB。
# Redis 官方要求
echo "vm.overcommit_memory = 1" >> /etc/sysctl.d/99-redis.conf
sysctl -p /etc/sysctl.d/99-redis.conf
3. free 命令:available 才是关键
这是最容易误读的一个命令。
free -h
# total used free shared buff/cache available
# Mem: 15Gi 4.2Gi 231Mi 128Mi 11Gi 10Gi
# ^^^^^ ^^^^
# 只剩 231MB? 其实有 10GB 可用
# Swap: 2.0Gi 0B 2.0Gi
很多人看到 free 只有 231MB 就以为要 OOM 了,这是完全错误的判断。
| 列 | 含义 | 要不要关注 |
|---|---|---|
total |
物理内存总量 | — |
used |
已被进程使用(不含缓存) | 关注 |
free |
完全空闲、未被任何用途占用 | 基本不用看 |
shared |
tmpfs / 共享内存占用 | 用共享内存时关注 |
buff/cache |
page cache + buffer,缓存文件内容 | 了解即可 |
available |
估算「新程序可以拿到多少」 | 看这一个就够了 |
3.1 为什么 free 很少而 available 很多
**因为 Linux 会主动用掉所有空闲内存来做 page cache。**空闲内存不产生任何价值,拿来缓存磁盘内容能大幅提升 IO 性能。
**关键在于:page cache 是「可回收」的。**当有进程需要内存时,内核会立刻丢弃干净的 page cache 页(数据在磁盘上有副本,直接丢即可,无成本)来腾出空间。
available ≈ free + 可回收的 page cache + 可回收的 slab
所以 available 反映的是「真正可用」的量,free 只是「当前完全没被碰的量」
判断内存是否紧张的正确方法:
# ① 看 available 占 total 的比例
free -m | awk 'NR==2 {printf "available: %.1f%%\n", $7/$2*100}'
# available: 65.3% <- 健康
# ② 看 swap 是否在活动(更灵敏的指标)
vmstat 1 3
# ---swap--
# si so
# 0 0 <- ✅ 健康,没有换页
# 45 120 <- ❌ 危险,正在换出内存
# ③ 看主要缺页
pidstat -r 1 3 | awk '$5 > 0'
规律:内存是否紧张看 available 和 si/so,不看 free。
3.2 buff/cache 能手动清吗
能,但几乎总是不该做:
# ⚠️ 清 page cache(会导致后续 IO 全部 miss,性能骤降)
sync && echo 3 > /proc/sys/vm/drop_caches
# 1 = 只清 page cache
# 2 = 只清 dentry 和 inode 缓存
# 3 = 全清
内核会在需要时自动回收,手动清除只会让接下来一段时间的所有文件读取都要走磁盘。唯一合理的场景是做性能基准测试时需要冷启动状态。
4. page cache 与脏页回写
4.1 读路径
read(fd, buf, 4096)
v
内核检查 page cache 里有没有这一页
+- 有(cache hit) -> 直接 copy_to_user,约 1~3 μs
+- 无(cache miss) -> 发起磁盘 IO,进程进入 D 状态
读上来后放进 page cache,再拷给用户
SSD 约 50~150μs,机械盘 5~10ms
page cache 命中率决定了 IO 密集型服务的性能上限。这也是「数据库内存越大越好」的原因——不是数据库自己要用那么多,是要让 page cache 装下更多数据文件。
4.2 写路径与脏页
write(fd, buf, 4096)
v
数据写进 page cache,页被标记为【脏页】(dirty)
v
write() 立刻返回 <- 注意:此时数据【还在内存里,没落盘】
v
稍后由内核线程(pdflush/writeback)异步刷到磁盘
**write() 返回成功不等于数据安全。**机器掉电会丢失所有未刷盘的脏页。要保证落盘必须显式调用:
fsync(fd); // 刷数据 + 元数据,最可靠
fdatasync(fd); // 只刷数据,不刷元数据(如 mtime),稍快
sync(); // 刷全系统所有脏页(很重)
数据库的 WAL 就是靠 fsync 保证持久性的——MySQL 的 innodb_flush_log_at_trx_commit=1 意味着每次提交都 fsync,这是最安全但最慢的配置。
4.3 脏页回写的调优参数
# 脏页占可用内存的比例,超过就【后台】开始回写
cat /proc/sys/vm/dirty_background_ratio # 10
# 超过这个比例,写入进程会被【阻塞】,同步回写
cat /proc/sys/vm/dirty_ratio # 20
# 脏页最长存活时间(厘秒),超过就回写
cat /proc/sys/vm/dirty_expire_centisecs # 3000 = 30 秒
# 回写线程的唤醒间隔
cat /proc/sys/vm/dirty_writeback_centisecs # 500 = 5 秒
dirty_ratio 是延迟毛刺的常见来源:
脏页比例 < 10% -> 不回写,write() 都很快
脏页比例 10% ~ 20% -> 后台异步回写,write() 仍然快
脏页比例 > 20% -> 写入进程【被阻塞】直到脏页降下来
^ 这里会产生几百毫秒甚至几秒的卡顿
大内存机器上默认值是危险的:256GB 内存的机器,dirty_ratio=20% 意味着允许积累 51GB 脏页。一旦触发同步回写,要把 51GB 刷到磁盘——即使 SSD 有 500MB/s,也要 100 秒,期间所有写操作全部卡死。
# 生产建议:大内存机器改用绝对值而不是比例
echo "vm.dirty_background_bytes = 268435456" >> /etc/sysctl.d/99-tuning.conf # 256MB
echo "vm.dirty_bytes = 1073741824" >> /etc/sysctl.d/99-tuning.conf # 1GB
# 设置 _bytes 会让对应的 _ratio 失效
sysctl --system
# 观察当前脏页量
grep -E "Dirty|Writeback" /proc/meminfo
# Dirty: 234560 kB <- 待回写的脏页
# Writeback: 8192 kB <- 正在回写的
规律:写入延迟出现周期性毛刺,先看 /proc/meminfo 的 Dirty 是否周期性冲高。
5. swap:该不该关
swap 的作用是把不活跃的匿名页(堆、栈)换出到磁盘,腾出物理内存。
free -h | grep Swap
# Swap: 2.0Gi 512Mi 1.5Gi
swapon --show
# NAME TYPE SIZE USED PRIO
# /dev/vdb2 partition 2G 512M -2
5.1 swappiness 的真实含义
cat /proc/sys/vm/swappiness
# 60
常见误解:swappiness=60 表示「内存用到 60% 就开始 swap」。这是错的。
它的真实含义是**「回收内存时,倾向于换出匿名页 vs 丢弃 page cache 的相对权重」**:
swappiness = 0 尽量不换出匿名页,优先丢 page cache(但内存极度不足时仍会 swap)
swappiness = 60 默认,两者平衡
swappiness = 100 积极换出匿名页
只有在内存回收被触发时这个值才起作用,内存充足时它完全不影响任何行为。
5.2 关不关 swap
这是个有争议的话题,分场景看:
| 场景 | 建议 | 理由 |
|---|---|---|
| K8s 节点 | 必须关(kubelet 曾强制要求) | swap 会让 cgroup 内存限制失效,OOM 行为不可预测 |
| 数据库(MySQL/Redis) | 关,或 swappiness=1 |
换出会造成毫秒级延迟毛刺,比 OOM 更难排查 |
| 延迟敏感的在线服务 | 关 | 同上 |
| 开发机、内存紧张的小机器 | 开 | swap 提供缓冲,避免直接 OOM |
| 批处理、离线计算 | 开 | 吞吐优先,延迟不敏感 |
核心权衡:swap 用「延迟不可预测」换取「不被 OOM 杀掉」。
对在线服务来说,一个响应 5 秒的请求和一次进程重启(有健康检查和自动拉起)相比,前者更糟糕——因为它会引发上游超时、重试、雪崩,而且难以定位。
# 关闭 swap
swapoff -a
# 并注释掉 /etc/fstab 里的 swap 行,否则重启会恢复
# 或者保留但极不情愿使用
echo "vm.swappiness = 1" >> /etc/sysctl.d/99-tuning.conf
注意:内核 5.8+ 和 K8s 1.22+ 开始支持有条件地使用 swap(
NodeSwapfeature gate),但生产环境目前主流做法仍是关闭。
6. RSS、VSZ、PSS:进程用了多少内存
这是最容易得出错误结论的地方。
ps -eo pid,vsz,rss,comm | grep myapp
# PID VSZ RSS COMMAND
# 1234 8234012 412340 myapp
# ^^^^^^^ 8GB 虚拟 ^^^^^^ 412MB 物理
| 指标 | 含义 | 局限 |
|---|---|---|
VSZ |
虚拟内存大小,含所有映射(未分配物理页的也算) | 参考价值极低,Go 程序动辄几百 GB |
RSS |
常驻物理内存 | 共享页会在每个进程里重复计算 |
PSS |
按共享比例分摊后的占用 | 最准确,但要读 smaps |
USS |
进程独占的部分(杀掉它能释放多少) | 判断「杀谁最有效」 |
6.1 为什么 RSS 加起来会超过物理内存
因为共享页被重复计算:
libc.so 占 2MB,被 100 个进程映射
v
每个进程的 RSS 都 +2MB
v
100 个进程的 RSS 总和 = 200MB
实际物理内存只用了 2MB
同理 fork 出来的子进程(COW 未触发前)与父进程共享大量页面,两者 RSS 都算了同一份物理内存。
所以「把所有进程 RSS 加起来看内存够不够」是错误方法。
6.2 用 PSS 得到准确数字
# 单个进程的精确内存构成
cat /proc/1234/smaps_rollup
# Rss: 412340 kB
# Pss: 398120 kB <- 按份额分摊后的真实占用
# Shared_Clean: 18432 kB <- 共享的干净页(如共享库代码)
# Shared_Dirty: 0 kB
# Private_Clean: 2048 kB
# Private_Dirty: 391860 kB <- 【进程独占的脏页】= 杀掉它能释放的
# Swap: 1024 kB <- 被换出去的部分
# 按 PSS 排序找出真正的内存大户
for p in /proc/[0-9]*; do
pid=${p#/proc/}
pss=$(awk '/^Pss:/{s+=$2} END{print s+0}' "$p/smaps_rollup" 2>/dev/null)
[ "${pss:-0}" -gt 0 ] && echo "$pss $(cat $p/comm 2>/dev/null)"
done | sort -rn | head -10
Private_Dirty 是最有决策价值的数字——它是这个进程独占且必须保留的内存,杀掉进程能立即释放这么多。
7. OOM Killer
当内存真的不够、且无法通过回收缓存或 swap 解决时,内核启动 OOM Killer 杀进程保全系统。
7.1 评分机制
# 每个进程的 OOM 分数(0 ~ 1000)
cat /proc/1234/oom_score
# 823
# 可调整的偏移量(-1000 ~ 1000)
cat /proc/1234/oom_score_adj
# 0
基础分数主要由「占用内存的比例」决定——占内存最多的进程分数最高,最先被杀。所以 OOM Killer 通常会杀掉你最重要的那个服务(因为它就是内存大户)。
保护关键进程:
# 让某进程几乎不会被 OOM Killer 选中
echo -1000 > /proc/1234/oom_score_adj # -1000 = 完全免疫
# 让某进程优先被杀(比如非关键的批处理)
echo 1000 > /proc/5678/oom_score_adj
# systemd 里配置
[Service]
OOMScoreAdjust=-500
7.2 怎么确认发生了 OOM
这是第 1 篇提过的关键排查点:进程被 OOM Killer 杀掉时收到的是 SIGKILL,没有任何机会写日志,应用日志里必然一片空白。唯一的证据在内核日志:
dmesg -T | grep -i -A 3 "killed process"
# [Wed Aug 6 03:12:44 2026] Out of memory: Killed process 12345 (java)
# total-vm:8388608kB, anon-rss:7340032kB, file-rss:0kB, shmem-rss:0kB,
# UID:1000 pgtables:14848kB oom_score_adj:0
# 或者从 journal 里查
journalctl -k --since "1 hour ago" | grep -i oom
# 看完整的 OOM 现场(内核会 dump 当时所有进程的内存占用)
dmesg -T | grep -B 30 "Out of memory"
anon-rss 是关键数字——它是被杀进程的匿名页占用(堆、栈),也就是它实际吃掉的内存。
7.3 容器里的 OOM 不一样
容器有两层 OOM,一定要区分清楚:
| 类型 | 触发条件 | 现象 | 排查 |
|---|---|---|---|
| cgroup OOM | 容器超过 memory.limit |
只杀容器内进程,宿主机正常 | kubectl describe pod 里 OOMKilled |
| 宿主机 OOM | 整台机器内存耗尽 | 可能杀任何进程 | dmesg 里有 Out of memory |
# 容器内查 cgroup 内存状况(cgroup v2)
cat /sys/fs/cgroup/memory.max # 4294967296 = 4GB 限制
cat /sys/fs/cgroup/memory.current # 4187593728 = 当前用了 3.9GB
cat /sys/fs/cgroup/memory.events
# low 0
# high 234
# max 12
# oom 3 <- 发生过 3 次 OOM
# oom_kill 3 <- 杀了 3 个进程
# cgroup v1 的路径
cat /sys/fs/cgroup/memory/memory.limit_in_bytes
cat /sys/fs/cgroup/memory/memory.usage_in_bytes
**关键:memory.current 包含 page cache!**这是容器 OOM 最容易被误判的地方:
容器限制 4GB
应用堆只用了 2GB
但读写了大量文件 -> page cache 占了 2GB
v
memory.current = 4GB -> 触发 cgroup OOM
好消息是内核在触发 OOM 前会先尝试回收容器内的 page cache,所以纯 cache 通常不会直接导致 OOM。真正的杀手是「匿名页 + 不可回收的内核内存」:
# 看容器内存的构成,判断是谁在占
cat /sys/fs/cgroup/memory.stat | grep -E "^(anon|file|slab|sock|kernel_stack) "
# anon 3221225472 <- 匿名页(堆栈),【不可回收】,这是主要嫌疑
# file 838860800 <- page cache,可回收
# slab 104857600 <- 内核对象缓存
# sock 8388608 <- socket 缓冲区
7.4 JVM 在容器里被 OOM 的经典原因
容器限制:4GB
JVM 配置:-Xmx2g(堆最大 2GB)
v
为什么还是被 OOM 杀了?
因为 JVM 的实际内存 = 堆 + 元空间 + 线程栈 + 代码缓存 + 直接内存 + GC 结构
堆(Xmx) 2.0 GB
Metaspace 0.3 GB
线程栈 500×1MB 0.5 GB <- 容易忽略
CodeCache 0.2 GB
DirectByteBuffer 0.8 GB <- Netty/NIO 用的堆外内存,最容易忽略
GC 元数据 0.3 GB
-------------------------
总计 4.1 GB > 4GB -> OOMKilled
修复方式:
# ✅ 用 MaxRAMPercentage 让 JVM 自动按容器限制算堆大小(JDK 10+)
java -XX:MaxRAMPercentage=60 -jar app.jar
# ^^ 堆只占容器限制的 60%,留 40% 给堆外
# ✅ 显式限制堆外内存
java -Xmx2g -XX:MaxDirectMemorySize=512m -XX:MaxMetaspaceSize=256m -jar app.jar
# ✅ 开启原生内存追踪,看到底谁在占
java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary
**规律:容器里的内存限制要留出 30~40% 给运行时的非堆开销。**Go 服务同理——GOGC 和 GOMEMLIMIT(1.19+)要配合容器限制设置:
# Go 1.19+ 的软内存上限,让 GC 感知容器限制
GOMEMLIMIT=3500MiB # 容器限制 4GB,留 500MB 余量
8. 实战:定位一个内存持续增长的服务
现象:一个用了 CGO 做图像处理的 Go 服务,上线后内存缓慢增长,2 天从 300MB 涨到 2.1GB,最终被 OOMKilled 重启。
① 先确认是哪一类内存在涨
# 连续采样,看 RSS 和各组成部分
watch -n 60 'grep -E "^(Rss|Pss|Private_Dirty|Swap):" /proc/$(pgrep myapp)/smaps_rollup'
# Rss: 2183680 kB
# Private_Dirty: 2164224 kB <- 几乎全是独占脏页 -> 是真的在吃内存,不是缓存
Private_Dirty 持续增长排除了「page cache 假象」,确认是真实的内存泄漏。
② 区分是 Go 堆内还是堆外
# Go 的 runtime 指标
curl -s localhost:6060/debug/pprof/heap?debug=1 | head -20
# heap profile: 3120: 188743680 [...]
# # runtime.MemStats
# # Sys = 2248146944 <- runtime 向 OS 申请的总量,接近 RSS
# # HeapAlloc = 188743680 <- 堆上存活对象只有 180MB!
# # HeapSys = 285212672
# # HeapReleased = 0
**关键发现:Sys 2.1GB 但 HeapAlloc 只有 180MB。**说明泄漏不在 Go 堆里——如果是堆内泄漏,HeapAlloc 会同步增长。
③ 查堆外内存
# 看进程的内存映射,找异常大的匿名区域
awk '/^[0-9a-f]/ {addr=$1} /^Rss:/ {if ($2 > 100000) print addr, $2" kB"}' \
/proc/$(pgrep myapp)/smaps | head
# 7f2c00000000- 524288 kB <- 512MB 的匿名映射
# 7f2c40000000- 524288 kB <- 又一个 512MB
# 7f2c80000000- 524288 kB
# 数一下 mmap 区域的数量
grep -c "^[0-9a-f]" /proc/$(pgrep myapp)/maps
# 8734 <- 八千多个映射,明显异常(正常几百个)
④ 用 strace 抓是谁在 mmap
timeout 30 strace -f -e trace=mmap,munmap -p $(pgrep myapp) 2>&1 | \
grep -c mmap
# 892 <- 30 秒 892 次 mmap
timeout 30 strace -f -e trace=mmap,munmap -p $(pgrep myapp) 2>&1 | \
grep -c munmap
# 3 <- 只 munmap 了 3 次!申请远多于释放
⑤ 定位到代码
# Go 的 CGO 或直接系统调用会绕过 Go GC
# 检查是否用了 cgo
go version -m ./myapp | grep -i cgo
# build CGO_ENABLED=1 <- 用了 CGO
# 看 CGO 部分的内存
curl -s localhost:6060/debug/pprof/goroutine?debug=2 | grep -c "cgocall"
# 234 <- 大量 CGO 调用
原因:代码里用 CGO 调用了一个图像处理库,每次调用 C.malloc 分配缓冲区,但只在正常路径 C.free,错误路径直接 return,漏了释放。因为是 C 侧分配的内存,Go 的 GC 完全管不到,pprof 的 heap profile 也看不见。
// ❌ 有泄漏的代码
func process(data []byte) ([]byte, error) {
buf := C.malloc(C.size_t(len(data)))
if buf == nil {
return nil, errors.New("alloc failed")
}
ret := C.do_process(buf, C.int(len(data)))
if ret != 0 {
return nil, errors.New("process failed") // <- 这里漏了 C.free(buf)
}
out := C.GoBytes(buf, ret)
C.free(buf)
return out, nil
}
// ✅ 用 defer 保证释放
func process(data []byte) ([]byte, error) {
buf := C.malloc(C.size_t(len(data)))
if buf == nil {
return nil, errors.New("alloc failed")
}
defer C.free(buf) // <- 所有路径都会释放
ret := C.do_process(buf, C.int(len(data)))
if ret != 0 {
return nil, errors.New("process failed")
}
return C.GoBytes(buf, ret), nil
}
结果:修复后 RSS 稳定在 320MB,mmap/munmap 调用数配平。
规律:RSS 涨但语言运行时的堆没涨 → 泄漏在堆外(CGO、mmap、内核对象)。这类泄漏用语言自带的 profiler 查不到,要看 /proc/<pid>/smaps 和 strace mmap。
另一种症状几乎相同但根因完全不同的情况是 goroutine 泄漏——同样是 RSS 涨而
HeapAlloc不涨,但泄漏在 goroutine 栈(StackSys)而非 CGO。两个案例对照着看能建立完整的判断路径,见 Linux-20 §2。
9. 一些量级感
| 项 | 量级 |
|---|---|
| 标准页大小 | 4 KB |
| 大页大小 | 2 MB / 1 GB |
| TLB 命中 | ~1 个时钟周期 |
| TLB miss + 页表遍历 | 100 ~ 300 ns |
| 次要缺页(minor fault) | ~1 μs |
| 主要缺页(major fault,SSD) | 50 ~ 150 μs |
| 主要缺页(机械盘) | 5 ~ 10 ms |
| 内存随机访问 | ~100 ns |
memcpy 吞吐 |
5 ~ 20 GB/s |
| 内核栈大小 | 8 ~ 16 KB |
健康的 available 比例 |
> 20% |
10. 面试题
Q:free -h 显示 free 只剩 200MB,是不是快 OOM 了?
不是。要看 available 而不是 free。Linux 会主动用掉几乎所有空闲内存做 page cache——空闲内存不产生价值,拿来缓存磁盘内容能大幅提升 IO 性能。关键在于 page cache 是可回收的:干净的缓存页在磁盘上有副本,需要内存时内核直接丢弃即可,零成本。available 就是内核估算的「新程序能拿到多少」,等于 free + 可回收的 cache + 可回收的 slab。判断内存紧张的正确方法是:available 占比是否低于 20%,以及 vmstat 的 si/so 是否非 0(有换页活动)。
Q:VSZ 和 RSS 有什么区别?为什么所有进程的 RSS 加起来超过物理内存?
VSZ 是虚拟内存大小,包含所有已映射但可能从未分配物理页的区域——Go 程序的 VSZ 动辄几百 GB 完全正常,参考价值极低。RSS 是常驻物理内存,即真正占用的物理页。但 RSS 会重复计算共享页:libc.so 占 2MB 被 100 个进程映射,每个进程的 RSS 都算了这 2MB,加起来就是 200MB,实际只用了 2MB。fork 出的子进程在 COW 触发前也与父进程共享大量页面。要精确统计就看 /proc/<pid>/smaps_rollup 的 Pss(按共享份额分摊)和 Private_Dirty(进程独占的脏页,杀掉它能释放的量)。
Q:swappiness=60 是什么意思?是内存用到 60% 就开始 swap 吗?
不是,这是常见误解。swappiness 表示的是**「内存回收时,倾向于换出匿名页 vs 丢弃 page cache 的相对权重」,取值 0~100。它只在内存回收被触发时才起作用**,内存充足时完全不影响任何行为。设为 0 是「尽量不换出匿名页,优先丢缓存」(但内存极度不足时仍会 swap,不等于关闭 swap),设为 100 是「积极换出匿名页」。生产上数据库和延迟敏感服务建议设 1 或直接关闭 swap。
Q:K8s 节点为什么要求关闭 swap?
因为 swap 会让 cgroup 的内存限制失去意义,OOM 行为变得不可预测。容器限制 4GB 内存时,如果允许 swap,进程可以超出限制继续运行(只是被换到磁盘),kubelet 无法准确判断 Pod 是否超限,调度决策和 QoS 保证都会失真。更实际的问题是延迟不可预测:换入一页要经历主要缺页(磁盘 IO,SSD 也要几十微秒,机械盘几毫秒),一个原本 1ms 的请求可能变成 5 秒。对在线服务来说,「响应 5 秒」比「进程被杀然后自动重启」更糟糕——它会引发上游超时、重试、雪崩,而且极难定位。
Q:write() 返回成功,数据一定落盘了吗?
没有。write() 只是把数据拷进 page cache 并把页标记为脏页,然后立即返回,实际落盘由内核的 writeback 线程异步完成。此时掉电会丢数据。要保证持久化必须显式调用 fsync(fd)(刷数据+元数据)或 fdatasync(fd)(只刷数据,稍快)。数据库的 WAL 就靠这个——MySQL 的 innodb_flush_log_at_trx_commit=1 表示每次事务提交都 fsync,最安全也最慢。相关的调优点:脏页超过 vm.dirty_ratio(默认 20%)时,写入进程会被阻塞做同步回写;在 256GB 内存的机器上这意味着可能积累 51GB 脏页,一次性刷盘会导致数秒的写卡顿,所以大内存机器应该改用 vm.dirty_bytes 设绝对值。
Q:怎么确认一个进程是被 OOM Killer 杀掉的?
看内核日志,因为 OOM Killer 发的是 SIGKILL,进程没有任何机会写日志,应用日志里必然一片空白。用 dmesg -T | grep -i "killed process" 或 journalctl -k | grep -i oom,能看到被杀进程的 PID、名字和当时的 anon-rss(实际吃掉的匿名内存)。K8s 里则是 kubectl describe pod 的 Reason: OOMKilled,或者看容器退出码 137(128+9)。另外要区分两层 OOM:cgroup OOM(容器超限,只杀容器内进程,看 /sys/fs/cgroup/memory.events 的 oom_kill 计数)和宿主机 OOM(整机内存耗尽,可能杀任何进程)。
Q:容器限制 4GB,JVM 堆只设了 2GB,为什么还是被 OOMKilled?
因为 JVM 的实际内存远不止堆。完整构成是:堆(-Xmx)+ Metaspace + 线程栈(每个线程约 1MB,500 个线程就是 500MB)+ CodeCache + DirectByteBuffer 堆外内存(Netty/NIO 大量使用,最容易忽略)+ GC 自身的元数据。这些加起来很容易突破 4GB。修复方式:用 -XX:MaxRAMPercentage=60 让 JVM 按容器限制自动算堆大小(JDK 10+),显式设置 -XX:MaxDirectMemorySize 和 -XX:MaxMetaspaceSize,用 -XX:NativeMemoryTracking=summary + jcmd VM.native_memory 定位具体占用。规律:容器内存限制要留 30~40% 给运行时的非堆开销——Go 服务同理,需要设 GOMEMLIMIT(1.19+)让 GC 感知容器限制。
Q:透明大页(THP)是什么?为什么数据库都建议关掉?
大页把内存管理粒度从 4KB 提到 2MB,同样数量的 TLB 条目能覆盖 500 倍内存,大幅降低 TLB miss。THP 是内核自动在后台把连续的小页合并成大页的机制。数据库要关掉它的原因是延迟不可预测:内核做内存规整(compaction)以凑出连续的 2MB 物理内存时,可能让进程卡住几百毫秒;而且对随机访问的工作负载,2MB 的粒度会造成显著的内存浪费。MongoDB、Redis、Oracle、PostgreSQL 官方文档都明确要求 echo never > /sys/kernel/mm/transparent_hugepage/enabled。注意区分:要关的是 THP(自动、有延迟毛刺),而手动预留的静态 HugePages 对数据库是有益的,两者不是一回事。
Q:一个 Go 服务 RSS 持续增长,但 pprof 的 heap profile 显示堆只有 200MB,怎么查?
这个特征说明泄漏在 Go 堆之外——如果是堆内泄漏,HeapAlloc 会同步增长。先看 runtime.MemStats 的 Sys(runtime 向 OS 申请的总量)是否接近 RSS,如果 Sys 大而 HeapAlloc 小,泄漏就在堆外。常见来源:① CGO 里 C.malloc 没配对 C.free(Go GC 完全管不到 C 侧内存,pprof 也看不见);② 直接 mmap 没 munmap;③ goroutine 泄漏导致栈内存累积;④ 内核对象(socket 缓冲、fd)泄漏。排查手段是 /proc/<pid>/smaps 找异常大的匿名映射区域、grep -c "^[0-9a-f]" /proc/<pid>/maps 数映射数量是否异常、strace -e trace=mmap,munmap 看申请和释放是否配平。CGO 场景的修复原则是用 defer C.free() 保证所有返回路径都释放。
上一篇:Linux-09 用户态、内核态与系统调用 | 下一篇:Linux-11 文件系统与磁盘 IO
xingliuhua