内存碎片整理实战:从原理到自建内存池方案
写这篇东西的起因是我之前折腾一个长时间运行的服务内存条没少加但进程看着却跟充了气一样持续膨胀。跑了几天之后我实在受不了开始认真做“内存碎片整理”结果发现一个很反直觉的事实大多数场景下碎片根本没法靠“整理”解决只能靠“预防”和“容忍”。这篇文章把我这段时间的完整思考、方案取舍和实际踩坑都写下来内容覆盖操作系统底层机制、主流运行时各自的处理方式以及一个可以自己动手实现的整理方案。1. 内存碎片的三种形态不是所有“碎片”都能被整理1.1 内部碎片、外部碎片、页表碎片聊内存碎片之前得先明确一个很多人都搞混的点我们说的碎片其实有三种成因和治理手段完全不一样。内部碎片是已经分配出去的内存里没有被使用的部分。比如你调用malloc(1024)分配器为了对齐或者元数据开销实际从堆上划给你 2048 字节那多出来的 1024 字节就是内部碎片。它在一段连续空间内部无法被别的分配请求使用但它不阻断其他分配。外部碎片才是大家印象里那种状态总空闲内存足够但没有一块连续空间能满足一个新请求。堆里散落着几十个 32 字节、64 字节的小洞这时来一个 4KB 的请求就只能失败或触发堆扩张。这是典型 C/C 长生命周期服务面临的问题。还有一种经常被忽略的我叫它页表碎片。操作系统按页映射虚拟内存和物理内存一个进程虚拟地址空间里如果到处是已映射页面页表就大如果物理页东一块西一块释放时还能凑合但分配时 TLB 命中率和内存带宽就会受影响。整页碎片在用户态几乎无解只能靠分配器尽量保持局部性。1.2 为什么“整理”这么难搬不动对象就要改全世界外部碎片最标准的治理思路是把散落在各处的小对象挪到一起腾出一大块连续空闲区。听起来很简单就像把书架上的书重新按大小归位。但问题在于内存对象不像书架上的书书本身没有“别人指着它”。如果一个对象是结构体里面有其他对象的内存地址指针那么这个对象的位置一动所有指向它的指针都得改。在 C/C 里你根本不知道全局变量、栈、其他堆对象里有多少地方存着这个对象的地址除非你用工具扫描整个进程的虚拟地址空间。更麻烦的是如果那个地址被暴力转换成整数存着或者被拿去做了指针运算搬家之后就是灾难。所以整理内存的本质不是“移动内存”而是移动内存 修正所有引用。修正引用的成本决定了这套方案能不能做、怎么做。GC 语言能做是因为运行时持有所有对象的引用图普通 C/C 做不了是因为语言本身没有这种信息的统一管理。1.3 “整理”虚拟内存不等于整理物理内存就算你解决了指针引用问题还有一个底层问题malloc 返回的地址是虚拟地址不是物理地址。你的程序看到的连续地址在物理内存里可能散落在不同页框上。理论上你整理好虚拟地址空间对进程来说确实解决了“找不到连续虚拟区”的问题但物理内存的碎片和映射关系用户态程序根本碰不到。内核里做内存规整内存规整通过移动页帧来合并大页、降低物理碎片也不是为了用户程序能分配大块虚拟内存而是为了能映射大页或连续 DMA 缓冲区。这意味着你在用户态做的碎片整理顶多优化了进程内部的虚拟地址布局和堆复用效率不能直接“压缩”物理内存占用。RSS物理常驻内存能不能降下来还得看释放出的虚拟页是否真的被操作系统回收了。这一层认知不建立后面做任何“整理方案”都会在测量阶段翻车。2. 主流运行时各有各的“整理”招数但思路完全不同2.1 标记-压缩GC 语言的标准答案能做真正内存搬移的主要是带 GC 的运行时。思路统一是标记-压缩Mark-Compact。大概过程从根集合出发把所有还在使用的对象标记一遍然后选一条“压缩点”之前的存活对象依次搬到堆前端让所有存活对象挤在一起搬完后再根据 forwarding address新地址映射修正所有引用。这个算法最核心的收益是存活率越低效果越好存活率高的对象越多搬移成本越高。所以现代 GC 堆往往都按分代结构设计新生代的高频分配回收区域才做压缩老年代尽量用标记-清理甚至交给后台线程慢慢处理避免每次 Full GC 都全堆搬移。某次我在本机写个小 demo模拟几千万个小对象分配再释放一半之后用自带的分析工具看堆布局直观感受就是压缩前堆里到处是空洞压缩后整个 heap 非常干净。这种效果在 C/C 里基本不存在理解这一点后你在两种生态间的认知就不会混乱。2.2 glibc malloc 的“伪整理”能合并但搬不动C 程序最常用的 glibc mallocptmalloc2 系做的事通常是合并 回收不是搬移。当你free一块内存时分配器会看它物理相邻的前后 chunk 是否空闲如果是就把它们合并成一个更大的空闲节点。合并不是把已分配的内存挪到一处只是把相邻的空闲块拼起来让大的连续空闲区有机会出现。如果空闲内存都在堆的顶部附近聚集分配器收缩堆归还给操作系统。但这只是特例绝大多数场景是堆中间散落着十几个小洞顶部还被长生命周期对象占着顶部的洞无法还给系统malloc 也束手无策。malloc 从来不会把正被使用的对象搬到别处这是设计哲学不是能力缺陷——搬了之后所有指针全错。所以你在生产环境里听到的“内存碎片整理”在 glibc 层面往往只是一定程度上减小内部碎片、优化 chunk 合并策略、及时 trim本质都是缓解不是根除。2.3 那些从根源避免碎片的分配器jemalloc 和 tcmalloc既然整理难很多现代分配器转向“尽量不产生碎片”。先分桶不同大小类用不同 size class避免内部碎片再用 arena 隔离线程局部缓存减少锁竞争和跨线程交叉分配导致的交错散落。jemalloc 还干一件事dirty page 延迟回收。它把释放的页保持映射一段时间攒多之后再逐渐 purge 回操作系统避免频繁的系统调用。这类分配器的哲学是与其事后整理碎片不如在设计上减少碎片出现的概率。jemalloc 里的 narenas 选择和等量zone策略tcmalloc 里的 thread cache都是在避免多个线程把对象交错分配到同一块区域这样每个 arena 内部相对同质化外部碎片天然小。2.4 JVM 和 Go压缩停顿和大小的妥协JVM 老年代用各种 GC 时不一定会做压缩Parallel Scavenge、CMS 可能只做标记清理内存长期空闲但散布各处。要真正压紧堆得用支持压缩的收集器或显式触发 Full GC。Go 运行时则是每个 P 管理自己的 mspan 区域对象通过 span 分配GC 时也可能移动对象目前是清除非移动对象的支持机制但整体靠着小对象分桶避免碎片问题。从应用层往下看这些运行时都认为“停顿时间”比“内存规整”更宝贵所以除非堆碎片严重到导致分配失败否则宁愿接受外部碎片存在。这是个很重要的思维方式做碎片整理永远是在吞吐、延迟和内存利用率之间做 trade-off不是单纯“内存越大越好”。3. Redis 的主动碎片整理一个敢在线上搬对象的实现3.1 为什么 Redis 非要自己做你可能想不到最出名的“在线内存碎片整理”其实是 Redis 4.0 的 active defragmentation 功能。Redis 自己维护的对象都是通过 jemalloc 分配的由 Redis 数据结构持有引用。当 Redis 运行久了特别是频繁修改哈希、列表键值对象反复被替换删除jemalloc 的 arena 里就会累积大量外部碎片。关键点来了Redis 有完整的对象引用元数据——它知道每个 key 对应哪个对象也知道对象引用的具体位置。这和 GC 运行时掌握全局引用图是一个道理。于是它就敢在服务运行期间把一个 dict entry 或 SDS 字符串搬走同时修正哈希表里的指针。3.2 搬一个对象背后发生了什么Redis 做碎片整理时用的其实是 jemalloc 提供的分配还在但对象地址变了则把旧指针上的对象内容复制到新地址然后更新所有引用。为了让 jemalloc 自己把碎片交还Redis 会周期性扫描各 arena 的 dirty page 比例当碎片率超过阈值时就对 fragment 比例最高的 arena 做处理。更细的流程是它会遍历哈希表的 bucket找出 key 的 value 对象调用zmalloc分配一块新内存把旧数据memcpy过去更新 value 指针再释放旧内存。这一步如果 m 个对象都散落在不同页框jemalloc 会觉得旧页空出来了等 purge 时机到来时物理页就能被真正还掉。每次搬多少个对象、搬完停顿多久都由配置里的循环周期控制不让单次长时间卡命令。3.3 限速、保护机制和实际配置直接在生产开这个功能前建议先量化评估。Redis 的相关配置默认activedefrag是 no默认碎片率阈值下限 10%、上限 100%低于下限不搬高于上限全速搬。另外还有忽略字节数默认 100mb意思是只有碎片化总内存超过这个数才开始。active-defrag-cycle-min和active-defrag-cycle-max控制 CPU 占用比例默认分别是 1 和 25也就是最多拿 25% 的 CPU 周期做搬运。最核心的是max-scan-fields控制单次扫描的哈希字段数避免一个超大的 hash 把主线程卡住。我记得曾有人在一个几十万个 field 的大 hash 上开整理单次扫描字段数很大命令延迟肉眼可见调整配置后症状消失。线上搬对象从来不是“免费午餐”这些参数每一档都是在延迟和碎片消除速度之间调平衡。3.4 如果只是调参数还不行呢对 Redis 而言碎片整理的收益锁定在“内存总量明显下降或者分配失败被缓解”。有一种常见焦虑是RSS 明明比 used_memory 高很多开整理却不见效。这时候要先排除另一种可能——操作系统 hadn’t reclaimed pages yet或者 jemalloc 的 arena 里确实有小部分不可合并的空闲页块。诊断这种问题要用 jemalloc 的malloc_stats_print导出 arena 信息看 dirty pages 数量和 active pages 占比再决定是调阈值还是加MALLOC_CONF环境变量如dirty_decay_ms。这里我自己的体会是Redis 整理能处理“分配-释放交错”产生的碎片但处理不了“内存池整体扩容后一直不收缩”的问题。如果你请求峰值过后used_memory 降下来了而 RSS 不变那通常是 jemalloc 缓存了 dirty pages可以调 decay 参数或用jemalloc的arena.i.purge接口手动触发更积极的回收。4. 给 C/C 应用自建“可整理内存池”的可行方案4.1 引入句柄层让对象地址可被修正如果你真想在自己的 C/C 程序里实现碎片整理且不想为所有指针做地址扫描唯一的出路就是不要用裸指针直接引用对象用句柄handle间接引用。句柄方案很简单分配一块表每行记录一个对象的实际地址和大小。程序里所有业务代码拿到的都是一个整数索引而不是指针访问对象时通过句柄表转一道obj pool.handle_table[index].ptr-data。要搬移一个对象时只需要在表里改一下地址业务代码下次访问时自然就用了新地址。代价是每次访问多一次间接跳转但这个开销比全进程地址扫描小得多。其实很多框架级系统早就这么干了。早年 Windows 内核句柄表很多嵌入式 UI 库的控件句柄都是防止对象被物理移动后句柄失效的典型做法。在服务端 C 里用这个方案等于把“指针稳定性”从对象所有权问题中剥离出来。4.2 一个最小可运行的设计我本地搭过一个 demo目标很简单内存池只负责分配固定大小的 slot比如 64B、256B、1KB 几档每档一块区域整块区域在初始化时就预留一个大虚拟地址空间mmap预留但不提交这样后面搬移只需要改句柄表不需要改业务指针。搬移算法用的是经典的两遍式第一遍扫描句柄表里所有存活对象把每个对象目标位置算出来按 slot 顺序排紧。第二遍从区域末尾往前搬避免覆盖还没搬的数据每次搬完后更新句柄表里的地址。全部搬完后再把区域后端一大块空闲池重置。伪代码大致是这样的简化typedef struct { uint32_t slot_size; uint16_t type_id; uint8_t active; char *addr; } handle_entry; void compact_pool(handle_pool *pool) { // 第一遍计算每个存活对象的新偏移 size_t cursor 0; for (int i 0; i pool-handle_cap; i) { if (!pool-handles[i].active) continue; char *new_addr pool-base cursor; pool-handles[i].new_addr new_addr; cursor pool-handles[i].slot_size; } // 第二遍从区域尾部向前搬移防止覆盖未搬数据 for (int i pool-handle_cap - 1; i 0; i--) { handle_entry *h pool-handles[i]; if (!h-active) continue; if (h-new_addr ! h-addr) { memmove(h-new_addr, h-addr, h-slot_size); h-addr h-new_addr; } } // 释放后端空闲区 size_t used cursor; size_t remain pool-region_size - used; madvise(pool-base used, remain, MADV_DONTNEED); }实际上memmove本身不能保证处理搬移时目标地址覆盖问题但因为我们从尾部往前搬且每个对象目标位置都比旧位置靠前只要保持一致方向就不会覆盖到未处理数据。这句并不复杂却是整个压缩算法容易出错的地方。4.3 关键取舍什么时候搬、搬多少搬移本身需要时间。我的建议是不要在请求热路径上全量整理而是定一个“碎片率阈值”“后台空闲期执行”的组合策略。比如每隔 30 秒采样一次空闲区统计判断内存池利用率小于 60% 且已经连续出现过 3 次则在epoll_wait的空闲分支里分批搬移一定数量的对象每次搬移控制在 0.5ms 以内避免阻塞请求处理线程。分批搬移有个复杂点增量搬移过程中句柄表里的对象可能被业务线程同时访问。这是所有“在线整理”都要面对的问题。最简单可靠的方案是整理期间带上读写锁或者用原子标记把对应对象暂时置为“搬移中”。搬移期间访问的线程会看到句柄指向一个临时迁移状态自旋等一会儿再重试。如果项目允许就只做全量停顿版本把整理窗口放在流量低谷能避免掉 90% 的并发问题。4.4 一个深层限制只能整理自己分配的内存自建内存池最大的限制是你只能整理池里的对象。业务代码里如果还散落着直接 new、malloc 出来的对象或者第三方库内部有自己的缓存池外碎片依旧存在。我见过某系统花了大力气做了池内搬移最后 RSS 下降不明显一查是另一个无锁队列缓存了海量消息对象跟池毛关系没有。所以动手之前建议先用 heap profiling 工具统计一下各类内存来源占比确定碎片确实集中在自己的池里再做整理方案。否则就是典型的“在错误的楼层打扫卫生”。5. 落地时的测量方法、常见误区和我的最终建议5.1 正确测量碎片率别光看 RSS做任何优化前先量化。Linux 上最粗糙的指标是 RSS但它包含共享库、匿名页、文件页不能直接代表堆碎片。想拿到堆内的分配器统计glibc 系用mallinfo2()看uordblks程序实际占用和fordblks堆内空闲字节数。把fordblks占总堆大小的比例当作外部碎片率参考。jemalloc 系调用malloc_stats_print(NULL, NULL, NULL)解析active、dirty、mapped等字段。mapped和active的差值就代表分配器持有但未使用的空间。/proc/pid/smaps可以看各虚拟区间的Rss和Pss用来区分堆与 mmap 区域的物理占用。合理的碎片率定义是 FragRatio 1 - (max_free_block_size / total_free_size)它比单纯“空闲内存占比”更准。因为碎片问题的本质不是空闲内存多而是没有一个足够大的连续块。空闲块数量越多、最大块越小碎片越严重。5.2 “RSS 降不下来”不等于整理失败很多人整理完看 RSS 没下降就判定方案无效。但 RSS 下降是有条件的整理后腾出的虚拟地址必须被分配器madvise(MADV_DONTNEED)或munmap真正归还并且内核在后续内存压力下才可能把对应物理页释放。分配器往往保留缓存页比如 jemalloc 的 decay 机制默认不会立即 purged。所以验证方案的正确姿势是整理前后对比同一负载下的mallinfo2的 fordblks 最大连续块大小、分配失败次数、以及压测中的延迟分布。RSS 只作为长期趋势参考不能作为短期验收指标。我还踩过一个坑在服务峰值半小时前开始整理整理过程占用 CPU把请求 P99 拉高了 30%然后 peak 时段 RSS 确实下降了 10%但对这个系统根本没有意义。整理这种收尾工作放在低峰期做才有价值宁可拉长时间跨度也不要牺牲高峰可用性。排错时也见过有人在共享内存中让对象句柄被外部模块直接使用一旦搬移外部模块访问时拿到的是旧地址整个出现脏指针问题。因此句柄模式适合内部封闭使用对外暴露 API 时一定要加一层适配协商好迁移期间的行为。5.3 什么时候根本不用做碎片整理最后说点泼冷水的。如果服务是短进程秒级、分钟级退出重启、或者堆内存总量很小几百 MB 以内、或者内存富余到可以随意扩碎片整理基本是负收益。整理占用的 CPU、停顿、复杂度都算迁移成本。另外如果碎片源头是内部碎片比如 size class 过大而不是外部碎片整理帮不上忙应该改分配策略或压缩对象结构。做这类优化的判断顺序我个人的经验是先测量各类碎片占比看有没有分配器配置可调再看热点对象能不能合并或改成 arena 私有缓存最后才考虑重写内存池和搬移算法。顺序反了经常做了一堆复杂机制收益却趋近于零。我最终给那台服务的方案其实分了三步把几个热点小对象改成一次性批量预分配减少交错调低 jemalloc 的 dirty page decay让空闲页更快归还然后写了一个离线压缩脚本在每天凌晨低峰期重排一次自建池。效果是碎片率从 38% 降到 11%内存高峰从持续紧张变成留有余量代价只是几十行代码和少量配置。关键不是方案多炫而是搞清楚那部分内存是谁产生的、整理完之后能不能被系统真正回收。