7BPF之巅:洞悉Linux系统和应用性能

内存分析

分配、缺页与泄漏追踪

memleak页缓存缺页异常slabOOMswap内存泄漏

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第7章 内存分析

分配、缺页与泄漏追踪

章节导读

内存问题是最隐蔽的性能杀手:泄漏以每天几十 MB 的速度蚕食系统,两周后 OOM killer 出手,服务重启,证据灰飞烟灭;页缓存被某个批处理任务冲刷,数据库的读延迟从 100 微秒跳到 10 毫秒,监控只看到"磁盘变慢了";一个频繁缺页的进程把 CPU 的 %sys 拉到 40%,火焰图里全是 handle_mm_fault,但没人知道是哪些页面、为什么缺。本章建立 BPF 驱动的内存分析体系:从用户态 malloc 到内核 slab,从缺页异常到 OOM 事件,从页缓存到 swap。

先建立 Linux 内存管理的分析框架。进程视角的内存分几层:虚拟地址空间(mmap 的许诺)→ 页表映射(PTE 的建立)→ 物理页(真正的 RAM)。三层之间的"落差"就是缺页异常的舞台:访问一个已映射但未分配物理页的地址触发 minor fault(内核分配页、建映射,成本约 1-2 微秒);访问被换出或文件页未缓存的地址触发 major fault(需要磁盘 I/O,成本毫秒级)。malloc 的行为链:小分配走 glibc 的堆管理(brk/sbrk 扩展堆,用户态完成,内核不可见);大分配(默认 >128KB)走 mmap(内核可见);free 不一定归还内核(glibc 缓存复用)。这个链条决定了 BPF 追踪的策略:追踪 malloc/free 用 uprobe(用户态),追踪 mmap/brk 用系统调用 tracepoint(内核态),追踪缺页用 page fault 事件——三个层次互补才是完整的内存图景。

内核自身的内存消耗分几块:slab(内核对象缓存,dentry、inode、task_struct 等,slabtop 可见)、页表(大内存进程的页表可达 GB 级)、vmalloc 区、内核模块、以及最大的可变项——页缓存(page cache,文件数据的缓存,通常占据空闲内存的大部分)。"Linux 吃光了所有内存"的常见恐慌就是对页缓存的误解:缓存内存是可回收的(reclaimable),压力到来时内核自动释放。真正要警惕的是不可回收内存的增长:slab 中的 dentry 缓存风暴(海量小文件遍历)、内核泄漏(罕见但存在)、以及应用的匿名内存(anon)增长。

memleak 是本章的核心工具,它解决用户态和内核态的泄漏追踪:挂载 malloc/free(用户态,uprobe)或 kmalloc/kfree(内核态,kprobe),把"分配了但未释放"的调用栈聚合起来,按泄漏量排序输出。它的设计精髓在于"栈聚合 + 差集":不是记录每一笔分配(那会撑爆内存),而是按分配栈为键累计"该路径分配总量 - 该路径释放总量",输出的就是每个代码路径的净泄漏。配合 -a(显示分配大小明细)、-p(限定进程)、-T(top 排序)、采样间隔,memleak 能在生产环境以可控开销定位泄漏源。

页缓存分析由 cachestat 和 cachetop 承担:cachestat 每秒输出页缓存的命中/未命中统计(HITS、MISSES、DIRTIES、RATIO),直接回答"应用的读有多少命中缓存";cachetop 按进程分解。它们的实现挂载在 mm_filemap_add_to_page_cache 等内核函数上(不同版本挂载点不同,这正是 kprobe 工具脆弱性的例证)。缓存命中率骤降是"性能突然变差但资源利用率正常"的常见根因:某个进程开始顺序扫描大文件(备份任务、日志分析),把热数据挤出缓存,数据库的读延迟随之飙升。诊断链条:cachestat 发现命中率下降 → cachetop 找到冲刷者 → 解决方案(ionice、cgroup memory 限制、posix_fadvise DONTNEED、或错峰调度)。

OOM 与 swap 是内存压力的两个终局。oomkill 工具追踪 OOM killer 事件(oom_kill_process 内核函数或 oom tracepoint),输出被杀进程、分数和触发上下文——事后取证的关键证据(dmesg 的信息会被轮转丢失,oomkill 可以持续记录)。swapin 工具追踪换入事件(每次从 swap 读回页面),swap 活动的意义在于:即使系统"还有 swap 空间",频繁的换入换出(thrashing)意味着内存已经实质性不足,性能已崩溃——major fault 率(sar -B 或 vmstat 的 si/so)是比"swap 使用量"灵敏得多的指标。现代实践倾向于禁用 swap(Kubernetes 传统上要求禁用)或用 zram/内存压缩替代,但分析技能仍然必备。

