浅谈C++中的内存分配

· · 科技·工程

0. 前言

在 OI 竞赛中,你通常 完全不需要 考虑手动内存管理。STL 已经足够了

然而,这篇文章是写给需要卡掉两个 \log 的人和抢最优解的人的。 接下来我会从最简单到最复杂分析各种内存分配策略。

1. 静态空间

在编译期就确定大小的数组,比如全局或静态数组。

2. 栈空间

栈空间通过函数调用栈进行分配。 C 语言提供了 alloca() 函数来动态地在栈上分配空间。

3. 堆空间

可以负责任地说,99.9% 的动态内存需求都由堆空间满足,无论是 OI 环境还是开发环境。 因此,这里是内存分配性能优化最重要的部分。

我们将从底层到高层进行讲解。

3.1. malloc / free

这是 C/C++ 中最底层的内存分配接口。

3.2. ::operator new / ::operator delete

这是 C++ 中与 malloc/free 对等的操作符函数。

3.3. new / delete

用于单个对象的动态分配和构造/析构。

说明:在 OI 中,基本不会单独分配对象,更多的是分配数组。

3.4. new[] / delete[]

用于对象数组的动态分配。

3.5. std::allocator

当之无愧的 STL 之光

3.6. 手写 operator new[] / operator delete[]

针对 OI 场景的特定优化。

// 一个简单的 POD 类型 operator new[] 重载示例
void* operator new[](std::size_t count) {
    return ::operator new(count);
}
void operator delete[](void* ptr) noexcept {
    ::operator delete(ptr);
}

3.7. 手写内存池

一个功能齐全且效率不低的内存池通常极长。 幸运的是,OI 环境是单线程的,可以减少大量码量。 👉 简易实现

常见的内存池优化技术(由重要到次要)

  1. 碎片处理

    • 场景:邻接表中有大量小的 std::vector。若无内存池,每个 vector 的扩容都会调用 malloc,产生巨大开销。
    • 方案:预分配一大块内存(一个池),维护一个指针指向剩余空间的起始位置。分配内存仅仅是将该指针向后移动指定大小,速度极快。
  2. 分类

    • 场景:产生了长度不一的内存需求,不能统一使用一个内存池。
    • 方案:提前定义多个内存池,选择能够使用的内存池中最小的一个,并在需要的内存过大时直接分配。
  3. 拉链表 / 空闲链表

    • 场景:池内的内存被分配后又释放,产生内部碎片,需要回收利用。
    • 方案:将释放出来的内存块组织成一个链表(空闲链表)。下次分配时,先从空闲链表中寻找合适大小的块。

3.8. 第三方库内存池

推荐以下高性能实现:

4. 总结

永远优先信任 std::allocator

性能(速度)排名(大致)

静态空间 >> 栈空间 > 第三方内存池 ≈ 手写内存池 > std::allocator >
malloc/free ≈ ::operator new/delete > 手写 new[]/delete[] >
new/delete >> new[]/delete[]

易封装性排名

第三方内存池 > 手写内存池 > std::allocator >>
手写 new[]/delete[] > new[]/delete[] >
new/delete > ::operator new/delete = malloc/free >> 栈空间

易管理性排名

手写内存池 > 第三方内存池 > 栈空间 >
std::allocator > 手写 new[]/delete[] >
new[]/delete[] > new/delete >
::operator new/delete = malloc/free

附:如果不用 std::allocator,手写 std::vector 时可以通过构造时 reserve(4) 凑合一下。 倍增时可以开 4 倍减少开销。 实现宏 + 栈空间时不要嫌麻烦或不优雅,没有自动构造/析构的都不推荐。 内存池推荐块长与系统页长相同,也可以尝试用静态数组代替动态内存(经测试不佳)。 linux 里 brk 比 mmap 快,但是不能释放。