☰
内存碎片整理实战:从malloc到内核伙伴系统的系统级治理
2026/10/13 3:38:39 网站建设 项目流程

内存碎片整理这个话题,很多人第一反应是Linux内核里那个compact_memory,或者是JVM的CMS GC,再不就是glibc的malloc_trim。但我今天想聊的,是把这个词当作一个系统性的工程问题来看——从你代码里的每次malloc,到内核伙伴系统里那些拆得七零八落的物理页,再到线上那个诡异的延迟毛刺,整条链路拉通之后,你才知道该动哪里、不该动哪里。

我印象很深的一次经历,是帮一个做实时数据网关的团队排查问题。他们的服务跑着跑着,latency p99会隔三差五跳一下,有时候能冲到几百毫秒。查业务逻辑、查锁竞争、查网络都无果,最后用perf和strace抓了一把,发现是mmap和缺页中断偶尔慢得离谱。再往下看/proc/buddyinfo,内存碎片已经严重到几乎申请不到高阶物理页了。那次之后我就养成一个习惯:遇到诡异的内存问题,先从碎片的角度看一眼,往往比瞎调业务代码更快接近真相。

这篇文章适合谁?如果你是做后端服务、中间件、游戏服务器,或者跟容器、嵌入式Linux打交道,遇到“内存看着还有,但就是分配慢/分配失败”这类问题,那这篇内容正好是对症的。我会把碎片产生的原理、诊断手段、用户态和内核态的整理方案、以及我踩过的坑,全部串起来讲一遍,尽量让你能直接拿去用。

1. 内存碎片是怎么产生的

1.1 两种碎片:方向不同,解法也不同

先说清楚一个基本概念:内存碎片至少分成两类,一类叫内部碎片,另一类叫外部碎片。

内部碎片,指的是分配出去的内存块比你实际要的大,多余那部分没法被利用。典型场景是slab分配器按固定大小的对象池管理内存,你申请一个 50 字节的结构体,但分配的是 64 字节的slot,那 14 个字节就是内部碎片。还有一种经典场景是伙伴系统里的kmalloc,它按 2 的幂次返回内存,你申请 100 字节,它给你 128 字节,28 字节被浪费。

外部碎片则不同,它是“总量够,但连续性不够”。想象一下一个停车场,总车位还有 50 个,但每个空闲车位都被车隔开,想停一辆需要连续 5 个车位的房车,根本找不到地方。物理内存分配也一样,伙伴系统以 2 的幂次拆页,长期混用不同大小、不同生命周期的内存后,空闲页被分割成很多不连续的小块,高order的连续大块就消失了。

这两类碎片的解法方向很不一样。内部碎片优先靠调整分配粒度、对象池化来解决;外部碎片则要么靠内存迁移、压缩,要么靠避开超长生命周期的大块分配来缓解。很多人在做“碎片整理”的时候没有先分清楚是哪一类,导致折腾半天方向反了。

1.2 伙伴系统分配器为什么会越拆越碎

Linux内核的物理内存分配走的是伙伴系统(buddy system)。它维护了从order 0到order 10(常见配置下MAX_ORDER=11)共11个空闲链表,每个order对应 2^order 个连续物理页,页面大小通常是 4KB,所以order 10 就是 4MB 的连续块。

分配大内存时,系统会做“拆借”:如果没有order 10的块,就找order 9,拆成两块,一块返回给调用者,另一块挂到order 9的空闲链表里。释放的时候反过来做“合并”,看看兄弟块也在空闲,就合成更大一级的块。

这个机制本身是没问题的,问题出在频繁的拆借和归还。任何malloc、page fault、文件缓存分配,都可能触发伙伴系统拆块。如果分配的内存像“脉冲”一样来了又走,尤其是大小参差不齐,空闲块就会长期处于分裂状态,合并永远凑不齐相邻的两个兄弟块。

我举个例子。有个服务反复分配128KB、256KB、512KB的缓冲区,用完就释放。时间长了,order 5、order 6、order 7这些链表里都有零星的空闲块,但如果每个块旁边都停着一个还在用的页,合并就永远发生不了。内存总量看起来充足,实际却凑不出一块2MB的连续内存,此时如果你要mmap一块大页或者内核要分配一个skb的高阶缓冲区,就会触发慢路径回收甚至等待。

1.3 不可移动页是碎片的“钉子户”

碎片能不能整理,关键不只看空闲块,还要看在用的页能不能搬走。内核把物理页分了类型:不可移动页(Unmovable)、可回收页(Reclaimable)、可移动页(Movable)。