内存分析的方法论总结:先看全局(free、/proc/meminfo 的各大项、vmstat 的 si/so)→ 定位进程(smem、ps aux --sort=-rss、cachetop)→ 深入机制(memleak 查泄漏、缺页分析查 minor/major fault、slabtop 查内核对象)→ 验证假设(cgroup 限制测试、分配模式追踪)。BPF 工具在第三步提供了传统工具完全没有的能力:把"内存增长"分解到"哪个代码路径在分配"。这是从"知道有问题"到"知道怎么修"的关键一跃。

核心概念

  • memleak
  • 页缓存
  • 缺页异常
  • slab
  • OOM
  • swap
  • 内存泄漏

要点精析

【缺页异常全解:类型、成本与追踪】缺页(page fault)分四类:(1) minor fault——页在内存中但页表未映射(如首次触碰 malloc 的内存、COW 写时复制、文件页已在缓存),成本约 1-2 微秒(页表操作 + 可能的物理页分配);(2) major fault——需要磁盘 I/O(换入 swap 页、首次读文件页),成本毫秒级,是 minor 的千倍;(3) invalid fault——访问非法地址,触发 SIGSEGV;(4) protection fault——权限不符(写只读页触发 COW 或段错误)。追踪手段:perf stat -e minor-faults,major-faults -p PID 计数;BPF 挂 handle_mm_fault 或 page fault 事件按栈聚合——stackcount -p PID 'handle_mm_fault' 直接给出"哪些代码路径在制造缺页"。major fault 高的排查:sar -B 的 majflt/s、ps -o maj_flt 按进程、结合 swap 使用判断是换入还是文件读。minor fault 风暴的常见来源:大内存进程启动时的堆预触碰(Java 大堆、Redis 加载 RDB)、频繁 mmap/munmap 的分配模式、透明大页(THP)的 khugepaged 合并。一个经典优化:数据库启动时用 MAP_POPULATE 或预触碰避免运行期缺页抖动——BPF 缺页分析正是发现这类问题的工具。

【memleak 工具原理与实战】memleak 的工作模式:挂载分配和释放函数(用户态默认 malloc/free,可 -c 指定其他如 calloc/realloc 或自定义分配器;内核态 -k 挂 kmalloc/kfree 系列),每次分配记录"栈 → (大小, 时间戳)"到 map,释放时删除对应记录,定期输出 map 中存活时间超过阈值(-z 只显示大于 N 字节的、-O 按分配大小 top)的条目按总泄漏量排序。关键选项:-p PID 限定进程(用户态必须指定或默认全系统会混乱)、-a 显示每笔分配明细、-T top N 栈、-s 采样率(高频分配时只追踪 1/N,降开销)、-r 用调用者的调用者做键(跳过分配器包装层)。实战流程:(1) 确认泄漏存在——ps 观察 RSS 持续增长且与负载相关(排除缓存增长);(2) memleak -p PID 60 采集一分钟,看 top 栈;(3) 对可疑栈做二次验证——重启进程重跑,泄漏栈应重现;(4) 区分"真泄漏"与"延迟释放"——缓存、连接池的内存是故意持有的,看栈的业务语义。局限:只追踪挂载的分配函数(自定义内存池内部不可见);uprobe 开销随分配频率线性增长(每秒 10 万次 malloc 时开销显著,用 -s 采样);多线程高频分配下 map 竞争。C++ 的 new/delete 最终走 malloc 所以默认可见;Go/Rust 有自己的分配器,需要挂 runtime.mallocgc(Go)等特定符号。

【页缓存深入:cachestat 与缓存行为分析】页缓存是文件 I/O 的第一层缓存,读文件先查缓存(命中则零磁盘 I/O),写文件先写缓存(脏页,由回写机制异步刷盘)。cachestat 输出四列:HITS(缓存命中的读页数)、MISSES(未命中需读盘)、DIRTIES(新增脏页,即写入量)、RATIO(命中率)。解读模式:(1) 命中率 >95% 且稳定——健康的缓存工作集;(2) 命中率骤降 + MISSES 飙升——缓存被冲刷(大文件顺序扫描、内存压力回收),用 cachetop 找元凶进程;(3) DIRTIES 持续高——写入密集,关注回写压力(第 8 章的 writeback 分析);(4) HITS 和 MISSES 都低但应用喊慢——问题不在缓存层。缓存回收机制背景:内存压力时 kswapd/直接回收扫描 LRU 链表(active/inactive 两级),文件页(可回写后释放)和匿名页(需 swap)分别处理;cgroup v1/v2 的 memory 限制使回收按容器隔离。BPF 增强分析:追踪 mark_page_accessed 观察缓存晋升、追踪 shrink_page_list 观察回收行为、mm_filemap_add_to_page_cache 观察新页进入。页缓存的"双刃剑":它让文件 I/O 快,也让内存账目复杂——free 命令的 buff/cache 不是"被占用"而是"可回收的加速层"。

