
1. 从一个内存耗尽的现场说起理解内存管理前先看懂故障先讲一件我自己的经历。有一年半夜接到告警一台 8GB 内存的服务器突然卡到 SSH 都敲不动上去看了一眼free -h输出大概是这样$ free -h total used free shared buff/cache available Mem: 7.6Gi 1.2Gi 326Mi 156Mi 6.2Gi 402Mi Swap: 2.0Gi 1.8Gi 214Mi很多刚接触 Linux 内存管理的朋友第一反应是used只有 1.2G内存还很多啊是不是网络问题但available那一列已经只剩 402MSwap 用了 1.8G系统其实已经在靠交换分区硬撑。这让我意识到内存管理这件事如果只停留在内存还剩多少的直觉层面遇到真实故障时非常容易误判。这篇文章就是写给想系统搞懂 Linux 内存管理的初学者的。我不会只罗列命令而是从底层机制讲到观测方法再讲清楚分配、回收和 OOM Killer 的完整逻辑最后用一次真实的内存泄漏排查收尾。内容会覆盖几个几乎必问的面试题malloc 之后物理内存真的立即分配吗buff/cache是不是内存泄漏available和free到底谁可信无论你是刚入门 Linux 的新人、做运维的同学还是写 C/Python 的开发者这套知识都能直接用在问题定位上。1.1 事件现场一张 free 输出引发的疑问回到刚才那张输出used不高但系统卡死根本原因在于 Linux 的内存管理并不像 Windows 那样内存条满了才紧张。Linux 倾向于把空闲物理内存拿来当文件缓存这部分归在buff/cache里。它看起来被占用了但只要进程真正需要内核可以随时回收这些缓存。问题是如果某一天缓存回收的速度跟不上新增进程的内存需求系统就会立刻进入 swap 阶段。所谓 swap是把内存里的数据先写到磁盘上的一块专门区域等真正要访问时再读回来。磁盘的速度比内存慢几个数量级所以一旦系统开始频繁 swap你会发现vmstat里的si和so数值不断跳动整个机器的响应就会呈断崖式下跌。这也是我为什么强调理解 Linux 内存管理不能只停留在看剩余量还要理解背后那套缓存优先、按需回收的设计哲学。1.2 这篇指南的学习地图我准备把内容分成几条线按顺序推进虚拟内存与进程地址空间每个进程为什么能独占一台机器。分页、页表与缺页中断虚拟地址翻译成物理地址的完整链路。系统内存观测free、vmstat 和 /proc/meminfo 到底该看什么。内存分配与回收机制malloc 到物理页之间发生了什么OOM Killer 怎么工作。内存泄漏排查实战一步步定位一个让系统慢慢变慢的进程。不管你是只在用户态写代码还是需要接触内核驱动这条线走通之后再遇到 DDoS、OOM、swap 抖动这类问题你至少能判断出问题出在哪一层。2. 虚拟内存与进程的地址空间为什么每个程序都觉得自己独占一台机器在物理内存之外引入虚拟内存这一层抽象可以说是操作系统发展史上最重要的决策之一。它同时解决了三个问题进程隔离、地址无关、内存超售。进程隔离很好理解如果每个程序都直接操作物理地址A 程序写坏了地址就会把 B 程序的数据也搞坏这在多任务系统里是不可接受的。有了虚拟内存每个进程看到的是一套从 0 开始的连续地址空间它在这一套地址里为所欲为内核负责把这些虚拟地址翻译到真实的物理地址。地址无关则意味着编译器和链接器不需要关心程序最终被加载到物理内存的哪个位置。可执行文件里的地址可以固定保持不变因为每个进程都拥有自己独立的地址空间映射。而内存超售这个概念今天在云服务器和容器场景里尤其重要。创建进程时内核只需要建立页表不需要立刻把程序的所有数据拷贝进物理内存。一个程序启动时声明了很大的虚拟地址空间但只要它没有实际访问那些地址物理内存就不必支付这笔账单。2.1 进程地址空间长什么样在 64 位 Linux 上一个进程的虚拟地址空间从低地址到高地址大致排列为代码段存放可执行机器码。数据段与 BSS已初始化数据和未初始化数据。堆通过 malloc/brk 向上增长用来分配动态内存。mmap 区域共享库、内存映射文件以及大块 malloc 分配。栈向下增长保存函数调用帧和局部变量。内核空间通常在最高地址区域用户态代码不可直接访问这也是用户态与内核态隔离的基础。想直观地看可以打开任意进程的映射表。比如查看当前 shell 的地址空间$ cat /proc/self/maps或者用下面这条命令查看正在运行的 nginx 进程$ PID$(pgrep nginx | head -1) $ cat /proc/$PID/maps输出内容是一长串地址范围和权限标记比如5632f1000000-5632f1023000 r-xp 00000000 08:01 123456 /usr/sbin/nginx 5632f1123000-5632f1124000 r--p 00023000 08:01 123456 /usr/sbin/nginx 5632f1124000-5632f1125000 rw-p 00024000 08:01 123456 /usr/sbin/nginx 7ffd3f5e2000-7ffd3f604000 rw-p 00000000 00:00 0 [stack]第一列是虚拟地址范围中间的 r-xp 表示读、执行、私有映射。如果你看到某个地址段有 rw-p 却没有对应文件路径那大概率就是堆或者栈。2.2 32 位与 64 位地址空间的差异早期 32 位系统里用户空间和内核空间通常按 3:1 划分进程最多只能用 3GB 左右用户态地址。这也是当年2G 内存够用说法的来源——因为地址空间本身就封顶了。到了 x86-64 架构用户态地址空间扩大到了 128TB 量级程序在实际碰到虚拟地址瓶颈之前先碰到的往往是物理内存不足。这带来的一个直接后果是64 位下一个进程的 VIRT虚拟内存数值可以非常大但它真实占用的物理内存可能很小。如果你只盯着 top 里的 VIRT 判断内存泄漏大概率会误判。所以后面讲到进程内存观测时我特别强调要看 RSS 与 PSS而不是 VIRT。3. 分页、页表与缺页中断从虚拟地址到物理页的翻译链路虚拟地址最终要落到物理内存这一层翻译工作由 MMU内存管理单元Memory Management Unit完成。为了管理简单Linux 并不按字节来映射而是按页为单位进行管理。3.1 分页内存的最小管理单元x86-64 下最常见的页大小是 4KB。也就是说虚拟地址空间被切成一个个 4KB 的虚拟页物理内存被切成一个个 4KB 的物理页框页表负责记录两者之间的对应关系。分页的好处是物理内存可以是不连续的。进程 A 的虚拟页 0 可以映射到物理页框 100虚拟页 1 映射到物理页框 3000。应用程序无需关心物理上是否连续这极大地简化了内存分配也降低了内存碎片化带来的麻烦。3.2 页表与 TLB一次内存访问背后的两步翻译页表本身存放在物理内存中它是一个多级结构。x86-64 通常使用四级页表PGD、PUD、PMD、PTE。每个虚拟地址在翻译时MMU 会取出地址中的不同字段逐级查表最后得到物理页框号再加上页内偏移量形成物理地址。这里面有个性能陷阱如果每次读取内存都要去查一遍多级页表性能会严重下降。所以 CPU 里专门有一块高速缓存叫 TLBTranslation Lookaside Buffer页表缓存用来保存最近用过的虚拟页到物理页框的映射关系。程序访问内存时的局部性越好TLB 命中率就越高性能也越好。用生活类比来说页表像一本字典告诉你某个虚拟地址对应哪个物理地址而 TLB 像是你记住的常用字发音——你不需要每次查字典就能直接读出来。3.3 缺页中断与按需分配当进程访问一个虚拟页但页表里找不到对应物理页框时MMU 会触发缺页异常内核接到异常后开始处理。缺页分两种minor fault页数据已经在物理内存里只是当前进程的页表里没有映射关系。典型场景是共享库首次被进程启动时数据已经在 page cache 中只需建立映射。major fault页数据不在物理内存需要从磁盘读取比如从 swap 换入或者从 mmap 的文件里读入。major fault 的代价很高常常伴随明显的性能抖动。内核的按需分配策略是这里的关键你用 malloc 申请 1GB 内存glibc 分配器可能只是通过 brk 或 mmap 把地址空间扩展了但物理内存并没有立刻分配。直到进程真的去写这块内存时才会触发缺页、真正分配物理页。这也是为什么malloc 之后内存就立刻被占用了是一个错误认知面试题里经常考这个。观察缺页情况可以用 ps$ ps -o pid,minflt,majflt,cmd -p 12345minflt 和 majflt 是累计值想看动态变化可以隔几秒采样两次做差值。3.4 大页和透明大页用空间换时间4KB 页对大多数场景够了但在数据库、JVM 等大量使用内存的场景下页表项太多TLB 很容易被塞满造成频繁的页表遍历。Linux 为此提供了大页机制。2MB 大页意味着同样一块物理内存只需要更少的页表项TLB 覆盖面更大性能自然上去。传统 HugePages 需要提前预留并显式使用而透明大页THP则试图让程序无感地自动使用 2MB 页。听起来很美好但实际生产环境里 THP 经常引发问题它需要分配连续物理页当内存碎片严重时反而会触发长时间的内存迁移或回收导致卡顿。我在很多数据库集群上都是直接关掉 THP 的$ echo never /sys/kernel/mm/transparent_hugepage/enabled要检查当前状态$ cat /sys/kernel/mm/transparent_hugepage/enabled如果你的机器是运行 Redis、MySQL 这类对延迟敏感的服务建议认真评估 THP 的影响。4. free、vmstat 与 /proc/meminfo别再被内存还剩多少骗了现在回到开头的场景。系统观测工具里free是最直观但也最容易误读的一个。4.1 free -h 输出逐字段解读$ free -h total used free shared buff/cache available Mem: 7.6Gi 1.2Gi 326Mi 156Mi 6.2Gi 402Mi Swap: 2.0Gi 1.8Gi 214Mitotal物理内存总量。used内核统计中已被进程使用的量。free完全没有被使用的物理内存。shared主要用于 tmpfs 等共享内存占用的量。buff/cache块设备缓冲和文件缓存之和。available估算的、无需额外交换就能满足新进程需求的可用内存。真正的关键点是free只表示当前完全空闲的物理页而 Linux 会把空闲内存尽量拿去做 page cache。used低不代表安全buff/cache高也不代表内存不够你应该优先看available。available估算的是还能回收多少 cache 来满足新分配比 free 更能反映真实压力。4.2 /proc/meminfo 里真正值得盯的字段free的数据源是/proc/meminfo。想看细节就直接读这个文件$ grep -E MemTotal|MemFree|MemAvailable|Buffers|Cached|SwapTotal|Dirty|Active|Inactive /proc/meminfo几个重点字段字段含义排查场景MemTotal物理内存总量与 dmidecode 对比确认 BIOS 预留等MemFree完全空闲内存仅作参考实际意义有限MemAvailable预估可用内存判断是否需要关注的直接指标Buffers块设备使用的高速缓冲看文件系统读写压力相关Cachedpage cache 大小高是正常现象不代表泄漏SwapTotal/SwapFree交换分区总量与剩余swap 快满时大概率内存压力严重Dirty需要写回磁盘的脏页数量一直很大说明磁盘写回能力跟不上4.3 vmstat 的 si/so 为什么是警报信号vmstat 1可以看到实时的内存换入换出情况$ vmstat 1 procs -----------memory---------- ---swap-- -----io---- -system-- ------cpu----- r b swpd free buff cache si so bi bo in cs us sy id wa st 1 0 1887224 32760 121340 5914400 86 212 512 4320 1200 2800 15 8 70 7 0si表示每秒从 swap 读入内存的数据量so表示每秒从内存写入 swap 的数据量。这两个数值如果有持续的非零输出说明内存已经非常紧张系统正在靠磁盘来回搬运数据。有一次我排查一个性能问题CPU 使用率不高但接口响应很慢。跑了几秒 vmstat 后发现 si/so 一直几十甚至几百立刻判断是内存不足触发 swap而不是应用代码问题。顺着这个思路看到了一个跑偏的 JVM 堆配置问题很快解决。这就是 vmstat 的价值它能让你把慢和内存这两个词快速关联起来。4.4 ps/top 中 VIRT、RES、SHR 的坑top或ps aux里的内存列是最容易被误读的信息VIRT进程看到的虚拟地址空间总大小。包含未提交的物理页不能反映真实物理占用。RES常驻内存集即进程当前真正占用的物理页总量。但因为共享库会被多个进程共映射RES 会重复统计共享的部分。SHR共享内存大小如果两个进程用同一份共享库每个进程的 SHR 都算一次。更准确的指标是 PSSProportional Set Size它按共享比例分摊。可以读/proc/pid/smaps_rollup看到$ cat /proc/12345/smaps_rollup在排查内存泄漏时判断趋势比看单个快照更重要。最怕的就是看到 VIRT 巨大就诊断成泄漏其实那可能只是进程 mmap 了大块虚拟区域但没有实际写入。5. 分配、回收与 OOM Killer内存用尽的最后防线是如何工作的搞清楚了观测接下来理解内核在背后做了什么。进程申请内存不是直接伸手向物理内存要中间隔着一整套分配与回收机制。5.1 malloc 到物理页之间发生了什么写 C 语言的读者对 malloc 都不陌生但很多人不知道 glibc 的 malloc 背后有两条主要路径小对象分配通过 brk 系统调用扩展堆段在堆内部用空闲链表管理。大对象分配一般超过 MMAP_THRESHOLD默认 128KB通过 mmap 分配匿名映射独立管理释放时可以直接归还内核。无论哪条路径最初都只是分配了虚拟地址空间。真正的物理页分配发生在首次访问时也就是前面提到的按需分页。所以如果你 malloc 了 1G 内存但只写前 1M那 RSS 只有 1M 左右这是正常现象不是系统在骗你。C 语言里最容易踩的坑是忘记 free。写服务端代码时一个循环里不断 malloc 却没有任何释放策略进程的 RSS 就会稳步爬升。等到 OOM Killer 出手时你已经看不到太多的诊断空间了。5.2 内存回收kswapd 与 LRU 如何决定牺牲谁当物理内存变得紧张内核需要回收一部分页面来满足新分配。这一步不是随便挑页杀掉而是有一套先缓存、后匿名的倾向。内核线程 kswapd 会周期性地扫描内存页根据 LRU最近最少使用算法维护多条链表。回收顺序大致是回收干净的文件页page cache不需要写回直接丢弃。回收脏文件页需要先写回磁盘再丢弃。回收匿名页即进程私有的堆栈数据这类页通常是不可再生的所以先尝试换出到 swap。这套机制保证了只要你磁盘不慢系统能通过回收文件缓存缓解压力。日志里如果频繁出现直接回收direct reclaim导致的延迟尖刺说明系统已经在紧急边缘了。5.3 swap 与 swappiness 的正确打开方式很多人问我是不是把 swappiness 调到 0 就能避免卡顿其实这是个流传很广的误解。/proc/sys/vm/swappiness控制的是内核使用 swap 的倾向默认值通常是 60调低确实会减少匿名页被换出的概率但代价是文件缓存被压缩、可回收缓存变少内存压力可能更快传导到直接回收路径上。更合理的做法是先保证有足够的 swap 作为紧急缓冲尤其在突发内存尖峰场景。别盲目追求 swappiness0除非你确定工作负载的内存访问模式完全不需要换出。定期看 si/so如果长期持续非零说明物理内存是真的不够光调参数没意义。5.4 OOM Killer最后一道门槛当内存压榨到极限内核会调用 OOM Killer 杀掉一些进程来释放内存。它并不是随机乱杀而是根据 oom_score 选择坏进程。oom_score综合考虑进程占用内存大小、运行时间、优先级等因素数值越大越可能被杀。普通用户态进程通常比内核线程分高内存占得多的进程分更高。你可以在运行时调整$ echo -500 /proc/12345/oom_score_adj负数表示降低被选中的概率正数反之。对数据库这类关键进程企业里常设置oom_score_adj防止它被误杀。当 OOM 发生时内核会在系统日志里留下现场$ dmesg | grep -i out of memory | tail输出里能看到哪个进程被杀了、当时系统统计的内存状况。如果是在容器里触发还要看 cgroup 的 memory 限制检查/sys/fs/cgroup/memory/memory.oom_control这类节点。很多线上容器无故退出的根因就是 cgroup 内存上限打到了根本轮不到系统级 OOM Killer 出手。6. 内存泄漏排查实战从一个缓慢变大的进程说起最后分享一次真实的内存泄漏排查过程。有一回后台服务的 RSS 值从 200MB 开始每过几天就涨到 2GB甚至连着出现了几次 cgroup OOM。听起来像是内存泄漏但我没有直接拿着 valgrind 冲进去跑而是按步骤做判断。6.1 第一步确认是真的在增长先写一个简单的 sampling 命令观察稳定增长而不是随机波动$ for i in $(seq 1 20); do grep VmRSS /proc/12345/status; sleep 5; doneVmRSS 不断上涨、且从不回落才有进一步排查的必要。如果只是周期性波动很可能是缓存或连接池的正常行为。6.2 第二步判断增长发生在哪个区域接着看 smaps 里的堆和匿名映射段$ grep -A6 ^7f.*rw-p /proc/12345/smaps | head -40也可以按前后两次采样对比堆段的 Rss 变化。如果堆段增长说明 C/C 动态分配的对象没有被释放如果匿名 mmap 区域增长可能是大对象分配没有归还内核。6.3 第三步定位泄漏源的工具与思路确认增长方向后选择工具语言/场景推荐思路C/C 程序valgrind --leak-checkfull或用 AddressSanitizer 重编译Python 程序tracemalloc、pympler 跟踪对象分配Java/JVM 进程看堆与非堆内存用 jstat 或 VisualVM 抓 GC 后曲线内核/驱动观察 /proc/slabinfo 里特定 slab 是否持续增长C 语言服务我一般先跑一下$ valgrind --leak-checkfull --show-leak-kindsdefinite ./your_server但 valgrind 对线上服务太重所以更实用的做法是先在预发环境配合高负载压测让泄漏快速暴露。还有一次问题不在用户态而是某个内核模块的 slab 缓存持续增涨用户在用户态怎么查都查不到最后是通过对比/proc/slabinfo两次快照差异发现的。6.4 排查中的常见误判经验里最容易出错的三个点把 page cache 当成泄漏。buff/cache高不代表你的进程有问题要对比进程 RSS 而不是 free 里的 used。把 VIRT 高当成 RSS 高。看 VIRT 判断内存泄漏基本是噪音哪怕是一个很小的进程也可能 mmap 了很大的虚拟区域。只看峰值不看重启后的趋势。如果进程重启后 RSS 还是逐步涨到同样高度大概率是真泄漏如果每次都直接涨到接近某个上限就不动了更可能是内存池或缓存正常构建到了稳态。用这套流程那次泄漏最终定位到一个第三方 C 库在每次请求时都申请了一块缓冲区但只有成功路径会释放失败路径直接 return。修复后 RSS 稳定在 300MB 左右连续观察了两周没有任何爬升迹象。内存泄漏问题很多时候不是工具不够强而是定位思路被 VIRT 和 page cache 这些表面现象带偏了。Linux 内存管理表面上是一堆数字和命令背后却是操作系统几十年来对空间、性能、可靠性不断权衡的结果。把这套机制理解透再回去看 free、vmstat、OOM Killer你会发现自己和不理解时的判断力完全不一样。