☰
内存碎片治理实战:从内核防碎片到用户态分配器优化
2026/10/3 9:23:06 网站建设 项目流程

先说一个常见现象:一台128G内存的服务器,free -h看着还剩几十G,进程却突然报“Cannot allocate memory”,甚至容器直接被OOM Killer干掉。遇到这种情况,很多人第一反应是内存泄漏,但排查一圈却什么都没漏。这时候十有八九是内存碎片问题。内存碎片整理这个方案,说白了就是解决“内存明明有剩余,但凑不出一块连续空间”的尴尬。

这篇文章我会从碎片产生的机制讲起,覆盖内核层的防碎片策略、用户态分配器的治理手段,再结合一次实战调优流程,把“整理碎片”这件事做得明明白白。适合后端开发、SRE、内核爱好者以及所有跟服务器内存死磕过的朋友。

1. 读懂内存碎片:从现象到机制

1.1 外部碎片与内部碎片,两个不同的麻烦

内存碎片分两种,一种是内部碎片,一种是外部碎片,很多人混着说,但治理思路完全不同。

内部碎片是因为分配粒度大于实际需求造成的浪费。比如内核分配对象时按2次幂对齐,申请30字节可能实际拿到64字节的块,那34字节就浪费了。这种浪费看不见摸不着,但它稳定存在。用户态malloc同理,每次分配都有头部开销和字节对齐,积少成多也很可观。

外部碎片才是传统意义上“内存碎片”的主角。物理内存是按页(通常是4KB)管理的,内核把连续页块做成不同order的伙伴链表。系统长时间运行后,页面被各种进程和内核对象占用,空闲页块被打散。你要分配一个order=4(16个连续页,64KB)的块时,可能有几百个零散的空闲页,但找不出相邻的16页。这时候内存总量不缺,却分配失败。

打个比方,一块空地停满了车,每辆车之间都有一点空隙,总和很大,但不够停一辆大巴。外部碎片就是“空隙很多,大块没有”。

1.2 碎片如何一步步拖垮系统

碎片不是一瞬间产生的,而是长时间累积的结果。主要来源有三条:

第一,短生命周期对象的反复分配释放。典型如高并发服务里的请求对象、连接缓冲、临时数组,频繁malloc/free会让堆空间千疮百孔。

第二,不同生命周期对象的交错分配。长生命周期对象(缓存、全局单例)和短生命周期对象穿插分配,回收后留下大量空洞。

第三,NUMA架构下的局部偏好。CPU优先分配本node内存,导致内存在不同NUMA节点上分配不均,某个节点的碎片可能反而严重,跨节点访问时延迟上升。

碎片严重后,系统会尝试触发内存规整(compaction),也就是把可移动页面搬走合并成大块。但这个操作本身消耗CPU,还可能导致进程卡顿。碎片到极端情况,大页分配连续失败,重负载进程启动直接崩掉。更隐蔽的问题是,碎片会让内存回收失效——回收算法优先回收干净页、文件页,但碎片太多时,回收半天凑不出连续块,性能反而恶化。

为什么碎片问题“越来越难搞”?因为内存越来越大、容器化越来越普及,单个机器上跑的进程数量暴增,每个进程的堆都是独立王国,碎片被分散到几十个进程里,靠“重启大法”重启一个解决不了整体问题。系统性治理就变得很必要。

2. 系统级整理:内核怎么扛住碎片

2.1 伙伴系统的防碎片设计,不只靠compaction

Linux内核的物理内存管理以**伙伴系统(buddy system)**为基石,空闲页按order(0到MAX_ORDER)挂到链表,分配大块时从对应order找,找不到就往高阶拆。这个算法的优点是分配释放快,代价就是容易碎片化。

为了对抗碎片,内核做了好几层设计,很多人在调优时只盯着/proc/sys/vm/drop_caches,其实完全忽略了更核心的机制。

第一层叫迁移类型(migrate type)。内核把页面按可迁移性分成:不可移动(Unmovable)、可回收(Reclaimable)、可移动(Movable)。内核对每种迁移类型分别维护空闲页链表,分配时优先用同类型的空闲页。这样做的意义在于:不可移动页面被聚集在一起,可移动页面可以随时被迁走,给大块连续分配腾地方。这就是防碎片的第一道防线,不是等碎片严重了再整理,而是让碎片集中在“可移动”的页面里。

