内存碎片整理这个话题,做后端和系统底层的人迟早都要正面撞上。大部分时候它并不会有直观症状,只是让你的服务GC时间悄悄变长、分配大块内存时偶尔失败、某台机器明明还有几百兆空闲却报出OutOfMemory。真正让人头疼的是:你根本不知道它是什么时候堆起来的,也不知道该从哪里下手清。我过去几年在线上环境里处理过不少这类问题,这套内存碎片整理的方案,就是我自己反复验证过、觉得可以拿出来分享的一套完整打法。
这篇内容适合谁看?第一类是跑Java服务、被Full GC和晋升失败折腾过的同学;第二类是维护数据库、缓存这类长生命周期进程,担心底层malloc碎片化的后端工程师;第三类是想搞懂操作系统内存规整、但又不想啃内核源码的偏应用开发者。看完之后,你至少能回答三个问题:碎片到底怎么量化?当前方案适合用哪种种整理手段?整理前后怎么验证效果、怎么回滚。这些内容不是教科书理论,是我在实际服务器上踩过坑之后沉淀下来的操作经验。
1. 内存碎片到底是怎么来的
1.1 先分清两类碎片:外部碎片和内部碎片
很多人在讨论内存碎片的时候,其实把两种完全不同的东西混在了一起。我一般喜欢把它们拆开讲,因为处理方式差异很大。
外部碎片指的是“空闲内存总量够,但分散成很多小块,导致一次大分配请求找不到足够大的连续空间”。你可以想象一个长条停车位,空位很多,但每个空位只够停一辆自行车,突然开来一辆大货车,转了一圈就是没有能容纳它的连续空位。在堆内存里,小对象频繁分配、部分释放后会留下很多这种“间隙”,当你要分配一个大数组或者大对象时,就会失败。
内部碎片则是“分配到的块比实际需要的块大,多余的那部分被浪费了”。最常见于固定大小的slab分配器或某些对象池。比如你按64字节为粒度分配,实际只需要50字节,那14字节就是内部碎片。这类碎片不管你内存多紧张,它都一直存在,只是比例高低的问题。
外部碎片是可被“整理”的——把存活对象搬移一下,合并相邻空闲块,就能腾出大的连续区域。内部碎片通常没法靠搬移解决,只能靠调整分配粒度、改造对象大小来缓解。所以后面讲的所有整理方案,主要目标是外部碎片。
1.2 长生命周期进程为什么特别容易碎片化
碎片化不是一蹴而就的,而是分配与释放模式共同作用的结果。最典型的场景是:进程刚启动时,内存是连续的;跑了一段时间之后,各个模块、缓存、连接对象不断分配和释放,释放位置不固定,就会产生犬牙交错的空洞。
我遇到过最典型的案例是一个常驻的推送网关,不定时会有大量连接断开,每一次断开都会释放掉一批内存块。这些内存块大小不一、位置分散,如果恰好之后的连接分配的内存块比释放出来的更大,那么旧的小空洞就无法被复用。时间一长,堆里充满了几十字节到几百字节的碎片,而真正的大对象反而没有地方放了。
JVM环境下的碎片化路径更隐蔽。对象在年轻代创建,熬过几次Minor GC后晋升到老年代,晋升的对象分散地落在老年代的不同位置。老年代的GC如果是标记-清除算法,存活对象本身不搬移,那释放后的空间就是不规则碎片。等到某天一个大对象需要连续空间,老年代明明占有率高,却因为碎片无法分配,触发Full GC,甚至直接提升失败。这类问题在线上的典型表现就是:stw时间异常长,日志里有“promotion failed”或者“allocation failure”。
操作系统层面的内存碎片,则主要发生在物理内存页分配上。内核的伙伴系统把物理页按order分组,order代表2的幂次页数。分配大块时,需要从高order的空闲链表中取页,如果高order列表空了,即使低order的页很多,也没法拼出一块大的连续物理内存。这在高负载、内存频繁申请释放的数据库服务上非常明显。
1.3 磁盘碎片和内存碎片不是一回事
顺便澄清一个常见的误解。磁盘碎片整理是把文件数据在物理磁盘上重新排列,因为磁盘是顺序访问的设备,连续放置能提升吞吐。而内存碎片整理的核心目的是“获得更大连续空闲区”,因为CPU访问内存是随机访问,碎片本身并不直接拉低访问速度,它真正伤害的是分配路径。
所以你在看到“整理”两个字时,脑子里应该想的是:把零散的空洞合并成大的空闲块,让后续分配能成功。它不像清理垃圾一样会释放多少内存出来,而是改变内存空间的分布质量。
2. 动手前先搞明白:碎片率到底怎么量
2.1 量化碎片率的基础公式
不量化就无法决策。我对碎片率用的定义很简单:
碎片率 = 1 -(当前最大连续空闲块大小 / 当前总空闲内存大小)
举个例子。如果总空闲内存是100MB,但最大的连续空闲块只有30MB,碎片率就是70%。这个70%意味着:你虽然看着还有100MB空闲,却连一个40MB的对象都可能分配失败。
这个公式不是内核或者JVM官方指标,但作为工程判断非常实用。关键是“最大连续空闲块”怎么拿到,这取决于你看的是哪一层内存。
2.2 JVM堆内碎片的观测手段
对于Java应用,我常用的观测路径有三条。
第一条是开GC日志,让JVM自己把堆状态打出来。加上-Xlog:gc*:file=/tmp/gc.log:time,level,tags,关注日志里老年代区域的使用率、GC前后的占用变化。如果发现每次GC后老年代使用率没有显著下降,甚至触发Full GC后下降也不明显,说明回收效率低,很可能就是碎片导致的可回收空间本身就少。
第二条是jmap -heap看各代空间的容量和已用量,估算老年代占用。不过jmap只能看到当前状态,看不出碎片分布。如果需要精确看空闲块分布,可以用CMS的统计参数,或者用jcmd GC.heap_info,可以看到比jmap更细的区域分布。
第三条是关注JVM的分配失败日志。promotion failed是最直接的信号,它的完整含义是:年轻代晋升对象需要连续空间,但老年代找不到足够大的连续空闲块。这类日志只要出现,说明碎片已经严重到影响对象晋升了。
2.3 操作系统物理内存碎片的观测手段
Linux下看物理内存碎片,我一般先看/proc/buddyinfo。它按node和zone输出每个order的空闲页块数量。order 0是4KB单页,order 1是8KB连续块,order 9是2MB,order 10是4MB。如果order 10那列的数据是0,而order 0和order 1非常大,说明4MB连续物理内存已经分配不出来了。
实际操作时可以这样快速观察:
watch -n 5 'cat /proc/buddyinfo'长期运行时我会写个简单脚本记录各order数量,方便对照时间点和业务事件。
另一个有用的文件是/proc/pagetypeinfo,它按迁移类型和order统计页块数量。迁移类型是指哪些页可以移动、哪些不能移动(比如内核核心页就不可移动)。这比buddyinfo多了一层维度,能看出“理论上有多少可移动页可以用来整理”。
2.4 什么阈值下必须整理
我个人的经验阈值是这样:
- 碎片率低于20%,通常不需要专门处理,保持监控即可。
- 碎片率20%到50%,要开始关注GC/分配模式,准备好整理方案。
- 碎片率超过50%,或者已经出现分配失败,必须安排整理窗口实施操作。
这只是一个经验参考。如果你的服务习惯一次性分配几百MB的连续内存(比如大缓存数组、大page cache预读),那么即使碎片率只有20%,也可能踩雷。判断标准永远是“需求侧”——你的服务需要多大的连续内存,空闲侧最大块能不能满足它。
3. 按场景选方案:不是所有碎片都要硬搬
3.1 高频小对象场景:从GC算法层面规避碎片
JVM堆内碎片的治理思路,不是等碎片满了再整理,而是选择本身就带整理性质的垃圾回收器。
CMS是我最不推荐在老年代碎片敏感场景下使用的收集器。它采用标记-清除算法,并发标记和清理阶段都不搬移对象,所以老年代必然产生碎片。CMS的碎片积累到一定程度,就会触发promotion failed,之后JVM会退化到Serial Old做Full GC,那种长时间的stw能让你直接怀疑人生。
G1则完全不同。它把堆划分为多个Region,回收时以Region为单位做复制,把存活对象从回收收益高的Region搬到其他Region。这个过程本身就是整理,在逻辑上不会产生不可用的碎片。它需要关注的是G1HeapRegionSize这个参数。Region大小影响大对象直接分配的行为——任何超过Region大小一半的对象都会被当作Humongous对象直接放进老年代Region,而且Humongous对象的分配要求也是连续Region区域。所以如果你的应用大对象多,Region设得太小,反而会加剧大对象碎片;设得太大,回收粒度变粗,GC调优空间变小。
ZGC和Shenandoah进一步把搬移操作做成了并发,不再因为整理而长时间冻结业务线程。ZGC的Region更灵活,有大中小三种,同时染色指针技术规避了绝大多数整理带来的stw开销。对于超大数据堆、毫秒级延迟要求的服务,Z系列基本是首选。
我这里给一个GC选型的经验矩阵,供大家参考:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 老年代碎片导致promotion failed | 切G1或ZGC,别用CMS | 复制整理消除碎片 |
| 堆大且延迟敏感 | ZGC | 并发整理,stw极短 |
| 对象分配极频繁,小对象多 | G1 + 调Region大小 | 自动整理,减少碎片 |
| 大对象(数MB以上)多 | 评估G1 Region或直接ZGC | 避免Humongous碎片 |
3.2 大对象与堆外内存:用池化和分段设计来防碎片
JVM堆内碎片还有一个重要来源是“大对象横跨Region/多个页”的分配。对这类场景,单纯靠换GC只能缓解,更应该从内存使用模式上改造。
一种有效方式是预分配内存池。对象池把相同大小的对象放进固定块中反复复用,避免频繁创建销毁导致空洞。Netty的PooledByteBufAllocator就是典型代表,它把不同尺寸的ByteBuf按大小分级存储,分配时直接从池里取,使用后回收到池里,内存分布非常规整。在Java里自己实现类似机制时,可以按哈希分桶:每个桶专门管理固定大小的对象,桶内不跨级分配。
另一种方式是控制大对象的晋升节奏。JVM提供了-XX:PretenureSizeThreshold参数,超过指定大小的对象直接进入老年代。很多人不理解这个参数的价值:让大对象绕过年轻代的复制过程,避免在Eden和Survivor之间多次复制。复制本身消耗时间,而且复制到一半如果Survivor空间不够,会直接晋升到老年代,晋升的位置碎片化更严重。直接进老年代至少能让大对象占用的空间集中、连续。
对于堆外内存(Direct Memory、mmap段),整理就更难了,因为JVM不管理这些区域的生命周期。我的建议是:堆外内存只分配给长生命周期对象,短生命周期数据一律走堆内或者池化,从源头减少堆外碎片。
3.3 操作系统级整理:内存规整的实践与边界
Linux内核其实自带内存碎片整理能力,官方叫memory compaction,中文一般叫内存规整。它的核心思路是:把可移动页从某个区域搬走、迁移到其他位置,从而在原来的区域空出连续的大块物理内存。
内核的整理机制能解决很大一部分物理内存碎片问题,但它有两个前提。第一,被迁移的页必须标记为MIGRATE_MOVABLE,用户态进程的普通页基本都是可移动的,但内核本身分配的一些不可移动页会成为碎片钉子户。第二,迁移页本身需要消耗CPU和IO,如果系统已经满负荷,做全系统规整反而会加剧延迟。
实际操作中,Linux系统管理员可以做两个动作。
第一个是手动触发一次全系统整理:
echo 1 > /proc/sys/vm/compact_memory执行后可以再次观察/proc/buddyinfo,看看高阶order的空闲块是否有增加。注意这不是一次性的系统命令,它会让内核立即调度内存规整任务,耗时和系统内存大小、页的可迁移比例有关,在生产环境建议在低峰窗口执行。
第二个是调整内核碎片整理的主动性。现代内核提供了/proc/sys/vm/compaction_proactiveness,默认值是20,数值越高内核越积极地预先整理内存。如果你发现系统经常在高order分配失败,但可移动页充足,可以把该值调到50左右看看效果。
需要特别提示的是:不要拿echo 3 > /proc/sys/vm/drop_caches当作内存整理手段。它只是清空page cache,释放出来的页面会被重新分配,并不改变碎片分布,而且会导致缓存命中率短期暴跌、服务性能显著下降。我见过不止一个新手把drop_caches和碎片整理混为一谈,后果非常尴尬。
4. 一套可落地的内存碎片整理完整流程
4.1 第一步:先做内存画像,不要急着上参数
每次调整内存相关的参数之前,我都强迫自己先花时间做画像。没有画像,你根本不知道碎片是高是低、整理后有没有效果。
画像的做法分三步走。
第一,记录当前内存分配情况。对Java服务,先用jcmd <pid> GC.heap_info和jmap -heap <pid>抓一次堆快照,同时启动GC日志采集,至少持续一个完整的业务周期(比如24小时)。对底层系统进程,记录/proc/buddyinfo和/proc/pagetypeinfo,同样持续一段时间。
第二,统计碎片率。你可以把buddyinfo导出来后,用下面这个简单脚本快速计算最高order可用的连续空间量:
import re with open('/proc/buddyinfo') as f: for line in f: parts = line.split() # 每行格式: Node 0, zone DMA 1 0 0 0 ... pages = [int(x) for x in parts[4:]] total_continuous_kb = 0 for i, count in enumerate(pages): # order i 的每块大小 = 4KB * (2^i) if count > 0: total_continuous_kb = 4 * (2 ** i) print(parts[0], parts[1], parts[2], f"max_order_bytes={total_continuous_kb * 1024}")这个脚本取的是最高非零order对应的块大小,就等于当前最大的连续物理内存块。拿它和系统总空闲内存对比,碎片率就出来了。
第三,确定回收触发源。观察GC日志里promotion failed、allocation failure出现的频率和时间点,对照业务请求高峰。对于系统底层,观察有无内核日志中page allocation failure的报错。只有知道是谁在哪个时刻触发了失败,才能判断该在哪个场景下手。
4.2 第二步:确定整理策略,不搞无差别操作
画像做完后,你会得到一张“内存碎片情况表”。整理策略最终就分三类。
第一类,JVM堆内问题明显(promotion failed / Full GC频繁),解决方案是更换或调优GC收集器。此时你的操作不是“清理碎片”,而是“让分配路径上不再产生碎片”。
第二类,JVM堆内没有明显问题,但Java进程的堆外内存分配失败,或者服务分配Direct Buffer直接OOM,核心方案是池化大对象、换用分段分配器,或者调整堆外内存限额。Java进程的堆外空间不像堆内由GC统一管理,任何碎片整理对它无效,只能靠分配模式改造。
第三类,物理机层面高order分配失败,腾挪空间无解,需要做操作系统级整理。比如数据库进程一次性要分配2MB的连续物理内存,映射为一个大页或大块缓冲区,而系统里只有零散的单个4KB页。这时compact_memory配合迁移类型治理才有效。
4.3 第三步:按参数实施整理,附常用参数对照
如果是JVM场景落地,我常用的推荐参数组合如下。
对于G1:
-XX:+UseG1GC -Xms8g -Xmx8g -XX:G1HeapRegionSize=4m -XX:MaxGCPauseMillis=200 -XX:+PrintGCDetails -Xloggc:/tmp/gc.log这里-Xms和-Xmx设成相等,避免堆动态扩展导致内存布局变化。G1HeapRegionSize=4m适合中小对象的服务,如果大对象多,可以改成8m或16m。MaxGCPauseMillis设定后,G1会通过调整年轻代大小来达成停顿目标,间接影响整理节奏。
对于ZGC:
-XX:+UseZGC -Xms16g -Xmx16g -XX:ZCollectionSpacing=10 -XX:ConcGCThreads=4ZGC本身并发整理,停顿极短。调整ConcGCThreads影响并发整理线程和业务线程之间的CPU分配,不要盲目加大,建议根据实际核数按1/8开始摸。
如果是操作系统层面整理,关键操作如下:
# 临时提高碎片整理主动性 sysctl -w vm.compaction_proactiveness=50 # 手动触发一次全系统整理(低峰窗口) echo 1 > /proc/sys/vm/compact_memory # 观察效果 cat /proc/buddyinfo如果你使用了大页,还要额外关注THP(透明大页)的状态。很多情况下THP开启后,khugepaged内核线程会尝试把连续的小页折叠成2MB大页,如果内存紧张,折叠失败会引发大量内存分配重试,反而制造延迟抖动。对于延迟敏感型应用,我见过不少团队直接显式关闭THP:
echo never > /sys/kernel/mm/transparent_hugepage/enabled这个操作做不做,取决于你的应用是否真正受益于大页。数据库类大内存进程可以考虑开,Java类的延迟敏感服务建议评估后再定。
4.4 第四步:验证与回滚,缺一不可
整理完成不是终点,验证是必须的。我每次调整后必然会做三件事。
第一,观察碎片指标变化。Java场景看GC日志中老年代回收成功率和promotion failed是否消失;物理机场景看buddyinfo高阶order块数量是否增加。
第二,观察业务指标。重点不是平均延迟,而是P99和P99.9。碎片导致的延迟长尾,在平均值上几乎体现不出来。我会把调整前后两天的P99曲线并排对比,确认没有劣化。
第三,长时间观察一个完整业务周期。内存碎片是慢性累积问题,它不会在整理后几小时内就复发,但如果你的分配模式没有改变,一周之后可能又回到原点。所以不要只看一天,至少跑满一个业务波动周期。
如果调整后出现性能下降或异常,回滚方案同样重要。JVM参数可以直接改回去然后滚动重启,操作成本不高。系统层参数如compact_proactiveness可以随时改回默认值。但要注意一点:物理内存整理如果已经搬移了大量页面,回滚不会自动把页搬回原位,只能等待后续分配自然覆盖。所以整理窗口尽量挑选业务流量比较低的时间段,给足系统“消化”的时间。
5. 常见问题与排查技巧实录
5.1 为什么整理之后性能反而变差
这是我自己入行早期踩过的坑。那时候我负责一个内存压力很大的服务,Full GC频繁,我就想着手动触发一次堆压缩。结果压缩期间整个服务stw了将近7秒,大量请求超时,用户反馈直接炸锅。
这个经历告诉我一个道理:整理内存意味着搬移存活对象,搬移是有代价的。对于JVM来说,对象越多越大,压缩整理耗时就越长。CMS触发Full GC退化为Serial Old做标记压缩时,几十GB堆的stw时间很可能就到几十秒量级。
解决思路不是不做整理,而是要让整理发生在可控的时间和空间内。G1和ZGC的设计初衷就是把整理动作切分到多个Region或分多次完成,减少单次停顿。如果应用必须使用CMS,那就得提前设置-XX:CMSFullGCsBeforeCompaction让JVM在连续几次Full GC后做一次压缩,或者直接承受碎片化带来的退化风险。我现在的建议很明确:还在用CMS的团队,如果堆内存超过4GB,越早切G1越好。
5.2 碎片率一直居高不下,整理没效果怎么办
整理没效果,通常不是整理动作错了,而是整理对象搞错了。
最常见的情况是:堆内碎片已经控制住了,但GC日志里依然频繁Full GC。打开Dump一看,大量DirectByteBuffer占着堆外内存,JVM堆本身反而不太满。这种问题做堆内存整理完全没有意义,要查的是-XX:MaxDirectMemorySize设置是否合理,堆外内存使用是否有泄漏,分配器是否足够高效。
另一种情况是:JVM堆内和堆外都没问题,但物理机空闲内存充足时,内核依然报分配失败。这时候要怀疑内存cgroup限制。容器环境下,你的Java进程看到的/proc/buddyinfo是宿主机全局视图,而容器实际可用的内存是cgroup限额内的。如果cgroup内存接近上限,即使宿主机空闲,分配同样会失败。排查方法是看/sys/fs/cgroup/memory/memory.usage_in_bytes和memory.limit_in_bytes。容器场景下的“碎片整理”本质上是优化应用自身内存占用,和宿主机整理关系不大。
5.3 大页到底要不要开
关于大页,我的观点是分场景看。大页能减少TLB miss,对内存访问密集型的计算场景收益明显。但同时,大页也意味着更大的连续内存需求。如果你开的是THP透明大页,内核在后台折叠页面的过程还会引入额外的CPU消耗和不确定的延迟抖动。
实际操作中我见过一个监控系统,开启THP后偶发毫秒级卡顿,关闭后恢复正常。也见过一个数据库实例,显式配置hugepages后查询性能提升明显。想测试大页收益,建议先在测试环境跑同样的压力测试,对比开启前后的P99和吞吐量,再决定生产环境策略。
5.4 JVM的Metaspace和Code Cache碎片怎么查
很多Java服务做了一轮堆整理,依然觉得内存不够稳,可能没注意到JVM还有两个常驻内存区域。
Metaspace用于存放类元数据,它的碎片往往源于动态生成的大量类,比如反射、代理、热部署。Metaspace碎片问题几乎没有直观的整理手段,主要靠限制动态类生成、及时清理类加载器、设置-XX:MaxMetaspaceSize来控制上限。
Code Cache用于存放JIT编译后的本地代码,如果太小,JIT编译器可能停止编译热点方法,导致吞吐量下降。调整-XX:ReservedCodeCacheSize可以给足空间,但它按顺序分配、不具备整理能力,所以原则上尽量把初始值设置合理,不要频繁收缩和扩展。这些细节平时不起眼,但在长生命周期服务上累积起来的碎片量相当可观。
6. 一些碎片治理上的心法
回到最初的问题:内存碎片整理的终极目标到底是什么?我个人现在越来越觉得,它的目标不是把内存“弄干净”,而是保证系统在未来的任意时刻、任意内存请求下都能稳定分配成功。碎片整理方案的核心,是在“整理代价”和“碎片收益”之间找平衡。
我自己的习惯是常驻监控碎片率这个指标。用Prometheus采集节点上buddyinfo解析出的最大连续块大小,配一个告警规则,当最大连续块小于某个阈值时触发通知。Java服务则盯GC日志中的promotion failed关键字。有了监控和告警,碎片问题不用靠救火,它会像其他性能指标一样成为日常巡检的一部分。
另外想提醒的一点是,内存碎片是系统性问题的结果,不是病根。如果你反复整理、反复复发,一定要去审视分配模式本身。是不是缓存队列设计得太大、对象生命周期设计得不合理、连接池频繁伸缩、临时对象创建太频繁。把分配行为理顺了,碎片率自然会降下来。内存整理是最末端的手段,而它最好的结局,是永远用不上。
这个领域的技术还在持续演进,像ZGC的并发整理、内核的multi-size THP,都是为了让“整理”这件事变得更透明、更便宜。但底层逻辑始终不变:理解你的内存是怎么被使用的,比你会多少种整理命令重要得多。只要把这个基础打牢,无论未来GC器怎么变、内核怎么改,你都不会在没有头绪的时候瞎操作。