内核分配内存时会带上迁移类型标签,这是/proc/pagetypeinfo里能看到的那几列。不可移动页大多来自内核自身的数据结构、kmalloc分配的内存。可回收页一般是文件页、匿名页,可以被写回或换出。可移动页则是用户态通过madvise(MADV_MOVABLE)或者某些内核子系统特别申请的内存,比如CMA区域里的内存。

外部碎片整理的原理,就是通过memory compaction:把可移动页从一块区域的边缘迁走,集中到一起,腾出一大片连续的物理空间。但如果一个区域里的不可移动页太多,尤其是它们像星星一样散布在各个order中间,压缩就寸步难行。这就是为什么早期版本里vm.zone_reclaim_mode调不好会导致分配失败——不可移动页卡住了整个区域。

所以一个务实的思路是:尽量让可移动的分配走可移动页,尽量少在内核里频繁分配大块内存。比如用户态频繁创建和销毁大缓冲区时,最好用mmap而不是频繁malloc大块,因为mmap走的是可移动匿名页,整理空间大得多。

2. 先诊断,别瞎整理

2.1 碎片严重的信号有哪些

不要一上来就echo 1 > /proc/sys/vm/compact_memory,那是万不得已的粗暴手段。做整理之前,你得先判断真的存在碎片问题。我总结了几个比较典型的信号:

  • 内存总量充足,但GFP_HIGHUSER、GFP_ATOMIC分配失败,日志里报page allocation failure。
  • 应用层出现诡异的延迟毛刺,strace看到mmap耗时任
  • drop_caches之后,内存释放了很多,但高阶order的空闲页依然起不来
  • 容器或者进程被OOM Killer误杀,但系统里明明还有不少available内存
  • JVM Full GC频繁,堆内碎片导致大对象连续空间分配困难

这些信号都算是“碎片可能是元凶”的提示,但要做确认还需要看具体指标。

2.2 用buddyinfo算碎片率

诊断碎片最直观的地方是/proc/buddyinfo。它会按内存区域(DMA、DMA32、Normal)和order维度展示当前空闲的页块数量。比如一行数据:

Normal: 1234 567 89 10 2 0 0 0 0 0 0

从左到右分别是order 0到order 10的空闲块数。用的时候可以换算成空闲页总数,再单独看order>=3(即>=32KB连续块)的占比。一个非常粗略的碎片率公式:

碎片率 ≈ 1 - ( order>=3的空闲页 / 总空闲页 )

但这个公式不一定代表分配失败风险,因为还要考虑迁移类型的分布。看/proc/pagetypeinfo能得到更细的信息。如果发现Unmovable的链表中,低order块很多而高order块很少,那就说明内核内存的碎片问题不小。

另外,还有一个便宜好用的监控参数是/proc/vmstat里的compact_stall和compact_success。compact_stall收尾字段如果持续增长,说明内核频繁进入慢路径、触发内存压缩,这本身就是碎片压力的报警器。我一般会把这两个字段的差值做成监控曲线,如果一个小时内compact_stall超过几十次,就该考虑介入了。

2.3 用户态和内核态的检测工具

内核侧我常用:

  • /proc/buddyinfo、/proc/pagetypeinfo:看碎片分布
  • /proc/vmstat:看compact_stall、compact_success、thp_*
  • tracepoint:echo 1 > /sys/kernel/debug/tracing/events/compaction/mm_compaction_begin/enable可以看到触发compaction的调用栈
  • perf record -e page-faults:抓页面分配慢的用户态调用栈,判断哪些线程是“受害者”

用户态侧:

  • glibc:mallinfo()里有uordblks、fordblks、hblks、hblkhd字段,分别对应使用中内存、空闲堆块数、mmap分配数、mmap占用的内存。如果fordblks持续增长且hblks很多,说明用户态堆碎片也不小。
  • jemalloc/tcmalloc:自带stats打印,能看到dirty pages、muzzy pages、active这些指标。
  • JVM:用jstat -gcutil看Old区的使用率和碎片化程度,必要时上-XX:+PrintHeapAtGC看每次GC前后的堆布局。
  • 应用层perf:抓malloc、free、mmap的调用频率和大小分布,统计出谁在频繁做大块内存的申请释放。

我之前在一个模拟项目X里遇到过一种很迷惑的情况:free输出显示内存根本没用满,但服务就是一直变慢。用perf抓了用户态调用栈,发现大部分时间卡在malloc的arena锁上。这么一看,问题其实是glibc的arena数量太少导致锁竞争,但背后的推手仍然是频繁的小块分配与释放把堆搞得支离破碎,促使每次分配都得遍历空闲链表。