第二层是**页块(pageblock)**机制,典型大小是order=10,即4MB。分配器尽量把同迁移类型的页分配在同一个pageblock内,避免不同类型页面互相穿插。

第三层才是内存规整(compaction)。当高阶分配(比如order>3)失败时,内核异步或者同步扫描可移动页,把它们搬运到其它地方,凑出连续的大块页面。这里有几个触发条件:

  • vm.compaction_memory相关参数只控制触发阈值;
  • /proc/sys/vm/compact_memory是手动触发全局规整;
  • 区分同步压缩和异步压缩,同步压缩更激进,也更可能导致延迟尖刺。

第四层是CMA(Contiguous Memory Allocator),它的思路是预留一块区域,平时允许可移动页面占用,但大块连续分配(比如GPU显存、多媒体编解码)时可以强制迁移腾出空间。CMA适合有稳定连续内存需求的场景,但不建议全局配置太大,否则日常内存使用率低的时候很浪费,内存吃紧时迁移频率又太高。

2.2 内核参数调整:别乱调,先看懂

很多人以为调内核参数是“照着网上的值填”,结果把系统调崩了。我实际用下来,以下参数的调整思路是这样的,仅供参考:

参数作用经验值 / 调整策略
vm.min_free_kbytes保留给紧急分配的最低空闲内存默认值通常偏小,内存压力大时调到物理内存的0.5%~1%左右,注意不是越大越好
vm.zone_reclaim_mode是否允许回收本node内存以满足本地分配默认0(不回收);NUMA多路服务器可以设为1,但要小心CPU开销
vm.compaction_proactiveness内核主动规整内存的积极性0~100,默认20;碎片严重的业务可以适当提高到50~70
vm.page_lock_unfairness页面分配时防止不公的阈值一般不用动,真出问题再关注
vm.vfs_cache_pressure控制目录项/索引节点缓存的回收倾向默认100;缓存类内存过多时调高,但别低于50

重点说下vm.min_free_kbytes。这个参数在内存碎片场景下是个隐性保护伞。它的用途是给原子分配、不可中断分配留底线。如果设置太低,系统在内存水位紧张时无法及时回收,容易触发OOM。过高也不行,会导致可用内存缩水。我的经验是:

# 128G内存的机器,经验做法是留1G左右 sysctl -w vm.min_free_kbytes=1048576

然后观察/proc/buddyinfo里的空闲页分布,如果高阶order(order>=8)长时间没有,min_free_kbytes可以继续上调,配合紧凑策略。

另一个容易被忽略的是触发规整的代价。内核做规整时,如果大量页面是“可移动”的,迁移压力小;如果某些进程的页面被锁在某个zone里,规整就会长时间扫描,甚至同步压缩时引发CPU软锁。所以调vm.compaction_proactiveness时要逐步增,我建议每次加10,观察系统负载和分配失败率,而不是一步到位。

2.3 手动整理操作:什么时候能“手动”,什么时候没用

网上很多帖子教人这么操作:

echo 1 > /proc/sys/vm/compact_memory echo 3 > /proc/sys/vm/drop_caches

这两个命令被当成“内存整理神器”,但实际效果差距很大。先说compact_memory,这个命令触发的是全局内存规整,把所有zone里可移动页搬移,确实能让高阶空闲页数量上升。但如果系统本身没什么可移动页,比如slab对象、内核数据结构占据大头,跑这个命令只会白烧CPU,/proc/buddyinfo基本不会变。

再说drop_caches,它只释放页缓存(page cache)和目录项/索引节点缓存,对“用户进程堆内存造成的碎片”毫无帮助。它可以释放一些文件缓存页,这些页属于“可回收”类型,回收后会给伙伴系统增加低阶空闲页,间接缓解分配压力,但解决不了根因。需要明确:

手动整理只是临时招式。碎片是长期运行的结果,如果业务代码本身反复申请释放连续内存,手动整理完很快又变碎片。

那什么情况下手动整理有用?典型场景是:内存峰值过后,比如原本有大量并发请求占用了内存、释放后空闲页都是小碎片,这时候可以先看/proc/pagetypeinfo确认可移动页占比,再触发compact,确实能缩短后续大分配等待时间。这是治标,需要结合用户态治理治本。

