1. 为什么自定义内存管理不是“手写 new/delete”

很多人第一次接触自定义内存管理,是从对象池开始的:

Player* p = player_pool.create(1001, "Alice");
player_pool.destroy(p);

看起来它只是把 new Playerdelete p 换成了 createdestroy

对象池真正要做的事情不是“缓存几个对象”,而是把 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 只处理原始内存

mallocfree 只负责申请和释放原始内存:

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::stringstd::vectorPlayer 自己的析构逻辑都不会执行。

正确方式是:

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};

这里 p1p2 的地址很可能完全相同,但它们指向的是两个不同生命周期的对象。

如果你始终使用 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 构建混用。
  • 忽略内存占用,只看速度。

更多推荐