1. 内存碎片到底是什么,它凭什么拖垮一个服务
做了七八年后台开发和中间件维护,我几乎每年都会遇到一类"看起来很像内存泄漏,但排查到最后发现根本不是泄漏"的诡异问题。现象很统一:服务刚启动时内存曲线很漂亮,运行三天后 RSS 一点一点往上涨,涨到某个水位又开始回落,但永远回不到初始值。最后内存持续上升,OOM 或者 GC 频繁报警,重启就好一阵,不重启就继续恶化。
遇到这种问题,大多数人的第一反应是"哪里有对象没释放",但打着日志反复看,引用计数、对象生命周期全部正常。真正的原因往往就是内存碎片。这篇文章想给所有被内存曲线折磨过的同行一套可以落地的诊断和治理框架。无论你是做服务端开发、中间件、游戏服务器还是数据库维护,只要你运行着长生命周期的后台进程,内存碎片就是你迟早要面对的话题。
1.1 一个让所有人都头疼的场景
我印象特别深的一个案例,是一个网关服务。它本质上就是一个常驻进程,不停地接收请求、做路由转发、返回响应,逻辑非常简单。正因为逻辑简单,出问题的时候才让人头疼。
服务配置了 4GB 的堆内存上限,压测环境里 QPS 跑满时也就用 1.5GB 左右。但真实环境里跑上四五天,内存占用能摸到 3.6GB,而且 GC 频率明显上升。查了对象报告,没有异常的大对象,没有明显的集合膨胀,老年代也没有异常增长。后来做了堆转储分析,发现了一个让人哭笑不得的事实:老年代里全是几百字节的小对象,数量高达几百万个,它们中间的缝隙小到新的对象根本填不进去。
这就是碎片化。内存空间总量是够的,但可用空间被切碎成很多小块,新对象想找一块连续的内存就找不到,系统被迫触发更频繁的 GC 去挪位置,或者从操作系统申请更多内存。GC 挪不动的时候,就直接 OOM。
1.2 碎片化的机制:外部碎片与内部碎片
要把碎片问题讲透,得先分清两类碎片,因为它们产生的机制和治理手段完全不同。
第一类是外部碎片,这是最常见的杀手。进程向操作系统申请了一大块连续内存,内部管理粒度是页,页的粒度通常 4KB 或者更大。分配器为了满足一次申请,需要找一块满足大小的连续区域。分配、释放、分配、释放,反复交错之后,空闲区域会被切成很多小段;每一段单独拿出来都不够大,但加起来很客观。就像停车场的车位,单个车位都是空的,但因为每一排零散分布,来一辆大巴车就停不进去,停车场明明还有很多空位却不接待大车。
第二类是内部碎片,指的是分配器给了你一块比你实际需要更大的内存,多余的部分浪费掉了。这类碎片在 glibc malloc 的分桶分配里很常见。分配 24 字节可能拿到 32 字节的块,分配 100 字节可能拿到 128 字节的块。单次浪费不大,但同样地,乘以百万次调用就是几 MB 甚至几十 MB 的差距。
我见过一些团队花了很大力气去寻找"泄漏点",最后发现是内部碎片造成的膨胀超过了 20%。如果你用 jemalloc 或 tcmalloc 的统计接口看实际提交的内存和业务期望的内存,差距一目了然。
1.3 碎片在真实服务中的三个典型症状
碎片问题很少直接报错,它更像一种慢性病,有三个很典型的症状,帮助你对号入座。
第一个症状是内存阶梯式上升。服务刚启动时内存很低,运行一段时间后突然上一个台阶,再运行一段时间再上一个台阶。这个台阶不是平滑曲线,而是每次 GC 或每次缓存重排之后发生跳跃。有人会误认为是周期性的资源泄漏,但泄漏一般是匀速的,碎片造成的台阶更加离散。
第二个症状是高水位内存无法下降。低峰期没有流量,GC 跑完了,内存还是下不来。如果你用工具去看 RSS,发现它和业务实际活跃内存差了一大截。这种时候就是大量空闲页被分配器缓存着,但它们的分布让分配器不愿意把它们还给操作系统,因为归还一批连续页面才是划算的,东一个西一个的页面没法批量归还。
第三个症状是单次大内存申请失败。服务整体还有不少空闲内存,但当你申请一块大的连续内存时,分配器返回失败。最典型的例子就是大数组、大缓冲区、批量读文件的场景。这种问题特别隐蔽,因为从操作系统的角度看,内存完全够用,你自己监控的内存余量也完全够,但就是分配失败。这个时候你才意识到,原来碎片在不知不觉间已经把"连续空间"这个资源侵蚀掉了。
2. 不是玄学:如何量化碎片化程度并定位来源
你在群里说"我怀疑是内存碎片",大家都很认同。但当你追问"碎片率是多少,碎片主要分布在哪,是什么代码路径产生的",往往就没人接话了。这不是大家不专业,而是碎片这个东西平时缺少度量手段。其实完全能量化,只要你愿意花时间去看几个数据。
2.1 先搞清楚分配器和它的"缓存"到底干了什么
很多服务的线上内存问题,其实不是业务代码直接把内存吃掉了,而是分配器把内存"缓存"在自己手里。以 glibc malloc 为例,它从操作系统要内存的单位是 arena,默认是 64MB 一块。快的小对象释放后不会立刻还给操作系统,而是留在分配器的 bins 里供下次使用,因为还回去再要回来的成本太高。
jemalloc 的思路类似,它把内存按 size class 划分成组,每个线程有自己的 arena。释放的页面会进入 dirty 状态,dirty 页面达到一定阈值后,后台线程才会把它 purge 回操作系统。换句话说,你看到的 RSS 高,很可能是分配器手里捏着大量"已释放但未归还"的页面。
要量化碎片,第一步不是看业务代码,而是看分配器给不给数据。你可以通过 malloc_info 拿到 glibc 的 arena、bins 使用情况,也可以通过 jemalloc 的 mallctl 接口读取 stats.arenas。这些接口能告诉你系统总共有多少块内存是从 OS 拿的、多少块是正在使用的、多少块是空闲但还没还回去的。
2.2 怎么判断碎片率是否危险
碎片率没有一个四海皆准的阈值,但有一个非常实用的经验公式。你拿"系统实际存在的连续空闲块数量"和"总的空闲字节数"对比,空闲块数量极多而总字节数占比很低的时候,说明碎片化非常严重。另一种更贴近业务的算法是:当前堆的总大小除以堆中最大连续空闲块的大小。比如堆里空闲内存累计有 500MB,但最大的一块连续空闲只有 8KB,这个比例就很恐怖了。
我自己的经验,一般服务的碎片率容忍线大约在 10% 到 15%。超过这个数,服务不会立刻崩,但你已经能观察到 GC 频率上升、CPU 消耗增加。超过 30% 的时候,就需要认真考虑治理方案了,否则高峰期来一个稍微大一点的分配请求就会出问题。
判断碎片化还有一个更直观的办法:对比"活跃对象总大小"和"堆总大小"。用工具遍历一次活跃对象,统计总和,再对比分配器报告的堆大小,多出来的部分基本就是碎片加分配器缓存。这个差值超过 25% 的时候,不管业务有没有报错,你的内存利用率已经是不健康的了。
2.3 定位碎片来源的几种手段
量化只是第一步,真正让人头疼的是定位"谁在制造碎片"。这里我有几个比较顺手的方法。
第一个是看分配大小分布。多语言的运行时基本都提供统计接口,Java 可以开 Native Memory Tracking,Golang 可以用 runtime.MemStats,C++ 可以用 jemalloc 的 prof 功能。打开统计跑一段时间,你会看到一个直方图:分配最多的 size class 是哪些、总量多大。如果分布极广,从小到 8 字节到大到几 MB 都有,那么碎片风险天然就高。如果分布比较集中,哪怕总量大,碎片风险反而低。
第二个是看生命周期。碎片不是"分配得多"造成的,而是"分配多了还反复释放,而且释放得没有规律"造成的。服务中最典型的长尾场景是:
- 短连接模型下每个连接都分配独立缓冲区,连接断开后释放;
- 业务里频繁创建中间对象,比如拼字符串、做序列化、临时组包;
- 缓存组件设置了很大的过期时间,对象长期悬在堆里,更新时新老对象并存。
你可以从代码里找到那些"高频创建 + 生命周期不固定 + 大小差异大"的路径,那就是碎片制造机。举个例子,一个消息中间件里每条消息进来分配一个 1KB 的 byte[],处理完就丢。这个路径看起来没问题,但千万次分配后,不同尺寸的消息交错释放,堆就变成筛子了。
第三个更暴力的办法是用 allocator 提供的 debug 功能,把每次分配的调用栈打出来。jemalloc 的 prof 模式配合 jeprof 可以生成火焰图,直接看到碎片字节数的来源占比。这个方法会带来不小的性能开销,一般只在预发环境或者已经确定问题时用。
3. 整理与治理:从止血到根治的落地手段
明确了碎片问题之后,就该想怎么解决了。这里我必须提醒一句:不要一上来就追求"彻底消除碎片",这不现实。任何分配/释放模型都会产生碎片,我们能做的是让碎片不要影响业务,让内存利用率维持在健康水位,让高峰期的连续分配需求不会被卡脖子。
3.1 第一刀:让崩溃之前先活下来
先说急救手段,适合问题已经在线爆发、你还没时间去根治的场景。
最重要的一件事是给进程加上内存保护机制。常见操作是:
- 设置 RSS 或者堆内存上限监控,超过阈值报警而不是等 OOM;
- 在 OOM 之前做主动降级,比如拒绝新建连接、触发缓存清理、缩小线程池;
- 如果是 Java 或 Go 服务,宁可优雅退出重启也不要让系统 OOM Killer 直接杀。
另外一个非常实用的操作是手动触发内存整理。glibc 环境可以调用 malloc_trim(0),它会扫描堆上的空闲块并尝试把连续的块归还给操作系统。如果是 jemalloc 环境,可以通过 mallctl 调用 arena.<i>.purge。这种手动整理能快速把 RSS 降下来,但它治标不治本,过几天碎片又会积累回来。有团队会做一个周期任务,每天低峰期手动 trigger 一次,这种思路在应急期是有效的,但不建议长期依赖。
注意一个关键陷阱:malloc_trim(0) 在高并发进行时调用会引入全局锁竞争,导致请求毛刺。所以手动整理一定要放在低峰窗口,而且最好先灰度一台观察耗时。
3.2 第二刀:替换或调优内存分配器
如果服务是 C/C++ 或者你能替换底层分配器,这往往是最立竿见影的手段。
默认的 glibc malloc 在多线程高并发场景下碎片率偏高,这个问题业界早就达成共识。把 glibc malloc 换成 jemalloc 或 tcmalloc,碎片率通常能明显下降,因为它们的 size class 分布更合理,而且有独立的 per-thread arena 降低锁竞争。
我用 jemalloc 的经验比较多,调优的关键参数主要有这几个:
- dirty_decay_ms 和 muzzy_decay_ms:控制释放页在多长时间后可以被回收并归还 OS。调小这两个值,内存会更积极归还,但 CPU 消耗会略升高;调大会让分配器更"懒惰",高吞吐时性能好但 RSS 偏高。一般从 5000ms 起步,按服务的流量特征调。
- narenas:控制 arena 数量,默认按 CPU 核数翻倍。线程数非常多的时候,arena 过多会导致切分过细,碎片反而更高,可以手动限制到 CPU 数的一倍。
- background_thread:开启后台线程执行 purging,能减少请求路径上的同步开销。
如果你的服务是 Go 写的,tcMalloc 的思路就对应到 Go 自己 runtime 的 mheap 管理。Go 的分配器已经做得不错,但仍有几个可以调整的点,比如 debug.SetMemoryLimit 可以限制 Go 向 OS 申请的总内存,配合 runtime.GC() 主动触发回收。这类分配器调优的原则只有一句话:不要盲目照抄参数,要在真实流量下对比 RSS 和 P99 延迟的变化。
3.3 第三刀:长生命周期对象池化,消灭碎片源头
调完分配器,碎片率会下降,但有些服务依然会有问题,因为业务代码里高频创建和释放的对象一直存在。这种时候就该考虑第三个手段:对象池化。
对象池的核心思路是,把高频创建和释放的对象提前分配好,用完归还,不真正丢弃。这样做的直接收益是:
- 内存分配次数大幅下降;
- 分配大小变得规整,不再是参差不齐的尺寸;
- 对象生命周期变长且稳定,不会制造大量同尺寸但不同时期分配的空洞。
一个经典的落地场景是 RPC 网关。每条请求进来都要构造请求头、路由表查询结果、响应缓冲区。不搞对象池时,这些结构体反复 new 和 delete,尺寸从几十字节到几 KB 不等;上了对象池之后,请求上下文和缓冲区都变成复用对象,分配压力小了很多。
但对象池有个风险:池子里的对象如果不活跃,等于变相的内存泄漏。所以必须给池子设置容量上限和空闲清理策略。比如用"缓冲池最多保留 1024 个实例,超过之后直接丢弃;空闲超过 30 秒的对象允许被 GC 回收"这样的策略。
还有一个容易忽略的点:对象池本身的锁竞争。很小的对象池用锁保护没问题,但大流量下锁就变成瓶颈了。经验做法是 per-thread 的池数组,线程私有缓存配上全局共享池,本地拿不到时再从全局取。
3.4 服务治理层面的兜底:空间换时间、容量规划、回收窗口
除了代码层面的手段,架构层面的策略也很重要,甚至更重要。
第一个思路是"空间换时间"。有些中间件服务明明可以复用内存,为什么还要频繁分配?因为它们的协议栈非常复杂,每个连接有独立的读缓冲和写缓冲。一个均衡的做法是:连接建立时就分配默认大小的缓冲区,超过阈值再翻倍扩。这个方案会增加少量常驻内存,但能显著减少运行时碎片。你可以把"每连接缓存 X MB"理解成用内存换稳定。
第二个思路是"回收窗口"。"碎片整治任务"不是一个一次性动作,而是要周期性执行。很多服务都有明显的峰谷特征,凌晨的流量可能只是白天的五分之一。在这个窗口强制做一次堆整理或进程重启,是完全划算的。千万不要在高峰期做这种操作,也不要在内存还没积累到危险水位之前做,你可以设置一个水位阈值,比如超过目标 RSS 的 75% 才触发。
第三个思路是容量规划里预留给碎片的 buffer。我见过好多团队的容器内存 limit 比 JVM 的 Xmx 只大一点点,导致 JVM 堆内没满,但容器却因为堆外内存、元空间、线程栈涨到 limit 被杀。这种部署方式等于把碎片问题从"服务问题"放大成"基础设施问题"。合理的做法是容器 limit = 堆上限 + 堆外预算 + 25% 的碎片和缓冲冗余,这个比例根据服务类型微调。
4. 预防胜于整理:日常开发中如何少制造碎片
说实话,碎片治理的上限在架构设计阶段就决定了。如果代码里到处都是随机大小的临时对象,事后再怎么"整理"都只能缓解。真正高效的做法是在开发阶段就有意识地减少碎片的生成。这一节聊几个我在工程实践里反复验证过的原则。
4.1 让分配尺寸尽量"整齐"
碎片率对分配尺寸的分布特别敏感。如果所有分配都集中在几个固定的 size class 里,那么释放之后空出来的块大小差不多,新来的分配也更容易找到合适的位置。比如你在做一个消息队列系统,消息长度从 100 字节到 10KB 不等,有两种做法:
- 做法 A:每个消息按实际长度分配,长度随机分布,内存利用率高,但碎片率高;
- 做法 B:把缓冲区按 512B、2KB、8KB、32KB 几个固定档位分配,内存利用率稍低,但碎片率极低。
在绝大多数场景里,做法 B 是收益更高的选择。少用那几 KB 内存,换来的是稳定可靠的长久运行,这笔账非常划算。我见过有些团队把这个问题归纳为"内部碎片 vs 外部碎片的取舍",做法 B 本质上是牺牲一点内部碎片去换外部碎片的减少,整体内存利用率反而更好。
如果你用的是高级语言,这个原则同样成立。Golang 里有一个众所周知的技巧:make([]byte, n)时按档位取整,而不是用随机大小;Java 里你可以给对象池设置统一的容量档位。别小看这种小改动,它能决定一个服务一个月后内存是否稳定。
4.2 识别并管理生命周期交错
碎片产生最少的情况是"同类对象同时生、同时灭"。如果你的代码里存在多个生命周期的对象交错分配,碎片率就会加速上升。
典型的反面教材是缓存。假设你有一个本地缓存,缓存的对象每 10 分钟更新一次,但每条业务请求都在创建临时对象,并且这些临时对象恰好被分配到缓存旧对象旁边。每次缓存更新,都有一批旧对象被释放,新对象又被分配到别处,堆里就留下大量空洞。
这类问题的治理核心不是"少创建对象",而是"让不同生命周期的对象分开"。你可以把长生命周期的对象放在一个独立的内存池或独立的 arena 里,短生命周期的对象放在另一个池子里。Go 的sync.Pool其实就在做类似的事,Java 的堆外内存和堆内隔离也是这个思路。
另一个非常实用的小技巧:在做批量更新或批量加载时,尽量用"整体替换"代替"逐条更新"。比如把一批旧对象标记为全部失效,然后一次性新分配一组,新老交替之间的碎片天然不会交错。
4.3 利用定时任务"温和地"整理内存
完全不让碎片产生是不可能的,所以要让整理动作成为日常工作的一环。这个动作本身要极低成本,不能影响业务。
在 Go 环境里,有人会挂一个 cron 定时调用debug.FreeOSMemory()。这个函数会把 runtime 持有的空闲内存尽量归还操作系统,代价是进行一次 STW。所以执行频率要低,比如 30 分钟一次,且要选在低峰。执行完你会看到 RSS 有一个断崖式下降,非常直观。
在 Java 环境里,G1 GC 的-XX:G1PeriodicGCIntervalMs参数可以周期性地做一次并发标记与清理。配合-XX:G1PeriodicGCSystemLoadThreshold,可以实现"系统负载较低时才触发",兼顾安全和效果。
我自己在中间件团队时的做法是:监控面板上放三个指标——当前 RSS、本次进程生命周期内触发的整理次数、整理后回收的内存总量。如果整理回收的内存持续变大,说明业务分配的混乱程度在上升,要回到代码层面去查新引入的逻辑。
4.4 内存水位的四档监控
预防措施再到位,也得有监控兜底。我建议每个服务的内存监控分四档,每档的动作不同:
- 第一档:正常水位(RSS 低于目标水位 60%)。不需要任何动作。
- 第二档:关注水位(RSS 达到目标水位的 60%-75%)。关注增长趋势,判断是正常容量需求还是碎片积累。
- 第三档:预警水位(RSS 达到目标水位的 75%-90%)。触发主动整理动作或者 GC,同时开始限制部分非核心缓存。
- 第四档:危险水位(RSS 超过目标水位的 90%)。触发优雅降级、拒绝新流量、必要时滚动重启。
这套机制的好处是,你不会在内存超标那一刻才被动发现,而是提前就有一系列梯度应对方案。碎片治理的所有手段中,我认为监控体系是最值得先建设的,因为没有监控的优化就像闭着眼睛调色,调得好不好全靠运气。
5. 一次真实案例:从内存阶梯上涨到回归平稳的完整记录
前面讲了不少方法论,最后用一个完整案例把整个思路串起来。这是我之前维护过的一个推送网关服务,业务形态很典型,可以做参考。
5.1 问题表象与排查思路
服务刚上线时很稳,内存占用约 1.8GB。上线两周后,运维反馈每天 RSS 涨 200MB 左右,GC 频繁,P99 延迟从 20ms 涨到 50ms。第一反应是查泄漏,但反复排查发现对象数量稳定、无集合膨胀、无外部资源句柄泄漏。
我让团队打开了 jemalloc 的统计接口,拿到两个关键数据:
- 活跃分配总量:约 1.2GB
- 从操作系统持有的内存总量:约 3.1GB
这个差距就是 1.9GB,远超正常分配器缓存范围。继续看 arena 分布,发现 small size class 里的空闲块数量异常大,尤其是 128B 到 1KB 区间。至此基本确认:碎片化严重,而且碎片来源大概率是大量连接进出的临时缓冲区。
另一个佐证是,每天晚上低峰期跑一次手动 purge,RSS 能跌回 2.2GB 左右,但白天高峰期又开始往上爬。这说明分配器本身的归还机制没有坏,只是碎片让归还效率变差了。
5.2 两个核心治理动作的设计与实施
我和团队商量后没有做太激进的重构,而是选了两个针对性动作。
第一个动作:把每连接的读写缓冲区从"精确分配"改成"档位分配"。连接建立时统一分配 2KB 读缓冲和 4KB 写缓冲,需要扩容时直接翻倍跳到下一档(8KB、16KB、32KB)。这个改动很小,但让大小分布立刻变得规整起来。改完后跑了一周,活跃分配总量从 1.2GB 降到 1.0GB,碎片增长速度慢了一半。
第二个动作:给分配器配置调优。我们用的是 jemalloc,把 dirty_decay_ms 从默认的 10000 调到 3000,开启 background_thread,并限制 narenas 为 CPU 核数的 1.5 倍。这三项配置降低了空闲页面的"滞留"时间,让低峰期能更积极地把空闲内存归还 OS。
同时还加了一个每天凌晨 4 点的定时任务,调用 mallctl 的 purge 接口,把残留碎片清理一次。这个任务跑得很轻,对业务影响在毫秒级。
5.3 治理效果与后续观察
三个改动上线两周后,数据非常明显地好转:
- RSS 从 3.1GB 稳定在 2.2GB 左右,高峰期最高 2.5GB,不再出现阶梯式上涨;
- P99 延迟回落到 22ms;
- GC 频率下降了约 40%;
- 连续运行 30 天没有触发过危险水位告警。
比较有意思的是,这套方案并没有做任何"激进的内存整理"。我们只是把分配尺寸变整齐、让归还更积极、再配合周期整理,碎片的积累速度就下降了一个量级。这个案例再次印证我前面反复提到的观点:碎片治理的核心不是整理,而是让分配行为规整化。
5.4 常见问题速查表
最后整理一份我在各种场合被问过的常见问题,直接标注经验和结论,方便你以后快速查阅。
| 问题场景 | 快速定位方法 | 推荐处理方案 |
|---|---|---|
| 内存阶梯式上涨但活跃对象无明显变化 | 对比分配器持有内存与活跃分配总量 | 先看 size class 分布,再降低 decay 值,必要时做手动 purge |
| 低峰期 RSS 无法下降 | 检查 dirty 页面数量 | 调大后台线程参与度,降低 dirty_decay_ms,或在低峰期主动 purge |
| 单次申请大块连续内存失败 | 查看最大连续空闲块 | 对分配器做整理,同时考虑把大块内存拆分成固定大小池 |
| 高并发时服务内存不受控增长 | 观察 arena 数量与线程数的关系 | 限制 narenas 或 arena 重启策略,避免过多 arena |
| 分配器换 jemalloc 后性能反而变差 | 检查 dirty_decay 与 purging 频率 | 适当增大 decay_ms,减少 purge 开销 |
| 使用了线程池但内存分配锁竞争严重 | 观察分配热点 | 开启 per-thread cache,配置线程缓存大小 |
这张表里没有一招鲜的解决方案,因为内存碎片的成因在不同服务里差异很大。但思路是通用的:先量化,再定位,再做最小干预,最后建立监控和预防机制。
我个人这几年最大的体会是,碎片问题的根源往往不是"内存分配器不够好",而是"业务代码对内存的使用方式不够有纪律"。分配器做的是在既定模式下尽量优化,真正决定碎片率的还是写代码的人。如果你在开发阶段就能对对象大小、生命周期、缓存策略有清晰的设计,碎片治理就不是一件需要时刻提心吊胆的事。