news 2026/9/30 18:35:31

从malloc到RSS:Linux内存分配器三层原理与排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从malloc到RSS:Linux内存分配器三层原理与排查

"这东西已经跑了两百多天,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 fault):CPU 访问这个虚拟地址,MMU 查页表发现没有对应映射,陷入内核,内核才去 buddy 系统要一个物理页,填好页表项,返回用户态重新执行那条指令。

这个机制带来两个后果。第一,malloc大块内存但只写第一个字节,实际物理占用可能就一个页。第二,free之后物理页不一定立刻归还——分配器会觉得你马上又要用,先留着。所以你在top里看到的 RSS(Resident Set Size)才是真实物理占用,VSZ 只是个虚拟地址空间大小,参考价值有限。我见过有人拿 VSZ 判断内存泄漏,那是在给自己找麻烦。

1.2 brk 和 mmap:两条系统调用通道的分岔点

用户态分配器向内核要内存,只有两条路。一条是brk,把进程的数据段末尾(program break)往上推,推出来的空间就是主堆。这条路的优点是连续、便宜,缺点是归还困难——你想缩回来,得保证堆顶是一整块空闲区域,现实里很难。另一条是mmap,直接映射一段匿名内存,归还时munmap一次就干净了。

glibc 的策略是:主线程的小块请求走brk,大块请求(默认超过 128KB)走mmap,子线程则各自开一个用mmap创建的 64MB 堆区。这个分界值不是固定的,后面会讲到它会动态调整。为什么要有这个分界?因为brk区内的内存碎片化之后无法归还给系统,而mmap可以整块退掉。对于"申请一次、用很久、然后释放"的大对象,走mmap显然更划算。

这里有个容易被忽略的细节:mmap每次都要建立新的 VMA(virtual memory area),内核需要管理这些 VMA 的区间树。如果你有大量小对象走了 mmap,VMA 数量会爆炸,/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/slub;malloc、new是用户态用的,走 glibc/jemalloc 这类实现。两者唯一的交集就是用户态分配器通过brk/mmap向内核心要页,以及内核在缺页时代它分配物理页。搞混这两条线,排查时就会找错地方。

2. 伙伴系统:页级分配器的秩序来源与碎片账单

buddy 系统是 Linux 内存分配器的地基,它只管一件事:以页(通常 4KB)为最小单位,分配和释放物理连续的内存块。所有 slab、所有用户态堆、所有内核缓冲区,最终都从这里拿页。

2.1 order、zone 与 free_area 的组织方式

buddy 把物理内存按 2 的幂次分块,用order表示块的大小:order-0 是一页(4KB),order-1 是两页(8KB),order-10 是 1024 页(4MB,这是默认MAX_ORDER能覆盖的最大块)。每个 zone 里有一个free_area数组,索引就是 order,每个元素挂一条空闲块链表。

zone 是什么?x86_64 上常见三个:ZONE_DMA(受限于老式设备寻址,一般前 16MB)、ZONE_DMA32(低 4GB,给 32 位 DMA 设备用)、ZONE_NORMAL(其余全部)。32 位系统上还有ZONE_HIGHMEM,64 位没有。此外还有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 为 0,pagetypeinfo显示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 0:PREV_INUSE,前一个 chunk 是否在用。
  • bit 1:IS_MMAPPED,这个 chunk 是不是独立 mmap 出来的。
  • bit 2:NON_MAIN_ARENA,属于哪个 arena。

因为 chunk 大小必须 16 字节对齐,低四位本来就用不上,借三位存标志完全没有信息损失。这个技巧很经典,但它也意味着你malloc请求的大小会被"向上取整"到 16 字节,再加上 8 或 16 字节的头部开销。请求 100 字节,实际 chunk 可能是 112 或 128 字节。写性能敏感代码时,这点开销值得算进去。

4.2 tcache 的收益与一次经典误用