【slab 与内核内存分析】slab 是内核对象的缓存分配器(kmalloc 的底层),缓存 dentry(目录项)、inode、task_struct、sk_buff(网络包)等高频对象。分析工具:slabtop(按 cache 排序的对象数与内存量)、/proc/slabinfo(原始数据)、/proc/meminfo 的 Slab/SReclaimable/SUnreclaim 三项(可回收 vs 不可回收是关键区分)。BPF 增强:stackcount 挂 kmem_cache_alloc 按栈聚合分配来源;追踪特定 cache 的对象生命周期(如 dentry 的分配与释放配对)。经典问题模式:(1) dentry 风暴——遍历海量小文件(备份、find、日志收集 agent)使 dentry cache 膨胀到数 GB,SReclaimable 高企;虽然可回收,但回收扫描本身消耗 CPU,且挤压页缓存;解法:限制遍历范围、用 posix_fadvise、调整 vfs_cache_pressure;(2) sk_buff 堆积——网络处理跟不上收包速率,skb cache 增长,伴随 netstat 的丢包计数;(3) 不可回收 slab 异常增长(SUnreclaim 持续上升)——可能是内核泄漏或驱动问题,用 kmemleak(内核自带泄漏检测器,非 BPF)或 crash 深入;(4) slab 碎片——/proc/slabinfo 的 num_objs 与 active_objs 比值低说明碎片严重。slab 分析是"内存去哪了"账目中内核部分的必修课:进程 RSS 总和 + 页缓存 + slab + 页表 + 内核其他 ≈ 总内存,对不上账时逐项排查。

【用户态内存分配的全景追踪】malloc 只是用户态分配的冰山一角,完整图景:(1) glibc malloc——小分配从堆(brk 扩展)切分,大分配(>MMAP_THRESHOLD 默认 128KB)直接 mmap,free 后小块留在 glibc 空闲链表(不归还内核!这是"free 了但 RSS 不降"的原因,mallopt 或 M_TRIM_THRESHOLD 可调);(2) 替代分配器——jemalloc、tcmalloc 有自己的缓存层级(thread cache、arena),追踪要挂它们的符号;(3) 语言运行时——JVM 堆是启动时 mmap 的大块(内部 GC 管理,BPF 看不到对象级分配,要用 USDT 的 object__alloc)、Go 的 mspan/mcache、Python 的 pymalloc 小对象池;(4) mmap 直接映射——大文件映射、匿名大页。BPF 追踪矩阵:malloc/free 用 uprobe(glibc 路径);mmap/munmap/brk 用系统调用 tracepoint;缺页用 fault 事件;语言运行时用 USDT 或运行时内部符号。诊断"RSS 增长"的完整流程:先分账(anon 增长还是 file 映射增长?/proc/PID/smaps 的 Rss/Anon/File 分解)→ anon 增长走 memleak → file 增长查 mmap 的文件(smaps 的路径)→ 都不是则查页表膨胀(大内存稀疏访问)。

【内存泄漏诊断方法论:从现象到根因】泄漏诊断的标准流程:第一步,确认是泄漏而非缓存——RSS 持续增长且不随负载回落;用 /proc/PID/smaps_rollup 分解 Rss/Anon/File,匿名内存增长才是泄漏候选(文件映射增长可能是缓存)。第二步,定位分配路径——memleak -p PID 采集,top 栈即嫌疑;采样降低开销(-s 10 每 10 次分配追 1 次,泄漏是累积行为,采样不影响定位)。第三步,验证——修复后对比 RSS 曲线;或故意加速(提高负载看泄漏速率是否线性)。第四步,处理"看不见的泄漏"——自定义内存池(池只增不减)、内核态泄漏(memleak -k)、文件描述符泄漏(lsof 计数增长,不是内存但常被混淆)、线程泄漏(每线程 8MB 栈,线程数失控即内存失控——ls /proc/PID/task | wc -l)。特殊场景:容器内存超限被 OOM kill——cgroup 内存账目包含页缓存(v1)或不含(v2 的 memory.current 语义),limit 设置要理解账目构成;JVM 容器——堆外内存(Metaspace、DirectByteBuffer、线程栈)不受 -Xmx 控制,容器 limit 必须留出堆外余量,BPF 的 memleak 对 JVM 原生内存泄漏(JNI 层)仍然有效。泄漏速率的定量:两次 RSS 采样差 / 时间差,结合分配栈的计数可以算出"每次请求泄漏多少字节"——这个数字直接指导修复优先级。