内核侧还有一个容易被忽略的选项:HugeTLB和透明大页THP。大页本质上绕过了碎片问题——连续2MB/1GB的映射,TLB压力小,分配时直接从预留大页池拿,不会因为碎片导致失败。但THP开启后,khugepaged会尝试把普通页折叠成大页,这个折叠过程需要迁移页面,反而增加碎片迁移开销;而且如果折叠失败,会造成额外内存浪费。在碎片敏感的场景(比如大数据组件、JVM),我倾向于在应用层关闭THP,改用显式HugeTLB:

echo never > /sys/kernel/mm/transparent_hugepage/enabled # 或者针对特定进程用 madvise 模式 echo madvise > /sys/kernel/mm/transparent_hugepage/enabled

这样做的逻辑是:让常规内存分配走普通路径,大页由明确预留的HugeTLB池承担,避免内核频繁做“折叠-迁移”的无用功。实际测试中,部分业务开启THP后分配延迟反而升高,因为折叠线程扫描全内存代价很高。

3. 用户态分配器:碎片治理的主战场

3.1 malloc分配器的碎片化差异

很多人以为内存分配是操作系统的事,用户态只是调用malloc。不对,用户态堆分配器才是碎片的主战场。你写的应用每次new/malloc都发生在堆上,堆怎么切块、怎么合并、怎么缓存,直接决定碎片率。

glibc默认的ptmalloc有三个特点:多线程通过arena隔离减少锁竞争、bin缓存管理空闲块、large bin按大小分类。但它对大块分配的处理不够优雅,长生命周期对象和短生命周期对象混在一个arena里时,很容易产生外部碎片。如果你用Java,JVM的C堆(metaspace、DirectBuffer)也都走glibc,碎片会直接戳到JVM。

jemalloc、tcmalloc、mimalloc这类现代分配器则在设计上就对抗碎片:

  • 它们按**大小类(size class)**分级,每个线程有本地缓存;
  • 大块分配走独立的chunk区域,避免和小的对象混杂;
  • 释放时尽量合并相邻空闲块,并在满足条件时把整块chunk归还给操作系统。

以jemalloc为例,通过dirty_decay_ms参数控制“脏页”(已释放但未归还OS)的保留时间。保留太久,内存占用虚高;归还得太快,频繁madvise引起系统调用开销。线上调优时,我会这样调整:

# 设置decay时间为10秒,既不会频繁系统调用,又能及时释放 export MALLOC_CONF="dirty_decay_ms:10000,muzzy_decay_ms:10000"

如果用jemalloc的prof模式,还可以打印堆内存的碎片分布图:

export MALLOC_CONF="prof:true,prof_gdump:true,lg_prof_sample:4096"

然后通过jeprof分析热点分配。实测中,一个线程多、短连接多的网关服务,从glibc切到jemalloc后,RSS峰值下降约20%,这就是碎片治理的立竿见影效果。tcmalloc的线程缓存机制类似,也适合高吞吐、多线程场景。

3.2 从分配策略上规避碎片

除了换分配器,更根本的做法是设计自己的分配策略。

我在消息中间件项目里做过一个简单但很有效的内存池方案:按固定大小分桶(16B、32B、64B…),每个线程一个空闲链表,跨线程释放时丢到全局回收队列。关键点是“定长块不合并”,每次分配和释放都是常数时间,块大小固定,永远不会产生不可分配的空洞。这种方案对线程模型固定、对象生命周期短的服务天然友好。

如果你开发的是Rust程序,其实标准库的Vec、Box之外,你可以考虑使用mimalloc或jemalloc作为全局分配器,一行依赖切换,就能带来碎片优化的收益。C++则可以通过tcmalloc或jemalloc替换默认的malloc,或用operator new拦截统一走内存池。

还有一点:大对象与池化要分开。比如你有一个缓存模块,常驻内存500MB,如果你把这块内存和小对象分配混在一个arena里,那缓存区会造成大量空洞,别的对象穿插进来,时间一长整个进程内存变成“瑞士奶酪”。正确做法是给缓存单独开一块large arena或者用mmap直接分配,避免跟常规对象混在一起。

