接到这类“学习记录”型的项目,我一向比较谨慎。因为大多数人的所谓记录,其实就是把 free 输出的数字抄一遍,然后配上几句“内存不够了,要加内存”的结论,看完毫无收获。真正有价值的记录,应该是把内存从内核管理到应用分配这一整条链路摸清楚,并且能在线上出问题时准确判断——到底是真泄漏,还是缓存迷惑了你,还是 JVM 的堆外内存搞的鬼。这篇就是我整理过的 Linux 内存学习与实战排查记录,覆盖了监控命令、分配原理、泄漏定位、调优方向,以及和 JVM、容器这些高频关联场景的对照。适合刚入门 Linux 运维、做后端开发,或者被“内存占用过高”折磨过的朋友参考。
1. 内存监控工具链:先看清现状再谈优化
1.1 free 命令的几个隐藏细节
我先说一句可能得罪人的话:很多教你“看内存”的教程,对 free 命令的解释是错的。他们只看第一行 total、used、free,然后告诉你“free 太小,内存不够了”。这套逻辑放在十年前勉强能看,放在今天会闹笑话。
现在的 free -h 输出,重点要关注的是 available 这一列,而不是 free。因为 free 只表示完全没有被使用的物理内存,而 Linux 的内存管理哲学是“闲着也是闲着”,它会把空闲内存大量用于磁盘缓存,也就是 buff/cache。这些缓存是可以在内存压力下回收的,真正能分给你的应用的内存,要看 available。
free -h total used free shared buff/cache available Mem: 31Gi 4.7Gi 1.1Gi 357Mi 25Gi 25Gi Swap: 8.0Gi 0.0Ki 8.0Gi上面这台机器里,free 只有 1.1G,感觉很危险对不对?但 available 有 25G,说明系统当前一点都不缺内存。真正判断内存够不够,一句话:持续观察 available,如果它不断下降、逼近 0,并且 swap 开始增长,才说明物理内存真的吃紧了。
还有个细节,free 默认以人类可读的方式显示,但不同版本对单位的处理不太一样。有些老版本没有按 1024 换算,而是按 1000 换算,容易产生误解。建议直接加 -w 参数按 KB 输出,或用 -m 固定按 MB 显示,做监控采集时更稳定。
free -w -s 5这个命令每 5 秒刷一次,适合你在现场盯着看变化趋势。我通常会在排查时开一个终端挂着它,一边执行其他操作,一边看 available 和 cache 的联动变化。
1.2 top、vmstat、pidstat 怎么搭配用
free 看的是整机维度,接着就得落到进程上。top 是最直观的,但要分清三列的定义:
- VIRT:进程申请的虚拟内存总量,包含了共享库、映射文件、申请后还没实际写入的区域。这个数值可以非常大,但它不等于真实占用的物理内存。
- RES:驻留内存,也就是进程真正占用的物理内存页数量。这才是排查“谁吃内存”时要看的。
- SHR:共享内存,包括和其他进程共享的动态库等。RES 里包含了 SHR,所以多个进程共享的库内存会被重复计算。
如果你在 top 里看到某个进程 VIRT 显示 20G,RES 只有 800M,不用慌,虚拟地址空间本来就是虚拟的,只有 RES 才是花出去的物理内存。
vmstat 是另一个常用工具,重点看两列。
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 0 1180560 112404 24060560 0 0 1 13 1123 2123 4 1 95 0 0si 和 so 分别是 swap 换入换出的量。如果这两个数字持续不为 0,说明物理内存已经严重不足,内核在不断把内存页倒腾到磁盘上,这种状态下整个系统的性能会像被掐住脖子一样。判定标准:si/so 持续增长,比 free 列的任何数字都更有说服力。
pidstat 则是看单进程更精确的选择,它能把 CPU、内存、IO 分开统计。比如只看内存:
pidstat -r -p 12345 2它会输出该进程的 RSS、虚拟内存、以及 %MEM。好处是字段干净,适合写脚本做巡检。
1.3 pmap 与 smaps:深入进程的地址空间
如果怀疑某个进程内存异常,pmap 是比 top 更深入的观察窗口。它能列出进程完整的虚拟内存地址分布图,让你看到到底是哪个区域在吃内存。
pmap -x 12345输出里会有 heap(堆)、stack(栈)、anon(匿名映射区)、以及各种 so 库的映射。排查泄漏时,重点关注 anon 区域的大小变化。如果这个值随着时间持续变大且不回落,内存泄漏的可能性就很高。
pmap 的信息其实来自 /proc/PID/smaps,这个文件更细,每一个内存段都带着 Rss、Pss、Private 等字段。这里引出一个重要概念:RSS 会把共享内存重复计算,而 PSS(Proportional Set Size)按比例分摊。比如一个 so 库被 10 个进程共享,它在每个进程的 PSS 里只算十分之一。所以评估一个进程真正“独占”了多少物理内存,看 PSS 更合适。
读取 /proc/PID/smaps 需要相应权限,普通用户看自己的进程没问题,看别人的进程会提示 Permission denied,这就是热词里“用户拒绝访问内存文件权限”的典型场景。排查系统服务时记得用 sudo,否则会漏掉大量信息。
2. 内存分配底层逻辑:从页表到伙伴系统
2.1 虚拟内存与页表的映射关系
想深入理解 Linux 内存,得先接受一个关键认知:应用 malloc 拿到的地址,不是物理内存地址,而是虚拟地址。内核通过页表把虚拟地址映射到真实的物理页框。进程每次访问一个还没映射的虚拟页,CPU 就会触发缺页异常,内核这才去分配一个物理页,建立映射关系。
这就解释了一类经典现象:程序 malloc 了 8G 内存,随后 sleep,你发现它的 RSS 可能只有几 MB。因为它申请到了 8G 的虚拟地址空间,但只有实际触发缺页的页才占用物理内存。这种叫“惰性分配”,是 Linux 为了效率做出来的机制,也说明了为什么千万别用 VIRT 来判断内存占用。
页的大小默认是 4KB,一个进程访问大量内存就意味着大量页表项。对于动辄几 GB 内存的数据库或 Java 应用,页表本身也会占用不少内存。内核提供 THP(透明大页)机制,把连续的 4KB 页合并成 2MB 的大页,减少页表项、降低 TLB miss。但这个机制并非没有副作用,有些程序会因此统计到更大的 RSS,甚至因为内存规整时要迁移内存页,导致短暂的卡顿。我的建议:默认场景别动,如果跑的是数据库这类对延迟敏感的服务,需要单独评估后再决定是否关闭或开启。
2.2 伙伴系统与 slab 分配器
物理内存页的管理核心叫伙伴系统。它把所有空闲页框按 2 的幂次分成不同大小的块,申请内存时,内核从合适大小的块里取,释放时再尝试把相邻的块合并回更大的块。这个设计是为了尽可能减少外部碎片。
/proc/buddyinfo能直接看到各个内存节点的空闲块分布情况。比如你想分配一块 2MB 的连续内存,但 2MB 级别的块数为 0,只剩散落的 4KB 小页,那就说明内存碎片化严重。判断碎片化有个实用心态:可用内存还很充足,但程序申请大内存失败,先怀疑碎片和 cgroup 限制,再怀疑真的不够。
伙伴系统分配的是整块物理页,但内核自身也需要频繁创建和销毁小对象,比如文件描述符、目录项 dentry、socket 结构体。每次都直接找伙伴系统又慢又浪费,于是有了 slab 分配器。它把同类型的对象放进缓存池,复用时不重新初始化。
排查内存问题时,/proc/meminfo里的 Slab 字段值得关注,细分是 SReclaimable 和 SUnreclaim。SReclaimable 可以回收,比如 dentry cache;SUnreclaim 则回收不掉。如果 SUnreclaim 持续增长且稳定在高位,往往是内核对象泄漏,比如驱动 bug、网络连接相关的内核结构没有释放。这种问题用户态工具看不出来,只能看内核日志和官方补丁。
2.3 NUMA 架构下的内存分配差异
多路服务器的内存不再是一个大池子,而是每个 CPU 有自己临近的内存节点。CPU 访问本地节点内存快,访问远端节点内存慢,这就是 NUMA 设计。Linux 默认的策略倾向“分配在发起分配请求的 CPU 所在节点”,也就是 localalloc。如果节点间负载不均衡,可能出现一个节点内存耗尽,另一个节点内存闲着,但可用内存总量还很充足的情况。
遇到这种问题,先用 numastat 看命中情况。
numastat node0 node1 numa_hit 412334231 389221033 numa_miss 2234561 8921012 numa_foreign 1029341 124592 interleave_hit 10424 132334hit 是分配成功的次数,miss 是分配到了远端节点的次数。miss 占比太高,说明节点间内存访问频繁,延迟会比理想状态高。如果程序绑定了 CPU 核,但内存分配到了远端节点,跨节点访问延迟会成为性能瓶颈。解决思路是用 numactl 让内存与 CPU 绑定在同一个节点:
numactl --membind=0 --cpunodebind=0 ./your_app我实际遇到过一个案例:一个内存密集型的服务,在 2 路服务器上跑,吞吐比预期低了 20%,排查一圈发现线程跑在 node0,但内存主要分配在 node1,跨节点访问拖了后腿。换成绑定策略后,性能立即回暖。NUMA 的问题很隐蔽,因为它不影响正确性,只影响性能。
3. 内存泄漏定位与修复:一次线上事故复盘
3.1 内存泄漏的典型症状与判定
先说结论:内存泄漏不是看一次 free 能确定的,而是看趋势。一个正常的后台服务,内存占用应该是平缓的、有波动的,但不会无限上涨。如果你发现某个进程的 RSS 每天固定增长几个百分点,重启后恢复正常,过几天又涨回去,基本就能判定是泄漏。
但这里有个陷阱:Java 程序的堆内存本来就是弹性增长的,上涨到 Xmx 之后触发 GC 又落下,那是正常波动。真正要盯的是堆外内存和 C/C++ 程序的 RSS。判定泄漏,我的做法是连续记录 7 天的 RSS 数据,画出趋势线,如果整体是线性上升且没有回落迹象,再开始排查。
排查开始前,先确认“看起来多出来的内存”到底是 RSS 还是 cache。cache 占用高不是泄漏,是可回收的缓冲。判断方式很简单:执行 sync 后观察 available,如果系统内存压力大,内核会自己回收 cache。如果回收之后单进程 RSS 依然居高不下,才有泄漏嫌疑。
3.2 valgrind 与 AddressSanitizer 定位泄漏点
如果程序是你的源码,最直接的工具是 valgrind。它通过模拟 CPU 执行来追踪每块内存的分配与释放,能指出“哪一行代码申请的内存没有被释放”。
用 valgrind 前,编译时建议保留调试信息和未优化代码,否则行号对不上。命令大概是这样的:
gcc -g -O0 leak.c -o leak valgrind --leak-check=full --show-leak-kinds=all ./leak输出里最关键的是 definitely lost 和 indirectly lost。前者表示完全泄漏,没有任何指针指向这块内存;后者表示指针链断掉导致的连带泄漏。只要修掉 definitely lost 的分配点,间接泄漏通常会随之解决。
valgrind 的代价非常大,程序运行速度会慢几十倍。所以别直接在线上跑。正确做法是:写一个可复现泄漏的最小用例,用 valgrind 跑,拿到分配栈后再回源码查。
如果你嫌 valgrind 太慢,还有另一个选择——AddressSanitizer(ASAN)。它是编译器内置的检测工具,开启后程序只慢 2 倍左右,能检测越界访问、use-after-free 和泄漏。
gcc -fsanitize=address -g leak.c -o leak_asanASAN 对内存的持续增长也能给出“在哪里泄漏”的线索,适合在大一点的测试集上复现。说实话,我遇到很多泄漏其实不是复杂的指针问题,而是把内存挂在全局链表上忘了释放,ASAN 的堆栈日志一眼就能看到。
3.3 无源码场景的现场取证
线上很多服务是没有源码的,或者你根本动不了它。这时候定位泄漏不能靠编译器工具,只能靠现场取证。我会同时做三件事:
第一,用 pmap 连续采样,观察哪个 anon 映射段在膨胀。记录每次快照的时间戳和段地址,对比增长分布。
第二,用 smaps 里的 Private 字段看独家占用。私有的脏页是最“实打实”的物理内存,Public 字段在多个进程间共享,别太在意。
第三,有条件的话用 gdb 挂上去看堆。虽然在没有符号表的情况下不太方便,但至少能浏览堆块信息,结合 malloc 库的实现来看分配了哪些大小的对象。如果是 glibc 的 ptmalloc,还能通过 mallinfo 或 malloc_info 拿到 arena 和 bin 的状态。
另一种偏方是改用 jemalloc 的堆剖析功能。在 LD_PRELOAD 中加载 jemalloc,并开启 prof 选项,程序退出或定期 dump 堆 profile,然后对比两个时间点的内存分配栈:
MALLOC_CONF=prof:true,lg_prof_sample:20 LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ./your_app这个思路的本质是:不需要编译器插桩,只要内存分配器本身记录分配调用栈,就能看出哪些调用点分配的总量在膨胀。对于绑架在第三方库上的泄漏排查,这一招非常有效。
4. 内存调优与常见误区:从 cache 到 swap 再到分配器
4.1 buff/cache 占用高不一定有问题
很多新人第一次看到 free 输出里 buff/cache 占了一半以上,第一反应是“内存是不是被浪费了”。在这里明确一下:Linux 把空闲内存用来做缓存,恰恰是设计精髓。磁盘读过的数据缓存在 page cache 里,下次直接命中,能省掉一次 IO。你用这 20 年来的服务器都尽可能让 cache 用满内存,不是 bug,是 feature。
判断要不要清理,只看 available。如果 available 一直维持在合理水位,哪怕 cache 显示 200G,都不用干预。如果有人非要执行 echo 3 > /proc/sys/vm/drop_caches“释放内存”,我的态度是:可以理解,但大可不必,甚至可能帮倒忙。drop_caches 确实能把可回收的缓存清掉,但清完之后,原本缓存热数据的地方空了,下一次访问那些文件又得从磁盘读,性能反而会短暂下降。除非你要做 benchmark 需要冷缓存,否则别手贱。
有一个确实值得看的:如果 cache 里有一类占比异常大且无法回收,比如某些文件持续被读,但业务根本不会再用,可以考虑调整文件访问策略。还有一种情况,SReclaimable 高,说明内核缓存了太多 dentry/inode。对大量小文件目录做遍历时,这类缓存涨得特别快。可以用 sysctl 调低 vfs_cache_pressure,让内核更积极地回收它们。
4.2 swap 与内存回收参数
swap 不是洪水猛兽,但也不是万灵药。默认 swappiness=60 的意思是内核在内存压力稍大时就有一定倾向把匿名页换出到 swap。桌面环境下,这个默认值还算合理;但对服务器,尤其数据库这类延迟敏感的应用,换出重要的热页到磁盘会带来毁灭性的 IO 延迟。我会把关键服务的 swappiness 调低到 10 以下,让内核优先回收 cache,而不是动匿名页。
sysctl vm.swappiness=10另一个重要参数是 min_free_kbytes,它控制内核为紧急内存保留的水位。如果设置得太低,可能触发 OOM 杀进程;太高则有大量内存躺平不用。实测建议:大内存服务器可以直接保留一个几 GB 的 min_free_kbytes,防止触发 direct reclaim 时的进程卡顿。
再说 swap 的一个常见误判:free 里 swap used 不为 0 就是内存不够。不一定。内核可能因为某些冷页很久没访问而主动换出,这种属于“策略性换出”,并不代表压力。真正有压力信号还是 vmstat 里 si/so 持续跳动。
4.3 glibc malloc 与 jemalloc/tcmalloc 的选择
应用层的大头,绕不开内存分配器。glibc 自带的 ptmalloc2 胜在通用,但对长期运行的高并发服务并不友好。它的多线程内存管理用 arena 分担锁竞争,默认每个 CPU 最多 8 个 arena,线程多了之后 arena 之间会频繁搬运内存,导致内存碎片和 RSS 虚高。用 top 看 RSS 不算特别大,但开 pidstat 或 pmap 看私有内存就会发现整体膨胀。
这类问题的典型修复方式,是把分配器换成 jemalloc 或 tcmalloc。两者都用更激进的多线程缓存策略降低锁竞争,同时减少了内存碎片。换法也简单,不用改代码:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ./your_app我见过一个网络转发服务,日均处理千万级消息,RSS 长期徘徊在 3G。换成 jemalloc 之后,RSS 降到 1.5G,而且 GC 频率没有变化。这个收益主要来自碎片减少和每线程缓存。
不过也别盲目跟风。如果你的服务是大量短连接、每次只分配很少内存、很快释放,glibc 的分配性能并不差。jemalloc 适合的是内存生命周期长、分配频率高的服务。判断要不要换,先看 pmap 里 anon 段的碎片情况,再看长时间运行的 RSS 曲线是否持续高于预期。
5. 关联场景:JVM 内存模型、容器限制与高带宽负载
5.1 JVM 内存模型在 Linux 视角下的映射
Java 应用的内存问题常常让人抓狂,因为它分为两大部分:堆内和堆外。堆内内存受 -Xmx 控制,是 GC 管理的那块,我们通常用 jstat 看。真正麻烦的是堆外。Metaspace、线程栈、DirectByteBuffer、JIT 编译产物、native 库自己的分配,都可能造成 RSS 超过 Xmx。
碰到“Java 进程 RES 比 Xmx 大”的疑问,我的排查路径是:先 jstat 看堆用量,如果堆远没满,问题就出在堆外,用 JVM 的 Native Memory Tracking 开启监控:
java -XX:NativeMemoryTracking=summary -jar your_app.jar然后用 jcmd 查看详细分类:
jcmd PID VM.native_memory summary输出能精确到堆、MetaSpace、线程栈、CodeCache、GC、internal、其他。我排查过最典型的一种情况:某个服务大量用 DirectByteBuffer 做网络 IO,容量开得太大,堆外内存持续上涨,而堆内始终维持在低水位。外人看 RSS 飙升还以为是堆溢出,实际是 NMT 里的 Internal 那一栏撑爆了。
还有一个老百姓场景:IDE 内存占用过大、导出 Excel 时 xssfworkbook 内存溢出,大概率也是堆内设置不合理。用 POI 解析超大 Excel 时,整个工作簿对象都堆在内存里,很容易把堆撑爆。解法不是无脑调 -Xmx,而是改用 SAX 模式的流式读取,或者限制单次导入条数。
5.2 cgroup 限制与容器 OOM
容器时代,内存限制不再是整个宿主机的事。你的进程运行在 cgroup 里,限制住了它最多能吃多少内存。cgroup v2 下看这几个文件:
/sys/fs/cgroup/memory.max # 最大限制 /sys/fs/cgroup/memory.current # 当前用量 /sys/fs/cgroup/memory.events # 触发过哪些事件,比如 oom容器里经常出现的问题:进程在容器内看到的 /proc/meminfo 还是宿主机的大内存,于是 JVM 按老经验只认宿主机内存来设置默认堆大小,结果直接在容器里超限被杀。JDK 10 之后默认开启 UseContainerSupport,JVM 能感知 cgroup 限制,老版本需要手动开启。
排查容器 OOM,不要只看容器本身的日志,还要看宿主机的 dmesg 里有没有对应进程的 OOM kill 记录。容器被杀不等于应用崩溃,很多时候是 cgroup 限制太小,或者容器内存里包含了 page cache,但可回收 cache 占着配额导致新分配失败。cgroup v2 里这类“cache 挤占”问题可以通过 memory.high 与水位的调整来优化。
5.3 内存带宽与高并发推理负载
内存优化的另一个隐藏维度,是带宽。CPU 运算再快,数据要从内存搬到寄存器才能算,搬的过程受内存带宽限制。现在跑大模型推理时,模型的权重都要从内存或显存读入计算单元,这就是所谓“内存带宽与 token 吞吐”的关联:权重读取越密集,内存带宽越成为吞吐瓶颈。
衡量内存带宽的经典工具是 STREAM benchmark,能测出不同操作(读、写、拷贝)的实际带宽。如果你发现多核压测时吞吐远低于理论带宽峰值,很大概率是 NUMA 拓扑和内存通道分配没优化好。调整方向有两个:确保内存条插满所有通道,尽量让每个 CPU 绑定的线程只访问本节点内存。
numactl --hardware查看本机 NUMA 节点数,以及每个节点的可用内存带宽。凡是内存带宽敏感的负载,我都建议先做一遍 numactl 规划,再谈优化代码。这个动作成本低、收益稳定。
6. 日常观测习惯与避坑清单速查
6.1 建议养成的观测习惯
- 给关键进程建立 RSS/PSS 基线。每周记录一次,趋势数据比任何一次瞬时值都有用。
- 统一监控命令,写入巡检脚本。比如
free -w、vmstat 1 5、pidstat -r组合输出,导成 CSV 便于读。 - 上线有状态服务前,用 systemd 或容器平台把内存限制设上,避免一个服务泄漏拖垮整台机器。
- 对 Java 服务,默认开启 NMT;对 C/C++ 服务,预埋 jemalloc profiling 开关。真出问题的时候,这些开关就是救命的勘察点。
- 内核补丁要跟上。很多“内存越界”类漏洞会让攻击者能利用内核态内存问题做提权,及时升级内核是省心又安全的一步。
6.2 避坑清单速查
| 现象 | 常见错误处理 | 正确思路 |
|---|---|---|
| free 显示 used 很高 | 直接加内存 | 先看 available 和 cache,缓存可回收 |
| top 里 VIRT 巨大 | 认为进程吃掉大量内存 | VIRT 只是虚拟空间,看 RES 和 PSS |
| 容器内内存余量不足 | 以为宿主机内存不够 | 检查 cgroup 的 memory.max 和 memory.current |
| 进程 RSS 持续涨 | 先重启恢复 | 先采样 pmap,保住现场再定位 |
| 想释放缓存 | 执行 drop_caches | 先确认是否真有内存压力,避免冷缓存惩罚 |
| 服务被杀 | 无脑增大限制 | 排查是堆内、堆外还是 cgroup 配额问题 |
这些坑我基本都踩过。尤其是第一行,我刚接触 Linux 那会儿看到 buff/cache 高就觉得不舒服,非要去清一下,后来才意识到这是系统在用空闲内存做加速,不该随意干预。内存问题的排查,最忌讳的是靠感觉和玄学,最有效的永远是连续的数据趋势+正确的工具组合。如果你能坚持记录一周的 RSS 采样数据,再配合 pmap、NMT 或 jemalloc profiling 去定位,绝大多数“内存占用过高”的问题都能在几小时内收敛到具体的分配代码或配置项上。