【OOM 事件追踪与内存压力预警】OOM killer 在内核无法满足内存分配请求且回收失败时启动:选择 oom_score 最高的进程(基于 RSS 占比,可经 oom_score_adj 调整)杀死。oomkill 工具(BCC)追踪 oom_kill_process,输出时间、触发上下文(谁在申请内存时触发)、被杀进程与分数——比 dmesg 及时且结构化。OOM 分析的要点:(1) 全局 OOM vs cgroup OOM——容器超 limit 触发的是 cgroup OOM(只杀容器内进程),dmesg 有 "Memory cgroup out of memory" 字样;(2) 被杀者未必是元凶——oom_score 按 RSS 排序,最大的进程被杀,但泄漏者可能是另一个(被杀的大进程只是"最胖");(3) 预警优于事后——监控 /proc/meminfo 的 MemAvailable、PSI(pressure stall information,/proc/pressure/memory,some/full 的 avg10 值直接量化内存压力导致的停顿时间,是比可用内存灵敏得多的饱和指标)、major fault 率、swap 增速。BPF 增强:追踪 direct reclaim(try_to_free_pages)事件——直接回收发生说明异步回收(kswapd)已跟不上,是 OOM 的前兆信号;用 offcputime 观察进程阻塞在回收路径的时长。Kubernetes 场景:Pod 的 OOMKilled 状态来自 cgroup OOM,排查用 kubectl describe 看 lastState,宿主机 oomkill 工具持续记录全节点事件。

【swap 与内存压缩分析】swap 分析的核心指标不是"用了多少"而是"换入换出多频繁":vmstat 的 si/so(每秒换入/换出 KB)、sar -B 的 pgscank/pgscand(kswapd/直接回收扫描页数)、major fault 率。thrashing(抖动)的指纹:si/so 持续非零 + major fault 飙升 + 应用延迟恶化——此时系统在"用磁盘当内存",性能已实质性崩溃,即使 swap 使用率只有 30%。swappiness(/proc/sys/vm/swappiness,0-200)控制回收时匿名页与文件页的倾向:值高更积极 swap(保页缓存),值低更保匿名页;数据库服务器传统建议 1-10,通用服务器 60。swapin 工具(BCC)追踪换入事件按进程聚合——"谁在被换出又换回"直接可见。现代替代方案:zram(内存压缩块设备当 swap,压缩比 2-3 倍,延迟微秒级远优于磁盘 swap,Android/ChromeOS/Fedora 默认启用)、zswap(swap 前置的压缩缓存层)。分析 zram 系统时注意:si/so 的"磁盘"是压缩内存,成本模型完全不同——换入成本是解压 CPU 而非 I/O 延迟。内存 PSI(/proc/pressure/memory)是最新的统一压力指标:some avg10=25 表示过去 10 秒有 25% 的时间至少一个任务因内存压力停顿——这个数字比任何传统指标都直接。

【大页与 THP:性能的双刃剑】透明大页(THP)把 4KB 页合并为 2MB 大页,收益:TLB 命中率提升(一个 TLB 条目覆盖 2MB 而非 4KB)、页表层级减少、缺页次数降低(1/512)。代价:分配大页需要连续物理内存(碎片化系统上分配失败或触发昂贵的内存规整 compaction)、khugepaged 后台合并消耗 CPU、大页的换出/回收粒度粗(2MB 一起动)。经典事故模式:数据库(Redis、MongoDB、Oracle)官方文档都建议禁用 THP——因为 khugepaged 的合并和 compaction 会造成毫秒到百毫秒级的分配停顿,直接体现为请求延迟毛刺。BPF 分析手段:追踪 compact_stall/compact_fail 事件观察规整压力;stackcount 挂 __alloc_pages_slowpath 看谁在慢路径分配(大页分配失败会走慢路径);runqlat 与 THP 事件的时间关联分析。查看状态:cat /sys/kernel/mm/transparent_hugepage/enabled(always/madvise/never),建议 madvise 模式(应用显式 opt-in,如 JVM 的 -XX:+UseTransparentHugePages)。显式大页(hugetlbfs)是另一条路:预留固定大页池,无 THP 的动态开销,DPDK 和数据库的标准配置。内存分析账目中注意:AnonHugePages(/proc/meminfo)显示 THP 实际使用量。

