ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

堆内存碎片:大数组分配失败的隐形杀手与优化实践

堆内存碎片:大数组分配失败的隐形杀手与优化实践 你有没有遇到过这种情况服务明明没有泄漏内存却一天天涨上去最后连一个几十 MB 的数组都分配不出来大多数人的第一反应是查泄漏但有一种更隐蔽的原因——堆内存碎片。大对象和数组一旦在堆上频繁分配释放很容易把完整的地址空间切碎。这篇博客记录我排查这类问题的完整思路并给出一套可落地的优化方案不只讲原理还会给代码、工具和参数。这篇文章会涉及 C/C、Java、Python、VBA 等常见环境但重点在于通用底层机制。适合后台服务开发、客户端开发、以及需要处理大数据量的脚本同学参考。如果你曾经怀疑“内存泄漏”但一直没查到指针问题建议把碎片也加入怀疑清单。1. 堆内存碎片到底是什么为什么和数组过不去1.1 两种碎片内部碎片与外部碎片堆内存碎片通常分两类。内部碎片是分配器为了满足对齐和元数据管理给每个请求分配的块比实际请求大一点多出来的那部分就是内部碎片。比如你请求 66 字节分配器按 16 字节对齐可能给你一个 80 字节的块多出的 14 字节当前无法使用。这类碎片单个看很小但分布到几十万次分配里浪费量很可观。外部碎片更致命。它发生在多次 allocate/free 之后堆上出现大量不连续的小空闲块。每个小块的尺寸都满足不了新的分配请求但把它们加在一起总空闲空间其实非常大。你可以把堆想象成一个停车场车开走后留下一块块半米宽的空隙现在开进来一辆大客车空隙的总宽度足够但没有一段连续长度能容纳客车结果就是分配失败。malloc/free 这类通用分配器通常会维护空闲链表分配时按 first-fit 或 best-fit 找块。外部碎片严重时每次分配要遍历很长的链表本身也拖慢性能。1.2 碎片不只是“浪费内存”很多人低估了碎片的连锁代价。第一分配失败。尤其是需要连续大块内存的数组碎片直接导致 new/malloc 返回失败或者抛出 std::bad_alloc、OutOfMemoryError。这不是浏览器在危言耸听是地址空间被切碎后的必然结果。第二RSS 虚高。因为主堆里堆着大量无法合并的小空闲块分配器满足不了后续请求时只能向操作系统申请新的内存段。结果就是合计空闲内存越来越多进程 RSS 持续上升看起来像泄漏实际是碎片。第三局部性恶化。访问相邻分配的对象时碎片化导致对象散落各处CPU 缓存的命中率会下降。这类性能问题很难调因为它不体现在火焰图里而是让一切变慢一点。所以碎片问题的本质不是“浪费了几个字节”而是“让堆失去了可预测性”。1.3 为什么大对象和数组尤其容易被碎片击中数组天然需要连续内存。new int[1024 * 1024]意味着分配器必须找到一段至少 4MB 的连续空闲区域。外部碎片最擅长破坏这种请求。动态数组的扩容还会加剧碎片。vector 或 ArrayList 在容量不够时会申请一块更大的连续内存把旧数据拷贝过去再释放旧块。如果旧块周围还散布着其他小对象释放出来的区域会被拆成不规则碎片下一次想找到足够连续的大块就难了。大对象的分配策略也特殊。不同分配器对超过阈值的分配有独立处理比如 glibc 的 malloc 对超过 128KB 的请求默认走 mmap而小对象走主堆。但引入 mmap 不是免死金牌如果阈值设置不合理或者大对象本身生命周期很短频繁的 mmap/munmap 会导致页表抖动和性能下降。2. 看看不同语言中的大对象与数组是怎么占内存的2.1 C/C从静态数组到动态容器C 语言里普通数组、malloc 出来的数组、以及二维数组本质都是一段连续内存。int a[100][100]其实是一整块 40000 个 int 的连续空间不是 100 个指针的数组。很多人被 a[i][j] 的语法误导以为内存也是指针套指针实际并不是。C 里std::vector内部维护一个连续缓冲区。它会因push_back自动扩容常见策略是按 1.5 或 2 倍扩容。每扩容一次就会先分配新内存、拷贝元素、再释放旧内存。如果你在循环里高频push_back又不提前reserve内存碎片的生成速度会非常快。std::string也是一个动态字节数组。大多数实现使用 SSO短字符串优化短字符串直接存在栈上但长字符串需要从堆上分配连续缓冲区。大量不同长度的 string 频繁创建销毁也会把堆切割得七零八落。关于“字符串数组初始化”注意区分两种情况const char* arr[] {a, b}里字符串字面量存在只读数据段arr本身可能是栈上的指针数组而std::string arr[10]存的是 string 对象每个对象的动态缓冲区才在堆上。如果这些 string 长度差异很大堆上会形成大量尺寸不一的小块。内存碎片问题在 C/C 中最直接因为一切都由分配器管理。2.2 Java/C#引用数组与大对象堆Java 的数组本身也是对象。基本类型数组如int[]在内存中是连续的基本类型数据对象数组如Object[]则是连续的对象引用真正对象散落在堆中。所以即使你创建一个很大的Object[]也只是引用区连续引用指向的对象依然会碎片化。JVM 对大对象有特殊处理。在 G1 GC 中如果对象大小超过 Region 的一半就被称为 Humongous Object必须使用连续的一组 Region 存储。老年代内存碎片化时分配这种大数组往往会触发一次 Full GC暂停时间很长。这也解释了为什么 Java 服务偶尔 Full GC 后内存还是不够分配。C# 有独立的大对象堆LOH超过 85KB 的对象会直接进大对象堆目的是避免频繁与大对象复制引发的小对象堆碎片。但 LOH 本身也会碎片化所以 .NET 里依然要谨慎创建大型数组。一个非常实用的建议能用int[]就别用Integer[]能用byte[]就别用Byte[]。包装类型数组会多一层对象引用对象散了内存自然容易碎片。2.3 Pythonlist 为什么也会产生内存碎片Python 的list底层是PyObject**也就是一个连续存储对象指针的数组。这个“指针数组”本身也需要连续内存。当你反复append触发扩容时底层会重新分配并复制指针数组旧数组释放后形成空洞。但 Python 的对象通常散落在堆各处所以即使list引用区是连续的整体内存布局依然松散。当 list 非常大的时候引用区本身就可能需要几百 MB 的连续内存碎片化堆里就容易 MemoryError。Python 的array模块和 NumPy 数组则是真正的连续内存块。它们更接近 C 数组节省内存但也更要求“一次性找到足够连续空间”。NumPy 做大型矩阵乘法时会为中间结果创建多个临时连续数组这些大块临时数组反复分配释放同样会造成堆碎片。tracemalloc是定位 Python 内存问题的好工具。它可以记录每个分配发生的调用栈并对快照做 diff能很快看出哪些大数组没有及时释放。2.4 脚本生态Excel/VBA 的数组陷阱热搜词里出现“excel 提取前两列匹配的数据成一个数组”这是一个很容易踩内存坑的场景。VBA 的数组是基于 Variant 的一个两列几万行的数组如果通过循环一个单元格一个单元格写会产生大量临时 Variant内存占用直线上升。我见过更夸张的写法先用循环把列读入数组再用Application.Transpose转置转置本身会创建一个新数组并复制全部数据。如果源数据是十万行转置一次就是一次大对象分配。再配合后续匹配逻辑临时数组可能同时存在多个。可靠的方案是用Range.Value2一次性把目标区域读入一个二维 Variant 数组内存里只保留这一份“连续矩形”处理完再一次性写回。不要频繁创建中间数组也不要依赖 Transpose 完成数据变换。脚本语言的优势是灵活但代价是隐藏了大量内存复制一旦数据量大起来碎片问题会被放大。3. 先下手为强定位碎片还是泄漏3.1 用系统工具和统计接口看堆状态在 Linux 上我会先快速看一眼几项数据。/proc/self/statm能看到进程整体内存size 是虚拟内存大小resident 是物理内存占用RSSpmap -x pid能看到具体段特别是堆段 [heap] 的大小。如果 heap 段很大但业务没有对应的大数据结构就要怀疑堆内部分配行为。更精细的是 glibc 的mallinfo2glibc 2.33 及以上。示例代码#include malloc.h #include cstdio void print_heap_stats() { struct mallinfo2 mi mallinfo2(); printf(arena%zu\n, mi.arena); // 非 mmap 分配区的总大小 printf(uordblks%zu\n, mi.uordblks); // 已分配块总计 printf(fordblks%zu\n, mi.fordblks); // 空闲块总计 printf(hblks%zu\n, mi.hblks); // mmap 分配的块数 printf(hblkhd%zu\n, mi.hblkhd); // mmap 分配的总字节 }fordblks大只能说明有很多空闲块并不代表这些块是连续的。如果fordblks有 3GB但你连一个 4MB 的数组都分配不出来外部碎片基本实锤。还可以进一步用malloc_info导出分配器内部状态看每个 arena 的 free 列表分布不过输出比较大适合脚本化解析。3.2 写个小实验区分泄漏和碎片有一个简单的现场实验按照下面的步骤跑一段小程序。记录基线内存。分配 1000 个长期保留的小对象比如 64 字节不释放观察 RSS 小幅上升。反复执行“分配 4MB 数组然后释放”循环记录成功次数。如果循环执行到后面开始失败但 RSS 并没有一直增长说明长期对象没有泄漏失败来自碎片。为什么如果存在真正的内存泄漏RSS 会一直增长失败迟早发生但失败是因为地址空间耗尽而不是碎片。如果 RSS 增长到一定阶段后不再增长却出现分配失败外部碎片是主要嫌疑。在线上环境也可以用bcc/BPF追踪任意 pid 的 malloc 返回错误码不过技巧性比较强。大多数人先跑上面的实验就够了。3.3 动态扩容的隐患旧块被切成筛子动态数组反复扩容是碎片制造的经典路径。以std::vectorint为例std::vectorint v; for (int i 0; i 1000000; i) { v.push_back(i); }每次扩容的二进制行为如下假设 capacity 从 1 变 2、4、8…… 到 1048576。每扩容一次分配器都要在堆上找一块更大的连续内存拷贝旧数据再释放旧块。被释放的旧块大小各不一样它们被分散在堆中和别的小分配交错分配器很难把它们合并成完整的大块。解决方法是提前预留std::vectorint v; v.reserve(1000000); for (int i 0; i 1000000; i) { v.push_back(i); }这样整个过程只有一次堆分配内存缓冲区从一开始就是连续的中间没有旧块发布自然没有碎片累积。4. 优化方案从分配行为到设计结构4.1 控制数组生命周期预分配与复用内存碎片的核心诱因是“频繁分配/释放不同大小的对象”。控制生命周期是最直接的手段。能预留就预留。C 的reserve、Java 的ArrayList(int initialCapacity)、Go 的make([]T, 0, n)、Python 的[None] * n都是提前把一次性大块申请好避免多次扩容。能复用就复用。对于高频请求中的临时数组用线程局部存储Thread Local缓存一个缓冲区请求结束不清空而是交还给下一个请求继续使用。类似 Go 的sync.PoolJava 的ThreadLocal缓存C 的thread_local std::vector。释放顺序尽量保持 LIFO。后分配的先释放让空闲块在分配器里更接近连续整体。不要在同一个循环里交替分配 A 和 B然后倒序释放 B 和 A这会把堆切成横竖交错的碎片。4.2 相近大小的对象用内存池或分桶通用分配器的目标是“什么大小都服务好”但代价是管理复杂度高。如果你的热点分配集中在少数固定尺寸直接上内存池。内存池的基本逻辑预先从系统分配一整块大内存切成固定大小的块每次请求只取一块释放时放回。因为每块尺寸完全一样空闲块之间不会出现大小参差不齐的“马赛克”外部碎片几乎为零。C 里可以用boost::pool或mimalloc。更简单的方式是重载 newclass ObjectPool { public: void* allocate() { if (free_list.empty()) { // 向系统拿一大块再切成固定大小 grow(); } void* p free_list.back(); free_list.pop_back(); return p; } void deallocate(void* p) { free_list.push_back(p); } private: std::vectorvoid* free_list; };注意池化不是万能的。如果池的大小和实际对象不匹配会产生新的内部碎片。多线程同时分配时需要给每个线程独立的池或用原子操作保护公共池否则池本身会成为性能瓶颈。4.3 调整分配器参数或直接换分配器glibc malloc 有几个关键参数可以通过mallopt设置也可以通过环境变量MALLOC_MMAP_THRESHOLD_默认 128KB。超过这个大小的分配会直接走 mmap。MALLOC_MMAP_MAX_默认 65536限制 mmap 分区的最大数量。MALLOC_ARENA_MAX多线程时限制 arena 数量。如果检测到大对象比如 4MB 数组频繁分配但都因为主堆碎片失败可以把 mmap threshold 调低到 1MB让大数组直接走 mmap。代价是每次分配和释放会陷入内核改页表如果数组生命周期短性能不升反降。jemalloc 和 tcmalloc 对碎片控制通常比 glibc 好。jemalloc 采用 size class 分级管理8 字节、16 字节、32 字节分别放在独立 page 里tcmalloc 也有线程本地缓存小对象分配不需要加全局锁。生产环境替换分配器后可以明显减少 RSS 和分配延迟。但“换分配器”不是银弹。如果你内存热点是超大型的临时数组jemalloc 帮你解决的问题也很有限还是要从结构和生命周期去治理。4.4 GC 语言中的大对象专门策略Java 方面大数组的分配要尽量“平稳”不要频繁创建大数组用缓存池或复用。G1 GC 下超大数组容易成为 Humongous Object频繁分配会触发 Full GC。一个可行方案是把超大数组拆分成多个中等大小数组避免单个对象跨多个 Region。Python 方面能少分配就少分配。numpy运算时尽量使用out参数让结果写入已有数组不产生新的大临时数组。例如c np.empty_like(a) np.multiply(a, b, outc)这比c a * b少一次临时分配。处理完大数组后马上del掉引用或把obj赋值为None给 GC 更快回收的机会。Go 方面slice预分配容量是老生常谈。另外在写append循环时如果最终长度已知直接make([]T, n)按索引赋值能显著减少扩容次数和堆碎片。4.5 结构设计用分块打破“必须连续”的执念有些业务场景并不要求整个数组是连续线性存储。比如一个 1GB 的矩阵但访问模式是分成若干块处理的那完全可以用“分块数组”。用 C 表示一个分块一维数组class ChunkedArray { public: explicit ChunkedArray(size_t total_size, size_t chunk_size) : chunk_size_(chunk_size) { for (size_t offset 0; offset total_size; offset chunk_size) { size_t cur std::min(chunk_size, total_size - offset); chunks_.push_back(std::vectorint(cur)); } } int at(size_t index) { return chunks_[index / chunk_size_][index % chunk_size_]; } private: size_t chunk_size_; std::vectorstd::vectorint chunks_; };这段代码把一个大数组拆成若干固定大小的连续块。每块独立分配块之间不需要连续。分配器可以轻松找到每块所需的空间整体碎片影响远小于一次分配 1GB。多维数组同样可以用扁平化方案。把a[i][j]映射到data[i * width j]用一维数组存储别用vectorvector...。这样要么不需要连续超大块分块要么只需要一次连续分配能更好地控制碎片。5. 实操一个 C 服务的内存碎片排查与优化5.1 现象RSS 涨上去4MB 数组分配失败我曾经维护过一个后台服务核心逻辑是接收请求、构建多个临时数组、缓存部分大对象。服务能够连续运行多天但 RSS 逐渐膨胀到 8GB 左右并且开始偶发std::bad_alloc。第一个想法是内存泄漏。于是带着 ASAN 编译跑压力测试没有发现“真正”的泄漏每次请求的临时对象到期都释放了。但 RSS 还是居高不下。启动时 RSS 只有 2GB运行 24 小时后稳定到 8GB业务量没有增长。5.2 用 mallinfo 看到了“假象”我在代码里加了mallinfo2打印看到类似下面的数据arena8388608 uordblks4500000 fordblks3888608 hblks120 hblkhd1024000解读一下主堆总面积 8GB已分配 4.5GB空闲块 3.8GB。这个 3.8GB 的空闲空间分布是碎片化的因为服务里需要分配一个 4MB 的连续数组时竟然找不到足够的连续块。随后我又统计了所有分配调用的 size 分布。一种简单方式是临时给operator new加日志统计每个 size class 的次数void* operator new(size_t size) { void* p malloc(size); g_alloc_log[round_up(size)]; return p; }结果显示8KB 到 16KB 的临时结构体数组占比极高4MB 的大数组数量少但每次分配都依赖连续空间。8KB 小块频繁分配释放把主堆切成了无数个 8~16KB 的小空洞4MB 的大块根本无法插入。5.3 三步优化预分配、池化、mmap 阈值第一步把高频的临时数组改成线程本地复用。原来的代码里每次请求都新建一个 8KB 的数组请求结束就销毁。我改成thread_local std::vectorItem temp_buffer; void handle_request() { temp_buffer.clear(); temp_buffer.resize(MAX_ITEMS); // 使用 temp_buffer }这样 8KB 数组只在线程启动时分配一次之后反复复用不再产生小块分配/释放。第二步为另一个频繁分配的对象建了一个固定大小分桶池。对象大小 96 字节不多不少。我用最简单的方式templatesize_t N class FixedPool { public: void* get() { std::lock_guardstd::mutex lock(mutex_); if (freelist_.empty()) { void* block malloc(N * 256); // 一次拿 256 个 char* p static_castchar*(block); for (int i 0; i 256; i) { freelist_.push_back(p i * N); } } void* p freelist_.back(); freelist_.pop_back(); return p; } void put(void* p) { std::lock_guardstd::mutex lock(mutex_); freelist_.push_back(p); } private: std::mutex mutex_; std::vectorvoid* freelist_; };这里 fixed pool 只是演示生产上要按线程拆分避免锁竞争。第三步针对 4MB 数组设置环境变量export MALLOC_MMAP_THRESHOLD_1048576这样 4MB 大数组的分配超过阈值直接走 mmap不再依赖主堆的连续空闲块。而且 4MB 数组生命周期比较长每次分配/释放次数不多系统调用开销可以忽略。5.4 优化前后数据对比我在压测环境用同样的请求量跑了两组对比指标优化前优化后RSS 峰值8.2 GB4.1 GB每日期 bad_alloc 次数23 次0 次mallinfo2.fordblks3.8 GB0.6 GB接口 P99 延迟750 ms600 msRSS 下降近一半分配失败清零。性能提升不是因为少用了内存而是分配器不需要频繁扫描超长空闲链表CPU 缓存更友好。5.5 踩坑复盘第一个坑把MALLOC_MMAP_THRESHOLD_调到了 64KB结果大量生命周期极短的小对象也开始走 mmap每次分配都进入内核修改页表性能暴跌 30%。阈值要根据实际分配分布设置不是越低越好。第二个坑线程本地缓存没有在线程退出时清理。服务用了动态线程池部分线程被销毁时thread_local vector 里还占着内存系统回收不了。后来改成线程复用或者在线程退出的钩子里释放 buffer。第三个坑固定内存池的桶大小算错了。96 字节请求被放进 80 字节的 pool结果越界踩坏了相邻对象。内存池必须严格保证 size 大于等于请求字节数最好加static_assert或启动时检查。第四个坑把二维数组想当然地当连续块释放。代码里用new int[rows][cols]分配的连续块释放时却写了delete[] arr[i]结果堆元数据损坏。这类错误不常出现但出现一次就够让人失眠。6. 常见问题速查数组与堆内存碎片的典型场景6.1 一张速查表以下是典型的“现象—原因—处理”速查都是我实际排查中见过或验证过的。现象主要原因推荐处理C 中 new int[1000000] 失败但空闲内存充足外部碎片导致无法找到连续块预分配、分块数组、调整 mmap 阈值vector push_back 导致性能逐渐下降多次扩容产生碎片和拷贝用 reserve 提前设置容量Java 中使用 ArrayList 大列表内存暴涨对象引用数组连续但对象散落尽量用基本类型数组减少包装对象Python 大 list 出现 MemoryError底层引用数组 realloc 产生碎片用 [None]*N 预分配或改用 array/numpyNumPy 三维数组相乘内存不足多个临时大数组同时存在使用 out 参数、分批计算Excel VBA 提取两列数据内存不足Variant 数组多次复制一次性读入 Range.Value2处理后再写回数组排序导致内存增加排序算法需要额外临时空间使用 in-place 排序、自写非递归快排二维数组使用 vector 内存分散每行独立分配块尺寸不一扁平化一维存储按索引换算多线程服务内存碎片加剧多线程并发分配导致分配器态混乱使用 jemalloc 或线程本地缓存大对象频繁分配释放性能下降mmap 和 munmap 系统调用频繁对象池复用避免反复分配大块6.2 “先测量再调优”内存碎片优化没有一套万能配方。你在网上看到的每个参数调优都是针对特定分配分布的。我一般会先回答三个问题最小/最大/热点的分配大小是什么大块内存的生命周期是长还是短分配频率是每秒几次还是每秒百万次回答完这些问题再去决定是预分配、池化、换分配器还是调整 mmap 阈值。顺序反了很容易白忙活。6.3 给未来留一个断路器我在生产代码里保留了一个统一的分配统计开关默认关闭。线上出现问题时会打开采样 30 秒记录每次分配的 size 和调用栈。这个开关不需要全量记录所有分配只需要按大小 class 做计数内存开销和性能影响都很小但下次再遇到类碎片问题时数据能直接告诉我该优化哪个模块。内存碎片很像城市交通拥堵不是一次清障就能一劳永逸。每次模块改动、每次数据规模升级都可能改变分配分布。我的习惯是把上面的 mallinfo 打印封装成内部接口每次发布后都会小队看一眼fordblks和arena的变化趋势这样碎片问题通常能在影响线上之前被发现。优化内存先学会观察分配别急着优化。很多次我以为的“优化”其实只是把问题从一个分配器搬到了另一个分配器。真正靠谱的手段仍然是减少无意义的分配、复用需要复用的缓冲区、以及从结构上打破对连续大内存的依赖。
返回列表