3. 用户态能做的整理方案

3.1 glibc malloc的调优与trim

大多数后端服务默认用的glibc malloc,它对碎片问题是原生免疫很差的。原因在于它的空闲链表管理,free回来的块五花八门,放回对应的bins里,时间长了就会变成“分裂态”,每次malloc都要扫描,性能自然劣化。

有几个环境变量可以直接调:

  • MALLOC_ARENA_MAX:限制per-thread arena数量,避免线程数很多时各持一个arena导致内存浪费,也减少跨arena访问。
  • M_TRIM_THRESHOLD:控制堆顶释放归还给OS的阈值。默认128KB,如果你有很多长生命周期的大块,建议调大。
  • M_MMAP_THRESHOLD:超过这个阈值的大块分配直接走mmap,而不是从堆里拆。调成较大值(比如4MB以上)能减少mmap次数,但会增加堆碎片的可能。
  • M_MMAP_MAX:限制mmap分配的总次数,一旦堆碎片严重,可以放宽这个值让大块走mmap。

另外还有一个直接手段,周期调用malloc_trim(0),强制把堆顶空闲内存归还给OS。这招对于“RSS降不下来”很管用,但代价是下次分配可能又要触发brk或者mmap,调用太频繁反而会增加额外开销。我见过有人用定时器每1秒调一次malloc_trim(0),结果系统调用量大增,延迟更高的例子。一般建议5分钟到10分钟一次的粒度就够了。

3.2 换掉分配器:jemalloc与tcmalloc的取舍

如果要治本,我建议直接把glibc malloc替换成jemalloc或tcmalloc。jemalloc的核心设计是采用多个arena和extent,按大小类管理内存,碎片率明显低于glibc。tcmalloc则强调线程本地缓存,分配快,且对小块内存碎片没那么敏感。

实际选型的时候:

  • jemalloc的优势在于后台线程可以定期purge脏页(background_thread默认开启),把碎片化解在后台。你有大量长生命周期对象、跨线程分配频繁时比较适合。
  • tcmalloc对延迟敏感的高并发读场景友好,线程缓存命中率高。但它对内存归还给OS的时机控制一般,长时间运行后RSS可能虚高——这是它的老毛病。

替换时要特别注意预加载和符号冲突。jemalloc用LD_PRELOAD接入或者链接进二进制时,要确认没有和别的malloc hook库打架。我踩过一个坑:某个图像处理Demo里先用LD_PRELOAD加了jemalloc,又同时启用了glibc的mtrace,结果符号被overwrite,程序一启动就段错误。所以替换分配器不是改个环境变量就算完事,必须做全量回归测试。

3.3 内存池和分代分配:从源头减少碎片

碎片整理做再好,也不如避免碎片产生。最有效的策略是内存池化。

思路很简单:把频繁分配、大小相近的对象,一次性申请一大块内存,然后再从中分配。释放时不是真正还给OS,而是放回池里复用。由于每个池管理的内存大小都是固定的,碎片根本不会产生。

我给一个模拟项目X做过一套业务对象池,把多数的会话结构体、编解码缓冲区分成几类池。启动时预分配,运行中完全避免malloc/free操作。效果非常明显:同负载下,分配器相关延迟消失,RSS还因为减少空洞内存省了20%多。当然,池化和通用分配的取舍在于,对象生命周期要可控,池子不能无限膨胀,否则反而是内存泄漏。

另一个策略是“分代分配”,这套思路借鉴自GC:把短生命周期和长生命周期的对象分配到不同的内存区域。例如IM服务里,每个连接的会话对象存活时间不定,但编解码用的临时buffer一定短命,那就把buffer从每个连接的长生命对象里拆出来,集中到一个“临时池”,定期整体释放。这样就能避免两类内存交叉分布,从根上降低碎片程度。

3.4 JVM堆与THP的调整

Java方向的内碎片主要发生在堆里。老年代里的对象存活时间不一,晋升和并发标记清理会留下很多空隙。调-XX:+UseG1GC的好处是Region动态分配,碎片比例比CMS好很多。G1的Region大小设置有讲究,过大会让内部碎片明显,过小则RSet开销飙升。一般配合-XX:G1HeapRegionSize控制在1MB到32MB之间,优先保证大对象能在连续区域放下。

另一个相关的是Transparent Huge Page。启用THP后,用户态大内存分配优先映射为2MB的大页。这能显著减少TLB miss,但是副作用是内核需要频繁cteate THP,碎片严重时分配THP会触发compaction。不少大数据、游戏服务器的建议是,应用内只对热区使用madvise(MADV_HUGEPAGE),其余情况关闭系统级THP,避免后台khugepaged给你引入堆碎片。