【容器内存账目:cgroup 视角的内存分析】容器内存问题的根源往往是账目理解错误。cgroup v1 的 memory.usage_in_bytes 包含:匿名内存 + 页缓存(容器内文件 I/O 产生的缓存也计入!)+ 内核内存(可选);v2 的 memory.current 类似。后果:容器 limit 4GB,应用 RSS 只有 2GB 却被 OOM kill——因为文件缓存把账目顶到了 limit(内核本应先回收缓存再 OOM,但回收速度跟不上分配速度时 OOM 仍会发生)。分析工具:cat /sys/fs/cgroup/memory/.../memory.stat 的完整分解(rss/cache/swap/mapped_file 等数十项);memory.events 的 low/high/max/oom/oom_kill 计数(v2)——oom_kill 非零即容器发生过 OOM;BPF 方案在宿主机追踪 cgroup 级事件(oomkill 工具天然覆盖所有容器)。K8s 实践要点:requests/limits 的内存值要基于 RSS + 缓存工作集的估算;memory QoS(v2 的 memory.high 软限制)提供"先限流后 OOM"的缓冲;emptyDir/日志文件的缓存也计入容器账目(大量写日志的容器要预留缓存空间或提高 limit)。排查口诀:容器 OOM 先看 memory.stat 分账(anon 还是 cache 主导),anon 主导查应用泄漏(memleak),cache 主导查 limit 设置与 I/O 模式。

代码示例

