C++ 内存碎片化:成因与解法

内存碎片化是 C++ 在高并发服务中的一个关键性能顽疾。碎片分成两类:外部碎片——空闲的内存块零散、不连续,总的空闲内存充足,却无法满足大块内存的分配;内部碎片——分配出去的内存大于实际需求,剩下的空间被闲置。

高并发场景下,高频次的小内存分配释放、多线程争抢内存,会使内存利用率不断降低,进而引发系统频繁调用 brk/mmap、服务 OOM、接口 RT 抖动等严重状况。

C++ 高并发场景下解决内存碎片化的关键思路:降低系统级的内存调用、统一内存的分配粒度、重复使用内存块、避免多线程争抢、精准把控内存的生命周期。核心是舍弃标准库默认的全面堆分配逻辑,运用分层、分级的管理办法。

高并发下,内存碎片化是什么?

C++ 编程中,高并发指很多线程一起运行、争抢资源。比如网络服务器同时接入大量客户端连接,每个连接都可能触发读写操作,要求快速响应;再比如金融交易系统,大量订单并发处理,要求在高并发下稳定运行。

高并发情境下,内存碎片化就像整理书架:起初书架规整,书一本本紧密排列。但不断地从不同位置抽走书(释放内存)、再插入新书(分配内存)之后,书架上会慢慢出现许多小空隙(空闲内存块),它们零散分布。这时再想放一本大厚书(申请大块连续内存),就很难找到放得下的连续空间——这就是内存碎片化。

在 C++ 中,内存碎片化指系统里存在大量不连续的小块空闲内存,无法满足大块内存的请求。

还不理解内存碎片化,可以参考这篇《告别内存碎片,从理解内存池开始》。

内存碎片的产生原因

(1)频繁的动态内存分配释放

高并发场景下,线程不断创建、销毁对象,意味着频繁的内存分配与释放。比如并发网络系统里,每新建一个连接就要分配内存存储连接信息,连接断开时又释放。这个过程非常频繁,就像前面的书架,内存很快就会碎片化。

(2)分配释放顺序不匹配

不同线程的内存使用模式不同,分配和释放的顺序也不一致。例如线程 A 先分配一大块内存,线程 B 在其中分配了几个小块,随后 B 释放其中一些——释放顺序与分配顺序不一致,就会在大块中留下许多小空闲区域,这些区域很难被其他线程再次利用,从而形成碎片。

(3)分配算法缺陷

部分内存分配算法在高并发下表现不佳。例如常见的首次适配(First Fit)算法,从内存起始位置找第一个放得下的空闲块。高并发下内存频繁分配释放,空闲块分布趋于零散,首次适配要花更多精力寻找合适的块,还容易把大块空隙分割成小块空闲,进一步加剧碎片化。

内存碎片化的负面影响

  • 性能下降:碎片化后,分配器为找到足够的空闲块,要花费更多时间遍历内存空间。比如游戏服务器频繁给新玩家分配内存,碎片严重时每次分配耗时增加,帧率下降,玩家感觉卡顿。
  • 内存分配失败:碎片导致没有连续大块内存。程序申请大块内存时,即使总空闲内存足够,也会因为小块分散存在而无法满足要求,导致分配失败。比如图像处理程序加载高分辨率图片需要大块内存,碎片严重时可能分配失败、图像加载出错。
  • 内存利用率降低:大量小块空闲内存无法被充分利用,造成浪费。就像仓库看上去还有不少空间,但都是狭小的角落,放不下大型货物,实际利用率很低。

解决内存碎片化,有哪些方法?

(1)减少动态内存操作

在 C++ 中,动态内存分配(new/delete)是导致碎片化的主要原因之一,减少动态内存操作是关键办法。优先使用栈内存或静态内存——它们的分配释放由编译器自动管理,不产生碎片:

void function() {
    int stackVariable = 10;         // 使用栈内存
    static int staticVariable = 20; // 使用静态内存
    // ...
}

stackVariable 是栈变量,函数执行时分配,函数结束后自动释放;staticVariable 是静态变量,程序启动时分配,程序结束后释放。它们都不像动态内存分配那样造成碎片。