实际项目中,我还踩过这种坑:错误地把线程local缓存设置得过大。某个服务用tcmalloc时把TCMALLOC_TRANSFER_NUM_OBJS调大以降低锁竞争,结果每个线程缓存了大量空闲对象,内存膨胀到原来的1.5倍。碎片少了,总内存却涨了,这是“用空间换碎片”的极端。调优要盯RSS和碎片率两个指标,不要只看一个。

3.3 内存池实战:一个简单可靠的方案

如果是自研服务,最省心的内存在池里是采用伙伴式内存池,原理和内核类似,但比通用分配器更可控:

  • 把一整块内存按2次幂分级(4K、8K、16K…);
  • 释放时尝试合并相邻块,升级到更高阶;
  • 当某高阶块空闲时间超过阈值,整块归还系统。

代码层面不需要很高深,关键是要维护好两个数组:空闲块链表数组和分配状态位图。参考实现思路:

class BuddyPool: # 以2^max_order字节的内存作为池 def __init__(self, max_order=12): self.max_order = max_order self.free = [[] for _ in range(max_order + 1)] self.free[max_order].append(0) # 初始整块 # 分配: 找最小可用阶,必要时逐级拆分留半块 def malloc(self, size): order = self._size_to_order(size) # 从当前order往上找 # 如果所找块是上一级拆开的,另一半挂到低一级链表 def free(self, addr, size): order = self._size_to_order(size) # 入链表并尝试与buddy相邻块合并,循环上推

这种池的好处是没有系统调用的开销、不会出现外部碎片(因为伙伴合并机制天然合并相邻块),但缺点也很明显:分配粒度固定,内部碎片依然存在。所以它适合“对象大小范围有限”的场景,比如网络协议包的缓冲区。

如果是高并发、多线程的场景,需要做到无锁化。简单的做法是每线程独立的池,但要注意线程退出时把空闲块回收到全局池——否则慢慢泄漏。另一个注意点:不要信任“释放后立刻可用”的假设,你要有调试模式,分配和释放时加magic number,越界访问很快就能抓出来。内存池的Bug很多是数组越界踩到池元数据,线上环境极难定位,前期埋点比什么都重要。

4. 梳理故障排查思路与实战案例

4.1 一次典型的内存分配失败排查

之前遇到过一次真实故障。服务是消息网关,几十个连接并发收发,运行一周后开始出现"Could not allocate memory"错误。当时free -g显示内存还有60%可用,swap也没怎么用,但进程就是分配不了内存。

第一反应看dmesg:

dmesg | tail -n 80

这里出现了大量“Page allocation failure: order:4”的字样。这说明内核在尝试分配order=4(64KB)的连续页时失败。这台机器不是物理内存不够,而是低阶空闲页很多,但高阶连续页被碎片吃掉了。

接着查/proc/buddyinfo,发现order=4以上的空闲块数量确实偏少。再查/proc/pagetypeinfo,发现Unmovable页分散在大量pageblock里,没有聚集,导致即便有Movable页也存在但迁移链太长,compact效率很低。

最终定位到两个根因:一是某些线程的长生命周期缓冲对象是用malloc分配的,跨过了内存池;二是内核vm.compaction_proactiveness设置太低,碎片已经比较严重才触发整理。

我们的处理方式是:

# 临时措施:手动规整 echo 1 > /proc/sys/vm/compact_memory # 调高主动规整 sysctl -w vm.compaction_proactiveness=60 # 调低NUMA回收的消极性,允许回收本node的页 echo 1 > /proc/sys/vm/zone_reclaim_mode # 同时给应用侧换jemalloc export LD_PRELOAD=/usr/lib64/libjemalloc.so.2

做了这三步,compaction生效后,/proc/buddyinfo里order=8以上的空闲块大幅上升,分配失败告警消失。但是,问题本质是代码里短生命周期的大缓冲没有走内存池。后来我们把网络收发缓冲统一用一个伙伴式内存池管理,再也没出现过同类故障。这个案例很典型:系统参数只是兜底,应用层分配策略才是核心。

4.2 常用观测工具与指标解读

遇到内存碎片问题,建议按以下步骤收集信息:

/proc/buddyinfo是最直接的碎片指标,输出格式为每个zone、每个order的空闲页块数量。order=0是4KB,order=1是8KB,依次翻倍。如果order>=4的块长期为0,说明外部碎片很严重。