/* 示例1:memleak 追踪用户态泄漏 */
sudo /usr/share/bcc/tools/memleak -p 2233 -a 60
/* 输出示例:
[10:23:45] Top 5 stacks with outstanding allocations:
  64 bytes in 1 allocations from stack
        malloc+0x1f [libc.so.6]
        create_node+0x2a [myapp]
        process_request+0x134 [myapp]
        main+0x2f0 [myapp]
  10485760 bytes in 2560 allocations from stack
        malloc+0x1f [libc.so.6]
        parse_buffer+0x88 [myapp]      <- 泄漏大户
*/
memleak 的输出按"未释放总量"排序,-a 显示每笔分配的大小和数量。上面的输出直指根因:parse_buffer 路径累计泄漏 10MB(2560 笔 × 4KB),而 create_node 的 64 字节可能是正常的长生命周期对象。栈中同时显示用户帧和库帧(malloc 本身),符号完整度依赖二进制的符号表。实战要点:(1) 采集时长要覆盖足够的泄漏累积(泄漏速率 × 时长 >> 正常波动);(2) 启动时的分配会被计入(进程启动即开始追踪才准确,对已运行进程用 -o 只追踪新分配);(3) 区分泄漏与缓存——业务语义判断:连接池、对象池的"未释放"是设计,parse_buffer 每请求分配且不释放才是 bug;(4) 高频分配场景加 -s 10 采样(每 10 次追 1 次,输出量除以 10 但泄漏比例不变);(5) -z 1024 过滤小分配聚焦大块。内核态泄漏用 memleak -k(挂 kmalloc 系列),输出内核栈——内核泄漏罕见但致命(不可回收内存持续增长最终全局 OOM),发现后需要内核团队介入(kmemleak、crash 工具深入)。
/* 示例2:cachestat——页缓存命中率监控 */
sudo /usr/share/bcc/tools/cachestat -T 5
/* TIME     HITS   MISSES  DIRTIES   RATIO
10:23:01  45213    203     1520     99.5%
10:23:06  44890    312     1480     99.3%
10:23:11   8921  38204     1502     18.9%   <- 缓存被冲刷!
*/
sudo /usr/share/bcc/tools/cachetop 5
/* 10:23:16  PID  UID  CMD    HITS  MISSES DIRTIES
            8821 root backup  102   37892  12      <- 元凶:备份任务
*/
这组输出讲了一个完整的生产故事:前两行是健康状态(命中率 99%+,数据库的工作集稳稳待在缓存里),第三行命中率崩到 19%——某个进程开始顺序扫描大量冷数据,把热页挤出缓存。cachetop 立即锁定元凶:PID 8821 的 backup 进程,MISSES 3.7 万(它在读从未缓存的数据)且把缓存搅乱。后果链:备份开始 → 缓存冲刷 → 数据库读请求从缓存命中变成磁盘读 → 延迟从微秒级跳到毫秒级 → 业务告警。解决方案菜单:(1) 备份限速(ionice -c3 + cgroup io 限制);(2) 备份错峰(业务低谷执行);(3) 用 posix_fadvise(POSIX_FADV_DONTNEED) 提示内核备份读的数据不进缓存(rsync 无此选项,可用 dd 或专用工具);(4) cgroup memory 限制备份进程的缓存占用。注意 cachestat 的实现挂载在页缓存核心函数上,不同内核版本挂载点不同(工具会适配),高频文件 I/O 下工具自身开销约 1-2%。这个案例是"资源利用率正常但性能恶化"的典型——CPU 不高、内存还有空闲、磁盘没满,只有缓存命中率揭示了真相。
/* 示例3:缺页分析——按栈聚合 page fault */
sudo /usr/share/bcc/tools/stackcount -p 2233 -i 5 't:exceptions:page_fault_user'
/* 或用 bpftrace 统计 minor/major fault 按进程: */
sudo bpftrace -e '
software:major-faults:1 { @major[comm] = count(); }
software:minor-faults:1 { @minor[comm] = count(); }
interval:s:5 { print(@major); print(@minor); clear(@major); clear(@minor); }'
缺页分析有两个层次。第一层是计数:bpftrace 的 software:major-faults/minor-faults 探针(perf 软件事件)按进程统计缺页速率——major fault 持续非零是危险信号(每次都是磁盘 I/O),minor fault 高速率(每秒数万)说明内存触碰模式低效(大堆首次触碰、频繁 mmap)。第二层是定位:stackcount 挂 page_fault tracepoint 按栈聚合,直接回答"哪段代码在制造缺页"。典型发现模式:(1) 栈集中在某个大数组的初始化循环 → 启动期预触碰优化(MAP_POPULATE 或 memset 预热);(2) 栈在 mmap/munmap 调用路径 → 分配模式问题(频繁映射小区域,改用池化);(3) major fault 栈在文件读取路径 → 该文件的工作集超出缓存(扩容内存或缩小工作集);(4) major fault 栈在 swap 读入路径 → 匿名内存被换出又访问(加内存、调 swappiness、或查泄漏)。性能账:minor fault 每秒 10 万次 ≈ 消耗 0.15 个 CPU 核(每次 1.5μs),major fault 每秒 100 次 ≈ 磁盘 IOPS 100 + 应用停顿——这就是"缺页风暴"为什么同时表现为 %sys 高和延迟毛刺。sar -B 1 是缺页的宏观对照(pgfault/s、majflt/s)。
/* 示例4:追踪 mmap 大分配与内存映射行为 */
sudo bpftrace -e '
tracepoint:syscalls:sys_enter_mmap /pid == 2233/ {
    printf("mmap len=%d KB prot=%d flags=%d\n", args->len / 1024, args->prot, args->flags);
    @total_kb = sum(args->len / 1024);
}
tracepoint:syscalls:sys_enter_munmap /pid == 2233/ {
    @freed_kb = sum(args->len / 1024);
}'
/* 输出:
mmap len=131072 KB prot=3 flags=34
mmap len=262144 KB prot=3 flags=34    <- 匿名大映射,prot=3(RW) flags=34(PRIVATE|ANON)
*/
mmap 系统调用追踪揭示进程的"大块内存行为"——glibc 的大 malloc(>128KB)底层就是匿名 mmap,JVM 堆、Go runtime 的 arena、数据库的缓冲池都是 mmap 建立的。输出解读:len 是映射大小,prot 是保护位(3=PROT_READ|PROT_WRITE),flags 的 34 = MAP_PRIVATE(2) | MAP_ANONYMOUS(32)(匿名私有映射,即堆内存);flags 含 MAP_SHARED(1) 且带 fd 的是文件映射。@total_kb 与 @freed_kb 的差值趋势 = mmap 路径的净增长。实战场景:(1) "free 了但 RSS 不降"——观察 mmap/munmap 配对,glibc 对中等大小分配 free 后不 munmap(缓存复用),这是正常行为,mallopt(M_TRIM_THRESHOLD) 或 malloc_trim() 可强制归还;(2) JVM 容器 OOM——追踪 JVM 的 mmap 总量验证堆外内存(Metaspace、CodeCache、DirectByteBuffer 都是独立 mmap);(3) 内存碎片怀疑——大量小 mmap(几十 KB 级)频繁创建销毁说明分配器阈值配置不当(M_MMAP_THRESHOLD 调优)。配套工具:/proc/PID/smaps 显示所有现存映射的明细(大小、RSS、权限、路径),pmap -x PID 是友好前端——BPF 追踪"映射的变化过程",smaps 显示"映射的当前状态",动静结合。
/* 示例5:slab 与内核对象分析 */
slabtop -o -s c | head -15
/*  OBJS ACTIVE  USE OBJ SIZE  SLABS  NAME
 2851200 2851198 100%   0.19K  68250  dentry      <- 目录项缓存 285万个
  452300  452298 100%   0.58K  16800  xfs_inode
*/
sudo bpftrace -e 'kprobe:kmem_cache_alloc { @[kstack] = count(); } interval:s:10 { exit(); }'
grep -E "Slab|SReclaimable|SUnreclaim" /proc/meminfo
slabtop -s c 按对象数排序,上面的输出显示 dentry 缓存有 285 万个对象——通常是海量文件遍历(find、备份、日志 agent 扫描)的痕迹。dentry 是可回收的(SReclaimable),但数量巨大时:(1) 回收扫描消耗 CPU;(2) 挤压页缓存;(3) 负 dentry(不存在的文件名缓存,反复 stat 失败的路径产生)堆积是特殊的病态模式。bpftrace 挂 kmem_cache_alloc 按内核栈聚合,能看到 dentry 分配的调用路径(d_alloc → lookup 路径即文件查找)。/proc/meminfo 三项分账:Slab 总量 = SReclaimable(dentry/inode 等可回收)+ SUnreclaim(内核结构、驱动分配,不可回收——这项持续增长才是内核泄漏信号)。调优手段:/proc/sys/vm/drop_caches=2 手动释放(应急,会造成随后的缓存未命中风暴,生产慎用);vfs_cache_pressure(默认 100,调高使内核更积极回收 dentry/inode);根治方案是找到遍历海量文件的进程并优化其行为。容器场景注意:dentry/inode 缓存是全局共享的(不属于任何 cgroup v1 账目,v2 部分计入),一个容器的文件风暴会影响全机缓存——多租户节点的隐性干扰源。
/* 示例6:OOM 事件持续监控 */
sudo /usr/share/bcc/tools/oomkill
/* 输出示例:
10:23:45 Task in /kubepods/pod8821 killed as a result of limit of /kubepods/pod8821
10:23:45 Memory cgroup out of memory: Kill process 4521 (java) score 1000
*/
/* bpftrace 等价实现(新内核有 oom tracepoint): */
sudo bpftrace -e 'tracepoint:oom:mark_victim { printf("OOM victim pid: %d\n", args->pid); }'
cat /proc/pressure/memory   /* PSI 内存压力:some avg10=0.00 full avg10=0.00 */
oomkill 工具持续监听 OOM 事件,输出比 dmesg 更结构化:它区分全局 OOM 和 cgroup OOM(上面的输出明确显示是 kubepods 的 cgroup 限制触发,被杀的是容器里的 java 进程——典型的"容器内存 limit 不足"而非"宿主机内存耗尽")。这个区分至关重要:cgroup OOM 只影响该容器(K8s 会重启 Pod 并标记 OOMKilled),全局 OOM 影响全机。bpftrace 版本展示底层机制:新内核(4.13+)有 oom:mark_victim 等 tracepoint,老内核用 kprobe 挂 out_of_memory/oom_kill_process。PSI(/proc/pressure/memory)是预防性指标:some avg10 表示"过去 10 秒至少一个任务因内存压力停顿的时间占比",持续 >10% 说明内存实质性紧张(回收频繁、直接回收发生),是 OOM 的前兆——把 PSI 接入告警比监控"可用内存百分比"科学得多(可用内存低但无压力 = 缓存用得好;可用内存尚可但 PSI 高 = 分配模式病态)。K8s 场景的完整排查链:Pod OOMKilled → oomkill 工具确认是 cgroup OOM → memory.stat 分账(anon 主导 = 应用泄漏或堆配置过大;cache 主导 = limit 未留缓存余量)→ 调整 limit 或修复应用。

