这东西已经跑了两百多天RSS 从 300MB 涨到 1.8GB你们查一下是不是内存泄漏。凌晨两点收到这条消息的时候我第一反应是打开top第二反应是打开heaptrack第三反应才是想起来——这台机器上根本没有泄漏涨的是 Linux 内存分配器自己留着没还的那部分。很多人对 Linux 内存分配器的认知停在malloc 就是申请内存这一层真到了线上排查才发现从malloc(1024)到物理 DRAM 之间横着三道关卡用户态的堆管理器、内核的页分配器、以及夹在中间的 slab/slub 层。这三层各自有自己的缓存策略、各自的回收时机、各自的碎片账本任何一层的行为没搞明白你看到的 RSS 曲线就永远是玄学。这篇文章不是操作手册也不是内核源码导读。我打算把这几层拆开讲清楚每一层为什么这么设计、在什么场景下会咬人、以及我实际踩过的那些坑。适合的读者是写过 C/C 服务、做过 Linux 运维、或者被 OOM Killer 找过麻烦的人。文章里会涉及不少参数和观测命令你不需要全部记住但至少要知道遇到内存一直涨这种问题时该往哪个方向看。1. 一次 malloc(1024) 背后Linux 内存分配器的三层接力我习惯把整条链路画成三段用户态的分配器管用户对象内核的 buddy 系统管物理页中间那层 slab/slub 负责把小对象塞进页里。三段之间通过系统调用和内核 API 交接交接点不多但每一处都有代价。1.1 从虚拟地址到物理页框中间那道页表进程调用malloc拿到的永远是一个虚拟地址不是物理内存。所谓分配成功仅仅是堆管理器在自己的数据结构里划了一块区间出来把你的指针指过去物理页框可能在几微秒后才真正挂上去。这个延迟绑定过程叫缺页异常page faultCPU 访问这个虚拟地址MMU 查页表发现没有对应映射陷入内核内核才去 buddy 系统要一个物理页填好页表项返回用户态重新执行那条指令。这个机制带来两个后果。第一malloc大块内存但只写第一个字节实际物理占用可能就一个页。第二free之后物理页不一定立刻归还——分配器会觉得你马上又要用先留着。所以你在top里看到的 RSSResident Set Size才是真实物理占用VSZ 只是个虚拟地址空间大小参考价值有限。我见过有人拿 VSZ 判断内存泄漏那是在给自己找麻烦。1.2 brk 和 mmap两条系统调用通道的分岔点用户态分配器向内核要内存只有两条路。一条是brk把进程的数据段末尾program break往上推推出来的空间就是主堆。这条路的优点是连续、便宜缺点是归还困难——你想缩回来得保证堆顶是一整块空闲区域现实里很难。另一条是mmap直接映射一段匿名内存归还时munmap一次就干净了。glibc 的策略是主线程的小块请求走brk大块请求默认超过 128KB走mmap子线程则各自开一个用mmap创建的 64MB 堆区。这个分界值不是固定的后面会讲到它会动态调整。为什么要有这个分界因为brk区内的内存碎片化之后无法归还给系统而mmap可以整块退掉。对于申请一次、用很久、然后释放的大对象走mmap显然更划算。这里有个容易被忽略的细节mmap每次都要建立新的 VMAvirtual memory area内核需要管理这些 VMA 的区间树。如果你有大量小对象走了 mmapVMA 数量会爆炸/proc/pid/maps行数上万内核在查找 VMA 时的开销会明显上升。所以mmap阈值不能设太小glibc 默认的 128KB 是个经过验证的平衡点。1.3 申请了和占用了之间差着一次写操作把这三层串起来看一次malloc(1024)的完整流程大概是glibc 从 tcache 或 fastbin 里挑一个合适的 chunk命中就返回不碰内核→ 否则从 arena 的 top chunk 里切一块可能触发brk扩展→ 你往里写数据缺页异常 → 内核 buddy 系统分配一个 order-0 的页 → 页表填好物理内存真正占用。注意第一步绝大多数小对象分配根本不会走到内核。glibc 在用户态留了各种缓存层就是为了避免每次都陷入。代价就是这些缓存无法被内核感知——cgroup 的 memory.max 管不到 glibc 的 arena 内部有多少空闲 chunk。这也是容器里内存超限被杀但进程自己觉得我才用了 500MB的根本原因之一。另外内存分配器还分内核态分配器和用户态分配器两个世界。kmalloc、vmalloc、kmem_cache_alloc这些是内核自己用的走的是 slab/slubmalloc、new是用户态用的走 glibc/jemalloc 这类实现。两者唯一的交集就是用户态分配器通过brk/mmap向内核心要页以及内核在缺页时代它分配物理页。搞混这两条线排查时就会找错地方。2. 伙伴系统页级分配器的秩序来源与碎片账单buddy 系统是 Linux 内存分配器的地基它只管一件事以页通常 4KB为最小单位分配和释放物理连续的内存块。所有 slab、所有用户态堆、所有内核缓冲区最终都从这里拿页。2.1 order、zone 与 free_area 的组织方式buddy 把物理内存按 2 的幂次分块用order表示块的大小order-0 是一页4KBorder-1 是两页8KBorder-10 是 1024 页4MB这是默认MAX_ORDER能覆盖的最大块。每个 zone 里有一个free_area数组索引就是 order每个元素挂一条空闲块链表。zone 是什么x86_64 上常见三个ZONE_DMA受限于老式设备寻址一般前 16MB、ZONE_DMA32低 4GB给 32 位 DMA 设备用、ZONE_NORMAL其余全部。32 位系统上还有ZONE_HIGHMEM64 位没有。此外还有ZONE_MOVABLE用于内存热插拔和 hugepage 预留和ZONE_DEVICE。分 zone 的原因是硬件地址限制。网卡 DMA 只能访问低 4GB那它申请的内存就必须落在ZONE_DMA32里不能随便给。这个约束会带来一个实际问题ZONE_DMA32在高负载下可能先被耗尽导致网卡分配缓冲区失败而ZONE_NORMAL还有大把空闲。这类问题在dmesg里表现为page allocation failure日志里会明确写出是哪个 zone、哪个 order 失败了。2.2 分裂与合并一次分配背后的位运算buddy 的核心机制是分裂和合并。假设你要一个 order-2 的块16KB但当前 order-2 链表是空的系统就往上找 order-3发现有于是把它拆成两个 order-2 块一个返回给你另一个挂到 order-2 链表。如果 order-3 也空就找 order-4拆成两个 order-3再拆一个成两个 order-2……依此类推。释放时反过来你释放一个 order-2 块buddy 会去检查它的伙伴buddy是不是也空闲。伙伴的地址可以通过异或算出来——如果我的起始页框号是pfn块大小是 2^k 页那么伙伴的 pfn 就是pfn ^ (1 k)。这个技巧让合并判断变成一次异或加一次状态查询不需要任何链表遍历非常高效。如果伙伴也空闲两块合并成 order-3再继续往上尝试与上一级伙伴合并。这个设计的好处是 O(log n) 的分配和释放代价是内部碎片和外部碎片同时存在。内部碎片来自幂次对齐你要 5 页只能拿 8 页order-3多出来的 3 页浪费了。外部碎片来自块被切碎后无法拼回大块系统跑了几天order-10 的块可能一个都不剩全被切成了 order-0 和 order-1。这时候哪怕总空闲内存还有几个 GB你也申请不到一块 2MB 的连续内存。2.3 迁移类型和水位线内核对抗碎片的两种办法对抗外部碎片内核用了两招。第一招是迁移类型migratetype每个页块pageblock通常是 2MB 大小对应pageblock_order被标记为MIGRATE_UNMOVABLE、MIGRATE_MOVABLE或MIGRATE_RECLAIMABLE。内核分配时尽量从同类型的页块里拿。不可移动的对象比如大部分内核数据结构集中在一起可移动的对象比如用户态匿名页集中在一起回收时就能整块腾出来。第二招是内存规整compaction当高阶分配失败时内核会尝试把可移动的页搬到一起腾出连续大块。这个动作由kcompactd后台线程或直接回收路径触发代价是 CPU 和延迟。你可以通过/proc/sys/vm/compact_memory手动触发一次全量规整——但生产环境慎用我见过在几十 GB 内存的机器上写这个文件导致业务抖动几秒的案例。水位线是另一套机制。每个 zone 有三个水位WMARK_MIN、WMARK_LOW、WMARK_HIGH。空闲内存低于 low 时唤醒kswapd后台回收低于 min 时直接分配路径自己动手回收阻塞调用者回到 high 才停止。WMARK_MIN由/proc/sys/vm/min_free_kbytes决定low 和 high 与 min 的间距由/proc/sys/vm/watermark_scale_factor控制默认是 10代表内存的 0.1%。这些值直接影响 OOM 的触发时机min_free_kbytes设太小会导致分配失败频繁设太大会浪费可用内存。2.4 用 /proc/buddyinfo 和 /proc/pagetypeinfo 读出碎片形态诊断碎片最直接的入口是cat /proc/buddyinfo输出形如Node 0, zone Normal 1204 892 431 156 62 18 6 2 1 0 0 Node 0, zone DMA32 621 403 187 71 24 7 2 1 0 0 0每一列对应一个 order数字是该 order 的空闲块数量。如果前面几列很大而后面全是 0说明碎片已经很严重任何需要高阶连续内存的分配都会失败。更细的视图在/proc/pagetypeinfo它按迁移类型分别列出每个 order 的空闲块还能看到 pageblock 的迁移类型分布。我排查过一个案例业务侧申请 2MB 大页总是失败buddyinfo显示 order-9 为 0pagetypeinfo显示Unmovable占了大半 pageblock最后是用进程重启加上调整vm.min_free_kbytes缓解的。还有一点值得记/proc/buddyinfo的第一列order-0数量大并不代表内存充足只代表内存碎。真正判断可用性要配合/proc/meminfo里的MemAvailable、MemFree和SReclaimable一起看。单看一个数字下的结论十有八九是错的。3. SLUB 怎么把小对象塞进页里kmalloc 的真实开销内核自己也要频繁申请小块内存——一个struct file、一个 socket 缓冲区、一个 inode 缓存。如果每次都要 buddy 给一整个页浪费会大到无法接受。slab 分配器就是为解决这个问题生的一次向 buddy 要一批页切成等大小的对象用完了回收不用就留着备用。3.1 从 kmem_cache 到 per-CPU freelist 的快速路径当前内核默认用的是SLUB早期是 SLAB还有给嵌入式用的 SLOB。SLUB 的模型很简洁每个对象类型对应一个kmem_cache这个 cache 管理若干 slab 页每个 slab 页被切成 N 个等大对象。分配路径上SLUB 有三层加速。第一层是per-CPU freelist每个 CPU 有一个kmem_cache_cpu结构里面直接存着一个当前 slab 和一个空闲对象链表。绝大多数kmalloc调用只做一次链表弹出连自旋锁都不用拿。第二层是per-node partial 链表当前 slab 用完了从节点NUMA node的部分空闲 slab 链表里摘一个。第三层才是向 buddy 要新页创建新 slab。这个设计的精髓在于避免锁竞争。在多核机器上如果所有 CPU 抢同一个 freelist性能会崩掉。per-CPU 化之后每个 CPU 在自己的一小片缓存上高速运转只在自己的 slab 用尽时才去碰共享结构。代价是内存利用率下降——每个 CPU 手里都攥着一些空闲对象核越多闲置越多。一台 128 核的机器某个 cache 在 128 个 per-CPU freelist 上各留几个对象累计起来就很可观了。kmalloc提供了一组通用缓存名字形如kmalloc-8、kmalloc-16、kmalloc-32、kmalloc-64、kmalloc-96、kmalloc-128、kmalloc-192、kmalloc-256、kmalloc-512、kmalloc-1k、kmalloc-2k、kmalloc-4k、kmalloc-8k。注意 96 和 192 这两个非 2 次幂的档位——它们是为了减少内部碎片特意加的x86_64 上有效。你申请 80 字节落到kmalloc-96申请 100 字节落到kmalloc-128。中间浪费的 28 字节就是内部碎片。3.2 对象布局、对齐与 SLAB_FREELIST_HARDENED 的代价SLUB 的 slab 页里对象是一个挨一个排的彼此之间可能有 padding 以满足对齐要求。空闲对象的第一个字段或者最后一个取决于配置存着下一个空闲对象的指针这就是 freelist 在 slab 内部的实现方式。因为空闲对象本身没数据正好拿来存指针不需要额外的元数据数组——这是 SLUB 比 SLAB 省内存的关键。安全加固带来了新的成本。开启CONFIG_SLAB_FREELIST_HARDENED之后现在很多发行版默认开freelist 指针不再是明文地址而是和一串随机值做异或后存储的。这能有效阻止通过堆溢出改写 freelist 的攻击但每次分配和释放都多了一次异或运算。开CONFIG_SLAB_FREELIST_RANDOM还会在 slab 创建时打乱对象顺序让攻击者难以预测相邻对象的位置代价是创建 slab 时多一次随机化。另一个值得关注的参数是SLAB_ACCOUNT。带这个标志的 cache其对象会被计入内存 cgroup 的核算。不是所有 cache 都有这个标志所以你在容器里看到的memory.current并不等于全部内核内存占用——有很多内核内存是漏网的。这个问题在 cgroup v2 里有改善memory.stat里的slab字段包含了大部分可核算的 slab 内存但依然有例外。3.3 slab 缓存的膨胀与收缩shrink 什么时候发生slab 页不会永远留着。当内存压力上来时内核会调用各 cache 的shrink回调把空闲的 slab 页还回 buddy 系统。触发路径主要有三条内存回收kswapd或直接回收会调用shrink_slab手动写/proc/sys/vm/drop_caches的2号以及各个 cache 自己注册的 shrinker。这里有个细节值得记住drop_caches写 2 清的是可回收的 slab而 dentry 和 inode 缓存占了大头。清完之后系统会变慢因为文件系统元数据要重新从磁盘读。我见过有人把它当释放内存的万能招每分钟跑一次脚本结果磁盘 IO 持续偏高。真要监控用slabtop看哪个 cache 在涨就行不必动手清。收缩还跟 NUMA 和 CPU 亲和性有关。per-CPU 缓存里的空闲对象在 CPU 离线或被迁移时才会被回收。如果你的服务用taskset绑核但绑定集合之外的 CPU 曾经有过负载那些 CPU 的 per-CPU 缓存可能一直留着对象不释放。这在核多的机器上会表现为进程停了但内存不降。3.4 slabtop 和 /sys/kernel/slab 的实战读法看 slab 状况slabtop -s c是最顺手的按缓存大小排序输出OBJS、ACTIVE、USE%、OBJ SIZE、SLABS、CACHE SIZE、NAME。判断是否有异常我一般看两点一是CACHE SIZE特别大的非预期 cache二是USE%极低但CACHE SIZE还是很大的 cache说明对象都空着但没还回去。# 找出占用最高的 10 个 slab 缓存 sudo slabtop -o -s c | head -20 # 某个缓存的所有信息 sudo cat /sys/kernel/slab/kmalloc-256/objects sudo cat /sys/kernel/slab/kmalloc-256/slabs sudo cat /sys/kernel/slab/kmalloc-256/partial sudo cat /sys/kernel/slab/kmalloc-256/objs_per_slab/sys/kernel/slab/name/下面有一堆可读文件objects是当前对象总数slabs是 slab 页数partial是部分空闲的 slab 数objs_per_slab是每个 slab 能切多少对象。把这些数字和object_size、slab_size对一下就能算出利用率和实际内存占用。/proc/slabinfo的字段更多但也更原始适合脚本处理不适合肉眼扫。一个经验判断如果某个 cache 的objs_per_slab × object_size远小于slab_size说明内部碎片严重可能是对象的对齐要求导致的。这种情况一般无解属于设计取舍。4. glibc malloc 的四个角色tcache、fastbin、arena 和 top chunk用户态这一层绝大多数 Linux 程序用的是 glibc 自带的 ptmalloc2。它的设计目标是通用性不是极致性能也不追求及时归还内存。理解它的四个核心结构基本就能解释大部分内存不还的现象。4.1 chunk 头里的三个标志位决定了什么glibc 把内存切成chunk每个 chunk 有个头部64 位系统上至少 16 字节前 8 字节是prev_size前一个 chunk 空闲时才有意义后 8 字节是size而size的低三位被借去做标志位。bit 0PREV_INUSE前一个 chunk 是否在用。bit 1IS_MMAPPED这个 chunk 是不是独立 mmap 出来的。bit 2NON_MAIN_ARENA属于哪个 arena。因为 chunk 大小必须 16 字节对齐低四位本来就用不上借三位存标志完全没有信息损失。这个技巧很经典但它也意味着你malloc请求的大小会被向上取整到 16 字节再加上 8 或 16 字节的头部开销。请求 100 字节实际 chunk 可能是 112 或 128 字节。写性能敏感代码时这点开销值得算进去。4.2 tcache 的收益与一次经典误用从 glibc 2.26 开始加了tcachethread cache这是影响最大的一次改动。每个线程、每个大小档位有一个最多存 7 个 chunk 的单链表一共 64 个档位覆盖到约 1032 字节。分配和释放只在这个单链表上操作完全不加锁比原来的 fastbin 路径还快。收益很明显单线程密集申请释放小对象性能提升一大截。但误用也集中在这个最多 7 个、不加锁的设计上。tcache 里的 chunk 不会立刻被合并也不会参与跨线程复用。如果你的程序在一个短生命周期线程里申请了大量小对象线程退出时这些 chunk 会还回 arena但之前每个档位压着的 7 个是滞留的。线程数一多滞留总量就很可观。更隐蔽的是 double free。tcache 的检查比 fastbin 宽松得多早期版本里连续free同一个指针两次第二次会把同一个 chunk 再压进链表形成自环后续分配就会拿到重复地址。这类问题现在在加固版本里有检测但依赖编译选项和运行环境。写代码时老老实实把指针置空比指望分配器兜底靠谱得多。4.3 arena 数量与线程模型为什么 64 核机器 RSS 会爆arena是 ptmalloc 里最大的内存池概念。主线程用一个叫 main arena 的 arena用brk扩展其他线程争抢非主 arena每个非主 arena 是一块 64MB 的 mmap 区域HEAP_MAX_SIZE内部再按 chunk 切分。默认情况下非主 arena 的数量上限是8 × CPU 核数64 位系统。这个数字在核多的机器上非常危险。一台 64 核机器理论上最多能开 512 个 arena每个 arena 最多 64MB 的 mmap 区域加起来是 32GB 的地址空间物理内存也会随着实际使用快速增长。而且 arena 一旦创建就不会销毁除非显式调用malloc_trim或者进程退出。我遇到过最典型的一次一个 32 核机器上的服务线程池常年保持 200 个活跃线程每个线程偶尔做一次较大的临时分配。运行一天后 RSS 从 400MB 涨到 2.3GBsmaps里能看到十几个 64MB 的匿名映射段。最后的解决办法不是改代码而是设了MALLOC_ARENA_MAX4RSS 稳定在 700MB 左右。代价是 arena 竞争变多但那台机器的分配压力不大实测吞吐没有下降。这里的原则是线程数远大于核数、单次分配不大、总内存敏感的场景把MALLOC_ARENA_MAX压到 2 到 8 之间通常划算。相反如果分配极其频繁且线程数接近核数让 arena 自然扩张反而更好。4.4 mmap 阈值与 trim 阈值归还内存的时机前面提到超过 128KB 的请求走mmap这个阈值是动态的。glibc 会记录你最近释放的 mmap 块的释放行为如果发现释放的块比当前阈值大就把阈值往上调最高到 32MBDEFAULT_MMAP_THRESHOLD_MAX。为什么上调因为频繁 mmap/munmap 的系统调用和页表操作开销大。上调的代价是大块内存归还变慢。M_TRIM_THRESHOLD控制brk堆顶什么时候收缩默认也是 128KB。堆顶空闲超过这个值下次free时会尝试把内存还给内核。但尝试不等于成功只有当堆顶是一整块连续空闲区域时才能收缩中间有任何一个还在用的 chunk 挡住就白搭。想手动归还malloc_trim(0)是最直接的办法它会尝试把堆顶所有能还的都还掉。jemalloc 有类似的je_mallctl(arena.i.purge, ...)。线上服务里我一般不会定期调用malloc_trim因为那会带来延迟抖动通常是发现内存异常增长后作为临时手段用一次。4.5 mallopt 与 GLIBC_TUNABLES 的可用旋钮调 glibc malloc 的行为有两条路。一条是代码里调mallopt常用参数参数含义默认值64 位M_MMAP_THRESHOLD走 mmap 的阈值128KB动态上限 32MBM_TRIM_THRESHOLDbrk 堆顶收缩阈值128KBM_TOP_PAD每次扩堆额外多要的量128KBM_ARENA_MAX非主 arena 数量上限8 × 核数另一条是环境变量以MALLOC_开头比如MALLOC_ARENA_MAX、MALLOC_MMAP_THRESHOLD_、MALLOC_TRIM_THRESHOLD_、MALLOC_TOP_PAD_。注意环境变量版本末尾带下划线这是 glibc 的历史遗留写错了不报错也不生效。改完之后可以用mallinfo2()打印一份快照arena是总堆大小uordblks是已分配字节数fordblks是空闲字节数keepcost是堆顶可回收的量。这几个数字之间的比例比top的 RSS 更能说明问题。5. 换掉默认分配器之前先搞清楚你在解决什么问题glibc 的 ptmalloc 是为够用设计的不是为高并发、低延迟、及时归还设计的。所以在以下场景里换成 jemalloc、tcmalloc 或 mimalloc 往往能立竿见影多线程高频分配、内存需要及时归还、长尾延迟敏感。但换之前要清楚每个分配器的脾气否则可能换个坑继续踩。5.1 jemalloc 的 arena、bin 与 decayjemalloc 的核心抽象是arena → bin → run → region。每个 arena 内部按大小档位分 bin每个 bin 管一组 runrun 是连续的若干页被切成等大的 region 给调用者。jemalloc 的档位划分比 glibc 细得多从 8 字节开始按 8 字节递增到 128之后按 2 的幂次和四分之一间隔递增能覆盖到很大的对象所以内部碎片比 glibc 小。最有特色的是decay机制。jemalloc 把不用的内存页标为 dirty脏但还在过一段时间dirty_decay_ms如果还没被用就通过madvise(MADV_FREE)标为 muzzy再等一段时间muzzy_decay_ms真正munmap归还。这个延迟是有意的给短期的峰谷波动留缓冲避免频繁系统调用。默认值在较新版本里是 10 秒级别。对内存敏感的容器环境可以把它调小甚至设为 0立即归还代价是系统调用变多。jemalloc 的 per-thread cache 让它在小对象分配上非常快同时又有 background thread 帮它做异步的 decay 和 purge。这里有个实用建议容器里用 jemalloc 时记得同时设置MALLOC_CONFbackground_thread:true,dirty_decay_ms:1000否则 decay 是在分配路径上同步做的反而增加尾延迟。5.2 tcmalloc 的 thread cache 与 spantcmalloc 的层数是thread cache → central free list → page heap。每个线程有一个无锁的 thread cache小对象的分配和释放完全在自己的缓存里完成不碰任何共享结构。缓存用满了就批量刷回 central free listcentral 再和 page heap 换 span。tcmalloc 的 thread cache 有个动态大小调整机制如果在 central 上等锁的次数多就自动扩大 thread cache 上限。这个自适应设计在负载变化剧烈的场景下很有效。它还有一套TCMALLOC_RELEASE_RATE新版本叫tcmalloc_release_rate之类的参数控制归还速率。我在这块踩过一个坑早期版本的 tcmalloc 在 thread cache 里的内存不会被 cgroup 感知到及时释放容器里跑 Go 程序Go 运行时早期用的就是 tcmalloc 思路经常出现 RSS 超过 limit 被杀。对策是设GOMEMLIMIT或者换 Go 1.16 之后默认的页分配器行为。这类问题的本质都是缓存层不感知外部限制不是分配器有 bug。5.3 mimalloc 的分片自由列表mimalloc 相对新设计上做了不少针对现代硬件的取舍。它把内存组织成segment → page → block64 位系统上 segment 是 4MB以前是 4MiB 还是 64MiB 依版本而定page 是 64KB每个 page 服务一个大小档位。自由列表做了分片free list sharding一个 page 内部的自由列表被分成几段减少单链表长度提升缓存局部性。mimalloc 还引入了延迟释放跨线程释放的对象不直接还给原线程的 page而是先放进一个 thread-free list等收集到一定数量再批量处理。这个设计降低了跨线程释放的同步成本在处理生产者-消费者模式时表现不错。另外它默认开启了较多安全加固指针混淆、页元数据校验都有代价是每个对象稍多一点开销。5.4 一份可执行的对比与切换方法三者没有绝对优劣看场景维度jemalloctcmallocmimalloc多线程小对象很好很好很好内存归还及时性好可调 decay一般需调 release rate较好内存碎片控制优秀良好良好调试与剖析工具强prof、stats强heap profiler中等生态成熟度高高中切换方式在 Linux 上很简单最省事的是LD_PRELOAD# 安装后确认路径 ldconfig -p | grep -E jemalloc|tcmalloc|mimalloc # 用 LD_PRELOAD 替换 LD_PRELOAD/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./your_program # 验证是否生效 lsof -p pid | grep -E jemalloc|tcmalloc用LD_PRELOAD有个前提程序不能静态链接 malloc也不能在启动早期就把 malloc 的地址缓存下来。另外要注意程序如果自己 dlopen 了另一个分配器加载顺序会互相覆盖这种时候用LD_DEBUGlibs看加载顺序。还有一个半切换的做法在代码里只对热点路径用自定义的池化分配object pool其他代码继续走 glibc。这通常比全局换分配器风险小收益也够。我在一个网络服务里就这么干过连接对象和收发缓冲区用固定大小的内存池实测比换全局分配器少了 30% 的 CPU 时间还完全可控。6. 把分配行为看清楚从 /proc 到 eBPF 的观测链路排查内存问题最忌讳的就是猜。下面这套观测链路从最粗到最细我按实际使用频率排序前三个基本能覆盖八成场景。6.1 五分钟能上手的命令清单先看全局free -m看 total/used/available注意available才是真正可用的估算值它包含了可回收的页缓存。vmstat 1看si/so换入换出非零说明在跟 swap 较劲、free、buff/cache。再看内核侧cat /proc/meminfo里重点看Slab、SReclaimable、SUnreclaim、AnonPages、Mapped、PageTables、AnonHugePages。SUnreclaim持续增长是危险信号说明有内核对象泄漏或者缓存膨胀且不可回收。然后看具体进程# 进程的物理内存占用排名 ps aux --sort-rss | head -20 # 某个进程的详细内存分解比 status 更细 sudo cat /proc/pid/smaps_rollup # 看哪些映射段占了大头 sudo awk /^[0-9a-f]/ {addr$0} /^Rss:/ {print $2, addr} /proc/pid/smaps | sort -rn | head -20 # slab 状况 sudo slabtop -o -s c | head -30smaps_rollup比/proc/pid/status好用因为它把Rss、Pss、Shared、Private、Swap都汇总了。特别是Pssproportional set size在多个进程共享同一份内存时它按比例分摊给容器算内存占用更公平。6.2 kmem tracepoint 与 bpftrace 一行脚本追内核侧的分配源头tracepoint 是最正规的入口。/sys/kernel/debug/tracing/events/kmem/下面有kmalloc、kfree、kmem_cache_alloc、mm_page_alloc、mm_page_free等事件。# 看谁在疯狂申请页 sudo perf record -e kmem:mm_page_alloc -ag -- sleep 10 sudo perf report --stdio | head -40 # 按调用点统计 slab 分配 sudo perf kmem stat --alloc --sortcall_site -- sleep 5如果机器上有 bpftrace用一行脚本就能查出热点# 统计 __kmalloc 的调用栈频次 sudo bpftrace -e kprobe:__kmalloc { [kstack] count(); } # 统计各 cache 的分配大小分布 sudo bpftrace -e tracepoint:kmem:kmalloc { bytes[comm] hist(args-bytes_alloc); } # 追踪某个进程的页分配 sudo bpftrace -e tracepoint:kmem:mm_page_alloc /pid 12345/ { [kstack] count(); }这些脚本的产出是内核侧的真相哪个函数、什么栈、分配了多少次。配合符号表基本上能定位到具体驱动或子系统。注意在生产环境跑要控制采样时间kstack的收集开销不小短时间跑几秒就够。6.3 用户态堆泄漏massif、heaptrack 与 malloc hook用户态堆的问题工具链已经很成熟。valgrind --toolmassif会记录堆的完整时间线输出可以喂给ms_print看峰值时刻的分配栈。它的缺点是慢跑在线服务上会拖垮性能一般用在预发环境。heaptrack是我更常用的选择它通过LD_PRELOAD挂钩 malloc采样开销比 valgrind 小得多输出可以用heaptrack_gui打开。它能告诉你峰值内存的分配栈、内存增长的时间点、以及泄漏候选排行榜。在一个 Python 服务里我用它定位过一个 C 扩展的缓存不释放问题从挂载到定位不到半小时。另外两个不常被提起但很有用的手段mtraceMALLOC_TRACE环境变量 mtrace()调用写一个日志文件用mtrace脚本分析和分配器自带的统计。jemalloc 的prof:true配置能采样分配栈并输出到文件用jeprof分析线上开启的额外开销可以接受。6.4 page_owner 与 kmemleak内核侧的真凶定位内核内存泄漏比用户态难查得多两个工具值得记住。page_owner需要内核开启CONFIG_PAGE_OWNER启动参数加page_owneron然后cat /sys/kernel/debug/page_owner能看到每个物理页是被谁的调用栈分配的。它的开销大只在排查时临时打开用完关掉。kmemleak是内核的对象级泄漏检测器需要CONFIG_DEBUG_KMEMLEAKy。开启后echo scan /sys/kernel/debug/kmemleak触发一次扫描然后cat /sys/kernel/debug/kmemleak看结果。它的原理是扫描内核内存找那些没有任何指针引用的已分配对象误报率存在但可控。我曾用它定位过一个第三方驱动的缓冲区泄漏报告里直接给出了分配栈省了几天时间。这两个工具的共性是需要专门的内核编译选项生产内核往往没开。所以更实际的做法是用slabtop/proc/slabinfo定期采样对比时间线看哪个 cache 单调增长。锁定 cache 名字之后再从源码里搜kmem_cache_create的调用位置反推是哪个子系统。7. 调优参数与踩坑记录THP、overcommit、NUMA 与 cgroup最后这部分是我实际踩过的坑每一条都对应过线上故障或者长期困扰。参数调优没有标准答案但有明确的错误方向。7.1 THP 带来的延迟毛刺与关闭方式透明大页Transparent Huge Pages把 4KB 页自动合并成 2MB 大页减少 TLB miss理论上提升吞吐。实际效果高度依赖负载内存顺序访问的批处理受益明显而大量小块随机分配、生命周期短的服务会被它折磨。问题在于 THP 的分配和拆分都是同步发生的。当内核需要分配一个 2MB 大页时可能要先触发内存规整或者直接回收这个动作会阻塞当前线程产生毫秒级的延迟毛刺。更糟的是khugepaged后台线程在后台合并页时也会占用 CPU 并持有内存管理相关的锁在延迟敏感的服务里表现为周期性的 p99 抖动。控制参数在/sys/kernel/mm/transparent_hugepage/下cat /sys/kernel/mm/transparent_hugepage/enabled # [always] madvise never cat /sys/kernel/mm/transparent_hugepage/defrag # [always] defer defermadvise madvise neverenabled的madvise模式是最实际的折中只有显式调用madvise(MADV_HUGEPAGE)的区域才用大页其他保持 4KB。defrag建议设成madvise或defer避免分配路径上同步规整。Redis、MySQL 这类对延迟敏感的服务官方文档基本都建议关掉always。顺带一提THP 还会让 RSS 数字失真。一个进程实际写了 100KB 数据因为落在 2MB 大页里Rss可能直接加 2MB。这也解释了为什么有些服务一开 THP 就内存暴涨多半是虚拟占用而非真实物理占用AnonHugePages字段能帮你确认。7.2 overcommit 三种模式的实际行为vm.overcommit_memory有三个值行为差异很大0启发式内核按经验判断明显过分的请求会被拒。这是默认值适合大多数场景。1总是允许从不过度承诺检查malloc几乎不会失败风险是真正的内存不足要等到写第一笔数据才暴露触发 OOM Killer。2严格按CommitLimit swap RAM × overcommit_ratio / 100 overcommit_kbytes限制超过就拒绝。我见过最典型的故障是 Redis 的fork场景。Redis 用 fork COW 做持久化fork之后子进程共享父进程的内存页但内核在overcommit_memory2时会按最坏情况计算虚拟内存需求导致fork直接失败。Redis 官方文档里专门提到要把overcommit_memory设成 1就是这个原因。与之相关的还有/proc/sys/vm/overcommit_ratio模式 2 下有效和/proc/sys/vm/panic_on_oom。panic_on_oom设 1 会让内核在 OOM 时直接 panic 而不是杀进程这是给关键设备用的普通服务器千万别开。OOM 发生时判断谁被杀不能只看内存占用要看oom_score。这个分数由内存占用、运行时长、oom_score_adj共同决定。关键进程可以设oom_score_adj -1000完全免疫有风险或者设一个负值降低被杀概率。cgroup v2 里还有memory.oom.group可以配置整个 cgroup 一起被杀避免留下半死不活的进程。7.3 NUMA 下的 zone_reclaim_mode 与内存本地性NUMA 机器上每个 CPU 访问本地内存比远程快不少所以分配策略直接影响性能。默认策略是本地优先先在当前 CPU 所属节点的 zone 里分配本地水位低时才去远程节点。vm.zone_reclaim_mode控制的是要不要在本地回收而不是去远程分配。值为 0 表示宁可去远程分配也不回收非 0 表示优先回收本地内存。这个参数在历史上引起过很大的性能争议早期版本默认值偏保守导致本地回收频繁吞吐下降后来内核默认改成了 0。我的建议是保持 0除非你的应用对远程访问延迟极度敏感并且能接受回收开销。更值得关注的是首次触页first-touch策略。Linux 在缺页时把页分配给访问它的那个 CPU 所在的节点。所以如果你在主线程malloc一块大内存然后交给多个工作线程去写所有物理页都会落在主线程所在的节点工作线程全在远程访问性能能差 30% 以上。正确做法是让每个线程自己去初始化它要用的那部分内存——这就是所谓的并行初始化。诊断 NUMA 命中率用numastat -p pid看local_node和other_node的比例。如果远程访问占比超过 20%就值得调整。7.4 cgroup v2 里 memory.max 与 slab 的核算边界容器环境里内存限制由 cgroup 施加。cgroup v2 的关键文件是memory.max硬限制、memory.high软限制超过后限流回收、memory.current当前用量、memory.stat分解统计。这里有个反复坑人的问题哪些内存被算进memory.current。在 v2 里memory.stat的file、anon、slab、kernel_stack、pagetables、sock这些都会计入。也就是说内核 slab 内存也会算在你头上。如果你的程序大量创建 socket 或者文件sock和slab可能占几百 MB而这些在进程自己的 RSS 里是看不到的。容器 OOM 的时候进程一脸无辜其实是内核内存超了。另一个坑是memory.high的回收行为。设置之后超过阈值会触发限流式回收分配会被节流而不是直接失败表现为吞吐下降但不报错。这在压测时能掩盖问题上线后才暴露。我一般建议生产环境只用memory.max把memory.high留给需要精细控制的场景。还有一点glibc 的 arena 内存是匿名映射属于anon计入memory.current。所以前面说的MALLOC_ARENA_MAX调优在容器里效果更明显——既降低 RSS也降低被 OOM 的概率。7.5 一份排查内存增长的顺序清单把前面所有东西串成一个可执行的顺序遇到内存一直涨时照着走先分清楚是哪个指标在涨。VSZ涨、RSS涨、还是 cgroup 的memory.current涨三者的排查方向完全不同。看进程内部结构smaps_rollup的Rss、Pss、Swap以及按段排序找到最大的映射。区分类型cat /proc/meminfo看AnonPages、Slab、SReclaimable、SUnreclaim的增长趋势。进程侧定位如果anon涨用 heaptrack 或者分配器自带的 profiler 找分配点如果slab涨用slabtop找是哪个 cache。内核侧定位前一步锁定 cache 名字后用bpftrace追分配栈或者在源码里搜kmem_cache_create。判断是泄漏还是缓存把malloc_trim或者分配器的 purge 调一次观察是否回落。回落就是缓存策略不回落才考虑泄漏。最后才是调参或者改代码而且一次只改一个参数改完观察至少一个完整的业务周期。这个顺序的价值在于先分类再深入。我见过太多人一上来就valgrind跑了两小时没结果因为他其实面对的是 slab 膨胀而不是堆泄漏。方向错了工具再强也没用。我个人在长期维护服务时最看重的一条经验是给内存设一个可以观测的基线。把 RSS、SUnreclaim、进程数、活跃 arena 数量这些指标做成定时采样和 QPS、延迟放在同一张图上看。什么时候内存曲线开始偏离 QPS 曲线什么时候就有问题不需要等到用户投诉。前阵子我们一个服务的内存增长就是因为采样图里发现 RSS 和 QPS 的相关系数从 0.9 掉到了 0.3顺着这条线索才找到那个每请求创建一次短生命周期线程的旧代码。