Appearance
内存分配性能:内存池 / 对象池 / 自定义 allocator / tcmalloc-jemalloc
面试高频:
new/malloc到底慢在哪?什么时候该自己写内存池?为什么服务端爱用 tcmalloc/jemalloc?以及各容器/allocator 的关系。
一、为什么频繁 small alloc 很慢(回答「new 慢」要有层次)
- 系统调用 / 内核路径:首次/扩展堆要走 brk/mmap(进内核)。
- 用户态分配器查找:glibc malloc 在 free-list/bins 找合适块、可能分割/合并、维护元数据(头部 cookie)。
- 锁:多线程共享堆,malloc 内部要加锁(或 per-arena 锁),竞争时慢。
- cache miss / 内存碎片:频繁 malloc/free 造成对象分散 + 行不连续,遍历缓存命中率掉。
- 元数据开销:每块头部 cookie (~16B),小对象浪费可观。
所以性能敏感代码的黄金法则是「减少分配次数」:复用 buffer、RAII + move、reserve、对象池。
二、常见策略
| 技术 | 思路 | 何时用 |
|---|---|---|
| 复用 buffer | 预分配一个 vector<char>/unique_ptr<uint8_t[]> 容器循环用,别每次 new | 热路径写临时数据 |
| reserve | 容器预留容量,避免反复扩容搬移 | 已知数量 |
| SSO 小对象优化 | 结构体内部 buffer 存小数据(如 small string/optional) | 数据结构中大量小对象 |
| 对象池 / free-list pool | 固定大小对象重复使用,free 后入池;无碎片、分配 O(1)、缓存友好 | 高频同构节点(网络包、矩阵 tile、内存块) |
| Monotonic/Arena 分配器 | 一块大 buffer 顺序分配(如 bump/linear),不逐块 free,最后整块还 | 每帧/每请求临时数据;块生命周期统一的场景(场景帧、请求一次性) |
| 自定义 allocator | 给容器传 Allocator 模板参,把分配定向到池 | 优雅集成 STL |
三、对象池手写(HPC/高频小对象标准答案)
cpp
// 简单 T* pool:固定容量 + 空闲栈(free-list)。T 需可原地复用。
template <typename T>
class ObjectPool {
// 维护所有块:一次大分配按 T 切
std::vector<T*> blocks_; // 用 new 原始内存再 placement new
std::stack<T*> free_; // 空闲指针栈
public:
T* allocate() {
if (free_.empty()) {
// 加一块(或用一次性大池切分)
void* mem = ::operator new(sizeof(T) * CHUNK);
T* base = static_cast<T*>(mem);
for (size_t i=0;i<CHUNK;i++) free_.push(base+i);
blocks_.push_back(...);
}
T* p = free_.top(); free_.pop();
return new (p) T(); // placement new 复用内存
}
void deallocate(T* p) {
p->~T(); // 析构但不释放内存
free_.push(p); // 回池
}
~ObjectPool() { /* 释放所有 blocks_ */ }
};要点:deallocate 只析构回池不释放;同构对象固定大小零碎片;free-list 用空闲块自身的 next 指针链(比 stack<pair> 更省)。生产可提 tcmalloc 已自带 thread-caching pool,别重复造太糙的轮子太多。
四、自定义 allocator(给 std::vector/std::string 定向到池)
cpp
template <typename T>
struct PoolAllocator {
using value_type = T;
PoolAllocator() noexcept {}
template <typename U> PoolAllocator(const PoolAllocator<U>&) noexcept {}
T* allocate(std::size_t n) {
return static_cast<T*>( /* 从 pool 分到 n*sizeof(T) 对齐好的内存 */ );
}
void deallocate(T* p, std::size_t) noexcept { /* 还回 pool */ }
// 需提供 rebind 才能被其它容器正确用(大多数编译器通过成员模板自动做)
template<typename U> struct rebind { using other = PoolAllocator<U>; };
bool operator==(const PoolAllocator&) const noexcept { return true; }
bool operator!=(const PoolAllocator&) const noexcept { return false; }
};
std::vector<int, PoolAllocator<int>> v; // 使用池常考:为什么标准容器要求 allocator 支持 rebind / propagate_on_container_copy|move|swap? —— 因为像 std::list 需要分配不同大小的节点,容器会通过 rebind 拿同族其它类型 allocator;传播策略决定容器拷贝/移动时 allocator 是否跟着拷贝(涉及状态性 allocator 的引用语义)。多数手写题的 stateless allocator 需保证 operator== 返回 true(同一容器间可传播)。
五、tcmalloc / jemalloc 比 glibc malloc 好在哪(服务端高频)
| 库 | 核心优化 | 适合 |
|---|---|---|
| tcmalloc (Google) | thread-caching:每线程本地缓存 small blocks,.减少锁与全局 free-list 竞争 | 多线程高频 small alloc(服务器、分配要求高吞吐) |
| jemalloc (Facebook) | arena (per-CPU 分区) + 碎块分级 + 元数据内存共享 | 大规模多核、free 频繁、减少碎片 |
| glibc ptmalloc | 相对简单,per-thread arena 但切 arena 有锁 | 一般负载 |
要点:
- 两者都做线程本地缓存 + 分区 + 减少碎片(可回收大块走 mmap)+ 对齐
- 但 arena/缓存使每个 CPU/线程的 free 不一定立刻回 OS,峰值内存可能更高 → 监控 RSS 看取舍。
- 关键还是「少分配」,自定义 pool 通常比换 malloc 更进一步。
六、monotonic / arena(bump allocator)范式
cpp
class Arena {
std::vector<std::unique_ptr<uint8_t[]>> blocks_;
uint8_t* cur_ = nullptr; size_t off_ = 0, cap_ = 0;
public:
void* alloc(size_t n) {
n = (n + 15) & ~15ull; // 对齐到 16
if (off_ + n > cap_) newBlock();
void* p = cur_ + off_; off_ += n;
return p;
}
void reset() { off_ = 0; } // 整块重用,不必逐块 free
};特点:分配 O(1)(不找块不锁)、零碎片、释放 O(1) 整块重置。缺点:无法单对象 free,必须整体生命周期一致。适用于帧/请求内产生的临时对象。
七、对齐注意(写 allocator 必踩)
- 返回内存必须满足对象对齐(如 long double/vector align 16+);自定义池别只按字节切,要 alignas(max_align_t) 或把对齐信息传给 alloc。
- 想要更高效:结构与 size 按
alignas(T)切。
八、一句话
分配慢 → 一次测 profiler 确认;默认先「复用 + 池/arena」;再用 tcmalloc/jemalloc 兜底;自定义 allocator 是让 STL 优雅地用池的那层接口。