资讯详情

内存碎片治理实战:从原理量化到分配器与对象池优化

📅 2026/10/10 16:54:21 | 华诺云谱 👁 阅读
内存碎片治理实战:从原理量化到分配器与对象池优化
凌晨两点被监控电话叫醒大概率不是 CPU 打满而是内存又出问题了。RSS 曲线像爬坡一样一天比一天高重启进程后立刻回落可到了业务高峰期还是时不时报bad_alloc或者 Redis 的INFO memory里mem_fragmentation_ratio飙到 4 以上。这种明明还有一堆空闲内存却偏申请不到一块连续内存的现象十有八九就是内存碎片在作祟。内存碎片整理这个话题我在 C 服务、Java 服务、Redis 上都踩过不止一次。它可以很小小到只是某个长期运行进程 RSS 虚高的一个解释它也可以很大大到直接引发 OOM 和一次凌晨的紧急变更。这篇文章我不打算讲教科书式的算法大全而是按我实际排查和治理的顺序来先搞清楚碎片碎在哪个层面再讲怎么量化然后分别给出分配器替换、业务层对象池、GC 堆压缩三类主流解法最后用一次完整的实战复盘把链路串起来顺带说清楚哪些整理方案是根本不可行的。1. 碎片究竟碎在哪个层面先分清外部碎片与内部碎片很多人一谈到内存碎片脑子里只有一个模糊概念内存用得七零八落。但动手治理之前必须先把碎片分两类因为这两类的成因、测量方式和解决手段完全不同。1.1 外部碎片放着大量空闲内存却申请不到大块外部碎片指的是进程手里确实有很多空闲内存但这些空闲内存被分隔成一块块互不相邻的小段无法满足一次较大的连续分配请求。打个比方一个停车场里有 100 个车位但都是分散的单个空位这时候来了一辆需要连续 10 个车位的卡车就只能干瞪眼。操作系统层面的 buddy 分配器、glibc 的 malloc、Java 老年代的堆都会出现外部碎片。它的经典特征是free报告的空闲内存总量很高但最大连续空闲块很小。一个长期运行的服务如果分配模式是大量小块 偶发大块交替外部碎片会越来越严重因为小块分配会不断把大块空闲区域切碎而大块请求又无法从碎片中拼出来。1.2 内部碎片你明明拿到了内存容量却在浪费内部碎片则恰恰相反指的是分配器因为对齐、size class 取整、slab 固定大小等因素给了你比请求更大的内存块多出来的部分你用不到却也不可被其他请求利用。你申请 1000 字节分配器出于 8 字节对齐给了 1008 字节这是小头但如果你走到 slab 这类定长分配体系里申请一个 100 字节对象可能占用了整块 512 字节的 slab 单元剩下的 412 字节就是内部碎片。内部碎片不会导致分配失败但会让 RSS 虚高而且它和对象生命周期绑定得很紧。治理内部碎片的主要方向是调整 size class、减小 slab 单元粒度、避免大材小用。很多人在排查内存问题时只看外部碎片结果反复优化分配策略 RSS 却降不下来这时候就该回头看看是不是内部碎片在吃内存。1.3 为什么碎片问题越到后期越致命碎片问题最恶心的地方在于它的滞后爆发特性。服务刚启动时内存是连续的一大块碎片的危害几乎看不见运行几小时甚至几天后空闲区域被慢慢蚕食得支离破碎此时某一次稍大的分配请求就可能失败进程直接崩溃。这也是为什么很多内存碎片问题总是跑一段时间就出事重启就好。我做过一个统计在某个高并发网关上问题爆发前的 12 小时里 RSS 只涨了 10%但malloc申请 1MB 缓冲区的失败率从 0 涨到了 3%。这就是碎片积累到临界点之后的雪崩效应。所以治理内存碎片不能等服务挂了才动手它需要的是日常监控和前置的分配模式优化。2. 怎么量化碎从系统指标到堆内统计的测量套路没有量化的优化都是玄学。内存碎片有很多测量口径但不同层面看同一台机器、同一个进程结论可能完全不一样。我一般从系统层、进程层、堆内层三个维度逐层往下看。2.1 从系统层看MemAvailable 不等于还有内存可用先看/proc/meminfo里的两个字段MemFree完全空闲的物理内存页这个数字在很多场景下会很低因为 Linux 倾向于把内存用作 page cache。MemAvailable估算出的在不触发明显 swap 的前提下还能分配多少内存它包含了可回收的 page cache、slab 可回收部分等。真正判断系统还剩多少余量应该看MemAvailable而不是MemFree。但这两个指标反映的是整机状态没法直接告诉你某个进程碎不碎。要看进程得往下钻。如果怀疑是内核 buddy 分配器层面的碎片可以看/proc/buddyinfo它按 order 列出每个 zone 的连续页块数量。如果低 order小块很多、高 order大块连续页几乎为零说明内核层面确实存在外部碎片但这种情况下普通业务进程一般抢不到大块连续内存多见于依赖大块 DMA 或 THP 的场景。2.2 从进程层看mallinfo 与 malloc_info对 C/C 进程先看 glibc 的堆状态。在 glibc 2.33 之后用mallinfo2()之前用mallinfo()关键字段是uordblks已分配的堆内存fordblks堆内空闲内存总量hblks/hblkhd通过 mmap 分配的内存块数量与大小一个快速判断外部碎片程度的方法是在进程内调用malloc_info(0, stdout)导出 XML或者直接gdb -p pid后调用mallinfo。里面能看到unsorted bin、small bins、large bins各自挂着多少空闲块。如果fordblks有几百 MB但里面每一块都很小那就是典型的碎片化了。jemalloc 和 tcmalloc 也都有自己的统计接口jemalloc调用malloc_stats_print(NULL, NULL, NULL)输出里直接有dirty pages、muzzy pages、每个 size class 的allocated和active。最有用的是每 size class 的 allocated 与 active 对比能直观看到内部碎片。tcmallocMallocExtension::instance()-GetStats()同样按 size class 列出活动内存和已释放未归还内存。2.3 从堆内看GC 日志与分配器内部统计Java 服务看碎片主要看 GC 日志里的堆使用情况。比如 G1 的humongous allocation频繁失败并触发 Full GC说明大对象在老年代找不到连续区域CMS 的promotion failed则是老年代碎片化后无法容纳晋升对象的直接表现。这些日志关键词比任何工具都更早暴露碎片问题。Go 服务则要关注runtime/metrics和 pprof 堆分析。Go 的堆按 size class 管理内部碎片看各个 size class 的利用率外部碎片问题相对小但 RSS 回收滞后你可以通过查看MALLOC_STATS或者debug.FreeOSMemory的行为来验证。2.4 碎片率怎么算一个简单可落地的口径量化的核心指标是外部碎片度。设largest_free为当前堆内最大连续空闲块大小total_free为总空闲内存则external_fragmentation 1 - largest_free / total_free这个值越接近 1说明空闲内存越碎。我自己的经验阈值是小于 0.2 可以接受0.2 到 0.5 需要关注排查分配模式大于 0.5 基本可以判定碎片已经威胁到稳定运行。注意这个口径只对需要连续大块的场景有意义如果业务全是小块分配碎片度高一点也无所谓。内部碎片度则用已分配容量中实际有效负载的占比来看size class 的active总量除以业务实际请求的字节总量越低说明浪费越多。jemalloc 的统计输出可以直接算。3. 换分配器用 jemalloc / tcmalloc 取代默认 malloc 的实战逻辑不要一上来就自己写内存池。绝大多数服务的内存碎片问题第一步解决方案应该是换一个更专业的分配器。默认的 glibc malloc 在复杂分配模式下的表现确实不如那些为高并发设计过的分配器。3.1 为什么默认 malloc 容易碎glibc malloc 在释放小块内存时小块会进入 fastbins 和 tcache这些块默认不与相邻空闲块合并因为合并本身有开销而且小块队列的复用效率高。问题在于当一个分配模式是大量小块 间歇大块时小块会把 top chunk 的连续大区域切成无数个小孔位大块释放后如果被小块插入隔开永远无法合并成原来的大块。虽然 glibc 在分配时会尝试从 unsorted bin 里做 consolidation但这种合并是被动的、局部的面对碎片化的堆无能为力。拿我遇到的一个实际场景举例一个服务平均每次请求分配 2KB 上下文和 8KB 响应缓冲偶尔分配 1MB 的上游缓冲。运行 72 小时后mallinfo显示fordblks有 1.2GB但最大连续空闲块不到 1MB。这就是典型的 glibc 碎片化状态。3.2 jemalloc 的设计size class arena 隔离 延迟回收jemallocRedis、Rust、FreeBSD 都在用它解决碎片的思路很清晰按 size class 分类管理每个大小类独立成池同类块之间天然可合并避免大小混杂。多 arena 隔离并发每个 CPU 核心对应 arena减少锁竞争的同时也减少了线程间的内存互相踩踏。引入 dirty page 延迟回收机制释放的内存不会立刻还给操作系统而是先挂在 dirty 列表里由后台线程周期性处理。延迟回收听起来和碎片矛盾——内存不归还 OS 不是更容易虚高吗实际上它的作用是把释放频率和归还频率解耦允许分配器保留一定量的空闲页以便复用同时在后台按opt_dirty_decay_ms默认 10000ms逐步归还。对高并发服务把MALLOC_CONF设置成下面这样通常能兼顾性能与可控性MALLOC_CONFbackground_thread:true,metadata_thp:auto,dirty_decay_ms:5000,muzzy_decay_ms:5000background_thread:true尤为关键它让内存回收在后台线程持续进行而不是等到下次分配才触发能明显平滑 RSS 曲线。3.3 tcmalloc 的思路线程缓存 页面级 span 管理tcmalloc 的核心是三层模型ThreadCache线程本地缓存→ CentralFreeList中央空闲列表→ PageHeap页堆。小块分配优先走线程本地缓存几乎无锁回收时先回 ThreadCache超过阈值再归还 CentralFreeList内存最终以 span连续页的形式管理。tcmalloc 对碎片控制同样优秀尤其在线程数量多、小对象分配频繁的场景。它还有一个release_rate参数控制内存归还 OS 的激进程度默认值偏保守线上可以把tcmalloc_release_rate0.5这类值写入环境变量观察 RSS 变化再按需收紧或放宽。3.4 三者的取舍与实测对比维度glibc mallocjemalloctcmalloc碎片控制能力弱依赖合并时机强size class 隔离强span 管理小对象分配性能中高arena 隔离极高线程缓存RSS 回落速度慢中可配 decay中可配 release_rate典型代表系统默认Redis、RustGoogle、LevelDB适用场景通用、短生命周期进程长驻高并发服务线程极多的小对象场景实测数据更直观。同一个网关服务保持代码不变只把链接的分配器从 glibc 换成 jemalloc 并开启 background_thread运行 72 小时后 RSS 从 3.2GB 降到 2.1GBp99分配延迟还略有下降。另一个纯小对象高频创建的服务换 tcmalloc 后线程利用率提升明显。换分配器不是万能但它是成本最低、见效最快的第一步。4. 业务侧治理对象池、定长分配与释放节奏如果换完分配器 RSS 还是压不下来或者碎片度依然高那问题大概率出在业务分配模式本身。这时候别急着调参数得看业务代码怎么分配和释放内存。4.1 定长对象池为什么天然抗碎片碎片产生的根源是不同大小的分配交错进行、生命周期交错结束。对象池的思路正好相反把某种固定大小的对象预先分配好、复用保证同一时刻活跃的对象都是同样的大小类别释放回去后与本类其他对象天然在一起不会去切碎别的 size class 的区域。以 Linux 内核的 slab 分配器为代表kmem_cache对每种对象建独立缓存对象大小固定、构造函数可配碎片率被压制到极低。用户态实现对象池也是一样的道理。最常用的手段是 sync.PoolGo、Apache Commons PoolJava、或自己维护一个无锁 freelistC。我在一个 C 服务里做过统计请求上下文约 2KB和响应缓冲约 8KB占了全部分配次数的 95%。引入两个定长对象池后分配次数从每秒 12 万次降到 1.8 万次堆里需要连续大块内存时的干扰源被成倍移除碎片度直接下降了一个量级。4.2 连接池、缓冲池的正确打开方式对象池不是简单的缓存几个对象有几个细节直接影响碎片治理效果池内对象大小必须严格固定或者按大小档位分桶。如果允许一个池里既有 1KB 又有 1MB 的对象那又变成了碎片化分配器。池的容量要设上限并做水位监控。池太小起不到抗碎片作用池太大则 RSS 虚高。大块的缓冲比如超过 256KB要单独建池和普通小块池隔离避免大块被小块请求拆碎。双缓冲池或环形缓冲适合读写交错型业务可以减少分配和释放的频率。4.3 别忘了释放阶段的节奏批量归还比零散释放更友好大部分人都盯着分配端其实释放端对碎片影响同样大。同一个 size class 的对象如果分配一批、随后逐个在不同时段释放就会在堆里留下参差错落的空洞如果批量使用、批量归还分配器才能把这些页完整地合并回去。有个简单的实操技巧在业务里把对象的释放操作尽量收拢比如每处理完 1000 个请求统一归还一次缓冲而不是每个请求结束就立刻 free。实测相同 QPS 下批量归还可以让 RSS 峰值下降 10% 到 15%。当然这会增加瞬时内存占用需要根据业务延迟容忍度权衡。5. GC 堆的移动式整理标记-压缩与分代回收机制如果你跑的是 Java、Go 这类带 GC 的运行时内存碎片治理的思路和 C/C 完全不同GC 可以直接把存活对象搬到一起腾出连续的大块区域。这是真正的碎片整理也是进程级 malloc 天然做不到的事。5.1 标记-压缩 vs 标记-复制真正的移动内存标记-清除算法会产生碎片因为它只回收不整理。标记-压缩则分两步先标记所有存活对象然后把它们往堆的一端移动移动过程中更新所有指向它们的引用。移动完以后堆的另一端就是一整块连续空闲区外部分配直接指针碰撞推进即可。标记-复制半区复制更激进把堆分成两半只在其中一半分配GC 时把存活对象复制到另一半。复制完成后旧半区整体清空天然零碎片。代价是浪费一半空间。分代收集器里年轻代用复制算法正是因为碎片管理成本低、对象存活率低、复制开销小。5.2 G1、ZGC 在碎片化下的表现G1 把堆划分成等大 region通过 region 级别的疏散evacuation实现压缩式回收每次 Young GC 和 Mixed GC 都会把存活对象从较空的 region 复制出去、整体回收原 region。它的碎片问题主要出在 humongous 对象超过 region 一半大小的对象humongous 需要连续多个 region分配频繁时会引来 Full GC。ZGC 用染色指针和多重映射实现了并发搬迁可以在业务线程几乎不暂停的情况下压缩堆。它把堆切成中粒度 region每次回收时搬迁存活对象从机制上很大程度消除了外部碎片问题代价是额外的访存开销和内存占用。如果 GC 日志里频繁出现Humongous Allocation request of 16MB has failed或者 CMS 时代的promotion failed基本可以判定老年代碎片化严重。调整手段包括增大 region/humongous 阈值、压缩大对象本身对超大数组分组、或者直接换用 ZGC 这类并发压缩收集器。5.3 Go 堆为什么很少谈压缩Go 的堆没有做整体压缩式 GC但碎片问题并不严重秘诀是严格的 size class 体系。Go 把所有小对象按约 70 多个 size class 归类申请时向上取整到最接近的 size class同类的对象都在同一批 span 上分配和释放不同 size class 之间互不干扰。这本质上就是内部对象池 分配器层隔离和我前面讲的对象池思路异曲同工。Go 的 scavenger 会周期性把不再使用的物理页归还给操作系统避免 RSS 无限虚高。所以 Go 服务如果内存占用高优先查的是 goroutine 泄漏和 slice 逃逸导致的对象滞留而不是外部碎片。只有当大量超大 slice数 MB 以上反复分配时才需要考虑大对象池本质上也是把大块请求和普通对象隔离开。6. 实战复盘一次高并发网关的内存碎片完整治理过程理论知识说完了我把之前做过的一次完整治理过程放出来给大家一个可以照着操作的排查和解决链路。6.1 问题现场与监控数据这是一个 C 写的网关服务16 核机器QPS 约 3 万。每个请求会创建约 2KB 的上下文对象和一个 8KB 的响应缓冲另外有约 5% 的请求会申请 1MB 左右的临时上游缓冲。监控上的现象是重启后 RSS 基线约 1.6GB。运行 24 小时后 RSS 涨到 2.8GB72 小时后逼近 3.5GB。高峰期偶尔出现bad_alloc且集中在 1MB 缓冲分配处。重启进程RSS 回落但 24 小时后再次爬坡。内存没有线性泄漏因为重启后基线不变、增速固定第一时间就怀疑碎片而不是对象泄漏。6.2 排查链路从 RSS 曲线到堆内统计排查顺序我建议严格从外到内避免过早陷入细节确认没有内存泄漏用长期的内存增量曲线除以请求量如果平稳线性增长且重启归零先排除泄漏再看碎片。看系统层free -g和/proc/meminfo确认整机型碎片不是主因。看进程层gdb -p pid后调用mallinfo()得到fordblks约 1.2GB但用malloc_info(0, stdout)导出的 XML 里找不到任何一块超过 4MB 的空闲块外部碎片度约 0.95。用ltrace或LD_PRELOAD的 malloc hook 统计分配调用分布确认 2KB 和 8KB 请求占 95%。用 valgrind massif 或者 heaptrack 在预发环境跑一版确认大块缓冲被小块分配切碎的位置和频率。结论很明确小块请求持续切碎大块区域导致 1MB 连续分配失败。不是内存不够是连续内存不够。6.3 落地改动与效果对比那次改动主要做了三件事按优先级从高到低第一换 jemalloc 并开启后台回收线程export MALLOC_CONFbackground_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000这一步就压掉了三分之一的多余 RSS因为 jemalloc 的 size class 隔离让小块不再无限制地切碎大块区域后台线程也在持续把脏页归还给 OS。第二给请求上下文和 8KB 响应缓冲各自建定长对象池把每秒分配次数从 12 万压到 1.8 万。这一步进一步降低了碎片累积速度bad_alloc彻底消失。第三改为批量释放每处理完 1000 个请求统一归还缓冲池中的对象。改造前和改造后的 72 小时数据对比指标改造前改造后72 小时 RSS3.5GB 且持续上涨1.8GB 基本平稳1MB 分配失败次数高峰期 3%0分配耗时 p9942us18us是否需要重启救急每 48 小时不需要整个改动花了两天半天排查、一天开发、半天压测观察。没有改任何业务协议只是把内存分配行为从随意变成有序。一次成功的碎片治理不需要奇技淫巧把分配模式理顺、把合适的分配器选对问题就能解决。7. 绕不开的限制与容易踩的坑碎片治理不是把所有手段堆上去就完事有几条边界和坑我是花过代价才明白的。7.1 不要指望在运行时搬动 malloc 的已分配内存网上偶尔会看到进程内存整理工具之类的说法用磁盘碎片整理的概念套内存。要明确一点C/C 进程里 malloc 出来的内存地址是外部可见的指针被业务代码、库、甚至系统调用内核态持有运行时移动这些内存意味着要遍历并更新所有指针这个动作在非托管环境里几乎不可能安全完成。所谓的进程 defrag本质是重启进程或者靠分配器自身机制不存在通用的运行时搬移方案。如果你确实需要自动整理的能力选型的根本答案是改架构要么换带压缩式 GC 的运行时Java、Go 相关机制要么在业务层彻底采用对象池和定长分配从源头不让碎片产生。我一直强调这一点是因为很多人把时间浪费在寻找整理工具上而不是解决分配模式。7.2 THP、overcommit 和碎片率的耦合透明大页THP对碎片感知的影响经常被忽略。THP 会把连续 2MB 合并成一个巨大页对追求性能的服务有一定帮助但开启 THP 后内存回收、分配和碎片统计都会因为找连续 2MB而变得更敏感。很多生产环境对延迟敏感的服务会建议关闭 THPecho never /sys/kernel/mm/transparent_hugepage/enabled另外overcommit_memory2时系统按 CommitLimit 限制内存申请碎片化导致的 RSS 虚高会更快撞到限制。这两个系统参数如果不变单靠进程内的碎片治理可能达不到预期。7.3 参数调优不是万能钥匙M_TRIM_THRESHOLD 的正确姿势glibc 提供了M_TRIM_THRESHOLD和M_MMAP_THRESHOLD可以调大让空闲内存更积极地归还 OS。理论上调小 trim 阈值能加快 RSS 回落但代价是brk/madvise系统调用变频繁CPU 上涨、吞吐下降。我见过有人把M_TRIM_THRESHOLD调成 0结果进程吞吐掉了 20%。这类参数更适合做方向验证先证明 RSS 能回落再决定是否长期采用而不是一上来就当作最终方案。还有一个测量上的坑进程 RSS 里包含共享库占用的共享页在不同进程数下横向对比会失真。排查碎片时建议用 PSS/proc/pid/smaps_rollup里的 Pss代替 RSS口径更干净。另外 jemalloc 的 RSS 回落有滞后性改完参数后至少要观察一个 dirty decay 周期通常 5 到 10 秒再下结论别因为改了没立刻降就急着改回去。最后说点个人体会。内存碎片治理做到后面最大的感悟不是某个技巧有多神而是分配模式决定碎片命运这句话有多正确。每次线上出内存问题我都会先问三个问题谁在分配多大的块什么时候释放把这三个问题回答清楚碎片治理其实已经完成了一大半。如果你也想建立一个可持续的防御机制建议把高峰 RSS 与重启基线 RSS 的差值占比作为一个常规监控指标一旦超过 50% 就主动排查分配碎片而不是等到bad_alloc和 Full GC 打上门来。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