必须使用动态内存时,尽量降低 new 和 delete 的使用次数,考虑对象复用,不要重复创建销毁对象。比如游戏开发中,子弹、怪物这类高频使用的对象,可以预先创建一批放进对象池,需要时从池里获取,用完放回,而不是每次都创建销毁新对象。

(2)内存池技术

内存池是常用的解决碎片化的技术。基本思路:程序启动时预先分配大块内存,运行过程中从这块内存里分配、释放小块,而不是直接向操作系统申请释放。这样避免了频繁的系统内存申请释放,降低碎片产生的概率。

比如服务器频繁分配释放内存存储网络数据包,用内存池能明显提高分配释放效率。一个简易的固定尺寸内存池:

class MemoryPool {
private:
    struct Block {
        Block* next;
    };
    Block* freeList;
    char* memory;
    size_t blockSize;
    size_t poolSize;
public:
    MemoryPool(size_t count, size_t size)
        : blockSize((size + alignof(Block) - 1) / alignof(Block) * alignof(Block)),
          poolSize(count) {
        memory = new char[blockSize * count];
        freeList = nullptr;
        // 构建空闲链表
        for (int i = count - 1; i >= 0; --i) {
            Block* block = reinterpret_cast<Block*>(memory + i * blockSize);
            block->next = freeList;
            freeList = block;
        }
    }
    ~MemoryPool() {
        delete[] memory;
    }
    void* allocate() {
        if (!freeList) return nullptr;
        Block* block = freeList;
        freeList = freeList->next;
        return block;
    }
    void deallocate(void* ptr) {
        if (ptr) {
            Block* block = static_cast<Block*>(ptr);
            block->next = freeList;
            freeList = block;
        }
    }
};

内存池适用于频繁分配、释放相同尺寸内存块的场景,例如网络数据包处理、游戏对象管理等。这类场景下使用内存池能极大提升内存使用效率,降低碎片。

(3)智能指针

智能指针是 C++ 标准库提供的自动管理动态内存的工具。它通过 RAII(Resource Acquisition Is Initialization,资源获取即初始化)机制,在对象生命周期结束时自动释放所管理的内存,避免手动释放带来的内存泄漏和碎片化。

标准库提供三种主要智能指针:std::unique_ptr 独占式管理动态内存,不允许拷贝但可以移动,性能开销小;std::shared_ptr 共享式管理,通过引用计数跟踪有多少指针指向同一对象,计数归零时自动释放;std::weak_ptr 是弱引用,不增加引用计数,主要用于解决 shared_ptr 之间的循环引用问题。

#include <memory>
#include <iostream>

int main() {
    // 使用 std::unique_ptr
    std::unique_ptr<int> uniquePtr = std::make_unique<int>(10);

    // 使用 std::shared_ptr
    std::shared_ptr<int> sharedPtr1 = std::make_shared<int>(20);
    std::shared_ptr<int> sharedPtr2 = sharedPtr1; // 引用计数增加

    // 使用 std::weak_ptr
    std::shared_ptr<int> shared = std::make_shared<int>(30);
    std::weak_ptr<int> weak = shared;
    if (std::shared_ptr<int> locked = weak.lock()) {
        std::cout << "Weak pointer locked, value: " << *locked << std::endl;
    } else {
        std::cout << "Weak pointer expired" << std::endl;
    }

    return 0;
}

依靠智能指针的自动内存管理机制,能有效降低内存碎片化的概率。

(4)打造专属分配器

自定义内存分配器根据程序的特定需求设计实现,能依据程序的内存使用模式采用更高效的分配算法,降低碎片产生概率。比如数据库管理系统的数据存取模式固定,用自定义分配器针对性优化,可以提高内存使用效率。常见的算法有伙伴系统、slab 分配器等:

  • 伙伴系统:把内存按不同大小划分成组,分配时从合适的组里找空闲块,没有合适的大块就拆分更大的块;释放时检查相邻内存块是否空闲,空闲则合并成更大的块。
  • Slab 分配器:把内存划分为若干 slab,每个 slab 包含多个大小相同的对象,分配时直接从对应 slab 获取,释放后放回原 slab,降低碎片产生的概率。

一个简易的自定义内存分配器示例:

