目录

Linux-10 内存管理

前置阅读:Linux-09 用户态、内核态与系统调用

内存问题是后端最常遇到也最容易误判的一类。典型的困惑: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 篇讲过

权限字段的含义:rwx执行 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'

规律:内存是否紧张看 availablesi/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/meminfoDirty 是否周期性冲高。

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+ 开始支持有条件地使用 swapNodeSwap feature 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 podOOMKilled
宿主机 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 服务同理——GOGCGOMEMLIMIT(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>/smapsstrace 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%,以及 vmstatsi/so 是否非 0(有换页活动)。

Q:VSZRSS 有什么区别?为什么所有进程的 RSS 加起来超过物理内存?

VSZ 是虚拟内存大小,包含所有已映射但可能从未分配物理页的区域——Go 程序的 VSZ 动辄几百 GB 完全正常,参考价值极低。RSS 是常驻物理内存,即真正占用的物理页。但 RSS 会重复计算共享页libc.so 占 2MB 被 100 个进程映射,每个进程的 RSS 都算了这 2MB,加起来就是 200MB,实际只用了 2MB。fork 出的子进程在 COW 触发前也与父进程共享大量页面。要精确统计就看 /proc/<pid>/smaps_rollupPss(按共享份额分摊)和 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 podReason: OOMKilled,或者看容器退出码 137(128+9)。另外要区分两层 OOM:cgroup OOM(容器超限,只杀容器内进程,看 /sys/fs/cgroup/memory.eventsoom_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.MemStatsSys(runtime 向 OS 申请的总量)是否接近 RSS,如果 Sys 大而 HeapAlloc 小,泄漏就在堆外。常见来源:① CGO 里 C.malloc 没配对 C.free(Go GC 完全管不到 C 侧内存,pprof 也看不见);② 直接 mmapmunmap;③ 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