实验笔记

本实验构建完整的内存分析技能树。预计耗时 3-4 小时。环境:内核 4.9+(PSI 实验需 4.20+),安装 bcc-tools、bpftrace、gcc、sysstat。虚拟机建议内存 4GB 以下(便于制造内存压力)。

【实验一:制造并追踪内存泄漏】写一个 leak.c:循环处理"请求",每个请求 malloc 4KB 并挂入链表(永不释放),每秒处理 100 个请求;另写一个 healthy.c:同样 malloc 但处理后 free。分别运行,用三个工具追踪:(1) watch -n 5 'ps -o rss= -p PID' 观察 RSS 曲线;(2) memleak -p PID -a 30 采集泄漏栈(leak 版本应显示你的处理函数,healthy 版本应几乎无输出);(3) /proc/PID/smaps_rollup 对比 Anon 与 File 部分。记录 memleak 定位到泄漏代码行所需的最短采集时间,思考泄漏速率与采集时长的关系。

【实验二:缺页风暴观察】写一个程序:mmap 1GB 匿名内存(MAP_NORESERVE),然后分两种模式访问:(a) 顺序触碰每页;(b) 随机触碰。用 bpftrace software:minor-faults 统计两种模式的缺页速率和总耗时差异(随机模式 TLB 不友好但缺页数相同——验证这一点)。再用 stress-ng --vm 1 --vm-bytes 2G --vm-method all 制造持续缺页,同时观察 mpstat 的 %sys(缺页处理在内核态)和 sar -B 的 pgfault/s。