class CustomAllocator {
private:
    char* memoryPool;   // 内存池
    size_t poolSize;    // 内存池大小
    void** freeList;    // 空闲链表
public:
    CustomAllocator(size_t size) : poolSize(size) {
        memoryPool = new char[poolSize];
        freeList = reinterpret_cast<void**>(memoryPool);
        // 初始化空闲链表
        for (size_t i = 0; i < poolSize / sizeof(void*); ++i) {
            freeList[i] = reinterpret_cast<void*>(
                reinterpret_cast<char*>(memoryPool) + (i + 1) * sizeof(void*));
        }
        freeList[poolSize / sizeof(void*) - 1] = nullptr;
    }
    ~CustomAllocator() {
        delete[] memoryPool;
    }
    void* allocate(size_t size) {
        if (freeList == nullptr) return nullptr;
        void* ptr = freeList[0];
        freeList = reinterpret_cast<void**>(ptr);
        return ptr;
    }
    void deallocate(void* ptr) {
        reinterpret_cast<void**>(ptr)[0] = freeList;
        freeList = reinterpret_cast<void**>(ptr);
    }
};

CustomAllocator 通过维护空闲链表完成内存的分配与释放,降低碎片出现的概率。

(5)定期整理内存

部分内存分配器具备内存整理功能,把分散的空闲内存块整合成更大的连续块,减少碎片数量。整理方式是遍历内存空间,把相邻的空闲块合并成更大的空闲块。

长时间运行的程序,随着内存不断分配释放,碎片会逐步增多,定期整理能有效缓解。以 jemalloc 为例,它提供整理函数:

mallctl("thread.tcache.flush", NULL, NULL, NULL, 0);

可以强制把线程缓存里的内存块归还到整个内存池,实现整理。但整理操作通常有一定性能开销,因为需要遍历内存并执行合并。所以整理方式与频率要综合权衡:频繁使用、性能要求高的程序不宜太频繁整理;内存使用稳定、对利用率要求高的程序可以适当增加整理频次。

(6)高效分配器推荐

除了自定义分配器,还可以直接选用针对高并发优化的高效分配器,如 jemalloc、tcmalloc:

  • jemalloc:Facebook 开发的高效分配器,碎片管理能力出色,采用多 arena 架构,每个线程使用独立的内存区域,减少锁竞争。高并发场景下能有效减少碎片、提高分配效率。
  • tcmalloc(Thread-Caching Malloc):Google 开发,Chrome 浏览器等大型项目广泛使用。每个线程维护本地缓存,减少锁竞争,小对象分配开销极低,适合多线程服务器和大量小对象分配的场景。

使用这些分配器通常只需编译时链接对应的库即可:jemalloc 在编译命令中添加 -ljemalloc,tcmalloc 添加 -ltcmalloc。根据程序特点和需求选择合适的高效分配器,可以提高内存使用效率、减少碎片化。

如何选择

实际应用中需要综合考虑程序的具体情况和需求:对象创建销毁非常频繁且大小固定,内存池是不错的选择;动态内存分配多、对内存泄漏敏感,智能指针能有效解决;对分配性能要求极高、有特定内存使用模式,自定义分配器或 jemalloc/tcmalloc 这类高效分配器更合适。


   转载规则


《C++ 内存碎片化:成因与解法》 吴杭沉 采用 知识共享署名 4.0 国际许可协议 进行许可。
 上一篇
glibc 内存分配器原理 glibc 内存分配器原理
内存管理是 C 程序员日常编码中最常见的技术点,能否用得 glibc 内置的内存管理函数,是衡量 C 程序员水平的重要标准。本文从几个 C 语言中常见的例子出发,深入剖析 malloc/free 这两个最常见内存管理函数的实现机制,并总结如
2020-08-08
下一篇 
Linux 创建一个可执行的共享库 Linux 创建一个可执行的共享库
本文讨论两个需求: 如何让共享库文件也可以直接执行; 如何在可执行文件中用 dlopen 解析自身的函数。 这两个需求汇总起来,可以理解为:如何让一个程序既可以作为共享库,又能够直接运行。这类需求在 Linux 下很常见,ld-linu
2020-07-27
  目录