内存碎片整理实战:从原理诊断到系统调优的完整方案

发布时间:2026/10/8 8:44:41
内存碎片整理实战:从原理诊断到系统调优的完整方案 1. 内容整体设计与思路拆解1.1 这项目到底解决什么问题先说清楚内存碎片这玩意儿但凡你写过一个跑上几个月的服务或者管过一台长期不重启的服务器大概率都见过它。现象很典型明明内存还有空闲系统却报告分配失败或者监听进程的常驻内存看着不大实际占用的物理内存却高得离谱再或者服务一跑就是上百个Compaction线程、缓存淘汰、网络缓冲区轮番上场整体性能就是上不去。所谓碎片整理核心目标就一个字把零散的空闲内存合并成连续的、可用的区间降低分配器在大块申请时的失败率同时减少TLB缺页带来的性能损耗。我要先说一个很多人的认知误区——碎片整理不是把内存压缩到用的更少而是把内存重新排布让大块申请更顺畅。就像搬家不是把东西扔了而是把柜子重新摆放让客厅能塞进一张新沙发。讲一个我在实际项目里碰到过的场景帮大家建立直觉。有个数据缓存服务单机内存128G跑了大半年之后某天一条消息打过来说Redis的COMMIT命令失败内存分配失败——可free一看还剩40多G空闲。这就是碎片最典型的翻车现场物理上有内存但每一块都小得可怜或者是分布极散导致glibc的malloc在申请一个16MB连续空间时直接拒绝了你。1.2 碎片整理方案的三条路线市面上喊的内存碎片整理方案大体可以分三层理解第一层分配器层面的优化。比如glibc的arena整理、jemalloc的arena和bin设计、tcmalloc的page heap机制通过在分配策略上减少碎片产生。这属于防患于未然。第二层系统层面的主动整理。Linux内核提供compact_memory节点可以触发内存规整还有/proc/sys/vm/compact_memory这类内核接口。Windows也有系统级的Defrag思路但这主要是文件层面的。内存层面的主动整理在Linux上用的多。第三层应用层面的内存管理设计。比如采用对象池、内存池、分代机制、大块预分配等方式从根本上规避频繁的小块分配和大块释放交错出现的模式。很多博客直接把三个层级混在一起讲新手看了反而懵。实际上我要强调一下这三条路线的优先级是不同的。应用层设计排第一因为它影响最大成本可控分配器参数调整排第二因为动它风险相对小收益立刻可见内核层面的整理排第三因为它需要root权限而且通常是在故障已经出现时才用得上。所以要我说一篇真正有参考价值的内存碎片整理方案应当是混合视角的方案而不是单讲某一个工具或某一段echo 1 /proc/sys/vm/compact_memory。下面我会把我们实践过程中踩过的坑、验证过的参数、以及几个照抄绝对信不过、需要按环境微调的细节全部摊开。2. 碎片从哪来又造成了什么实质影响2.1 碎片的两个来源内部碎片和外部碎片讨论碎片时先把概念分级不然你连排查方向都定不下来。内部碎片指分配器给程序的内存块大于程序实际请求的内存块。比如你请求32字节但分配器的对齐粒度是16的倍数或管理粒度是64字节那么可能给你48字节的块多余的16字节就是内部碎片。这类碎片攒多了整体内存利用率就降下去了但它不影响分配的成功率。jemalloc的size class分级、glibc的chunk复用都会造成内部碎片。这个是蚊子腿单个看不多时间久了累计起来相当可观。外部碎片指空闲内存被分割成大量不相邻的小块导致即使总数够但无法满足大块连续分配。这类碎片才是杀手级别的。为什么因为操作系统和分配器处理连续内存分配时你申请的内存越大越需要一段连续物理页或者大块虚拟连续区。如果外部碎片严重你明明还有40G空闲却可能要触发一次代价极高的swap或者直接被OOM杀死。在Linux上内部碎片和外部碎片的区分在Buddy系统和SLUB分配器里表现得很清晰Buddy处理的是物理页框级别的外部碎片SLUB则更关注对象缓存层面的碎片。讲到这里我会给出一个具体类比帮助有需要的读者建立直觉。内部碎片就像是书架上塞了一排大小不一的书每本书都占了一个格子但格子和书大小并不完全匹配有些格子空出来一指的宽度外部碎片则像整个书架东倒西歪到处是缝隙缝隙宽度全放不下一本书但你数一数总长度明明还能放下几十本书。整理这两种书架思路是不同的。2.2 碎片恶化到什么程度才值得处理很多开发者喜欢见碎片就慌但这个态度不可取。碎片是非常自然的操作系统行为你只要做动态内存分配碎片就一定存在。我们需要的是一个阈值判断——什么水平的碎片要处理在Linux环境下有一个可靠指标可以看/proc/buddyinfo它展示不同order即2的幂次方页块的空闲页分配状态。如果一个系统里order3的空闲块很少而order3的小块很多说明外部碎片正在积累。实际操作中我习惯用一个简化的脚本统计一下最高order的空闲块数量它在长时间运行服务里出现持续下降就该审视应用代码了。另外还有一个经验阈值是从运行时长维度判断的。服务跑了7天以内碎片增长对分配成功率影响不大跑了30天以上并且业务的QPS峰值波动超过5倍或者有大量申请-释放-申请交错模式就需要警惕跑了90天以上且没有做过任何形式的内存规整那么分配失败的概率会指数上升。这里的指数上升不是危言耸听我记得有个服务就是第40天左右开始频繁报Cannot allocate memory当时4.8G的free空间却连64KB都申请不到就是因为碎片严重到所有区域都没有连续页了。还有一个环境因素必须单独提——如果你们服务跑在Kubernetes这类容器编排环境里碎片问题会更隐形。因为容器的内存limit是cgroup层面的你在容器里看free -m很可能看到宿主机内存cgroup限制导致你在容器内分配时可用内存被压缩到一个固定上限。在K8s环境里碎片导致的分配失败往往表现为容器反复OOMKilled但看宿主机的free又还有好多内存。这类问题不排查到cgroup层根本定位不到后面我会在实操部分把排查思路展开。2.3 碎片成本不只是分配失败除了直接分配失败碎片带来的三个隐性成本非常不值得忽视。第一个是TLB效率下降。当物理内存碎片化进程的虚拟内存到物理内存的映射表需要更多条目来覆盖同样大小的连续虚拟区域。意味着你访问内存时TLB命中率下降多级页表遍历次数变多很多非内存密集型应用其实也能感受到几个百分点的性能回退。第二个是跨NUMA节点访问。如果你没有配置NUMA绑定碎片整理可能在迁移内存页时把页挪到远端节点造成内存访问延迟跳变这对于延迟敏感型服务是巨大的隐患。第三个是内存浪费率。前面提到的内部碎片堆积到一定程度直接就是内存买回来了用不上需要扩容才能扛住业务高峰。扩容带来了钱的问题。我在一个网关项目里遇到过这种典型情况16G内存的老机器跑满之后频繁触发GC因为大量内存都浪费在碎片化的旧生代对象上后来把垃圾回收策略从G1改成ZGC结合内存池改造内存占用下降超过30%服务反而更稳了。3. 实操方案从分配器参数到内核整理的完整链路3.1 第一步先诊断碎片现状不管你是准备调优应用代码还是要做内核算子的规整第一件事永远是量化。没有量化就动手等于蒙着眼拆炸弹。推荐几步走第一步采样/proc/buddyinfo。连续采集一周数据每天同一时间点记录。这里的字段解读不复杂Node后面跟的是NUMA节点的编号Zone后面通常是DMA、DMA32、Normal三种区域。你会看到一行里有很多数字代表order 0、order 1、order 2一直到order 10的空闲块数量。观察order 3以上的空闲块数量趋势是碎片恶化的指征。第二步看/proc/pagetypeinfo。这个文件记录了不同迁移类型MIGRATE_UNMOVABLE、MIGRATE_MOVABLE、MIGRATE_RECLAIMABLE等在各order上的页面分布。内核在内存规整时正是依赖这些迁移类型来判断哪些page能迁移、哪些不能迁移。如果Unmovable页面大量散步在各order的各个区间那么碎片整理会异常困难因为这些页面挪不动。第三步用/proc/$pid/smaps观察应用自身的堆碎片情况。这里有很多字段但重点看Private_Dirty和KernelPageSize这些值。连续观测几次如果Private_Dirty持续增长但实际业务流量变化不大往往说明应用在频繁分配和释放中度内存对象堆碎片在膨胀。这里给一段诊断脚本的片段可以直接存成shell脚本跑#!/bin/bash # 碎片诊断快照脚本 INTERVAL${1:-300} # 默认每300秒采样一次 echo time,node,zone,order3_active,order10_active while true; do awk /Node/{ for(i5; iNF; i) { if(i8) o3$i; # order 3 if(i14) o10$i; # order 10具体列请以实际buddyinfo为准 } print strftime(%Y-%m-%d %H:%M:%S), $1, $3, o3, o10 } /proc/buddyinfo sleep $INTERVAL done注意上面脚本里列位置可能因内核版本而异千万别照抄列号就完事务必先cat /proc/buddyinfo对一列列数明白再套用。我在CentOS 7上和Ubuntu 22.04上看到的列数完全不一样一个DMA区域多几列少几列都很正常。3.2 第二步从分配器和运行时层面调优诊断完了先别急着去做内核规整那玩意儿是事后的后悔药属于打地鼠。这里我先说分配器层面。在Linux的C/C环境里jemalloc是我长期推荐的选择尤其适合高并发多线程服务。原因在于它的arena机制将线程分配的压力打散到不同arena减少锁竞争的同时也减少了因锁等待造成的堆块释放不及时带来的碎片。关键配置有两个background_thread开关和dirty_decay_ms。举个例子我们网关服务之前用glibc分配器运行三天后RSS就能涨到2.2G但实际业务对象总量也就1.2G左右。切换到jemalloc后设置如下参数运行一周RSS稳定在1.5G左右export MALLOC_CONFbackground_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000这里dirty_decay_ms代表脏页在多少毫秒后可以被回收返还给操作系统。设置为5000ms意味着释放的内存不会立刻归还而是保留5秒复用。这个延迟让分配器可以减少向操作系统发起brk/mmap的频率也就减少了页分配和释放交错产生的页级碎片。如果你把值设成0内存会立刻归还频繁的大块分配和释放可能反而造成内核片外碎片更严重所以不建议设0除非你的业务内存曲线非常平稳。对于Java服务不找分配器而是看GC策略。以HotSpot虚拟机为例CMS收集器对碎片问题极其敏感因为它的并发清理阶段本质上就是在整理老年代碎片。如果你的JDK版本还停留在8并且被迫用CMS那很容易出现Concurrent Mode Failure和Promotion Failed。推荐的做法是升级到JDK 11以上直接上G1配合-XX:G1HeapRegionSize16m这类参数让整个堆划分成更大的region也能有效抑制碎片。如果追求低延迟ZGC在JDK 15之后成熟度已经够了我在一个延迟敏感的数据服务上用ZGC替换G1GC停顿从几十毫秒降到亚毫秒级但吞吐量有轻微下降所以也别盲目上。3.3 第三步内核层面的主动整理如果你已经确认应用层和分配器层都调到位了但碎片仍然恶化那就轮到内核的主动内存规整出场。Linux内核有几个接口干活方式不同。第一个是经典的compact_memory。写入方式如下echo 1 /proc/sys/vm/compact_memory这个操作触发的是全系统的内存规整内核会尝试将可迁移页挪动到一起形成连续空闲块。它执行期间可能会有短暂延迟如果你在业务高峰期手动规整可能会看到整体RT抖动几十到几百毫秒取决于内存大小和页迁移速度。所以这个操作适合在业务低峰期做或者作为监控脚本触发出来之后、由值班同学评估后手动执行。第二个是drop_caches和compact_memory的组合套路。先释放页缓存再规整效果会更显著。释放页缓存是破坏性的它只影响文件缓存不会伤害进程数据但会导致后续访问文件时需要重新从磁盘读I/O压力会上升。操作顺序很重要先释放缓存再做内存规整echo 3 /proc/sys/vm/drop_caches echo 1 /proc/sys/vm/compact_memory我踩过的坑是有人为了追求完美每5分钟就触发一次compact_memory结果内存倒是规整了但CPU飙升业务RT频繁抖动。内核做页迁移也不是免费的午餐它会去遍历页表、移动页内容、更新映射这个开销在TB级内存机器上尤其夸张。频率上我建议是每天最多一次最多最多不需要更频繁。第三个是周期性自动规整机制即vm.compact_when_zone_nodify这类内核参数。不同内核版本的命名不同我把比较通用的参数配置分享出来vm.compact_memory0 # 0表示只做后台自动规整不响应手动触发 vm.extfrag_threshold500 # 内存碎片指数阈值超过500时启动规整 vm.min_free_kbytes131072 # 预留128MB最小空闲内存避免无法规整的尴尬extfrag_threshold这个东西很多人没听说过。它是个碎片指数范围0~1000值越高表示碎片越严重。内核在满足某些条件时会根据这个阈值决定是否触发内存规整。默认值一般是500但对于大内存机器我通常建议调到300~400让内核更积极一点。但别调到太低比如100以下内核会在内存分配失败时频繁尝试规整白白消耗CPU。3.4 第四步以代码层设计消灭碎片产生的源头做一个V型反转——说了半天内核和分配器我要敲黑板的是真正的终极大法在代码层。因为不管调优再多如果应用本身一直在做恶意的内存使用模式新产生的碎片速度永远快过整理速度。碎片友好的代码设计有几条原则第一尽量用对象池和内存池而不是每次请求都新建对象。比如网关的请求上下文、数据库连接对象、线程池中的任务包装体这类对象生命周期极短又反复生成销毁正是制造碎片的头号嫌疑犯。用一个简单的复用池比如用sync.Pool在Go里能极大降低GC压力和内存碎片。Go语言虽然自带GC但次生的碎片问题在分配大量小对象时依然会造成堆的增长。我之前把一个Go服务改成使用sync.Pool复用解码器对象RSS从6GB降到2.8GB效果立竿见影。第二控制分配粒度。如果你知道经常分配的对象只有几个固定大小就用预分配的固定长度数组或者环形缓冲。比如RPC框架的传输缓冲区固定给8KB或者32KB而不是每次按包大小动态调整。这样既可以复用又让分配器在同一个size class里分配碎片产生的机会大幅降低。第三避免峰值突刺和持续释放交替。这是最难改的一种模式。举个真实例子某个数据分析服务每天凌晨会有一个批处理任务一次性申请50G内存做排序任务结束立刻释放。这个任务运行期间旧生代被撑破释放之后堆上留下巨大的空洞之后白天的常规业务在这些洞之间分配内存碎片率急剧上升。后来我们把批处理任务挪到独立的子进程里跑数据通过管道传递任务结束后整个子进程退出内存由操作系统统一回收碎片从此不再影响主进程。这是架构层面的隔离式解法对于不想大改代码的场景非常实用。4. 实操过程与核心环节实现一次完整的碎片规整记录4.1 案例背景和初始状态这一节我找一个内存碎片在那个网关服务上的完整处理记录来复盘这样大家能看到从诊断到执行的完整链路。某网关集群单台配置是64G内存、32核运行着一个基于Netty的Java服务JDK是8u202GC用的是CMS。症状是每过十几天会出现整台机器的内存分配失败表现为Java进程偶尔抛出OutOfMemoryError: unable to create new native thread但堆内存反而不是瓶颈。且GC日志中CMS: concurrent mode failure频繁出现Full GC时间越拉越长。初始状态数据如下/proc/buddyinfo: Node 0, zone DMA32 363 221 174 112 54 12 0 0 0 0 0 Node 0, zone Normal 1234 892 763 402 178 45 7 0 0 0 0这个输出意味着什么order 3以上的块还勉强存在order 8以上也就是4MB以上的连续块已经空无一物了。在这种状态下JVM的Native线程栈或者DirectBuffer申请一大块内存时极易失败。再加上CMS的老年代碎片问题整个服务就像定时炸弹。4.2 分步骤操作与效果对比第一步先做应用层面的GC切换。我当时把启动参数从CMS改成G1因为JDK8的G1已经可以支撑生产了。关键参数做了如下调整-Xms32g -Xmx32g -XX:UseG1GC -XX:G1HeapRegionSize4m -XX:MaxGCPauseMillis200 -XX:ParallelRefProcEnabled切换后的第一个变化是Full GC彻底消失了CMS时代常见的由碎片导致的晋升失败问题从根本上被消除。G1通过将堆划分为等大小的Region并把大对象保存在连续的Humongous区域间接使外部碎片的影响降到可控范围。改造后一周的GC日志显示只有普通的Mixed GC和Young GC停顿时间稳定在80ms左右。第二步给JVM的C层内存换分配器。Java的Native内存是由C运行时分配的所以服务里一些JNI和Netty的DirectBuffer场景照旧会走glibc。虽然换成jemalloc对纯Java堆的帮助有限但对Native线程栈和Epoll缓冲区的碎片问题有改善。在启动脚本里加上了LD_PRELOADexport LD_PRELOAD/usr/local/lib/libjemalloc.so export MALLOC_CONFbackground_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000,narenas:8第三步处理整机层面的碎片积累。因为这台机器上除了网关服务还有其他底层的Agent、日志采集程序它们的分配请求也会在操作系统层面制造碎片。我们写了个定时脚本放在crontab里凌晨2点低峰期执行daily_compact.sh脚本内容已经精炼过了可以当作模板直接用#!/bin/bash # 每日低峰期进行一次内存规整 # 使用前提确认不是云厂商独享物理机或重要单点有风险评估后再用 if [ $(cat /proc/sys/vm/drop_caches | wc -l) -ne 1 ]; then exit 1 fi sync echo 3 /proc/sys/vm/drop_caches echo 1 /proc/sys/vm/compact_memory logger manual compact_memory and drop_caches executed at $(date)有一点必须提醒——drop_caches释放了文件缓存如果的部署环境有频繁刷日志或读数据文件的需求凌晨执行后第一次访问会有点冷启动的I/O开销。我通常会把脚本安排在业务最低峰之后再错开10到15分钟执行这样即使I/O有波动也影响不到核心业务。整体做完后一个监控周期里我们收的数据时期order8空闲块Full GC次数分配失败事件优化前0每日2~3次每周1次GC/分配器优化后7~1000加上每日规整后稳定20004.3 参数选择背后的逻辑说明这里把上面几个关键参数的决定逻辑说透方便你根据自己环境灵活调整。G1HeapRegionSize4m的选取是根据我们服务的对象大小分布来的。网关请求体和响应体绝大多数在几百KB以内但偶尔会有几个10MB以上的大Buffer。如果Region设置成1MB大对象分配会直接到大对象区设置成4MB既能容纳常见的块又不至于让Region数量太少。Region过多会导致G1的Remembered Set管理开销变大Region过少又会让混合回收粒度太粗。narenas:8这个配置是匹配机器的32核处理器。arena数量取CPU核数的四分之一比较合适太少会加剧锁竞争太多会浪费空间并增加内存碎片风险。jemalloc的默认arena数是CPU核数的四倍在高并发下别的没问题但内存释放不立刻归还时arena越多整体碎片概率越高。我一般在大内存机器上会压缩到CPU核数的四分之一到二分之一。dirty_decay_ms5000的选择则是通过几轮对比得出的。设成1000msRSS波动大内存频繁向OS归还又申请碎片问题反复设为60000msRSS涨幅太大内存利用率低5000ms是我们在该场景下的甜点位。你可以从5000ms起步按自己服务的请求周期微调。5. 常见问题与排查技巧实录5.1 这些操作有风险我先把话放这第一compact_memory不是无害的。它是一个同步操作在某些极端情况下如果内存非常紧张规整过程会令系统短暂失去响应。我在内存剩余低于200MB的机器上跑过一次机器CPU被打满将近10秒SSH命令都执行不动。所以最最核心的禁区就是内存剩余极少时绝不要手动触发规整。这个操作顺序一定要反过来先把内存压力降下来比如释放缓存再考虑规整。第二drop_caches会清掉PageCache这是把双刃剑。如果你的服务要频繁读一个GB级别的索引文件drop_caches之后的第一次读取会让磁盘I/O骤增可能直接拖垮数据访问延迟。看服务和数据层的耦合度如果机器是专门的Redis、MySQL实例我通常不建议清PageCache而是直接跳过drop_caches单独做compact_memory毕竟文件缓存对数据库类业务来说反倒是有益的。第三jemalloc替换glibc并不是你在所有场景都能直接用的。有些第三方库对分配器有强假设比如它会比较指针或者依赖malloc_usable_size返回的容量包装类的库在替换后可能行为异常。在我们实际的替换过程中遇到过Netty的PooledByteBuf和jemalloc配合时某些大块DirectMemory被反复分配释放反而增加了分配器开销。所以重要前提是替换分配器前一定要压测至少跑通全链路接口和长稳测试。5.2 你们可能遇到的实际问题排查表症状内存碎片没有缓解RSS还在涨排查方向先确认/proc/buddyinfo里的Unmovable页面是否占比过高。/proc/pagetypeinfo如果显示大量MIGRATE_UNMOVABLE类型的页在高order区间散步那么主动规整基本无效因为这些页根本动不了。这时候只能从应用层找看看哪些代码在长期持有固定内存块或者有共享库映射占据了页框。再确认你观察的指标维度对不对。RSS只是一个表象指标它包含了共享库的映射、匿名页、页缓存等很多读者看了RSS上涨就以为碎片在恶化其实很可能是文件映射累积。症状执行compact_memory后CPU飙升排查方向规整的本质是页迁移页越多迁移成本越高。内存超过512G的机器每次全区域规整都能让CPU忙很久。评估机器内存规格如果是大内存尽量改用/sys/kernel/debug/extfrag里监控到的碎片指数做定向处理而不是每次都全盘触发。考虑NUMA拓扑。在多路服务器上规整页迁移可能跨节点代价几何级增长。一台双路服务器上跨节点访问内存比单节点访问延迟高约1.5到2倍。如果条件允许绑定CPU和内存到同一NUMA节点。症状K8s环境里容器频繁OOMKilled即容器内存超限被杀排查方向先确认并不是真实内存溢出而是碎片导致分配的连续物理页失败触发cgroup级别的回收动作。通过kubectl describe pod看到Exit Code 137再进宿主机看dmesg里是Out of memory: Killed process还是page allocation failure。后者就说明碎片是主因。K8s场景里我在有问题的节点上会把vm.min_free_kbytes调大到物理内存的2%~3%确保内核有足够的内存启动回收流程避免内存限额到达临界点后根本没有余量做规整。5.3 最后的独家经验写到最后分享两个个人感受很深的点。一个是监控碎片一定要看趋势而不是看瞬时值。碎片是慢慢积累的跟血压一样偶尔高一次不致命但持续走高就要干预。我们后来在监控里加了一条规则每天扫描/proc/buddyinfo计算order 5以上空闲页的占比如果低于5%超过连续三天自动告警让运维评估是否需要干预。这套规则比任何单点告警都有用。另一个是碎片整理和容量规划是一体两面。我们有个服务在做了完整的内存碎片整理方案后稳定运行期RSS降低了差不多20%相当于同样的硬件多扛了20%的流量。所以在项目汇报时我从来不会把碎片整理说成解决问题而会说成释放冗余因为它确实是在帮公司省真金白银的服务器成本。最后再提一句无论你采用哪个方案动手前先在测试环境里模拟真实流量跑至少48小时观察/proc/buddyinfo和GC指标。碎片整理不是一次性的操作而是一个持续观测、持续优化的系统工程。把这套闭环做完你的服务不仅能跑得久还能在流量突增时多一道保命的缓冲。