8. 内存碎片:内部碎片与外部碎片

内存碎片这个话题,说实话,很多C++开发者写了三五年代码都不一定真正重视过。我早年在一个嵌入式项目里吃过它的亏——程序跑着跑着突然malloc失败,但一看剩余内存还有不少。当时排查了好久,最后发现是碎片把内存搞得支离破碎。嗯,从那以后,我对碎片问题就格外敏感了。

什么是内存碎片?

简单说,内存碎片就是内存被切割成一块块不连续的小区域,导致大块连续内存分配失败。它分两种:内部碎片和外部碎片。你想想看,这就像搬家——内部碎片是你买了个大柜子,里面只放了几件衣服,空间浪费了;外部碎片是客厅里到处散落着小物件,想放个大沙发却找不到一整块地方。

内部碎片(Internal Fragmentation)

内部碎片发生在分配器给你的内存块比你实际需要的要大时。比如你申请了17字节,但分配器按32字节对齐给你,那15字节就白白浪费了。这就是内部碎片。

核心原因: 内存分配的对齐要求 + 固定大小的内存池设计

我在项目中遇到过这样一个场景:一个高频交易系统,每秒要分配释放上百万个小对象。每个对象实际只需要12字节,但默认的malloc会按16字节对齐。看起来只浪费4字节对吧?但乘以百万级,内存开销直接飙升30%以上。

// 内部碎片的典型场景
struct SmallObject {
    int id;      // 4字节
    char type;   // 1字节
    // 实际需要5字节,但对齐后占8字节
    // 3字节内部碎片
};

// 更好的做法:手动控制布局
#pragma pack(push, 1)
struct PackedObject {
    int id;
    char type;
    // 现在只占5字节,但访问速度可能下降
};
#pragma pack(pop)
我的建议: 如果对象数量巨大,考虑用自定义分配器。比如Google的TCMalloc就对小对象做了专门优化,内部碎片率能控制在5%以内。

外部碎片(External Fragmentation)

外部碎片更隐蔽,也更难对付。它指的是空闲内存总量足够,但没有一块连续区域能满足你的分配请求。说白了,就是内存被切成了很多小块,每块都不大,但加起来不少。

为什么会这样?因为你的分配和释放模式不规律。一会儿分配个大的,一会儿释放个中的,久而久之,空闲内存就像瑞士奶酪一样,到处是洞。

// 外部碎片的经典场景
void cause_fragmentation() {
    std::vector<void*> blocks;
    
    // 分配大块
    for (int i = 0; i < 10; i++) {
        blocks.push_back(malloc(1024 * 1024)); // 1MB
    }
    
    // 释放奇数索引的块
    for (int i = 1; i < blocks.size(); i += 2) {
        free(blocks[i]);
        blocks[i] = nullptr;
    }
    
    // 现在尝试分配一个连续的大块
    void* big = malloc(5 * 1024 * 1024); // 5MB
    // 很可能失败!虽然空闲总量有5MB,但都是1MB的碎片
}
我曾经踩过的坑: 在一个网络服务器里,我用std::list管理连接对象,频繁地插入删除。跑了48小时后,内存占用看起来正常,但新连接就是分配失败。后来用工具一查,外部碎片率高达60%。改用内存池后,问题彻底解决。

两种碎片的对比

维度 内部碎片 外部碎片
本质 分配块内部浪费 空闲块不连续
主要原因 对齐、固定大小池 分配释放模式不规则
检测难度 容易(看分配大小) 较难(需要分析空闲列表)
常见场景 小对象大量分配 长期运行的服务
优化手段 自定义分配器、紧凑布局 内存池、伙伴系统

如何检测内存碎片?

我个人习惯用这几个方法:

  1. 查看/proc/self/smaps(Linux):看Heap区域的连续空闲块大小分布
  2. 使用Valgrind的massif工具:能画出内存使用的时间线,碎片一目了然
  3. 写个小工具遍历空闲列表:对于自定义分配器,我经常加一段debug代码,打印所有空闲块的大小和地址
// 一个简单的碎片率计算思路
double fragmentation_ratio(size_t total_free, size_t largest_free_block) {
    if (total_free == 0) return 0.0;
    // 碎片率 = 1 - (最大连续块 / 总空闲量)
    // 越接近1,碎片越严重
    return 1.0 - (double)largest_free_block / (double)total_free;
}

实战中的应对策略

说实话,没有银弹。但有几个套路我屡试不爽:

  • 对象池:对于频繁创建销毁的同类型对象,用固定大小的对象池。内部碎片可控,外部碎片几乎为零。
  • slab分配器:Linux内核的玩法,把内存按2的幂次分成不同大小的slab,分配时找最合适的。我在一个数据库项目里用过,碎片率从30%降到了5%。
  • 定期整理:某些场景下,可以暂停服务,把存活对象挪到一起,释放大块连续内存。嗯,这招有点暴力,但管用。
一个小技巧: 如果你用std::vector代替std::list,外部碎片问题会大大缓解。因为vector一次性分配大块连续内存,而不是每个元素单独分配。我见过太多人无脑用list,结果把自己坑了。

知识体系图

下面这张图帮你理清内存碎片的来龙去脉:

内存碎片知识体系 内存碎片 内部碎片 外部碎片 对齐要求 固定大小池 padding填充 分配释放不规则 长期运行累积 多线程竞争 对策:紧凑布局、自定义分配器 对策:内存池、伙伴系统、GC 目标:降低碎片率,提高内存利用率

总结一下

内存碎片这东西,说白了就是内存管理中的「隐形杀手」。内部碎片浪费的是空间,外部碎片浪费的是连续性。我个人经验是:先搞清楚你的分配模式,再选对策。如果小对象多,优先考虑对象池或slab分配器;如果分配释放模式复杂,考虑用tcmalloc或jemalloc这类现代分配器,它们内部已经做了大量碎片优化。

嗯,最后提醒一句:别等到线上出问题了才想起内存碎片。写代码时就多想想——这个vector会不会频繁resize?这个list的节点生命周期是怎样的?提前规划,比事后救火强得多。

专注资料整理