4. 内核态整理策略

4.1 手动compaction的触发与观察

如果用户态已经优化过,但系统侧碎片还是严重,就需要内核级别的介入。最直白的操作:

echo 1 > /proc/sys/vm/compact_memory

这个操作会触发每个zone的memory compaction,尝试把可移动页搬移到zone的另一端,腾出连续大块。注意,这是同步操作,期间会阻塞部分内存分配路径,所以在线上业务高峰期做这件事儿很危险。我自己一般安排在低峰期,且先看一眼/proc/buddyinfo,手动触发一次后对比前后变化。

保守一点,也可以触发针对某个内存区域的compaction。比如/sys/kernel/debug/compaction里有一些控制接口,但不同内核版本差异很大,我建议不要过分依赖debugfs在线上搞事,优先用compact_memory并配合监控。

4.2 内核参数调优:让碎片少发生

除了手动触发,更优雅的方式是让核减少碎片发生。几个关键的参数:

  • vm.zone_reclaim_mode:默认0,分配失败会在zone之间进行回收。设为0时允许跨zone借用,能缓解局部zone碎片。但设太大(非0)会导致本地页回收太激进,性能不升反降。
  • vm.extfrag_threshold:它决定了内核是否愿意在外部碎片严重时尝试compaction,默认500。如果/proc/buddyinfo显示高阶块缺失严重,可以调低(比如100到200),让内核更积极迁移内存。
  • vm.swappiness:调低交换倾向可以减少匿名页被换出再换入造成的页表抖动,但过低可能让文件缓存回收更频繁。对碎片影响其实是间接的,主要影响回收路径的选择。

还有一点和THP强相关:如果业务对大页依赖重,但又担心THP碎片的副作用,可以使用显式大页hugetlbfs。为关键业务预留一批持久化的大页,从/proc/meminfo里能看到HugePages_Total和HugePages_Free。这样虽然牺牲一些通用内存,但大块连续分配几乎不会失败,对数据库这类的应用很划算。

4.3 CMA和迁移类型:嵌入式场景的杀手锏

嵌入式、多媒体、图形栈的场景里,CMA(Contiguous Memory Allocator)是一个很实用的方案。它提前预留一块物理连续内存,平时可以被普通可移动页面借用,但设备需要时,通过dma_alloc_contiguous把其中页面迁移走,归还成连续的块。

使用CMA的时候需要注意几个点:

  • 预留的CMA区域不能太小,否则高分辨率摄像头、显示控制器这类dma buffer分配照样失败。
  • CMA区域要尽量选择在物理地址低端?不一定,看设备DMA的能力。但必须保证分配时没有不可移动页长期霸占,否则迁移会失败,驱动拿不到内存。
  • 查看CMA使用率:cat /sys/kernel/debug/cma/cma-0,可以看到used页数和free页数。

我在某个嵌入式项目里,因为没有给camera pipeline预留足够的CMA空间,导致运行一小时后帧率骤降,原因就是其它内核组件把CMA区域的可移动页占用后,camera要的连续大块一直等不到compaction完成。后来我把CMA扩大到128MB,又调整了cma_alloc的调用时机,问题才彻底解决。

5. 实操案例与验证

5.1 模拟案例:网关服务的高阶页分配抖动

某跨平台系统里有个核心网关模块,业务本身不复杂,但运行一段时间后延迟波动明显。复现步骤大概是:

第一,在低峰期抓取/proc/buddyinfo隔离分析。输出里Normal区域的order 7及以上都是0,order 3到order 6的块东零西碎,碎片率高达85%以上。同时/proc/vmstat中compact_stall涨得很快,说明内核正在频繁尝试内存压缩,但成功率不高。

第二步,用perf record -e page-faults -g -p <pid>抓现场,发现事件集中在alloc_pages路径。调用栈里有大量__do_page_fault和shmem等文件相关分配,也就是说业务逻辑里些微的读写操作都会触发内核走慢路径。

第三步,做应用层改造:把网关的每个连接缓冲区从固定4KB改成内存池管理,避免大量碎片化的小块堆分配。同时为流式数据的收发区域改用mmap+madvise(MADV_HUGEPAGE),减少TLB压力。跑了一周后,观察/proc/buddyinfo里order 6以上的可用块明显回升,compact_stall曲线几乎不再走高。p99延迟从几十毫秒回落到个位数毫秒。

