C++ 自定义内存管理:从 placement new、内存对齐到对象池实践
1. 为什么自定义内存管理不是“手写 new/delete”
很多人第一次接触自定义内存管理,是从对象池开始的:
Player* p = player_pool.create(1001, "Alice");
player_pool.destroy(p);
看起来它只是把 new Player 和 delete p 换成了 create 和 destroy。
对象池真正要做的事情不是“缓存几个对象”,而是把 C++ 默认帮我们完成的一整套动作拆开:
申请原始内存
↓
在原始内存上构造对象
↓
使用对象
↓
调用析构函数结束对象生命周期
↓
回收原始内存,等待下次复用
这里面至少有四件事必须分清楚:
分配内存 ≠ 构造对象
释放内存 ≠ 析构对象
复用内存 ≠ 复用对象
地址相同 ≠ 对象相同
普通业务代码里,我们写:
auto* p = new Player(1001, "Alice");
delete p;
C++ 把“分配内存 + 构造对象”和“析构对象 + 释放内存”封装得很好,所以我们很少需要关心中间细节。但一旦要写对象池、内存池、容器、allocator,这些细节就会全部暴露出来。
这篇文章的重点就是把这些底层概念连起来,最后你应该能回答下面这些问题:
malloc得到的内存里有没有对象?placement new到底做了什么?- 为什么 placement new 后不能直接
delete? - 为什么对象池必须关心
alignof(T)? - 析构后的地址还能不能重新构造对象?
- 同一地址上的新对象和旧指针有什么关系?
std::launder到底是不是“玄学”?- 一个最小可用对象池应该怎么写?
2. 先建立一个核心模型:内存、对象、生命周期
在 C++ 里,内存 和 对象 是两个不同层面的概念。
内存是一段字节区域;对象是某种类型在这段字节区域上开始生命周期之后的实体。
例如:
alignas(Player) std::byte storage[sizeof(Player)];
这行代码只得到了一段原始存储空间。它的大小足够放下一个 Player,对齐也满足 Player 的要求,但此时里面还没有 Player 对象。
如果你写:
Player* p = reinterpret_cast<Player*>(storage);
p->attack(); // 错误:Player 对象还没有构造
这是未定义行为。因为地址看起来像 Player*,不代表这个地址上真的存在一个 Player。
对象生命周期从构造开始:
Player* p = new (storage) Player(1001, "Alice");
从这一刻起,storage 这段内存中才存在一个真正的 Player 对象。
对象生命周期到析构结束:
std::destroy_at(p);
析构之后,这段内存仍然存在,但 Player 对象已经不存在了。它重新变回一段可以复用的原始存储空间。
这就是自定义内存管理的核心心智模型:
raw storage --construct--> live object --destroy--> raw storage
3. new/delete
3.1 malloc/free 只处理原始内存
malloc 和 free 只负责申请和释放原始内存:
void* mem = std::malloc(sizeof(Player));
std::free(mem);
它们申请空间时不会调用构造函数,释放空间时也不会调用析构函数。
所以这段代码是错误的:
void* mem = std::malloc(sizeof(Player));
Player* p = static_cast<Player*>(mem);
p->attack(); // 错误:Player 没有构造
std::free(mem);
如果要在 malloc 返回的内存上创建 C++ 对象,需要额外调用 placement new:
void* mem = std::malloc(sizeof(Player));
if (!mem) {
throw std::bad_alloc();
}
Player* p = nullptr;
try {
p = new (mem) Player(1001, "Alice");
} catch (...) {
std::free(mem);
throw;
}
p->attack();
std::destroy_at(p);
std::free(mem);
这段代码把“内存管理”和“对象生命周期管理”拆开了。
3.2 new/delete
普通 new:
Player* p = new Player(1001, "Alice");
概念上做了两步:
1. 调用 operator new 分配 sizeof(Player) 字节原始内存
2. 在这块内存上调用 Player 构造函数
普通 delete:
delete p;
概念上也做了两步:
1. 调用 Player 析构函数
2. 调用 operator delete 释放原始内存
所以,new/delete 管的是完整对象生命周期,而 malloc/free 管的只是裸内存。
3.3 operator new 不是 new expression
这点容易混淆。
operator new 是一个分配函数:
void* mem = ::operator new(sizeof(Player));
它只分配原始内存,不构造对象。
new expression 是语法表达式:
Player* p = new Player(1001, "Alice");
它会调用 operator new,然后调用构造函数。
理解这一点之后,placement new 就很好理解了:它把“分配内存”这一步拿掉了,只保留“在指定地址上构造对象”。
4. placement new:在已有内存上构造对象
4.1 placement new 是什么
placement new 的基本形式是:
T* obj = new (address) T(args...);
它的含义是:
在 address 指向的那块已有内存上,构造一个 T 对象。
它不申请内存。
例如:
void* mem = pool.allocate(sizeof(Player), alignof(Player));
Player* p = new (mem) Player(1001, "Alice");
这里 pool.allocate 负责提供原始内存,new (mem) Player(...) 负责在这块内存上启动 Player 的生命周期。
4.2 最完整的使用流程
自定义 allocator 或对象池里的标准流程应该是这样的:
void* mem = pool.allocate(sizeof(Player), alignof(Player));
Player* p = nullptr;
try {
p = new (mem) Player(1001, "Alice");
} catch (...) {
pool.deallocate(mem);
throw;
}
p->attack();
std::destroy_at(p);
pool.deallocate(mem);
这段代码里有几个关键点。
第一,pool.allocate 只返回内存,不返回对象。
第二,placement new 才真正构造对象。
第三,使用结束后必须显式析构:
std::destroy_at(p);
第四,原始内存必须交还给最初提供它的 allocator:
pool.deallocate(mem);
不能写:
delete p; // 错误
因为这块内存不是普通 new Player 分配出来的。delete 会尝试调用匹配的 operator delete,但对象池内存应该回到对象池,而不是交给全局 delete。
4.3 为什么必须手动析构
placement new 只构造对象,不负责销毁对象。
假设 Player 内部有资源:
struct Player {
std::string name;
std::vector<int> buffs;
Player(std::string n) : name(std::move(n)) {}
~Player() {
// 释放业务资源,取消注册,减少计数等
}
};
如果你只回收内存:
pool.deallocate(p); // 错误:没有析构
那么 std::string、std::vector 和 Player 自己的析构逻辑都不会执行。
正确方式是:
std::destroy_at(p);
pool.deallocate(p);
std::destroy_at(p) 等价于调用:
p->~Player();
但在模板代码里,std::destroy_at 更统一、更推荐。
4.4 构造失败时的异常安全
这是对象池里非常容易漏掉的点。
看这段代码:
void* mem = pool.allocate(sizeof(Player), alignof(Player));
Player* p = new (mem) Player(1001, "Alice");
return p;
如果 Player 构造函数抛异常,会发生什么?
内存已经从 pool 取出来了
对象没有构造成功
析构函数不会被调用
如果不归还 mem,这块内存就泄漏了
因此 create 函数必须这样写:
template <typename... Args>
Player* create_player(Args&&... args) {
void* mem = pool.allocate(sizeof(Player), alignof(Player));
try {
return new (mem) Player(std::forward<Args>(args)...);
} catch (...) {
pool.deallocate(mem); // 归还之后抛出异常
throw;
}
}
注意:如果构造函数抛异常,C++ 会销毁已经构造完成的成员和基类子对象,但不会调用最外层对象的析构函数,因为这个对象没有完整构造成功。原始内存仍然需要手动归还。
4.5 C++20 的 construct_at / destroy_at
C++20 提供了更明确的接口:
T* obj = std::construct_at(reinterpret_cast<T*>(mem), args...);
std::destroy_at(obj);
它和 placement new 本质上做的是同一类事情,但在模板和库代码中语义更清晰。
传统写法:
T* obj = new (mem) T(args...);
obj->~T();
现代写法:
T* obj = std::construct_at(reinterpret_cast<T*>(mem), args...);
std::destroy_at(obj);
如果你写的是 C++17 对象池,可以继续用 placement new;如果项目已经是 C++20,建议使用 construct_at / destroy_at。
4.6 placement new 的常见误区
最常见的错误有这些。
错误一:对 placement new 出来的对象使用 delete。
T* p = new (mem) T();
delete p; // 错误
正确:
std::destroy_at(p); // 手动调用析构
allocator.deallocate(mem); // 内存回收待后续复用
错误二:忘记调用析构。
T* p = new (mem) T();
allocator.deallocate(mem); // 错误:T 没有析构,导致对象内部资源没有及时释放
错误三:对象没有构造就当对象用。
alignas(T) std::byte storage[sizeof(T)];
T* p = reinterpret_cast<T*>(storage);
p->foo(); // 错误,此时内存之上没有对象
错误四:对非平凡对象使用 memset 清理。
std::memset(p, 0, sizeof(T)); // 如果 T 有 string/vector 等成员,这是灾难
错误五:析构后继续访问。
std::destroy_at(p);
p->foo(); // use-after-destroy
5. 内存对齐:为什么“空间够”还不够
5.1 什么是对齐
每个 C++ 类型不仅有大小,还有对齐要求。
sizeof(T) // T 占多少字节
alignof(T) // T 的对象起始地址必须满足几字节对齐(也就是对象起始地址是几字节的倍数)
例如在常见平台上:
alignof(char) == 1
alignof(int) == 4
alignof(double) == 8
如果一个类型的对齐要求是 8,那么它的对象起始地址必须是 8 的整数倍:
0x1000 OK
0x1008 OK
0x1010 OK
0x1001 BAD
0x1002 BAD
所以,自定义内存分配时不能只看 sizeof(T),还必须看 alignof(T)。
5.2 为什么需要对齐
对齐的原因主要有三类。
第一,CPU 访问效率:
如果一个 4 字节 int 放在 4 字节边界上,CPU 一次读就能拿到完整数据。如果它从一个奇怪地址开始,CPU 可能需要多次读取和拼接,性能会变差。
第二,硬件或指令要求:
x86 对很多未对齐访问比较宽容,但部分 ARM、嵌入式平台或者 SIMD 指令可能要求严格对齐。未对齐访问可能直接触发硬件异常。
第三,C++ 语言规则要求:
C++ 编译器会假设 T* 指向的地址满足 alignof(T)。如果你破坏这个前提,程序就是未定义行为。它可能正常运行,也可能在优化级别变化后出错。
5.3 placement new 为什么要求对齐
placement new 不负责分配内存,也不负责调整地址。
这行代码:
T* p = new (mem) T(args...);
实际上等于你在向编译器承诺:
mem 指向的地址足够大
mem 指向的地址满足 alignof(T)
mem 指向的是一块合法可写的存储空间
如果 mem 不满足对齐要求,placement new 仍然会尝试在这个地址上构造对象,但行为是未定义的。
错误示例:
struct Player {
double hp;
int id;
};
std::byte buffer[sizeof(Player) + 1];
void* bad = buffer + 1;
Player* p = new (bad) Player{100.0, 1}; // 未定义行为
Player 通常要求 8 字节对齐,但 buffer + 1 很可能不是 8 的整数倍。
正确写法:
alignas(Player) std::byte buffer[sizeof(Player)];
Player* p = new (buffer) Player{100.0, 1};
std::destroy_at(p);
5.4 alignas 与对象池 Slot
对象池最常见的错误写法是:
template <typename T>
struct Slot {
std::byte storage[sizeof(T)];
};
问题是:std::byte 数组本身只保证 1 字节对齐,不一定满足 T 的对齐要求。
正确写法:
template <typename T>
struct Slot {
alignas(T) std::byte storage[sizeof(T)];
};
这行代码同时表达了两个条件:
sizeof(T):这块空间大小足够
alignas(T):这块空间起始地址满足 T 的对齐要求
如果使用 union 作为空闲链表节点,也可以这样写:
template <typename T>
union Slot {
Slot* next;
alignas(T) std::byte storage[sizeof(T)];
};
空闲时,这个 slot 存 next 指针;使用时,这个 slot 的存储区域承载一个 T 对象。
5.5 自定义 allocator 如何处理对齐
一个 allocator 的接口最好不要只有:
void* allocate(std::size_t size);
更合理的是:
void* allocate(std::size_t size, std::size_t alignment);
一个简单的 bump allocator 会这样做地址向上对齐:
void* allocate(std::size_t size, std::size_t alignment) {
auto current = reinterpret_cast<std::uintptr_t>(current_); // 地址转换为整数
auto aligned = (current + alignment - 1) & ~(alignment - 1); // 获得下一个对齐的地址
auto* result = reinterpret_cast<std::byte*>(aligned);
if (result + size > end_) {
throw std::bad_alloc();
}
current_ = result + size;
return result;
}
这个按位公式要求 alignment 是 2 的幂。C++ 类型的对齐要求通常满足这一点。
5.6 对齐和 cache line
还有一类对齐不是为了“能不能正确访问”,而是为了性能。
例如多线程计数器:
struct Counter {
std::atomic<int> value;
};
Counter counters[2];
如果两个线程分别频繁修改 counters[0] 和 counters[1],这两个变量可能落在同一条 cache line 上。虽然它们是两个不同变量,但硬件缓存会以 cache line 为单位同步,导致 false sharing。
常见优化是:
struct alignas(64) PaddedCounter {
std::atomic<int> value;
};
这里的 alignas(64) 不是为了满足类型基本正确性,而是为了尽量让对象独占 cache line,减少多线程缓存抖动;64是因为缓存行大小一般是64字节大小。
6. std::launder:重新取得当前对象的合法指针
std::launder 是 C++17 引入的一个底层工具。它的名字很怪,但解决的问题很明确:
当你在同一块存储上重新构造对象后,
std::launder可以帮助你重新取得一个指向当前活着对象的合法指针,并阻止编译器继续沿用旧对象的优化假设。
头文件:
#include <new>
基本形式:
T* q = std::launder(p);
6.1 为什么要有std::launder
看一段代码:
struct X {
const int n;
};
alignas(X) std::byte storage[sizeof(X)];
X* p1 = new (storage) X{1};
std::destroy_at(p1);
X* p2 = new (storage) X{2};
这里 p1 和 p2 的地址很可能完全相同,但它们指向的是两个不同生命周期的对象。
如果你始终使用 placement new 返回的新指针 p2,通常没问题:
std::cout << p2->n << '\n';
问题出现在你不保存新指针,而是从原始存储或旧指针重新获得对象指针:
X* q = reinterpret_cast<X*>(storage);
std::cout << q->n << '\n';
在简单程序里,这样可能能跑。但从标准语义上,更严谨的写法是:
X* q = std::launder(reinterpret_cast<X*>(storage));
std::cout << q->n << '\n';
它告诉编译器:
这个地址上现在确实有一个活着的 X 对象。
请不要继续使用旧对象的假设。
请给我一个指向当前对象的有效 X*。
6.2 std::launder 做不到的事
std::launder 不会构造对象:
alignas(X) std::byte storage[sizeof(X)];
X* p = std::launder(reinterpret_cast<X*>(storage));
p->n; // 错误:X 对象还没构造
它也不能修复不对齐:
std::byte buffer[sizeof(X) + 1];
void* bad = buffer + 1;
X* p = std::launder(reinterpret_cast<X*>(bad)); // 如果 bad 不对齐,仍然错误
它不能复活已析构对象:
X* p = new (storage) X{1};
std::destroy_at(p);
X* q = std::launder(p);
q->n; // 错误:没有新的 X 对象
它也不能修复类型错误:
int x = 1;
double* p = std::launder(reinterpret_cast<double*>(&x)); // 错误
6.3 什么时候需要 std::launder
直接使用 placement new / construct_at 返回的新指针:通常不需要 launder。
从 raw storage、旧指针、union 成员重新取得对象指针:建议使用 launder。
典型场景包括:
- 对象池内部从
std::byte[]存储重新转回T*。 - 同一地址反复析构、重建对象。
- 类型含有
const成员或引用成员。 - union 活跃成员切换。
- 基类子对象、派生类对象在同一地址上原地替换。
- 自定义 allocator、optional、variant、容器底层实现。
对象池里常见写法:
template <typename T>
struct Slot {
alignas(T) std::byte storage[sizeof(T)];
bool used = false;
};
template <typename T>
T* get_object(Slot<T>& slot) {
if (!slot.used) {
return nullptr;
}
return std::launder(reinterpret_cast<T*>(slot.storage));
}
注意前提:slot 里必须真的有一个活着的 T 对象。
6.4 std::launder 和 transparent replacement 的关系
C++ 有一套**“透明替换”规则**。简单理解:如果你在同一地址上销毁一个对象,又构造了一个相同类型、完全重叠、非 const 的新对象,那么旧的名字、指针、引用有时可以自动指向新对象。
例如:
struct C {
int x;
};
C c{1};
c.~C();
new (&c) C{2};
std::cout << c.x << '\n'; // 通常可以
但不是所有场景都满足透明替换规则。比如 const 完整对象、基类子对象、带 [[no_unique_address]] 的子对象、某些 union 切换场景等。
当你不确定旧指针是否能自动指向新对象,并且你是从原始存储重新取得对象指针时,std::launder 是更严谨的选择。
6.5 如何看待std::launder
业务代码里你很少直接用它。但如果写这些东西,就应该知道它:
对象池
内存池
自定义 allocator
optional / variant 类容器
raw storage 管理
union 活跃成员管理
底层容器实现
最实用的记忆方式是:
// 构造时,直接保存并使用返回值
T* p = new (storage) T(args...);
// 后续如果从 storage 重新拿指针,用 launder 更严谨
T* q = std::launder(reinterpret_cast<T*>(storage));
7. 对象池简单实现
目标:
- 预分配 N 个槽位。
- 每个槽位可以承载一个
T对象。 - 空闲槽位通过 free list 管理。
create时使用 placement new 或construct_at构造对象。destroy时调用析构函数并回收槽位。- 支持基本 double free 检测和统计。
7.1 Slot 设计
一个 slot 在空闲状态下需要保存 next 指针;在使用状态下需要保存 T 对象。所以可以用 union结构:
template <typename T>
union Slot {
Slot* next;
alignas(T) std::byte storage[sizeof(T)];
};
空闲时使用 next:
free_list -> slot -> slot -> slot -> nullptr
使用时,storage 里承载一个通过 placement new 构造出来的 T。
7.2 完整代码
#include <array>
#include <cstddef>
#include <cstdint>
#include <memory>
#include <new>
#include <stdexcept>
#include <type_traits>
#include <utility>
#include <iostream>
#include <string>
template <typename T, std::size_t N>
class ObjectPool
{
static_assert(N > 0);
private:
// 对象内存槽位
union Slot
{
Slot *next; // 用于连接free list -- 槽位空闲时使用
alignas(T) std::byte storage[sizeof(T)]; // 原始内存 -- 槽位忙碌时使用
Slot() {}
~Slot() {}
};
public:
ObjectPool()
{
// 初始化free list
// free_list_ -> slots_[0] -> slots_[1] -> slots_[2] -> ...... -> slots[N - 1] -> nullptr
for (std::size_t i = 0; i + 1 < N; ++i)
{
slots_[i].next = &slots_[i + 1];
}
slots_[N - 1].next = nullptr;
free_list_ = &slots_[0];
}
ObjectPool(const ObjectPool &) = delete;
ObjectPool &operator=(const ObjectPool &) = delete;
~ObjectPool()
{
// 简单处理:析构池时销毁仍然存活的对象。
// 工程中更推荐要求调用者先明确释放,debug 模式下 assert used_count_ == 0。
for (std::size_t i = 0; i < N; ++i)
{
if (used_[i]) // 直接销毁正在存活的对象
{
T *obj = std::launder(reinterpret_cast<T *>(slots_[i].storage));
std::destroy_at(obj);
}
}
}
// 申请空间并构造
template <typename... Args>
T *create(Args &&...args)
{
// 暂时没有空闲槽位
if (free_list_ == nullptr)
{
throw std::bad_alloc();
}
Slot *slot = free_list_;
free_list_ = free_list_->next;
// 根据地址计算获得index
const std::size_t idx = index_of(slot);
try
{
// 构造对象
T *obj = std::construct_at(reinterpret_cast<T *>(slot->storage), std::forward<Args>(args)...);
used_[idx] = true; // 标记为使用
++used_count_;
if (used_count_ > peak_used_)
{
peak_used_ = used_count_;
}
return obj;
}
catch (...)
{
// 构造失败时,必须把 slot 放回 free list(头插链表)
slot->next = free_list_;
free_list_ = slot;
throw;
}
}
// 析构对象并回收空间
void destroy(T *obj)
{
if (obj == nullptr)
{
return;
}
Slot *slot = slot_from_object(obj);
const std::size_t idx = index_of(slot);
if (!used_[idx])
{
throw std::runtime_error("double free or invalid object");
}
std::destroy_at(obj);
used_[idx] = false;
--used_count_;
slot->next = free_list_;
free_list_ = slot;
}
std::size_t capacity() const noexcept
{
return N;
}
std::size_t used_count() const noexcept
{
return used_count_;
}
std::size_t free_count() const noexcept
{
return N - used_count_;
}
std::size_t peak_used() const noexcept
{
return peak_used_;
}
private:
// 由Slot结构指针获取index,便于后续slots_和used_操作
std::size_t index_of(const Slot *slot) const
{
const auto base = reinterpret_cast<std::uintptr_t>(&slots_[0]);
const auto addr = reinterpret_cast<std::uintptr_t>(slot);
if (addr < base)
{
throw std::runtime_error("pointer does not belong to this pool");
}
const auto diff = addr - base;
if (diff % sizeof(Slot) != 0)
{
throw std::runtime_error("misaligned pool pointer");
}
const auto idx = diff / sizeof(Slot);
if (idx >= N)
{
throw std::runtime_error("pointer does not belong to this pool");
}
return idx;
}
// 由对象指针获取Slot结构指针
Slot *slot_from_object(T *obj) const
{
// union 成员和 union 对象起始地址相同。
// 这里依赖对象确实由本池的 slot.storage 构造而来。
return reinterpret_cast<Slot *>(obj);
}
private:
std::array<Slot, N> slots_{}; // 对象池槽位
std::array<bool, N> used_{}; // 槽位是否使用
Slot *free_list_ = nullptr; // 槽位空闲链表
std::size_t used_count_ = 0; // 当前正在使用的对象数量
std::size_t peak_used_ = 0; // 历史峰值使用数量
};
使用示例:
struct Player
{
int id;
std::string name;
Player(int id, std::string name)
: id(id), name(std::move(name))
{
std::cout << "construct Player " << this->name << '\n';
}
~Player()
{
std::cout << "destroy Player " << name << '\n';
}
void attack()
{
std::cout << name << " attack\n";
}
};
int main()
{
ObjectPool<Player, 4> pool;
Player* arr[4];
for(int cnt = 0; cnt < 10; cnt++)
{
for(int i = 0; i < 4; i++)
{
arr[i] = pool.create(i, "player" + std::to_string(i));
std::cout << i << ": " << arr[i] << std::endl;
}
for(int i = 0; i < 4; i++)
{
pool.destroy(arr[i]);
}
}
std::cout << "peak used = " << pool.peak_used() << '\n';
}
7.3 关键要点
第一,slot 不是 T 对象数组。
std::array<Slot, N> slots_{};
这里只是 N 个原始存储槽位。T 对象只在 create 之后才存在。
第二,构造对象时使用 construct_at:
T* obj = std::construct_at(reinterpret_cast<T*>(slot->storage), args...);
这就是 placement new 思想。
第三,销毁对象时必须调用析构:
std::destroy_at(obj);
第四,空闲 slot 通过 free list 管理:
slot->next = free_list_;
free_list_ = slot;
第五,构造失败时要回滚 free list:
catch (...) {
slot->next = free_list_;
free_list_ = slot;
throw;
}
第六,从 slot storage 重新取得对象指针时,可以使用 std::launder:
T* obj = std::launder(reinterpret_cast<T*>(slots_[i].storage));
7.4 当前版本的不足
上面的对象池能说明核心原理,但还不是工业级实现。
真正工程中还要考虑:
- 是否固定容量,是否支持扩容。
- 是否线程安全。
- 是否支持跨线程释放。
- 是否需要 debug 模式下的 red zone / canary。
- 是否要检测越界写。
- 是否要延迟复用来辅助查 use-after-free。
- 是否要暴露裸指针,还是使用 handle + generation。
- 是否要支持批量分配。
- 是否要对对象生命周期做更严格的状态机。
- 是否要监控峰值、失败次数、扩容次数、跨线程释放次数。
对象池最难的部分不是 placement new 语法,而是生命周期和工程边界。
8. 对象池之外:内存池、Arena、pmr 和系统 allocator
对象池只是自定义内存管理的一种形式。
8.1 对象池和内存池的区别
对象池管理的是某种具体类型的对象:
ObjectPool<Player>
ObjectPool<Packet>
ObjectPool<TimerNode>
它知道 T 的构造和析构。
内存池管理的是裸内存块:
allocate(size, alignment)
deallocate(ptr, size, alignment)
它通常不知道对象类型,也不直接调用构造析构。
很多对象池内部会依赖内存池:先从内存池拿原始内存,再用 placement new 构造对象。
8.2 Fixed-size Block Allocator
固定块分配器把内存切成同样大小的 block。
block -> block -> block -> nullptr
每次分配拿一个 block,每次释放还回 free list。
适合:
- 网络包节点。
- 消息队列节点。
- 任务对象。
- 定时器节点。
- 大量大小接近的小对象。
优点是 O(1) 分配释放、实现简单、碎片较少。缺点是不适合大小差异很大的对象。
8.3 Slab Allocator
slab allocator 按 size class 管理内存,例如:
8B
16B
32B
64B
128B
256B
...
申请 70 字节时,可能分配 128 字节块。
它牺牲一些内部碎片,换取快速分配、低外部碎片和更好的缓存局部性。
jemalloc、tcmalloc、mimalloc 等系统 allocator 都有类似思想:size class、thread cache、central cache、page/span/slab 管理。
8.4 Arena / Region Allocator
Arena 的思想是:一批对象一起分配,一起释放。
例如游戏服务器中的一场战斗:
战斗开始:创建 BattleArena
战斗期间:所有临时对象从 Arena 分配
战斗结束:Arena 整体 reset 或释放
Arena 非常适合生命周期成组的临时对象:
- 一次请求中的临时数据。
- 一帧 tick 的临时列表。
- 一次协议解析过程。
- 一场战斗的临时计算对象。
- 编译器中的 AST / IR 临时数据。
优点是分配极快,释放几乎免费。缺点是不支持任意顺序释放,如果把长生命周期对象误放进 Arena,会导致内存保持过久。
8.5 std::pmr
C++17 引入了 polymorphic memory resource:
std::pmr::vector<int>
std::pmr::string
std::pmr::memory_resource
它的意义是把容器和内存资源解耦。
例如:
std::byte buffer[4096];
std::pmr::monotonic_buffer_resource resource(buffer, sizeof(buffer));
std::pmr::vector<int> values(&resource);
values.push_back(1);
values.push_back(2);
values 的内部动态内存会优先从 resource 里分配。
常见资源:
std::pmr::monotonic_buffer_resource
std::pmr::unsynchronized_pool_resource
std::pmr::synchronized_pool_resource
如果你想在业务代码中体验自定义内存管理,std::pmr 往往比手写完整 STL allocator 更实用。
8.6 替换系统 allocator
在高并发服务端里,有时不需要手写对象池,替换默认 malloc 就能得到明显收益。
常见选择:
- jemalloc
- tcmalloc
- mimalloc
它们通常优化了:
- 多线程扩展性。
- 小对象分配。
- per-thread cache。
- 碎片控制。
- heap profiling。
- 内存归还策略。
Linux 下常见替换方式:
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so ./server
但替换 allocator 不能盲目做。必须通过压测观察吞吐、p99 延迟、RSS、碎片率和回滚风险。
9. 什么时候该自定义内存管理
自定义内存管理会增加复杂度,所以它应该是有明确目标的工程手段。
适合做对象池或内存池的情况:
- 同类对象频繁创建销毁。
- 对象大小固定或接近固定。
- 生命周期较短且清晰。
- 数量上限可以估计。
- 对尾延迟敏感。
- 默认 allocator 成为热点。
- 需要批量释放临时内存。
- 需要更细粒度的内存统计。
不适合的情况:
- 对象生命周期复杂且跨线程流动频繁。
- 对象内含大量外部资源,复用成本高。
- 对象大小差异极大。
- 创建销毁频率不高。
- 只是猜测
new/delete慢,但没有数据支撑。 - 代码团队对生命周期规则掌握不足。
一个很实际的判断标准是:
如果你不能说明它解决了哪个性能问题,也不能设计指标证明它有效,那就先不要引入自定义内存管理。
在很多项目里,优先级应该是:
先写清楚所有权和生命周期
↓
使用 RAII / 智能指针 / 容器避免错误
↓
通过 profiling 找到分配热点
↓
尝试 std::pmr 或替换系统 allocator
↓
最后再针对热点对象设计专用池
10. 调试、压测与工程化指标
自定义内存管理一旦出错,bug 往往非常隐蔽。必须配套调试工具和指标。
10.1 常见错误
需要重点防范:
- memory leak
- double free
- use-after-free
- buffer overflow
- uninitialized read
- alignment bug
- lifetime bug
- 构造失败后内存泄漏
- 析构函数未调用
- 对象复用导致脏状态
- 跨线程释放引发数据竞争
- free list 损坏
10.2 Sanitizer
开发和测试阶段建议经常使用:
-fsanitize=address
-fsanitize=undefined
-fsanitize=thread
-fsanitize=leak
对应:
- ASan:越界、use-after-free、double free。
- UBSan:未定义行为。
- TSan:数据竞争。
- LSan:内存泄漏。
对象池可能会让 ASan 更难发现 use-after-free,因为内存被池保留而不是立即还给系统。工程上可以在 debug 模式下引入 quarantine:释放后先不立即复用,延迟一段时间再放回 free list。
10.3 对象池应该监控什么
一个对象池至少应该统计:
capacity
used_count
free_count
peak_used
allocation_count
free_count_total
allocation_failure_count
expansion_count
cross_thread_free_count
如果是多线程池,还应关注:
锁等待时间
线程本地缓存命中率
central cache 获取次数
跨线程归还次数
每线程内存倾斜
如果是服务端,还要结合进程级指标:
RSS
VSS
PSS
heap allocated
heap active
heap retained
fragmentation ratio
p50/p90/p99/p999 latency
10.4 benchmark 不要只看平均值
自定义内存管理经常是为了降低延迟抖动,而不是只提高平均吞吐。
应该关注:
- 平均分配耗时。
- p50 / p90 / p99 / p999。
- 多线程吞吐。
- 峰值内存。
- RSS 是否持续增长。
- cache miss。
- 锁竞争。
- 不同生命周期分布下的表现。
常见 benchmark 误区:
- 测试对象太简单,被优化器消掉。
- 只测单线程,不测真实多线程竞争。
- 只测平均值,不看尾延迟。
- 没有 warm-up。
- 没有模拟真实分配/释放顺序。
- debug 构建和 release 构建混用。
- 忽略内存占用,只看速度。
更多推荐




所有评论(0)