1. 从 free 命令的一个反直觉现象说起:内存到底去哪了
先说个我每次培训新同事都会抛出去的场景:一台 64G 内存的服务器,上面跑了四五个 Java 服务,登录进去执行free -h,结果 available 只剩 2G 左右,buff/cache 却占了 50 多 G。新同事的第一反应是"完蛋了,内存泄漏",第二反应是"要不要把服务重启一下"。而我一般会先让他们执行一条cat /proc/meminfo,看清楚里面的CommitLimit、MemAvailable、Dirty这些字段,再解释为什么这台机器其实一点都不危险。
如果你也对 Linux 内存有过类似的困惑——明明 free 显示没多少可用内存,但服务跑得好好的;或者反过来,free 看着还有几个 G,程序 malloc 大块内存却失败——那这篇"linux内存学习记录"就是为你准备的。这不是一篇教科书式的原理综述,而是一条从问题出发、逐步把物理内存分配、虚拟地址空间、页缓存、回收机制、排查工具串起来的完整学习路径。阅读过程中你会频繁用到free、vmstat、top、ps、/proc这些老面孔,但看完之后你会对它们背后发生的事情有完全不一样的理解。
我默认你至少对 Linux 基础命令有一定了解,比如能看懂ps aux、知道top里有 RES 和 VIRT 两列。如果你连这些也还没完全搞明白,也没有关系,文中凡是涉及关键概念的地方,我都尽量给了一个从"直觉层面"理解的类比,你可以先建立起整体框架,再回头补细节。
先说结论,避免你后面越看越慌:Linux 内存语义里的"已用"和你脑子里"被程序占用的内存"不是一回事。其中相当可观的一部分是 page cache(页缓存),它本质上是内核在用空闲内存帮你缓存磁盘数据,一旦有程序真的需要内存,这些缓存可以立刻被回收。所以,正确评估一台 Linux 服务器内存够不够用的指标,首选不是 free 里的"used",而是available或者MemAvailable。这个字段专门用来表示"在不触发 swap 的前提下,还能安全分给新程序的内存估算值"。
那为什么很多人还是喜欢盯着 used 看?因为 Windows 和 macOS 的"内存压力"可视化做得比较好,任务管理器会直接把"已缓存"和"已使用"分开给色块,而 Linux 的 free 输出看起来就是冷冰冰的数字,你不懂语义就很容易误读。下面我会把这块面板彻底拆开。
2. 先理清虚拟内存和物理内存的关系:进程眼里永远是一整块连续的地皮
2.1 每个进程都有自己独立的虚拟地址空间
很多初学者最容易绕进去的一个点,是把"进程分配了多少内存"直接等同于"物理内存少了多少"。实际上,当你调用malloc(1024 * 1024 * 1024)申请 1G 空间的时候,系统给你的是 1G 的虚拟地址空间,而不是立刻划走 1G 物理内存。只有在代码真正去读写这片区域的每一个页(page)时,内核才会通过缺页异常(page fault)把物理页映射上来。
这里我常用一个地产开发的类比:虚拟地址空间像一张"规划图",上面画好了道路、绿化、商业区的格子,但格子里并没有真的盖楼。只有当建筑队(进程代码)进场施工(访问内存页)时,才一栋一栋地盖。盖楼需要砖头,砖头就是物理页。你规划了 1G 的地,不代表你马上需要买 1G 的砖。
在 Linux 里,每个进程的虚拟地址空间从低地址到高地址大体分为这么几段:代码段、数据段、堆(heap)、内存映射区(mmap 区域)、栈(stack)、内核映射区。在 64 位系统上,用户空间通常占 128T 左右的虚拟地址(取决于具体架构的分配策略),内核空间在最高的地址段。你可以在/proc/pid/maps文件里看到某一个进程完整的虚拟地址空间布局,每一行代表一段映射,右边还标注了权限(r/w/x/p)和对应的文件或设备。
2.2 页表和缺页异常是虚拟内存落地的关键
虚拟地址要转换成物理地址,依赖的是页表(page table)。每个进程有一套属于自己的页表,里面记录着虚拟页号到物理页框号的映射关系。如果某个虚拟页在页表里没有有效的映射,进程一访问它,CPU 就会触发缺页异常,内核的缺页处理路径就开始工作:
- 如果是合法地址但物理页还没分配,就从伙伴系统(buddy system)拿一个零页或文件页,填入页表;
- 如果是被换出到 swap 的页,就从 swap 分区读回来;
- 如果是内存映射的文件页,而且内容还没在物理内存里,就发出磁盘 I/O 把文件内容缓存进 page cache,再建立映射。
这个过程对进程来说是透明的,但每次缺页都有微小开销。如果一个程序的局部性差,频繁访问新页面,就会频繁缺页,这时候你可以在vmstat里看到cs(context switch)和in(interrupt)升高,也可以直接用perf看 page fault 事件。
这里有一个经验判断:程序实际占用的物理内存,可以粗略用top里的 RES 衡量,但要精确到"多少个物理页被这个进程独占、多少个页和其他进程共享",就得借助/proc/pid/pagemap或者smem这类工具。后面我会专门讲怎么观测。
2.3 overcommit:为什么 malloc 100G 也可能"成功"
你要是申请过特别大的内存就会好奇:我机器物理内存才几十 G,为什么malloc(100 * 1024 * 1024 * 1024)居然返回了非空指针?因为内核默认允许内存过量分配(overcommit),具体行为由vm.overcommit_memory决定:
| 值 | 含义 | 典型场景 |
|---|---|---|
| 0 | 启发式:对明显过大的申请拒绝,一般申请放行 | 大多数发行版默认值 |
| 1 | 永远不拒绝,申请多少都批 | 数据库等需要大内存映射的场景禁用 |
| 2 | 严格模式:不允许超过 CommitLimit | 想做资源隔离的服务器建议开启 |
CommitLimit的计算公式大致是:swap 大小 + 物理内存 × vm.overcommit_ratio / 100。默认为 50,意思是系统承诺可以分配"物理内存的一半 + swap"这么多虚拟空间。如果你把 overcommit 设为 2,那么一旦超出这个额度,malloc 就会失败。这个"承诺额"你看free -h的最后一行也能看到:CommitLimit和Committed_AS。
为什么默认不严格限制?因为大多数程序申请内存后并不会马上全量触达,尤其像 Java 的堆、Python 的某些缓冲池,都是预留大量的虚拟空间,真正使用的量小得多。严格模式能防"某进程恶意申请一大片虚拟内存导致其他进程遭殃",但也会误伤那些正常"预留 + 渐进使用"的现代运行时,所以很少有人全局开 2。
3. 物理内存分配的核心组件:从伙伴系统到 slab 再到 perf event
3.1 伙伴系统:按 2 的幂次拆分的页块管理
Linux 物理内存的分配最底层是伙伴系统。它的思路很简单:把物理内存按照 order(阶)组织成一系列块,order 0 是一页(通常 4K),order 1 是两页,order 2 是四页……一直到 order 10 左右(也就是一大块连续内存)。每个 order 的空闲页块挂在对应的链表里。分配时,如果请求 order N 的块,系统会从 order N 的链表找,找不到就向上到 order N+1 借一半拆开,剩下的另一半挂回低阶链表;释放时,如果发现相邻的兄弟块也是空闲的,就把它们合并成更大的块。
你可以在/proc/buddyinfo文件里看每个内存区域(Node0 的 DMA32、Normal 等)各 order 的空闲块数量。如果 0 阶空闲页很多但高阶空闲页几乎没有,说明内存碎片化比较严重,后续即使整体内存还有富余,也可能分配不出大块的连续物理内存。有些驱动(比如 DMA、显卡)会要求连续的物理内存,这时碎片化就很致命。
3.2 slab/slub 分配器:给内核对象准备的"隔间仓库"
内核本身频繁需要分配各种小结构体,比如 task_struct、inode、dentry、文件描述符等。如果每次都走伙伴系统去申请一页或若干页,浪费极大。所以 Linux 在伙伴系统之上做了一层 slab 分配器(现在很多发行版默认用 SLUB 变体)。它把一页或几页拆成等大小的对象(object),同一类型的对象放在一个 cache 里。你用cat /proc/slabinfo能看到所有 cache 的名字、活跃对象数、总对象数和每个对象的大小。
排查具体问题的时候,/proc/slabinfo经常能给你指路。比如说你怀疑某个内核态文件句柄泄漏,可以用slabtop看一眼,如果 dentry 或 filp 那个 cache 的 active 对象数量一直在上涨,基本上就能锁定泄漏方向。另外很多"内存莫名被吃掉"的案例,最后查出来其实是内核某个驱动模块在疯狂申请 DMA buffer 或 skb,这类内存你用top看进程是看不到的,必须看 slab。
3.3 现代内核的 per-cpu 页框缓存
为了减少多核 CPU 在分配物理页时的锁竞争,内核给每个 CPU 都准备了一个页框缓存(per-cpu page list)。当某个 CPU 需要分配页时,优先从自己私有的缓存列表里取;释放页时也先放回自己的缓存,只有当缓存超过高水位时才会把一批页归还给伙伴系统。这个机制大幅提升了内存分配性能,但也带来一个观测上的小坑:你通过/proc/buddyinfo看到的空闲页数量,可能没有把 per-cpu 缓存里的页算进去,导致看起来"空闲内存比预期少"。好在这些缓存页通常不多,一般不会造成实质困惑。
3.4 Page Fault 的性能代价与观测手段
缺页异常分两种:一种是"轻微的"(minor fault),页表建个映射就行,不需要读磁盘;一种是"严重的"(major fault),需要从磁盘把数据读进来,代价是毫秒级别的。一个程序如果老是产生 major fault,运行速度会肉眼可见地卡顿。
用time -v跑一个命令,可以看到 "Minor (reclaims) page faults" 和 "Major (faults) page faults" 的具体数字。用perf stat也可以看 page-faults 和 dTLB-load-misses 之类的事件。对分析内存行为来说,这比空泛地看 RES 更有指导意义。
4. 程序视角的 malloc:用户态分配器怎么跟内核要内存
4.1 brk 与 mmap 两条通道
从用户态视角看,进程向内核要内存就两条正经路径:
brk/sbrk:调整堆顶指针,适合分配小块内存。特点是内存在地址空间里是连续的,释放后不一定立刻还给内核,而是留在堆里供下次复用。mmap:在内核地址空间里创建一个匿名映射或文件映射,适合大块内存或需要独立映射的场景。调用成本比 brk 高,但分配和释放更干净:munmap 之后地址空间立即释放。
glibc 里的 malloc 实现默认对阈值以下的请求(默认MMAP_THRESHOLD是 128KB)走 brk,超过阈值走 mmap。而且这个阈值是动态调整的:如果程序大量使用超过阈值的块并频繁释放,glibc 会把阈值调高,避免频繁 mmap/munmap 的系统调用开销。这个细节值得记住,你以后想优化一个频繁分配释放小块内存的程序时,第一步一定是改造内存池,而不是去调整系统参数。
4.2 为什么 top 里 VIRT 巨大但 RES 正常
很多人看到 Java 进程的 VIRT 是几十甚至上百 G,直接吓一跳。实际上 VIRT 只是进程"看到"的地址空间总量,包括了很多从未被访问过的映射。现代 JVM 的 G1/ZGC 为了管理堆内存,会保留大量虚拟地址,但只在需要时才真正触达物理页。所以 VIRT 大不代表 RSS 大,更不代表物理内存吃紧。
判断一个进程真正占了多少物理内存,最直接的指标是 RSS(Resident Set Size)。但 RSS 并没有考虑共享页面的问题:两个进程共享一个动态库,库的物理页在/proc统计里可能被重复计算。更精确的指标是 PSS(Proportional Set Size),它把共享页按进程数量均摊。smem工具可以列出 PSS,也可以按用户、按命令聚合统计,这个工具在排查"到底谁占了内存"时非常实用。
4.3 内存池和 jemalloc/tcmalloc 存在的意义
如果你接手过 C/C++ 服务,大概率听过 jemalloc 和 tcmalloc。它们替代 glibc malloc 的核心动机是:在多线程高并发场景下,glibc 的 malloc 在 arena 处理上会产生锁竞争,线程多了以后分配效率直线下降。jemalloc 和 tcmalloc 都做了 per-thread cache,线程先在自己私有的 cache 里取内存,减少全局锁。它们在改善性能的同时,也会让 RSS 看起来比 glibc 下高一点,因为每个线程都囤了一些内存块。
选型方面没有绝对优劣,但我个人的实践感受是:追求稳定和广泛兼容先用 glibc;高并发小对象分配密集的服务优先尝试 tcmalloc(Google 系配套好);追求极致的低碎片化看 jemalloc(Rust 和很多数据库都在用)。想直接验证效果,就用LD_PRELOAD=/path/to/libjemalloc.so your_program替换运行,对比valgrind --tool=massif或者heaptrack记录的内存峰值曲线。替换前记得做好多环境兼容性测试,有些程序对 malloc 实现很敏感。
5. page cache 与脏页回写:为什么 buff/cache 高是健康状态
5.1 文件读写的缓存机制
free输出里的 buff/cache 分为两块:buffer 和 cache。严格讲,buffer 是对块设备缓冲区的统计,cache 是文件页缓存(page cache)。不过在 2.6 之后的内核里,两者越来越趋同,很多资料直接合称"内核页缓存"。核心思想其实一句话:内核把读过的磁盘数据留在内存里,下次再读就不用碰磁盘。写数据也一样:先写进页缓存,标记为脏页(dirty),再由内核异步刷到磁盘。
我可以再打一个比方:page cache 相当于你工位旁边的一个小货架。你从仓库(磁盘)拿材料时,顺手多拿一点放在货架上,下次要同一种材料就直接从货架取,速度快得多。材料用得少了,货架空出来,又随时可以腾给别的用途。所以 page cache 永远不会是"浪费的内存",它是一种自动化的加速器。
5.2 dirty 页的触发阈值与回写节奏
脏页不会立刻写回磁盘,内核按几个阈值控制回写节奏:
vm.dirty_background_ratio(默认 10):脏页占内存比例达到这个值时,后台内核线程开始异步刷盘,不阻塞应用。vm.dirty_ratio(默认 20):脏页占内存比例达到这个值时,应用自身的写操作会被阻塞,强制参与刷盘。
这两个参数在传统机械盘环境中比较重要。如果你把 dirty_ratio 调得过大,脏页积压多,一旦系统需要回写,会造成长时间的 I/O 抖动。调得太小,磁盘写频繁,吞吐变低。近些年 NVMe 时代,有人会把这两个值调大来提升大批量写性能,但要结合掉电风险考虑:断电时未落盘的脏页会全部丢失,所以别极端。
想实时看脏页数量,用cat /proc/meminfo里的 Dirty 字段,或者vmstat 1看bo(blocks out)的变化趋势。当一个进程频繁写文件、但磁盘利用率不高时,大概率是数据大量窝在 page cache 里。
5.3 手动 drop_caches 到底该不该用
很多运维老哥在一看到 buff/cache 偏高就来一句:echo 3 > /proc/sys/vm/drop_caches。我强烈不建议把这个当成常规操作。drop_caches只是丢弃那些干净页缓存,释放出的内存看起来是多了,但下次再访问这些文件时,系统又要重新读磁盘,性能瞬间降回去,相当于你为了省空间把货架全清空,然后每次取料都往仓库跑。
真正需要 drop_caches 的场景只有两种:一是你想做冷启动性能测试,必须清掉页缓存才能测出真实的读盘速度;二是内核或驱动有 bug 导致缓存异常膨胀。日常监控只要看 available 就行,不要动不动去"清理内存"。
6. 内存回收和 swap:系统在内存吃紧时怎么保持运转
6.1 回收的优先级:先文件页,后匿名页
当系统物理内存不足时,内核会启动内存回收(memory reclaim)。它优先回收的文件页分两种:
- 干净文件页:直接丢弃,不需要写磁盘,代价最低;
- 脏文件页:需要先写回磁盘再丢弃,代价稍高。
接下来才是匿名页(匿名映射,比如堆、栈、写时复制产生的私有页)。匿名页没有磁盘后备文件,只能写进 swap 分区或者压缩内存(zram 之类),所以回收代价更高。这就是为什么系统总是优先清 page cache 而不是去换出进程内存。所以你会看到一种现象:服务器内存明明很紧,top 显示进程 RES 都很大,free 的 available 都快见底了,swap 才开始缓慢增长。这不是内核反应慢,而是它已经把最廉价的回收手段用完了。
6.2 LRU 链表与回收效率
每个内存区域会维护若干 LRU 链表,最核心的是active和inactive两类列表,分别再按匿名页和文件页细分。页面刚被访问时通常进入 inactive;被访问次数多了之后会被激活到 active。回收时,内核优先扫描 inactive,尽量不影响活跃进程。
你可以从/proc/meminfo里看到这些分类:Active(anon)、Inactive(anon)、Active(file)、Inactive(file)。如果Inactive(file)很大,说明有很多应该被轻松回收的缓存页;如果Inactive(anon)一直在涨,说明程序可能在制造大量匿名页然后又弃用,这种场景值得你用后面提到的排查方法去查一下是不是存在内存泄漏。
6.3 swappiness 与 swap 的误区
vm.swappiness这个参数大概是 Linux 内存话题里被误解最严重的一个。很多人看到默认值 60,就以为系统会"很积极地用 swap",于是无脑改成 0,希望"优先使用物理内存"。但实际上 swappiness 控制的是内核在回收匿名页和文件页之间的倾向性:值越高,越倾向于回收匿名页(把它们换到 swap);值越低,越倾向于回收文件页。
在纯物理内存服务器上,把 swappiness 调低可以让进程尽量少被换出,感觉上"流畅了";在有 swap 的场景下,合理设置可以保护 page cache 不被过度挤压。需要注意的是,如果系统内存已经非常紧张,即便 swappiness=0,内核也依然会把匿名页换出去,因为文件页已经回收干净了,别无选择。所以想靠调 swappiness 根治内存不足,基本是缘木求鱼。
6.4 OOM Killer:最后一根救命稻草
当回收都救不了,内存仍不够用时,内核会触发 OOM Killer。它会遍历进程,根据一个 oom_score 分数来挑选"最该被杀"的进程。评分综合考虑进程的 RSS、是否 root、是否直接访问设备等因素,用户可以用echo 1000 > /proc/pid/oom_score_adj让该进程优先被杀,或者-1000表示几乎不可杀。
日常运维中,如果发现 OOM 频繁,不要只盯着日志里的进程名。日志只会告诉你谁在被杀的那一刻占内存最多,不代表它是问题的根源。有可能是某个父进程没处理好子进程,导致内存暴涨;也有可能是 cgroup 限制设置得太小。我记得有一次排查线上 OOM,最后发现是 Java 进程的-Xmx没跟着容器memory.limit调整,堆内分配逻辑正常,但堆外的 metaspace 和线程栈把容器配额打爆了。那根本不是代码泄漏,而是配额语义没对齐。
7. 附:内存排查工具链与常见误判复盘
7.1 从 free 到 /proc 的完整观测路径
最后集中总结一下我最常用的内存排查手段,从宏观到微观排一个顺序:
free -h:看总体水位,重点看 available,而不是 used。vmstat 1:看 si/so(swap in/out)、cache 变化、cs(上下文切换),判断系统是否在频繁换页。top按 M 排序:快速找出 RES 高的进程,再看它的%MEM。ps aux --sort=-rss | head:命令行下快速定位 TOP 内存进程。cat /proc/meminfo:看详细字段,尤其是MemAvailable、Cached、Dirty、Active(file)。slabtop/cat /proc/slabinfo:排查内核态占用。smem -k -s pss:按 PSS 统计用户态进程的真实物理内存占用。/proc/pid/status里的VmRSS、VmSize、VmSwap,看单进程明细。
对于内存泄漏,Linux 用户态通常有两种定位手段:进程 RSS 持续上涨但业务没有对应增长,可以先考虑分配器层。用valgrind --tool=memcheck适合中小型程序;大型服务建议用jemalloc自带 profiling 或者heaptrack抓采样数据,在内存曲线陡增的时间窗口里统计分配栈,基本能锁定热点。Rust 和 Go 程序的内存排查则优先看 runtime 自身的指标,别一上来就怀疑 C 库分配器。
7.2 两个经典误判案例
先说 antimalware service executable 这类词为什么经常出现在"什么占内存"的热搜里?这是 Windows 上的杀毒进程在扫描时会把很多文件读入内存,造成瞬时内存占用飙升。这本身不一定是 bug,而是杀毒软件在大量读取文件时没有及时释放缓存,或者扫描引擎设计得比较吃内存。类比到 Linux,很多"内存占用高"的原始现象背后其实是缓存语义没搞清楚——不是 Linux 独有的问题,而是大家跨平台看多了,习惯把所有内存增长都当成异常。
另一个常被吐槽的是 Spark、JVM 这类 Java 系应用。JVM 的堆外内存(metaspace、直接缓冲区、线程栈、JIT 代码缓存)很容易被人忽略,导致你按 -Xmx 预估内存,结果 RSS 远超预期。比如你设置堆 4G,想着容器给 5G 应该够,结果运行后发现 RSS 到 6G。这种情况不是内存泄漏,也不是 Linux 分配有问题,而是 JVM 的全貌本来就不止堆。排查时要看 Native Memory Tracking,jcmd <pid> VM.native_memory summary可以列出每个区域的使用量。
7.3 容器环境下内存限制的正确打开方式
云原生时代还有个高频问题:容器里用 cgroup 限制内存之后,怎么观察内存是否真的接近上限?方法是在容器里读/sys/fs/cgroup/memory.max(cgroup v2)或/sys/fs/cgroup/memory/memory.limit_in_bytes(cgroup v1),再对比/sys/fs/cgroup/memory.current(v2 的 memory.current / v1 的 memory.usage_in_bytes)。注意一点:在容器里执行free,看到的主机全局内存,不是容器自己的配额。很多人在容器里看到 free 还有几十个 G,就觉得内存充足,其实容器已经被 OOM 好几次了。要养成"看 cgroup 而不是 free"的习惯。
如果业务应用同时有多个实例部署在同一批主机上,务必给每个实例的堆外内存也留余量,别把 cgroup limit 等于 -Xmx 加上一两个 G 就完事。最好是先在预发环境用压测跑出实际的 RSS 峰值,再决定 memory limit。我在项目里一般会预设峰值余量 20%,等监控上线后再逐步收敛。
8. 写在最后:我的几个实操体会
我在很长一段时间里也犯过"看到 buff/cache 高就想 drop"的毛病,后来被一个老前辈纠正过,才认真去翻内核文档。现在我把排查思路总结成一条比较顺的路径:先看available判断整体水位,再用vmstat判断有没有持续的 swap 换页,接着用top和smem定位具体进程,最后用/proc和 cgroup 确认边界。整个过程不需要花哨的工具,但要真正理解每一步在说什么。
如果你正要系统地学 Linux 内存,我建议按这个顺序读:先理解虚拟地址空间和页表,再看伙伴系统与 slab 的区别,接着理解 page cache 和回收机制,最后才是花式排查技巧。很多人一上来就背命令、抄参数,遇到症状稍一变化就抓瞎,根子就在于"不知道机制只管现象"。先把原理层打通,后面所有命令都只是验证手段。
最后分享一个小习惯:我会在每台服务器上留一个简短的采集脚本,定期把/proc/meminfo、/proc/buddyinfo、/sys/fs/cgroup/memory.current这些关键值记录下来。出现问题时,先去看时间线而不是急着 kill 进程。内存问题大多不会是瞬时的偶发,只要数据留得够细,"到底是什么在涨"通常一眼就能看出来。这套方法不需要什么高深工具,但对稳住线上环境极其有效。