【实验三:页缓存冲刷实验】(1) 用 dd 把一个 500MB 文件读两遍,第二遍计时(应显著快于第一遍——缓存命中);(2) cachestat 1 监控的同时,用 dd 顺序扫描一个 3GB 的大文件(大于可用内存),观察 HITS/RATIO 的变化曲线;(3) cachetop 确认冲刷来源;(4) 对比实验:给扫描进程加 ionice -c3 和 cgroup memory 限制(memory.low/memory.high,v2),观察缓存保护效果。写出"缓存冲刷对并发数据库负载的影响"的实验设计(提示:同时跑 sysbench 或简单的缓存读循环,对比冲刷期间的延迟变化)。

【实验四:内存压力与 OOM】(1) cat /proc/pressure/memory 记录基线;(2) 用 stress-ng --vm 2 --vm-bytes 90% 制造接近极限的内存压力,观察 PSI some/full 值的变化、vmstat 的 si/so(如有 swap)、kswapd 的 CPU 占用(top 中 kswapd0);(3) 在 cgroup 中限制一个进程 100MB 并让它分配 200MB,用 oomkill 工具捕获 OOM 事件,对照 dmesg 和 memory.events;(4) 记录 OOM 发生前 30 秒内各指标(PSI、major fault、si/so)的变化序列——这就是"OOM 预警指标"的实证依据。

【实验五:slab 与 dentry 风暴】(1) slabtop -o 记录基线 top 10 cache;(2) mkdir -p /tmp/dirstorm/{1..1000}/{1..1000} 创建百万目录(或用脚本创建百万文件),期间观察 dentry cache 增长和 /proc/meminfo 的 SReclaimable;(3) 用 bpftrace 挂 kmem_cache_alloc 按栈聚合确认 dentry 分配路径;(4) echo 2 > /proc/sys/vm/drop_caches 手动回收,观察回收耗时和随后的文件访问变慢(缓存冷启动);(5) 讨论:为什么生产环境慎用 drop_caches?

【实验报告要求】五个实验的数据曲线(RSS 时间序列、PSI 变化、缓存命中率曲线)、memleak 输出摘录、OOM 事件记录。核心交付物:实验四的"OOM 前兆指标时间线"——按时间顺序列出各指标何时开始异常,这个序列就是生产环境内存告警体系的设计依据。附加思考:memleak 对 Go 程序(自带分配器)为什么可能失效?如何改造追踪方案?(提示:挂 runtime.mallocgc,或追踪 mspan 分配。)

延伸阅读

  • Brendan Gregg, "BPF Performance Tools" 第 7 章——本章原始出处,含内存分析工具全表、memleak 各语言适配说明和页缓存机制图解。
  • 《深入理解计算机系统》(CSAPP) 第 9 章 (虚拟内存)——虚拟内存、页表、缺页处理、动态分配的教科书级讲解,是理解本章所有工具的理论地基,强烈建议精读。
  • Linux 内核文档 Documentation/admin-guide/mm/ 目录——THP、hugetlbpage、OOM killer、PSI、multigen LRU 的官方文档,内存调优参数的权威说明。
  • Brendan Gregg, "Systems Performance" 第 2 版第 7 章 (Memory)——内存子系统的全景讲解:从 CPU 缓存到 NUMA 到 swap,含传统工具与 BPF 工具的完整对照。
  • /proc/meminfo 与 /proc/PID/smaps 的 kernel.org 文档 (Documentation/filesystems/proc.rst)——内存账目每个字段的精确定义,做"内存去哪了"分账时的字典。
  • PSI 设计文档 (facebookmicrosites.github.io/psi) 与 LWN 文章 "Pressure stall information"——PSI 指标的动机与语义,理解为什么它比利用率类指标更能反映资源饱和。
  • jemalloc (jemalloc.net) 与 tcmalloc (github.com/google/tcmalloc) 文档——主流替代分配器的设计(arena、thread cache、size class),理解它们才能解释"free 后 RSS 不降"等分配器行为。
  • Brendan Gregg, "Memory Leak Analysis" 系列博客与 Netflix 的内存排查案例 (brendangregg.com/blog)——真实泄漏案例的完整排查记录,方法论的最佳示范。
7/13内存分析
0%