/proc/pagetypeinfo展示了每个zone里不同迁移类型的页面数量。Unmovable分散程度高,规整难度大;Movable占比高,则规整效果好。

cat /proc/meminfo重点看Committed_AS和MemAvailable,前者是承诺给进程的内存总量,后者才是真正可分配量。注意MemAvailable是估算值,在碎片存在时它会偏乐观。

dmesg排查page allocation failure,这个行文里有order、gfp_mask、内存波动情况,非常重要。看到order>3的失败时,基本可以确定是碎片问题。

smem用来查各进程真实RSS和PSS,判断谁是内存大头:

smem -rs pss | head -n 30

另外还可以用perf分析内存分配热点,但碎片治理阶段最有用的还是jemalloc的prof模式或者valgrind massif,它们能画出堆内存的分配/释放历史,帮你找到“长生命周期和短生命周期穿插分配”的代码位置。

4.3 碎片治理速查表

我把常见的问题场景和对应处理方式整理成一张表,实操时可以直接对照:

场景现象首选处理进阶处理
内核分配大块失败dmesg出现order>=4的allocation failure调高min_free_kbytes,手动compact开启/调大CMA,调整migrate type,考虑HugeTLB
用户进程堆碎导致RSS膨胀free总内存够,单个进程RSS用了好几个G切换jemalloc/tcmalloc,调decay参数业务层接内存池,优化长连接缓存结构
文件缓存过多触发回收存储型机器,缓存页占了大头drop_caches释放页缓存调低vfs_cache_pressure,限制cache上限
线程局部缓存放大了内存RSS增幅明显但活跃对象不多调小线程本地缓存上限定期flush线程缓存到全局池
NUMA节点碎片某node多次分配失败,其他node空闲zone_reclaim_mode=1绑定CPU与内存,推荐numactl --interleave或--membind
大页分配失败HugeTLB预留不足预留更多HugeTLB页检查kernel.shmmax限制,调整nmemb

这几类问题不是互斥的,实际项目里经常叠加。比如一个JVM服务既用了DirectBuffer(走内核分配),又有大量短生命周期Java对象(走堆外C堆),还开启了THP(内核折叠线程忙碌),三者叠加时碎片治理难度成倍增加。这时候最好的策略是先逐层剥离问题:关掉THP,切换分配器,再静态分析DirectBuffer的分配释放规律。

5. 实操心得与扩展建议

最后说一些我的经验。内存碎片治理和性能优化一样,讲究“先量化,再优化”。我一上来会先执行五分钟内的连续采样,把/proc/buddyinfo的历史数据拉出来,看看碎片到底是在逐步恶化还是周期性波动。如果是周期性波动,说明业务负载特征导致——比如定时批处理任务集中申请大块内存,这时只需错峰处理,不用全局调参。

另一个心得是内核参数要配合应用层策略一起改,而不是单独依赖某个参数。比如你把vm.compaction_proactiveness调到很高,但应用层还在不停制造不可移动页,那内核再勤奋也白搭。反过来,应用层做了完美的内存池,但如果内核在内存压迫时疯狂回收文件页,导致分配器迟迟拿不到底层页,瓶颈依然在系统层。理想的局面是:应用层稳定复用内存、碎片少,系统层保留足够的高阶空闲页,两边各自管好“大块连续内存”的供需。

如果你做的是云原生或容器化场景,还要考虑一层:容器内存限制是cgroup维度,内存碎片往往跨容器存在。某个容器频繁分配大块内存,它的失败可能影响同主机的其他容器(内核无法区分cgroup做内存规整)。这种场景下,我更倾向给关键业务容器使用HugeTLB或者预留独立内存池,把干扰隔离掉。这个思路尤其适合数据库、Redis、消息队列这类延迟敏感组件。

这个方案后续还可以继续延伸:依靠内核的BPF能力实时追踪分配失败事件、定期输出碎片报告,甚至结合K8s的节点资源管理做自动化迁移。内存碎片不会彻底消失,但可以把它控制在“几乎不影响业务”的范围里。我的感受是:只要把系统层和应用层两条线同时拉起来,这套整理方案基本能应对绝大多数生产环境的碎片问题。

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

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

立即咨询