ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux内存排查实战:从监控命令到泄漏定位与JVM容器调优

Linux内存排查实战:从监控命令到泄漏定位与JVM容器调优 接到这类“学习记录”型的项目我一向比较谨慎。因为大多数人的所谓记录其实就是把 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 显示 20GRES 只有 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 会把共享内存重复计算而 PSSProportional 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 cacheSUnreclaim 则回收不掉。如果 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 --membind0 --cpunodebind0 ./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-checkfull --show-leak-kindsall ./leak输出里最关键的是 definitely lost 和 indirectly lost。前者表示完全泄漏没有任何指针指向这块内存后者表示指针链断掉导致的连带泄漏。只要修掉 definitely lost 的分配点间接泄漏通常会随之解决。valgrind 的代价非常大程序运行速度会慢几十倍。所以别直接在线上跑。正确做法是写一个可复现泄漏的最小用例用 valgrind 跑拿到分配栈后再回源码查。如果你嫌 valgrind 太慢还有另一个选择——AddressSanitizerASAN。它是编译器内置的检测工具开启后程序只慢 2 倍左右能检测越界访问、use-after-free 和泄漏。gcc -fsanitizeaddress -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_CONFprof: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 不是洪水猛兽但也不是万灵药。默认 swappiness60 的意思是内核在内存压力稍大时就有一定倾向把匿名页换出到 swap。桌面环境下这个默认值还算合理但对服务器尤其数据库这类延迟敏感的应用换出重要的热页到磁盘会带来毁灭性的 IO 延迟。我会把关键服务的 swappiness 调低到 10 以下让内核优先回收 cache而不是动匿名页。sysctl vm.swappiness10另一个重要参数是 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:NativeMemoryTrackingsummary -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 之后默认开启 UseContainerSupportJVM 能感知 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 去定位绝大多数“内存占用过高”的问题都能在几小时内收敛到具体的分配代码或配置项上。
返回列表