内存碎片这个词,做后端和 C/C++ 的同学应该都不陌生,但真正把它和老 SD 的服务、大数组反复扩容、buffer 频繁分配这些场景联系起来,往往是在线上跑了很久之后才突然“炸”出来的。我前阵子排查一个老服务,表现是:RSS 不算高,但每次需要分配 2MB 连续对象时就开始卡顿,偶尔直接分配失败,吓得我赶紧去翻堆栈和分配器状态。最后根本不是内存泄漏,而是大对象与数组在堆里制造了大量无法合并的小孔。这篇文章我把这些年踩过的坑和几套有效方案整理出来,从分配器原理讲到容器层、对象池和 arena 的落地写法,不需要你有内核背景,只要平时写 Java、Go、C++,或者经常和数组、buffer 打交道,应该都能直接用上。
1. 先看清问题:大对象与数组为什么会成为碎片的“重灾户”
1.1 碎片到底是什么:别把内存浪费和空洞搞混
内存碎片其实分两种,一种是内部碎片,一种是外部碎片,网上很多文章把这两个概念混在一起讲,导致实际排查时思路容易乱。
内部碎片说的是:某次分配请求是 5KB,但分配器给你的是一个 8KB 的块,这多出来的 3KB 在块里面闲着,谁也拿不到。这种浪费主要来自 size class 的对齐取整、内存对齐策略,还有你自己定的对齐要求。比如 C++ 里你用alignas(64)定义一个数组,每个元素都按 64 字节对齐,那么实际占用的空间会比逻辑大小多出不少。
外部碎片才是标题里最要命的东西:整块堆内存看似还有很多可用字节,但空闲区域被切成了一个个分散的小孔,每个小孔单独拎出来都不够满足一个连续的大数组请求。拿停车来类比,一个停车场里零散空着十几个车位,加起来容量不小,但你想停进一辆加长大巴,停不了,因为没有任何连续空间能容纳它的长度。
数组和大对象之所以容易撞上外部碎片,是因为它们的分配特征是“要一整块连续内存”。你申请一个大数组,分配器必须在堆里找到一块足够大的连续空洞,找不到就失败,哪怕堆里零散空闲的总量远远够用。
1.2 动态数组扩容是怎么一步步制造碎片的
动态数组几乎是每个语言的基础工具,但只要你不注意它的扩容机制,它就能不动声色地让你的内存变得越来越碎。
以 C++ 的std::vector为例,它的常见扩容因子是 2 或 1.5。当元素数量超出当前容量时,会执行四步:申请一块更大的新内存,把旧元素逐个拷贝过去,析构旧元素,释放旧内存块。这看起来没什么问题,但在高频场景下,旧内存块被释放后会以“一整块空闲区域”的身份回到堆里。如果这块内存不够大,或者分配器没办法把它与相邻空闲块合并,它就可能被后续的小分配请求切碎。
Java 的ArrayList扩容因子是 1.5,Go 的 slice 在容量小于 1024 时按 2 倍扩容,之后按约 1.25 倍增长,底层逻辑都差不多。一旦频繁触发扩容,旧的大块内存被释放,紧接着又有很多大小不一的对象被分配,这个大块很快就会被“切”成很多小块。时间一长,之前那些几 MB 的大空洞都不会存在了,全被中间穿插的小对象占据。
这里有一个容易被忽略的细节:释放旧数组的时机也影响碎片。比如在 C++ 写:
std::vector<Foo> v; for (int i = 0; i < 100000; ++i) { v.push_back(Foo()); }如果没提前reserve,这段代码会反复扩容、释放旧块,期间堆里会积累大量大小呈倍比关系的空闲块。更危险的是,如果同时还有别的大数组在申请内存,这两个操作会互相把对方需要的“大空洞”打散。
1.3 生命周期错配:为什么“分配-释放-分配”循环会越跑越碎
碎片产生的根本原因可以用一句话概括:对象的分配大小和生命周期没有规律可循。
假设堆一开始是一大块空闲空间,程序依次做这些操作:分配 1MB 数组 A,分配 64KB 数组 B,分配 32KB 数组 C,释放 A,分配 768KB 数组 D,释放 C,分配 40KB 数组 E。整个过程下来,堆里虽然有差不多大小的空闲总量,但 1MB 原来所在的位置被 D、C 的残留和 E 的碎片分割成了好几块。只要再来一个需要 1MB 连续空间的请求,就会失败或触发额外的堆扩展。
更有意思的是,很多程序并不是一开始就这么碎。刚启动时堆是一片连续的 top chunk,分配器可以像切豆腐一样往后切,直到某些块被释放后插入了不同大小的新分配,堆才开始“长刺”。所以这类问题往往是运行几小时甚至几天后才爆发,这也给排查增加了迷惑性:年轻人的服务通常没事,老服务突然出问题,第一反应是泄漏,结果查了半天不是泄漏。
注意:大对象和数组对碎片的敏感度不一样。几 KB 的小对象即便碎片化,分配器用大小类缓存也能勉强拼接;而大数组只要找不到连续空洞,就立刻显现为失败或延迟。
2. 内存分配器视角:从 malloc 到大数组请求发生了什么
2.1 glibc malloc 的 bins 策略和伙伴系统的局限
到了分配器这一层,我们才能真正理解为什么“明明总量够,却分配不出来”。
glibc 的malloc本质上是一个基于 chunk 的空闲链表分配器,内部根据块大小划分了 fastbins、small bins、large bins 等。小尺寸释放的块会进入对应的缓存桶,同尺寸的块可以被快速复用;大尺寸请求则会在 large bins 里查找,找不到就去 top chunk 切一块,或者触发brk扩展堆。
这种设计的优点是快,但弱点是:块一旦被分割,只有邻居都空闲并且分配器主动做合并时,才能重新变成大块。合并需要检查前后相邻 chunk 的标记位,实际代价并不高,但很多场景下分配器出于性能考虑不会频繁做合并扫描,碎片就这样积累下来。
伙伴系统是另一种常见思路,很多操作系统页分配器用它。系统把内存按 2 的幂次分成块,每次分配时把大块逐级切半,释放时再尝试与相邻的“伙伴”合并。它的内部碎片最多可能浪费 50%(比如申请 3 个页,实际给你 4 个页),外部碎片则表现为:一堆不同幂次的空闲块,彼此不是伙伴关系,永远无法合并成大块。数组申请通常大小任意,尤其容易踩中这种粒度的错配。
2.2 大对象分配有没有“免死金牌”?mmap 阈值的作用
很多人以为大对象直接走mmap就不会有碎片问题,这个认知只对了一半。
glibc 有一个MMAP_THRESHOLD阈值,默认约 128KB。超过这个阈值的分配请求会被mmap单独映射一块匿名内存,释放时直接munmap还给操作系统,不进堆管理。所以 1MB、10MB 的大数组在 C/C++ 里其实不太容易造成进程堆碎片,反而是 16KB、32KB、64KB 这种“不上不下”的中等大小块最危险:它们大多还没到 mmap 阈值,必须在堆里分配,又不像小对象那样有丰富的同尺寸复用桶,一进一出很容易把大洞切开。
在某些新的 glibc 版本里,MMAP_THRESHOLD会被动态调整:如果频繁释放大块,系统会把阈值提高,让更多块留在堆中复用。这会导致一个隐蔽现象:同一份代码,在不同 glibc 版本下碎片表现完全不同。排查时如果发现相同业务在一个环境没问题、另一个环境问题严重,可以先查一下分配器的阈值状态。
2.3 替换成 jemalloc / tcmalloc 为什么立竿见影
如果你没有时间改业务代码,最快的验证手段就是换一个现代分配器,比如jemalloc或tcmalloc。它们从设计上就在对抗碎片:
- jemalloc 将内存划分为多个 arena,每个线程优先绑定一个 arena,减少锁竞争。堆内部按 size class 组织成 runs,同一大小的分配聚集在一起,避免了不同大小块互相穿插。对超过一定阈值的大对象直接走 mmap,释放后归还系统。
- tcmalloc 为每个线程准备了一个 thread cache,小分配几乎不碰全局锁;central cache 和 page heap 负责更大块,通过 span 管理内存页,按需分配并保持页级对齐。
用LD_PRELOAD=/path/to/libjemalloc.so启动同一个程序,有时候问题直接就消失了,因为碎片根本来不及积累。但这只能算“缓解”,真正的长期方案还是要在应用层控制分配行为。
经验:遇到莫名的内存分配失败,先别急着分析业务逻辑。用 jemalloc 替代跑一轮,如果现象消失,说明至少一半的责任在系统分配器与业务分配模式的配合上。
3. 优化方案落地:从容器预分配到自定义内存池
3.1 容器层的第一道防线:预分配与原地操作
“不要重复扩容”这句话我已经说过无数遍,但它确实是成本最低、收益最大的优化。
C++ 里尽量在一开始就设定容量:
std::vector<int> v; v.reserve(1000000); // 一次性申请足够容量 for (int i = 0; i < 1000000; ++i) { v.push_back(i); }Java 里对应写new ArrayList<>(1000000),Go 里写make([]int, 0, 1000000)。这样旧缓冲区不会被反复释放和重新申请,堆内存的格局保持稳定,也就避免了扩容带来的“大洞被切碎”链条。
同样的逻辑适用在数组的中间操作。如果你需要对一个大数组做去重、排序、提取子集,优先选择原地算法或返回视图的方式。JavaScript 的Array.prototype.sort()只是原地排,而filter、splice某些用法会生成新数组;Python 的sorted()会创建一个新列表,但list.sort()是原地排序。C++ 里对 vector 做去重常见做法是std::sort+std::unique,也应尽量复用原有存储,不要每轮循环都重新生成临时 buffer。
还有一类常见操作是切片。Python 的列表切片会复制出一个新列表,numpy 的基本切片是视图,但某些高级索引仍会复制数据。如果在一个长循环里反复对大数组做切片,中间数组会和主数组交错分配,内存峰值会涨得很难看。能用memoryview或视图就尽量用视图,能复用中间 buffer 就复用。
3.2 固定大小对象池:消除同尺寸对象的分配抖动
对于频繁创建销毁的大对象或中等数组,最简单有效的方法就是固定大小池。
逻辑其实很朴素:预先向系统申请一大块内存,把它切成 N 个固定大小的槽位,用一个空闲链表串起来。每次分配从链头取一个槽,释放时把槽放回链头。由于所有槽大小一致,释放的槽可以被任意同尺寸请求复用,不会产生大小错配的碎片。
一个最小实现思路如下:
class FixedBlockPool { public: FixedBlockPool(size_t blockSize, size_t count) : blockSize_(blockSize) { if (blockSize_ < sizeof(void*)) blockSize_ = sizeof(void*); // 对齐到最严格类型 blockSize_ = (blockSize_ + alignof(std::max_align_t) - 1) & ~(alignof(std::max_align_t) - 1); base_ = ::operator new(blockSize_ * count); for (size_t i = 0; i < count; ++i) { void* p = static_cast<char*>(base_) + i * blockSize_; *(reinterpret_cast<void**>(p)) = freeList_; freeList_ = p; } } ~FixedBlockPool() { ::operator delete(base_); } void* allocate() { if (!freeList_) return nullptr; void* p = freeList_; freeList_ = *(reinterpret_cast<void**>(p)); return p; } void deallocate(void* p) { *(reinterpret_cast<void**>(p)) = freeList_; freeList_ = p; } private: size_t blockSize_; void* freeList_ = nullptr; void* base_ = nullptr; };代码里有两个容易踩的细节。第一个是blockSize必须至少能存下一个指针,否则空闲链表没法串起来;第二个是对齐,分配出来的每块内存都要满足最严格对齐要求,否则放类对象或结构体时会未定义行为。上面的实现把块大小向上取整到max_align_t对齐,可以规避大部分问题。
使用池的时候也要注意:对象池解决的是外部碎片,不是内部碎片。如果你池化 4KB 大小,但实际业务数据平均只有 3.5KB,每块浪费 0.5KB,这属于内部碎片。合理做法是把高频对象按尺寸分成几个池,或者选一个能覆盖大多数请求的规格,而不是盲目统一到一个大尺寸。
3.3 Arena 线性分配:把同生命周期的大对象打包管理
如果一组大对象或数组的生命周期一致——比如一次网络请求的处理、一帧渲染的临时数据、一批批量任务的中间结果——用 arena 效果比对象池更稳。
arena 的核心思想是一次性向系统拿一大块连续内存,然后内部用一个“当前偏移指针”做线性分配。每个对象挨着排,不存在空闲链表、大小类这些概念,所以外部碎片几乎为零。释放的时候可以逐个说“我不需要了”,也可以直接把整个 arena 标记为重置,下一次分配从头开始。
一个简单的 bump allocator 可以长这样:
class Arena { public: Arena(size_t blockSize) : blockSize_(blockSize) { ptr_ = static_cast<char*>(::operator new(blockSize)); end_ = ptr_ + blockSize; } void* alloc(size_t size, size_t alignment = alignof(std::max_align_t)) { uintptr_t p = reinterpret_cast<uintptr_t>(ptr_); uintptr_t aligned = (p + alignment - 1) & ~(alignment - 1); if (aligned + size > reinterpret_cast<uintptr_t>(end_)) { return nullptr; // 需要额外扩展块,这里从略 } ptr_ = reinterpret_cast<char*>(aligned + size); return reinterpret_cast<void*>(aligned); } void reset() { ptr_ = reinterpret_cast<char*>( (reinterpret_cast<uintptr_t>(base_) + alignof(std::max_align_t) - 1) & ~(alignof(std::max_align_t) - 1)); } private: size_t blockSize_; char* base_; char* ptr_; char* end_; };实际工程里 C++17 可以直接用std::pmr::monotonic_buffer_resource配合std::pmr::vector达到类似效果,没必要手写。但理解原理很重要:arena 的代价是,即使单个对象已经没有价值,只要 arena 还活着,内存就不会被还给系统。所以它只能用在批量生命周期清晰的场景,不适合长期存活的容器混用。
用 arena 还有一个额外好处:缓存局部性大幅提升。同一时间创建的大对象在物理内存上挨着,访问完第一个再访问第二个时,cache line 命中率高很多,这也是性能提升的重要组成部分。
3.4 业务层的再设计:避免大对象和小对象交错分配
分配器和容器是最底层的手段,但业务层的分配模式往往起着决定性作用。一条非常重要的原则是:不要让短暂生命周期的小对象夹在大数组之间频繁分配。
举个例子,你有几个长期持有的大 buffer,同时有一个日志系统会频繁分配几 KB 的小字符串。两件事本身都没问题,但它们的分配交织在一起就很麻烦:大 buffer 释放后,那块连续区域会被小日志对象切得支离破碎,等下一次你需要同样大小的大 buffer 时,分配器找不到连续空间了。
解决方案有两种。第一种是把大 buffer 像前面那样池化或 arena 化,让它们不在系统堆里反复进出;第二种是给小对象自己也建一个池,让它们的生命周期在一段段连续区域内完成,而不是在堆里随处穿插。实际操作里,我见过把日志缓冲、网络收发 buffer、编解码临时数组统一放进一个预分配的FixedBlockPool后,内存碎片问题直接清零的情况。
还有一个容易被算法题思维带偏的坑:在做“子集和”“区间最大值”“数组匹配”这类需要辅助数组的算法时,很多人习惯每次调用都new一个辅助数组。短时间频繁调用,尤其当辅助数组大小不一、又有外部大对象存在时,堆会快速碎片化。更好的做法是调用方传入可复用的临时数组,或者用线程局部数组实例。这在写服务端代码时尤其重要,不能有“做完一次就不管”的心态。
3.5 各语言环境的差异化策略:Java、Go、JavaScript、Python
虽然标题核心是堆内存,但不同语言的实际解决姿势差异挺大。
Java 里,JVM 对“大对象”有自己的定义。并行收集器下有个-XX:PretenureSizeThreshold参数,超过阈值的大数组直接分配在老年代,避免在新生代反复拷贝晋升;但在 G1 里,对象超过 region 大小的 50% 就会变成 “humongous object”,需要连续分配多个 region。频繁创建超大数组会带来明显的 GC 压力:humongous 对象分配可能提前触发 Full GC,而回收后如果占用的是巨大的一段连续区域,又会形成老年代碎片。应对手段主要是两个:用池化技术复用大数组,以及调大 G1 region 大小(-XX:G1HeapRegionSize)以减少 humongous 对象的线程。
Go 的分配器整体已经做了 size class 分级,32KB 以下的小对象基本由 mcache 和 mspan 管理,碎片问题比 C/C++ 轻很多,但大量临时大[]byte还是会让 GC 频繁启动,RSS 居高不下。推荐用sync.Pool复用 buffer,或者预分配大切片后在业务里切片复用,而不是每次都make([]byte, n)。
JavaScript 里数组本质上是对象,V8 的老生代也会面临碎片问题。写 Node.js 服务时,尽量避免在请求循环里用filter、map创建超大临时数组,能用索引读写、能原地排就原地排。V8 对数组空间的管理比较隐晦,但减少中间大数组生成总是对的。
Python 更需要注意 list 的过度分配机制。它扩容时也是按比例预留余量,频繁 append 的代价不只是时间,还有内存峰值。对 numpy 大数组做高级索引切片时,如果触发了复制,就等于同时存在原数组和临时数组两份大内存。把原地排序、视图切片、复用中间 buffer 形成习惯,对长驻服务有实际帮助。
4. 常见问题与排查技巧实录
4.1 如何证明是碎片而不是内存泄漏
判断碎片问题最怕的就是和泄漏混淆。我的排查路径一般是这样:
先用系统统计看整体趋势。Linux 下读/proc/<pid>/status里的VmRSS和VmSize,再看VmData的变化。如果 RSS 持续高位,但程序已知的所有大数组只占了其中一部分,就有碎片或分配器缓存的嫌疑。
再用 glibc 提供的malloc_info()输出分配器内部状态,能看到total free bytes、fastbin、bins 里的块数量。如果total free bytes很大,但你的大数组请求还是失败,基本可以断定是“有用字节无法凑成连续块”。
Valgrind 的 massif 工具是另一个利器,它可以把堆上的分配点按栈快照组织起来,直观看到到底是哪些调用点持有大量内存,以及有哪些块长期没释放。massif 慢,但定位准。
最后,如果观察到进程 RSS 高、分配失败,但重启就好转、跑几天又复发,且概率随业务波动,十有八九就是碎片。这时用LD_PRELOAD换 jemalloc 跑一轮,如果现象消失,证据链就完整了。
注意:不要把分配器缓存当作泄漏。glibc 释放大块后可能并不归还操作系统,而是留着复用;jemalloc 也可能在 arena 里缓存内存。这本身不是错误,只是表现形式像泄漏。
4.2 实际工作中踩过的三个典型坑
第一个坑是我自己早期写批处理程序时踩的。有个模块要从一个大文件里读记录,存进vector,处理完再清空重复做。起初没提前reserve,结果每秒几十次扩容,每次扩容都申请 2 倍内存并释放旧的。跑了一阵后 RSS 是理论峰值的约 3 倍,程序还变慢了。后来改成一次性reserve,RSS 立刻下降,处理时间也稳定了。核心原因不是内存总量,而是反复扩容释放导致堆里积累了大量大小不一的空洞。
第二个坑是一个长时间运行的消息处理服务,它需要不定长的大数组保存报文。平时 64KB 的 buffer 频繁创建和销毁,偶尔一次需要 1MB 时开始失败。查下来发现 64KB 的 buffer 被释放后,正好散落在 1MB 大洞中间,分配器又没能及时合并。最后没有改业务逻辑,直接给服务挂了 jemalloc 并调优,问题消失。这类问题结论很快,但如果你怀疑是泄漏去查,会浪费几天。
第三个坑在医院项目里出现过:G1 下频繁创建超大数组,每次对象大小超过 region 的 50%,导致高并发时不断触发 humongous 分配和 Full GC,服务经常出现秒级停顿。当时没有改业务,裁掉了不必要的超大临时数组,改为用ThreadLocal复用 buffer,Full GC 频率立刻降下来。这让我意识到:所谓“大对象”问题,优化点不一定在对象本身,而在“避免反复创建”。
4.3 碎片优化避坑速查表
| 场景 | 推荐做法 | 不推荐做法 | 预期收益 |
|---|---|---|---|
| 动态数组反复扩容 | 提前reserve/ 指定容量 | 放任自然扩容 | 减少旧块释放和复制,内存峰值降低 |
| 同生命周期的大数组批量存在 | Arena 或 PMR 单调池 | 每个数组分别 malloc/free | 外部碎片归零,局部性提升 |
| 高频同尺寸对象/数组反复创建销毁 | 固定大小块池 | 直接依赖通用 malloc | 避免同尺寸空洞被切散 |
| 大批量数据处理中出现临时数组 | 复用中间 buffer / 原地算法 | 循环内反复 new 辅助数组 | 降低分配次数和峰值 |
| C/C++ 服务出现莫名大块分配失败 | 尝试 jemalloc/tcmalloc 验证 | 只改业务代码,忽略分配器 | 快速定位和缓解分配器层碎片 |
| Java 服务频繁大数组分配导致 Full GC | 池化大数组、调整 region | 每次请求都 new 超大规模数组 | 减少 GC 停顿与老年代碎片 |
| Go/JS/Python 内存峰值异常 | 用 Pool 复用 buffer、减临时数组 | 随手创建中间切片/数组 | 控制峰值,避免频繁 GC 与碎片 |
排查时还有个实用小技巧:如果程序里有大量的“数组排序、数组去重、提取子集”这类操作,先确认它们是否生成了太多临时数组。我见过有人对大数组做一遍sorted()再原样赋回,白白多占一份大内存,改掉之后内存峰值直接降了一半。
4.4 最后的经验之谈
做了这么多次内存优化,我最深的体会是:内存碎片不是一个“单点问题”,而是一个由容器策略、分配器选择和业务生命周期共同塑造的“系统行为”。它不会在你写第一版代码时就出现,而是随着运行时长和业务模式一点点累积,等到连续内存需求出现时才爆发。所以与其等线上出问题,不如在代码里建立几个习惯:所有动态数组先给合理容量;所有生命周期清晰的大对象批次进 arena;所有高频同尺寸对象进固定池;所有临时中间数组尽量复用。
如果你现在正被某个内存问题折磨,我的建议是先别急着优化业务算法,花十分钟看一眼分配器状态,再做一个“换 jemalloc 试试”的小实验。很多时候答案立等可取,而且会帮你把真正的问题锁定在分配层还是业务层。这套方法论我用过很多次,确实比盲目重构靠谱得多。