干了这么多年服务端开发,我对"操作系统——内存管理"这一块的感情挺复杂。刚入行那会儿,我把它当成考试题:页表、段页式、LRU、Belady 异常,背完就扔,觉得跟写业务代码八竿子打不着。真正把我打醒的是接手一个跑了三个月就开始告警的服务:监控上 RSS 一路往上爬,重启就好,过几天又爬回去。我那时候以为是"内存泄漏",拿着工具一通乱查,最后发现问题既不在业务代码,也不在第三方库,而是我对操作系统内存管理这套机制的误解——把虚拟内存当成了物理内存,把 malloc 返回的地址当成了真实可用的内存,把 free 当成了立刻归还给系统。
从那次开始,我把这块知识重新啃了一遍,而且是带着问题啃的:为什么一个进程的 VIRT 显示 8G,RSS 只有 200M?为什么/proc/meminfo里 MemFree 只剩几百兆,机器却一切正常?为什么明明还有空闲内存,进程却被 OOM killer 干掉了?这些问题,光看教材上的"分页存储管理"是答不上来的,得把虚拟地址翻译、页表结构、缺页中断、伙伴系统、slab 分配器、页面回收水位线这一整条链路串起来看,才看得清全貌。
这篇内容我打算按"从硬件到内核、从内核到用户态、从原理到排查"的顺序讲一遍内存管理。写 C/C++ 的、做后端服务的、调 JVM 和 Go runtime 参数的、搞嵌入式的,还有准备面试想把这块讲透的,都能在里面找到能直接用的东西。我会给出可复现的代码和命令、关键参数的计算过程、以及我自己踩过的坑。教材上讲得对但不够用的地方,我会补上"为什么这么设计"和"实际会出什么问题"。
1. 先把内存管理的全貌摊开:它到底在管什么
1.1 从一次性能塌方说起:内存管理到底解决什么问题
我记得很清楚,某个服务上线后 P99 从 40ms 涨到 800ms,CPU 使用率却不高,磁盘 IO 也不高,top上看到的异常只有两个:si/so(swap 换入换出)有零星数值,以及%sy(系统态 CPU)偏高。后来定位到是某个同事为了提高吞吐,把一个缓存结构开得太大,导致进程常驻集超过了机器物理内存,内核开始频繁回收匿名页,走到直接回收路径时,申请内存的线程被同步阻塞,尾延迟就爆了。
这个案例说明一件事:内存管理从来不只是"分配和释放"。它至少同时承担四件事——给每个进程造一个独立的地址空间(隔离)、把虚拟地址映射到物理页(翻译)、在有限物理内存里切分与复用(分配)、在不够用时挑出牺牲品(回收与置换)。这四件事任何一环出问题,表现出来的都不是"内存不足"这么直白,而是延迟抖动、吞吐下降、随机被杀进程,甚至文件系统写入变慢。
所以理解内存管理,先要有一个正确的分层视角:CPU 和 MMU 负责翻译,内核负责建表和策略,分配器负责切分,应用程序负责用对。绝大多数线上内存问题,根子在这四层里的某一层没对齐,而不是"内存太小"。
1.2 四层职责拆解:谁在干什么活
把这套机制按职责分层,是我认为最省脑子的理解方式。
最底下是硬件层,MMU 拿着页表基址寄存器(x86-64 上是 CR3)去查表,把虚拟地址翻成物理地址,翻译结果缓存进 TLB。硬件只认页表和 TLB,不懂什么进程、什么堆栈,它就是个死板的查表机器,查不到就抛一个缺页异常给内核。
往上是内核的地址空间层,负责给每个进程维护一套页表、划分用户态和内核态地址范围、管理 VMA(虚拟内存区域)。你在/proc/<pid>/maps里看到的那一堆行,就是这一层的产物。
再往上是物理内存管理层,包括页框分配器(伙伴系统)、对象缓存(slab/slub)、页面回收(kswapd 与直接回收)、以及交换。它管的是"物理页从哪来、什么时候还回去"。
最上层才是我们天天打交道的用户态分配器,glibc 的 malloc、Go 的 mspan、JVM 的 TLAB,它们在内核之上再做一层小对象切分,目的是减少系统调用、减少碎片。
分层的价值在于:每一层只解决自己那层的问题,上层可以假装下层不存在。malloc 的人不需要知道页表怎么建,写内核的人不需要知道你的业务对象多大。
1.3 为什么非要把虚拟地址和物理地址拆开
这是我觉得整个内存管理里最值得想清楚的一个"为什么"。早期机器确实可以直接用物理地址,省掉翻译开销,但代价是三件事做不了:进程隔离、内存超额分配、以及内存整理。
进程隔离是硬需求。没有虚拟地址,A 进程一个野指针就能改掉 B 进程的数据,甚至改掉内核的数据。有了虚拟地址,每个进程一套独立页表,A 的地址在 B 的页表里根本不存在,访问直接触发异常。
超额分配(overcommit)是实用需求。一个进程 mmap 了 1G,可能只写了 100M,剩下的页压根没分配物理页框。这在 fork 密集型、Java 大堆、以及各种预留缓冲区的场景里非常常见。代价是内核要维护"承诺"和"实际"两本账,Committed_AS和MemAvailable就是这两本账的体现。
内存整理也是刚需。物理内存用久了必然碎片化,想凑出一块连续的 2MB 大页可能凑不出来。有了虚拟映射,内核只要把零散的物理页重新映射到一段连续的虚拟地址区间上就行,物理上不连续完全没关系。
代价当然也有:每次内存访问多了一次(或几次)查表,缺页异常让首次访问变慢,页表本身也要占内存。所以现代 CPU 用 TLB 缓存翻译结果,用多级页表压缩页表体积,用大页减少表项数量。这些设计全部是在"翻译开销"和"管理灵活性"之间做权衡。
2. 地址翻译这条链路:页表、TLB 与大页取舍
2.1 一次地址翻译要走几步:把计算过程摆出来
拿最常见的 x86-64 四级页表、4KB 页来说。虚拟地址 48 位有效,被切成 5 段:9 位 + 9 位 + 9 位 + 9 位 + 12 位。前四段是四级页表的索引,最后 12 位是页内偏移(2^12 = 4096)。
翻译过程是这样的:CR3 指向 PGD(一级页表)的物理基址,取出虚拟地址第 47:39 位作为索引,找到 PUD 表项;PUD 表项指向 PUD 表,再用第 38:30 位索引找 PMD 项;PMD 项指向 PMD 表,用第 29:21 位找 PTE 项;PTE 项指向 PTE 表,用第 20:12 位找到最终的物理页框号;最后拼上页内偏移,得到物理地址。
一次冷访问要走四次内存读(每次读一个 8 字节表项)。这就是为什么没有 TLB 的话性能会塌:四次访存串行,等于把内存延迟翻了四倍。TLB 命中时,整个翻译在 1 个周期左右完成,这就是 TLB 存在的全部理由。
算一下页表的体积:一个页表页是 4KB,每个表项 8 字节,所以一页能放 512 项,覆盖 512 × 4KB = 2MB 的虚拟地址空间。也就是说,每 2MB 虚拟地址,至少要一个 PTE 页(4KB)。一个进程如果用了 1GB 虚拟地址,光 PTE 页就要 512 个,即 2MB 内存。这也解释了/proc/meminfo里PageTables这个字段为什么会涨:进程越多、映射越多,页表本身的开销越大。
如果用单级页表呢?48 位地址、4KB 页,需要 2^36 个表项,每个 8 字节,总共 512GB——显然不可能。所以多级页表的本质是"按需展开":没用到的那段地址,对应的下级页表页根本不存在,省下大量内存。
2.2 TLB 命中率:比页表结构更影响性能的东西
我在做一次数据库类负载调优时发现,两台配置完全相同的机器,吞吐差了 30%。最后查到的差异是 TLB 命中率。其中一台跑的是大量随机访问大数组的负载,工作集远超 TLB 覆盖范围,每次访问几乎都 TLB miss,只能走页表遍历(page walk),CPU 的dtlb_load_misses.walk_completed指标直接顶满。
TLB 容量是有限的,典型的 L1 dTLB 只有 64 项左右,L2 TLB 也就一千多项。按 4KB 页算,L1 dTLB 全命中只能覆盖 256KB,这点空间对现代应用来说太小了。这就是大页存在的直接动因。
用 2MB 大页,同样 64 项的 TLB 能覆盖 128MB;用 1GB 大页,能覆盖 64GB。所以对内存密集型负载(数据库、缓存、虚拟机),大页带来的提升往往非常可观。但大页不是白拿的,代价在下面这张表里。
| 页大小 | 单个 TLB 项覆盖范围 | 同等内存下的页表项数 | 内部碎片上限 | 缺页时的清零成本 | 适用场景 |
|---|---|---|---|---|---|
| 4KB | 4KB | 1 倍(基准) | < 4KB | 低 | 通用负载、小对象密集 |
| 2MB | 2MB | 1/512 | < 2MB | 中 | 数据库、Redis、JVM 大堆 |
| 1GB | 1GB | 1/262144 | < 1GB | 高 | 超大内存机型、虚拟化宿主 |
内部碎片的账很好算:一个只用了 8KB 的结构,如果被分到 2MB 大页上,浪费接近 2MB。所以大页必须配合"内存对齐分配"和"预热"(提前把大页 touch 一遍,触发合并),否则不但不省,反而更浪费。
Linux 上有两种大页:透明大页(THP)和显式 hugetlbfs。THP 由内核自动合并,优点是无需改代码,缺点是合并时会持有锁、造成延迟抖动,/sys/kernel/mm/transparent_hugepage/enabled我一般设成madvise,让应用自己决定哪段用大页。显式 hugetlbfs 则是提前预留,稳定但需要预留池子,不够用时会直接失败。
注意:把 THP 设成
always在延迟敏感的服务上是个常见雷区。我做压测时见过 THP 合并导致的几百毫秒毛刺,改成madvise并对大数组显式madvise(MADV_HUGEPAGE)后,毛刺消失,吞吐还略有提升。
2.3 缺页中断:首次访问为什么慢,以及什么时候该预热
虚拟页到物理页的绑定是懒惰的。你 malloc 了 1G,内核只记了一笔账,物理页框一个都没给;真正第一次写某个页时,MMU 查表发现 PTE 是空的,抛出缺页异常,内核进来分配一个物理页框、清零、填 PTE、返回用户态重试指令。这就是按需调页(demand paging)。
缺页分几种,性能影响差很多:
- 匿名页首次访问缺页:分配零页并清零,成本主要在清零(4KB 的清零大概几百纳秒,2MB 更贵)。
- 文件页缺页:从 page cache 里找,找不到就去磁盘读,这是最慢的,一次可能是几十微秒到毫秒级。
- 写时复制(COW)缺页:fork 之后父子进程共享物理页,任意一方写入时触发复制。
- 换入缺页:页被换到 swap 上了,需要读回来。
这解释了一个常见现象:服务刚启动时延迟高,跑一会儿就好了。因为热路径上的页还没被 touch 过,第一批请求全在等缺页。对延迟敏感的服务,我会在启动时做一次"内存预热"——把关键数据结构按页大小跨度写一遍,让物理页提前分配好。
预热代码很简单,但要按页跨度写,不能一个字节一个字节写,否则编译器优化加上页内局部性,实际只 touch 了少量页:
#include <stddef.h> #include <string.h> #define PAGE 4096 void warmup(void *buf, size_t len) { char *p = (char *)buf; for (size_t off = 0; off < len; off += PAGE) { p[off] = 0; /* 每页写一个字节,触发一次缺页 */ } }实测下来,对 1G 的堆做预热大约消耗 200~400ms(取决于内存带宽和是否零页),换来的是启动后前几分钟的尾延迟稳定。这笔买卖在核心服务上很划算,在短生命周期任务上就不值。
3. 分配器怎么挑:伙伴系统、slab 与用户态 malloc
3.1 内核页框分配:伙伴系统的分裂与合并怎么算
内核层面的物理页框分配靠伙伴系统。它的设计目标只有两个:快速找到合适大小的连续块、尽量减少外部碎片。
原理不复杂:把所有空闲页框按 order 分组,order n 表示 2^n 个连续页。分配 order k 的块时,如果 order k 的链表为空,就往上找 order k+1,把它劈成两半,一半返回,一半挂到 order k 的链表上;如果 k+1 也空,继续往上。释放时反过来,如果相邻的"伙伴"块也空闲且同 order,就合并成高一阶的块,继续往上合并。
算一个具体例子。申请 13KB,按 4KB 页算需要 4 页,向上取整到 2 的幂是 4 页,即 order 2。如果此时 order 2 的链表为空,order 3 有块,就把它劈成两个 order 2,一个给你,一个挂到 order 2 链表;如果 order 3 也空,就劈 order 4,得到两个 order 3,一个挂起来,一个继续劈成两个 order 2……以此类推。
伙伴系统有个天然局限:它只按 2 的幂分配,13KB 的请求实际占用 16KB,浪费 3KB;而且它是按页粒度工作的,对几字节、几十字节的内核对象分配来说太粗了。这就是 slab 存在的理由。
看碎片情况直接读/proc/buddyinfo:
$ cat /proc/buddyinfo Node 0, zone Normal 1024 512 256 128 64 16 8 4 2 1 0每一列对应 order 0 到 10。如果 order 0~2 数量很多,而 order 5 以上都是 0,说明碎片严重——总量够,但凑不出大块。这时候drop_caches只能救一时,真正的解法是减少长生命周期的大块分配,或者用 CMA/预留区。
3.2 slab 与 slub:为什么内核对象要单独缓存
内核里大量分配固定大小的小对象:inode、dentry、task_struct、sk_buff。如果每次都走伙伴系统申请整页再切分,一是浪费(一个 200 字节的对象占 4KB),二是每次都要初始化。
slab 分配器的做法是:为每种对象类型建一个缓存(cache),缓存里预先切好一堆同样大小的槽位(slot),分配时直接从空闲链表摘一个,释放时挂回去。对象构造和析构函数可以挂在缓存上,新槽位自动初始化。这样分配变快了,碎片也小了。
现在主流内核用的是 slub,相比经典 slab 简化了元数据,把管理信息塞进 page 结构体本身。看 slub 的运行情况:
$ sudo slabtop -o -s c | head -20 Active / Total Objects (% used) : 1234567 / 1300000 (95.0%) Active / Total Slabs (% used) : 45678 / 46000 (99.3%) Active / Total Caches (% used) : 130 / 180 (72.2%) Active / Total Size (% used) : 3200.5K / 3350.2K (95.5%)重点看几项:dentry和inode缓存特别大,通常意味着文件遍历多(比如遍历大目录、容器镜像层多);kmalloc-*涨得快,可能是某个内核模块在泄漏;skbuff_head_cache大,跟网络流量相关。
我遇到过一次 dentry 缓存吃掉 20G 的情况,原因是某个定时任务每分钟递归 stat 一遍全盘目录,内存全被 dentry 占了。这类缓存是可回收的(SReclaimable),内存紧张时会自动释放,但它会挤占 page cache,间接拖慢 IO。
3.3 用户态 malloc:tcache 与 arena 的取舍
到了用户态,glibc 的 ptmalloc2 是默认选手。它的核心结构是 arena + bin + chunk。每个线程分配时先看自己的 tcache(glibc 2.26 引入,每线程 64 个 bin,每 bin 最多 7 个 chunk),命中就直接返回,完全无锁;tcache 不命中再进 arena,arena 里用 fastbins(小 chunk 快速链表)、smallbins、largebins、unsorted bin 组织空闲块;再不够就brk或mmap向内核要。
这套设计的关键权衡是"多线程性能 vs 内存碎片"。多线程下,每个线程抢同一个 arena 会打架,所以 glibc 允许最多 8 × CPU 核数个 arena,线程按轮转绑定到 arena。代价是每个 arena 都要预留空间,线程多、arena 多时,实际内存占用会比预期高不少。我见过一个 64 核机器上开 200 个线程的服务,RSS 里有一大截是各 arena 的闲置空间,MALLOC_ARENA_MAX=4之后内存降了将近 30%,吞吐只降了个位数百分比。
对比一下几个常见分配器,选型时心里要有个谱:
| 分配器 | 小对象性能 | 多线程扩展性 | 内存回收 | 碎片控制 | 典型适用 |
|---|---|---|---|---|---|
| glibc ptmalloc2 | 中(有 tcache 后提升明显) | 中(arena 竞争,闲置内存多) | 差(free 后常不还给系统) | 中 | 默认场景、兼容性优先 |
| jemalloc | 高 | 高(per-thread cache) | 好(有 decay 机制) | 好 | 高并发服务、长期运行进程 |
| tcmalloc | 高 | 高 | 中 | 中 | 短生命周期、RPC 服务 |
| mimalloc | 高 | 高 | 好 | 好 | 新项目、对延迟敏感 |
选 jemalloc 的最直接理由通常是它的 decay 机制:空闲页会在一段时间后真正madvise(MADV_DONTNEED)还给系统,RSS 能降下来,而不是像 ptmalloc 那样常年占着不放。对容器化部署(有 memory limit)尤其重要,不然很容易被 cgroup 判超限。
3.4 亲手看一次分配行为:VIRT 和 RSS 的差异从哪来
写段小代码验证前面的结论,比看十页文档管用:
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> static void show(const char *tag) { char buf[256]; FILE *f = fopen("/proc/self/status", "r"); while (fgets(buf, sizeof buf, f)) { if (!strncmp(buf, "VmSize:", 7) || !strncmp(buf, "VmRSS:", 6)) printf("%-10s %s", tag, buf); } fclose(f); } int main(void) { show("start"); size_t n = 512UL * 1024 * 1024; /* 512MB */ char *p = malloc(n); /* 只是登记,尚未分配物理页 */ show("after-malloc"); memset(p, 1, n); /* 逐页写,触发缺页 */ show("after-touch"); free(p); /* glibc 通常不立即还给系统 */ show("after-free"); return 0; }用gcc -O2 -o memdemo memdemo.c编译跑一下,你会看到经典的阶梯:malloc 之后 VmSize 涨了 512M,VmRSS 几乎没动;memset 之后 VmRSS 涨到 512M 左右;free 之后 VmRSS 大概率不降——因为这块内存是brk出来的,glibc 只在堆顶有足够大的连续空闲时才brk回退。
这里我要强调一个排查心得:RSS 高不等于泄漏,VmSize 高也不等于有问题。判断泄漏的唯一标准是"长时间运行后 RSS 单调上升且不回落",而不是某一时刻的绝对值。另外,free之后 RSS 不降,在容器里就是实打实的额度占用,这一点必须提前设计好——要么换 jemalloc,要么对大块内存直接走mmap自己管理。
4. 页面回收与置换:内核在内存紧张时做了什么
4.1 LRU 的理论与实现困境:为什么用 clock 而不是完美 LRU
理论上最优的置换策略是 LRU:淘汰最久未使用的页。问题是精确 LRU 需要每次访问都更新链表,开销爆炸,而且需要硬件支持(比如访问位)。
Linux 的做法是近似 LRU:把页分成 active 和 inactive 两个链表,页刚进来时放在 inactive 头部,被访问两次(PTE 的 Accessed 位被置位后被扫描到)就提升到 active。回收时从 inactive 尾部开始摘。这就是所谓的双链时钟(two-list clock)算法。
这么做的好处是开销可控:内核不需要每次访问都记账,只要定期扫描、看 Accessed 位就行。代价是精度下降——热页可能被误杀,冷页可能多留一会儿。对绝大多数负载,这个精度绰绰有余;对极端的扫描型负载(比如一次全表扫描把 page cache 冲干净),就会表现出明显的"缓存污染"。
文件页和匿名页是分开管理的。文件页(page cache)回收时如果能确认干净(没被改过),直接丢弃即可,下次读盘再来;脏页必须先回写。匿名页(堆、栈)没法丢弃,只能写到 swap。这就是为什么 swap 的存在意义不是"内存不够时救命",而是"让不活跃的匿名页腾出位置给 page cache"。
提示:swap 不是洪水猛兽。在内存充足的机器上开一块 swap 并设低 swappiness,能让内核更从容地做页迁移;但把 swappiness 设很高,会导致热匿名页被换出,引发大量换入换出,
si/so一涨,延迟立刻崩。
4.2 水位线:内核什么时候开始着急
Linux 给每个内存 zone 设三档水位:min、low、high。
- 空闲页高于 high:一切正常,kswapd 睡觉。
- 空闲页跌破 low:唤醒 kswapd 后台回收,回收目标是把空闲页拉回 high 以上。
- 空闲页跌破 min:进入直接回收(direct reclaim),申请内存的进程自己同步去回收,这是延迟杀手。
- 回收也凑不出页:触发 OOM killer。
水位本身由/proc/sys/vm/min_free_kbytes决定,low和high是按watermark_scale_factor(默认 10,即千分之一)推算出来的。举个例子,如果 min_free_kbytes 是 67584KB(64M 左右),那 min 水位就是约 64M,low 大约比 min 高一点点,high 再高一点。具体数值可以直接读:
$ grep -E "min|low|high" /proc/zoneinfo | head -12 min 1234 low 1542 high 1850单位是页。这里的数字要跟 zone 的总量对照着看才有意义。
我调这块的经验是:不要为了"让回收更早开始"去大幅提高 min_free_kbytes,那样会白白浪费内存;反过来,在大内存机器上把 min_free_kbytes 设得太小,会导致突发分配时来不及回收,直接进入同步回收。大内存机器(几百 G)我一般把 min_free_kbytes 提到 1~2G,并适当调大 watermark_scale_factor,让回收更平滑。
监控这套机制,vmstat 1是最直接的工具:
$ 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 2 0 0 812345 12000 9500000 0 0 12 88 1200 3400 12 3 84 1 0si/so非零说明在换页;free很小但cache很大是正常现象(缓存本就是用来占满的);真正要警惕的是free低于 min 水位同时%sy升高。
4.3 OOM killer 为什么会杀掉"看起来不占内存"的进程
这是最让人抓狂的场景:机器 64G,某个 200M 的小进程被杀,旁边一个 30G 的大进程活得好好的。
原因在于 OOM killer 的打分逻辑。它对每个进程算一个oom_score,大体基于该进程占用的物理页(RSS)、页表页、swap 使用量之和,占系统总可用内存的比例,然后可以用oom_score_adj(-1000 到 1000)调节。分数最高的被杀。
所以那个 200M 的小进程被杀,通常有几种可能:一是它oom_score_adj被设得很高(某些守护进程会这么做);二是它申请了一次超大分配(比如 malloc 了 10G 但没 touch),内核在分配时就地触发 OOM,按当时的账本挑了个倒霉的;三是它虽然 RSS 小,但它的子进程很多,OOM killer 遍历时选中了它。
查证方法:
$ cat /proc/<pid>/oom_score $ cat /proc/<pid>/oom_score_adj $ dmesg -T | grep -i "killed process"dmesg 里会打印一行包含总内存、可用内存、被杀进程的 RSS 和打分,信息很全,别忽略。
保护关键进程的正确做法是给它设oom_score_adj = -1000(完全不杀)或者一个较低的负值,但要注意:设成 -1000 的进程太多,等于把风险转移给了别人。我一般只对极少数不可替代的组件设 -1000,其余靠systemd的MemoryMax之类的 cgroup 限制来做隔离——让它在自己的 cgroup 里 OOM,而不是拖垮整机。
5. 排查实战:内存泄漏、碎片、抖动怎么定位
5.1 一套固定的排查顺序,从粗到细
我现在遇到内存问题,基本按这个顺序走,很少绕远路。
第一步看总量和趋势。free -m和/proc/meminfo里,我重点看四个数:MemAvailable(真正还能用的,包含可回收缓存)、MemFree(完全空闲)、Slab与SReclaimable、PageTables。MemAvailable长期低于总量 10% 就要警惕。趋势用sar -r 1 60或采集系统看,单点数据没有意义。
第二步定位到进程。top -o %MEM或者ps aux --sort=-rss | head。找到候选进程后,看它是不是单调增长:连续采 30 分钟的 RSS,画出来是持续上扬还是锯齿形。锯齿形通常是正常的分配释放,持续上扬才可能是泄漏。
第三步区分是堆内存还是映射内存。pmap -x <pid>给出每一段映射的大小和 RSS,看是[heap]涨了(malloc 出来的),还是某个anon段涨了(mmap 出来的),还是某个文件映射涨了。这个信息能直接排除掉一大半方向。
第四步进代码层。如果确认是堆,用工具:C/C++ 用 valgrind massif 抓快照对比,或者 heaptrack 看分配热点;Java 用 jmap + MAT;Go 用 pprof 的 heap profile。关键技巧是"两次快照对比"——在增长前和增长后各抓一次,看哪些调用栈的分配量增加了,而不是看谁分配得最多。很多工具的默认视图是"总分配量",那个榜单常年被初始化代码霸占,对比视图才看得出泄漏。
第五步验证。改完代码不要直接上线,先在预发环境跑够时长(至少一个业务周期,比如跨过一次定时任务、一次大促流量),确认 RSS 曲线平了再说。
5.2 常见问题速查表
下面这张表是我这些年积累的对应关系,遇到症状先查表,能省掉大量试错:
| 症状 | 高概率原因 | 快速验证手段 | 处置方向 |
|---|---|---|---|
| RSS 单调上升不回落 | 应用层泄漏、容器未回收 | 两次 heap 快照对比 | 修代码或引入定期重启 |
| RSS 高但业务正常 | 分配器不还内存、slab 缓存大 | pmap -x、slabtop | 换 jemalloc、限制 arena 数 |
| 延迟周期性抖动 | 直接回收、THP 合并、脏页回写 | vmstat看%sy、dmesg | 调水位、关 THP、限脏页比例 |
| 有 free 内存却分配失败 | 碎片、overcommit 限制、cgroup 限额 | /proc/buddyinfo、MemAvailable、memory.max | 整理碎片、调 overcommit、放限额 |
| 进程被随机杀 | OOM killer 打分 | dmesg、oom_score_adj | 设保护值、做 cgroup 隔离 |
| 启动后几分钟慢 | 冷缺页、页缓存未热 | perf看缺页次数 | 启动预热 |
| 容器内存超限被杀但主机有空闲 | cgroup 统计口径差异 | memory.current、memory.stat | 调 limit、降 page cache 占比 |
这里要专门说一句 cgroup 的坑:容器里看到的MemAvailable是宿主机的,很多应用据此判断"内存充足"然后疯狂分配,最后被 cgroup 的memory.max直接 OOM。正确做法是读/sys/fs/cgroup/memory.max和memory.current,Java 的-XX:MaxRAMPercentage就是干这个的。给 JVM 手动写死-Xmx而不设容器感知,是容器化迁移时最常见的翻车点。
5.3 我踩过的三个坑,都是血泪
第一个坑:把free的buff/cache当成"被占用"。刚工作时看到机器 60G 内存free只有 2G,慌得不行,重启了服务。其实 cache 是可回收的,真正的信号是MemAvailable。这件事之后我给自己定了规矩:任何"内存告警"的规则都必须基于MemAvailable而不是MemFree,否则就是天天狼来了。
第二个坑:内存预热反过来伤了性能。我给一个服务加了全量预热,结果启动时间从 3 秒变成 40 秒,滚动发布时被健康检查判死。后来改成按访问概率预热——只预热确定会走的热数据结构(比如索引表、常量池),冷数据不碰,启动时间回到 6 秒,尾延迟也能接受。教训是:预热是有成本的,只预热你确定要用的部分。
第三个坑:jemalloc 调参调过头。为了降低 RSS,我把 jemalloc 的dirty_decay_ms设成 0,让空闲页立刻归还。结果在高频分配释放的负载下,madvise系统调用打得飞起,CPU 系统态占用涨了 8 个点。后来改成dirty_decay_ms:5000和muzzy_decay_ms:0的组合,在 RSS 和 CPU 之间找到了平衡。分配器的参数基本都是这种"两头堵"的权衡,没有免费的午餐。
6. 动手写一个极简分配器:把机制真正跑一遍
6.1 设计取舍:为什么从 mmap 拿一大块
读完原理不写一遍,很多东西是虚的。我自己写过一个玩具分配器,用来理解 chunk 切分和空闲链表。设计上做了三个选择:
第一,底层不调brk而用mmap拿整块 arena。理由是brk操作的是堆顶,多线程下需要加锁,而且回退条件苛刻;mmap独立性好,回退时一个munmap就干净还回去。
第二,用带边界标记的空闲链表(free list)而不是位图。位图适合固定大小的块,而我们要支持变长分配,链表更直接。
第三,采用首次适配(first fit)而不是最佳适配。首次适配实现简单、速度快,实测碎片表现对多数负载够用;最佳适配要遍历全表,小规模下差距不大,规模一大就成瓶颈。
对齐按 8 字节处理,因为 64 位平台上指针和size_t都是 8 字节对齐,不对齐会导致未定义行为甚至在某些架构上崩溃。
6.2 核心代码:切分、合并与首次适配
#define _GNU_SOURCE #include <stddef.h> #include <stdint.h> #include <string.h> #include <sys/mman.h> typedef struct block { size_t size; /* 本块可用字节数,不含头部 */ int free; struct block *next; } block_t; #define ARENA_SIZE (1u << 20) /* 1MB */ #define ALIGN8(x) (((x) + 7u) & ~7u) #define HDR ALIGN8(sizeof(block_t)) static block_t *g_head = NULL; static block_t *extend(size_t need) { size_t sz = ARENA_SIZE; while (sz < need + HDR) sz <<= 1; /* 不够就翻倍 */ void *p = mmap(NULL, sz, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); if (p == MAP_FAILED) return NULL; block_t *b = (block_t *)p; b->size = sz - HDR; b->free = 1; b->next = g_head; /* 头插,顺序无关紧要 */ g_head = b; return b; } void *my_alloc(size_t n) { if (n == 0) return NULL; n = ALIGN8(n); for (block_t *b = g_head; b; b = b->next) { if (!b->free || b->size < n) continue; if (b->size >= n + HDR + 16) { /* 余量够大才切,避免碎渣 */ block_t *rest = (block_t *)((char *)b + HDR + n); rest->size = b->size - n - HDR; rest->free = 1; rest->next = b->next; b->next = rest; b->size = n; } b->free = 0; return (char *)b + HDR; } block_t *b = extend(n); /* 没有合适的,扩一块 */ if (!b) return NULL; return my_alloc(n); /* 递归复用上面的逻辑 */ } void my_free(void *p) { if (!p) return; block_t *b = (block_t *)((char *)p - HDR); b->free = 1; /* 简单版:只标记空闲,相邻合并留作练习 */ }有几处细节值得说:切分阈值里的+16是经验值,余量太小切出来的碎块没人用得着,反而增加链表长度;extend里sz <<= 1循环保证了极大请求也能满足;my_free只标记不合并,是最简形态,真实分配器必须做相邻块合并,否则反复分配释放后会碎得没法用。
补一个合并的思路:每个块里再加一个prev_size字段记录前一块大小,free时先看后一块是否空闲(b + HDR + b->size处的块),是就合并;再用prev_size往前找,是空闲就一起合并。glibc 的 chunk 结构就是这么干的,只不过它用了更省空间的技巧(利用空闲块的用户区存链表指针)。
6.3 验证:跑起来看行为是否符合预期
#include <stdio.h> extern void *my_alloc(size_t); extern void my_free(void *); int main(void) { void *a = my_alloc(100); void *b = my_alloc(200); void *c = my_alloc(100); printf("a=%p b=%p c=%p\n", a, b, c); my_free(b); void *d = my_alloc(150); /* 应该能复用 b 释放出来的空间 */ printf("d=%p (期望落在 b 附近)\n", d); my_free(a); my_free(c); my_free(d); return 0; }跑出来如果d落在b的地址区间内,说明首次适配和切分逻辑是对的。这时候再回头去看/proc/buddyinfo、/proc/slabinfo这些文件,感受会完全不一样——你已经在用户态复刻了一遍"申请大块、切分、复用、还回去"的完整循环,只是粒度从页变成了字节。
写完这个玩具之后,我再看 glibc 的 tcache、jemalloc 的 size class、Go 的 mspan,就不再是"一堆看不懂的名词",而是一系列针对具体问题的优化:tcache 解决锁竞争,size class 解决碎片,mspan 解决 GC 友好。理解机制的最好方式,永远是自己动手实现一个粗糙版本,然后看工业级实现补了哪些漏洞。
最后分享一个我一直在用的习惯:每接手一个新服务,先花二十分钟把它的内存画像摸一遍——/proc/<pid>/status里的 VmRSS/VmSize、pmap -x的分布、/proc/<pid>/smaps_rollup里的 Pss、以及它所在的 cgroup 限额。这套画像建好之后,后面任何一次内存异常,你都能立刻判断是"它一直这样"还是"它变了",而判断"变了没有",比判断"高不高"有用得多。