这个案例要说清楚的一个点是:只靠内核compaction救火是短效的,真正持久的是让用户态不再“喂”碎片。

5.2 模拟案例:容器平台的长时间运行内存碎化

另一个项目是某容器平台,跑了很多Java服务和C++服务,用户反馈“运行一个月后新容器经常启动失败”。怀疑是cgroup内存限制导致OOM,但看监控发现内存还有剩余。后来直接用root权限看宿主机,/proc/buddyinfo的Normal区域order 0到order 3有大量空闲,但order 4以上基本为0。

所有容器创建时刻,都需要为新的进程申请一个进程相关的页表、task_struct以及mm_struct等。这些都属于不可移动页。长期累积后,Unmovable页把物理内存切得支离破碎,新容器启动时分配连续内核内存就非常难。

处理方式分两步:

  1. 把宿主机的vm.compact_memory定期触发,固定放在凌晨低峰。
  2. 对每个容器设置明确的memory.high和memory.limit_in_bytes,控制active内存峰值,避免容器在宿主机上制造无差别的大块内存分配压力。

此外,Java服务的大量Thread栈分配会不断制造不可移动页。后来我们统一调整了JVM的栈大小配置,减少线程栈的内存消耗,宿主机碎片状况就平稳多了。这验证了一个观点:不可移动页越少,内存越容易整理。

6. 常见问题与排查技巧实录

6.1 问题速查表

现象可能原因排查方向解决建议
内存总量充足却报分配失败外部碎片严重,高阶物理页缺失/proc/buddyinfo、/proc/pagetypeinfo触发compaction,应用层排查大块分配频率
主机RSS虚高glibc堆顶不回收,内部碎片mallinfo看fordblks、hblkhd设置M_TRIM_THRESHOLD、周期malloc_trim(0)
compact_stall频繁增长内核频繁进入内存压缩/proc/vmstat调低extfrag_threshold,减少不可移动页分配
JVM老年代GC后仍无连续空间堆内碎片积累jstat -gcutil、PrintHeapAtGC换G1,调Region大小,减少大对象分配
mmap耗时高、TLB miss多非连续物理页多,页表压力大perf stat -e dTLB-load-misses局部启用THP或hugetlbfs
容器/进程被OOM但系统available充足低zone内存或不可移动页卡死查cgroup限制、buddyinfo降低memory.high,定期compact

6.2 一条被反复验证的排查路径

我个人的排查顺序永远是:先看/proc/buddyinfo和/proc/vmstat确认是不是内核碎片,再确认进程内是glibc堆碎片还是VMA碎片,最后才是动手调整。步骤固定为:

  1. 确认现象和期望指标(分配延迟、分配失败次数、RSS、p99)。
  2. 五分钟内连续记录三次/proc/buddyinfo、/proc/vmstat、/proc/meminfo,看趋势不是偶发。
  3. 用strace -f -e trace=mmap,brk验证进程的内存申请系统调用频率和大小分布。
  4. 对照进程内部分配器状态(mallinfo / jemalloc stats)。
  5. 预判哪一侧是主要问题源,先做无侵入的观测周期,再做改造。
  6. 改造后持续观察,拉长时间维度确认没有反弹。

6.3 避坑心得

很多初学者喜欢一上来就echo 1 > /proc/sys/vm/compact_memory,觉得“整理了就完事”。但线上这么干,可能引发阶段性的CPU飙升和分配阻塞,反倒造成短暂超时。这跟吃药一个道理:紧急情况可以用,但不能当维他命天天吃。

替换分配器的时候,也不要以为LD_PRELOAD就万事大吉。jemalloc和tcmalloc都有一套自己的线程缓存机制,压测环境和生产环境线程数差距很大时,缓存阈值配置要重新调。我见过不止一次,测试环境好好的,一上生产RSS就狂涨,最后发现是jemalloc的dirty_decay_ms没调,脏页没及时purge。

最后特别提醒:任何内存整理动作,都要配合监控去做效果验证,不要只凭借“感觉快了”。量化指标可以统一成三个:内存分配延迟(通过strace周长采样或者/proc/vmstat的pgalloc类事件)、碎片率(buddyinfo计算)、业务延迟(p99)。三个指标同步改善,才算真的治理到位。

根据我的经验,内存碎片问题很少是单一因素造成的,往往是业务分配模式、分配器策略、内核参数三者互相叠加的结果。真正高效的路线,永远是先从上层业务减少碎片的制造,再把内核参数作为辅助。你按这个思路排查和治理,大概率能做到“药到病除”,而不是疲于救火。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询