从 glibc 2.26 开始加了tcache(thread 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.3GB,smaps里能看到十几个 64MB 的匿名映射段。最后的解决办法不是改代码,而是设了MALLOC_ARENA_MAX=4,RSS 稳定在 700MB 左右。代价是 arena 竞争变多,但那台机器的分配压力不大,实测吞吐没有下降。

这里的原则是:线程数远大于核数、单次分配不大、总内存敏感的场景,把MALLOC_ARENA_MAX压到 2 到 8 之间通常划算。相反,如果分配极其频繁且线程数接近核数,让 arena 自然扩张反而更好。

4.4 mmap 阈值与 trim 阈值:归还内存的时机

前面提到超过 128KB 的请求走mmap,这个阈值是动态的。glibc 会记录你最近释放的 mmap 块的释放行为,如果发现释放的块比当前阈值大,就把阈值往上调,最高到 32MB(DEFAULT_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,动态上限 32MB
M_TRIM_THRESHOLDbrk 堆顶收缩阈值128KB
M_TOP_PAD每次扩堆额外多要的量128KB
M_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 与 decay

jemalloc 的核心抽象是arena → bin → run → region。每个 arena 内部按大小档位分 bin,每个 bin 管一组 run,run 是连续的若干页,被切成等大的 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_CONF=background_thread:true,dirty_decay_ms:1000,否则 decay 是在分配路径上同步做的,反而增加尾延迟。

5.2 tcmalloc 的 thread cache 与 span

tcmalloc 的层数是thread cache → central free list → page heap。每个线程有一个无锁的 thread cache,小对象的分配和释放完全在自己的缓存里完成,不碰任何共享结构。缓存用满了就批量刷回 central free list,central 再和 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 → block:64 位系统上 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_DEBUG=libs看加载顺序。

还有一个"半切换"的做法:在代码里只对热点路径用自定义的池化分配(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 -30

smaps_rollup比/proc/<pid>/status好用,因为它把Rss、Pss、Shared、Private、Swap都汇总了。特别是Pss(proportional 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 --sort=call_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 --tool=massif会记录堆的完整时间线,输出可以喂给ms_print看峰值时刻的分配栈。它的缺点是慢,跑在线服务上会拖垮性能,一般用在预发环境。

heaptrack是我更常用的选择,它通过LD_PRELOAD挂钩 malloc,采样开销比 valgrind 小得多,输出可以用heaptrack_gui打开。它能告诉你峰值内存的分配栈、内存增长的时间点、以及"泄漏候选"排行榜。在一个 Python 服务里我用它定位过一个 C 扩展的缓存不释放问题,从挂载到定位不到半小时。

另外两个不常被提起但很有用的手段:mtrace(MALLOC_TRACE环境变量 +mtrace()调用,写一个日志文件,用mtrace脚本分析)和分配器自带的统计。jemalloc 的prof:true配置能采样分配栈并输出到文件,用jeprof分析,线上开启的额外开销可以接受。

6.4 page_owner 与 kmemleak:内核侧的真凶定位

内核内存泄漏比用户态难查得多,两个工具值得记住。page_owner需要内核开启CONFIG_PAGE_OWNER,启动参数加page_owner=on,然后cat /sys/kernel/debug/page_owner能看到每个物理页是被谁的调用栈分配的。它的开销大,只在排查时临时打开,用完关掉。

kmemleak是内核的对象级泄漏检测器,需要CONFIG_DEBUG_KMEMLEAK=y。开启后,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 defer+madvise madvise never

enabled的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_memory=2时会按"最坏情况"计算虚拟内存需求,导致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 一份排查内存增长的顺序清单

把前面所有东西串成一个可执行的顺序,遇到"内存一直涨"时照着走:

  1. 先分清楚是哪个指标在涨。VSZ涨、RSS涨、还是 cgroup 的memory.current涨,三者的排查方向完全不同。
  2. 看进程内部结构:smaps_rollup的Rss、Pss、Swap,以及按段排序找到最大的映射。
  3. 区分类型:cat /proc/meminfo看AnonPages、Slab、SReclaimable、SUnreclaim的增长趋势。
  4. 进程侧定位:如果anon涨,用 heaptrack 或者分配器自带的 profiler 找分配点;如果slab涨,用slabtop找是哪个 cache。
  5. 内核侧定位:前一步锁定 cache 名字后,用bpftrace追分配栈,或者在源码里搜kmem_cache_create。
  6. 判断是泄漏还是缓存:把malloc_trim或者分配器的 purge 调一次,观察是否回落。回落就是缓存策略,不回落才考虑泄漏。
  7. 最后才是调参或者改代码,而且一次只改一个参数,改完观察至少一个完整的业务周期。

这个顺序的价值在于先分类再深入。我见过太多人一上来就valgrind,跑了两小时没结果,因为他其实面对的是 slab 膨胀而不是堆泄漏。方向错了,工具再强也没用。

我个人在长期维护服务时最看重的一条经验是:给内存设一个可以观测的基线。把 RSS、SUnreclaim、进程数、活跃 arena 数量这些指标做成定时采样,和 QPS、延迟放在同一张图上看。什么时候内存曲线开始偏离 QPS 曲线,什么时候就有问题,不需要等到用户投诉。前阵子我们一个服务的内存增长,就是因为采样图里发现 RSS 和 QPS 的相关系数从 0.9 掉到了 0.3,顺着这条线索才找到那个每请求创建一次短生命周期线程的旧代码。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 18:34:58

ESP32 esp-idf环境搭建:用TaoToken统一Key打通编译与烧录链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 18:33:39

Model-Optimizer:面向边缘部署的模型瘦身工程方法论

1. 这不是“一键压缩”工具&#xff0c;而是一套模型瘦身的手术方案 “Model-Optimizer”这个词最近在工程师茶水间、算法群和GitHub trending页频繁刷屏&#xff0c;但它绝不是某个新出的GUI软件图标&#xff0c;更不是点几下就能让大模型变小的魔法按钮。它代表的是一整套面向…

作者头像 李华
网站建设 2026/9/30 18:30:21

一分钟派活:把灵感快速转成AI Agent任务的实战指南

派活这个词&#xff0c;听起来挺职场&#xff0c;但用在自己的 AI Agent 身上&#xff0c;我觉得再贴切不过。最近一个月我一直在折腾一件事&#xff1a;怎么把脑子里突然蹦出来的需求&#xff0c;以最短的路径变成 Agent 能立刻动手干的活。典型场景是这样的——我在路上&…

作者头像 李华
网站建设 2026/9/30 18:30:16

工业相机像素精度漂移的七层物理动因与工程对策

1. 为什么工业相机不是“放大版手机摄像头”&#xff1a;从像素精度漂移说起很多人第一次接触机器视觉项目时&#xff0c;下意识会把工业相机当成“专业版手机摄像头”——不就是拍得更清楚、帧率更高一点吗&#xff1f;直到某天调试产线上的缺陷检测系统&#xff0c;发现同一块…

作者头像 李华
网站建设 2026/9/30 18:29:25

SPSS Modeler企业级统计建模实战:从数据到可部署决策引擎

1. SPSS Modeler不是“点点点”的玩具&#xff0c;而是统计建模的精密工作台很多人第一次听说SPSS Modeler&#xff0c;是在某次公司内训PPT里看到一张“拖拽式数据挖掘流程图”&#xff0c;配文写着“零代码实现客户分群”。接着就去搜“SPSS Modeler下载破解免费版”&#xf…

作者头像 李华