Skip to content
C++
Idiom
since C++11
Intermediate

Object Pool

Pre-allocate a fixed set of objects and reclaim them without heap allocation — ideal for frequently created objects with bounded lifetimes.

Object Poolsince C++11

An object pool pre-allocates a fixed-capacity block of typed storage and maintains a free list, handing out and reclaiming objects via placement new and explicit destruction without touching the heap.

Overview

Every call to new T(...) reaches the global allocator: lock acquisition, bookkeeping update, possible OS syscall, then a symmetric round-trip on delete. At low frequency this is invisible. At high frequency — game entities spawned every frame, per-request parser nodes, socket read buffers — allocation cost dominates and heap fragmentation compounds it.

An object pool sidesteps this by front-loading allocation at startup. Internally it maintains a free list: a singly-linked list threaded through the unused slots. Acquiring an object pops the list head and constructs in-place via placement new (available since C++98). Releasing runs the destructor explicitly and pushes the slot back. No allocator is called during steady-state operation.

The C++11 unrestricted union rules are the key enabler. Before C++11, a union member of class type with a non-trivial constructor was ill-formed. C++11 lifted this restriction provided constructors and destructors are user-defined — exactly what makes the Slot trick compile.

Core Implementation

cpp
template<typename T, std::size_t Capacity>
class ObjectPool {
    union Slot {                // C++11: unrestricted union
        T     object;
        Slot* next;

        Slot() noexcept : next{nullptr} {}  // must be user-defined (C++11)
        ~Slot() {}                          // trivial body — pool owns T lifetime
    };

    std::array<Slot, Capacity> slots_;
    Slot*       free_head_ = nullptr;
    std::size_t in_use_    = 0;

public:
    ObjectPool() noexcept {
        for (std::size_t i = 0; i + 1 < Capacity; ++i)
            slots_[i].next = &slots_[i + 1];
        free_head_ = &slots_[0];
    }

    // Non-copyable and non-movable: every Slot::next points into slots_
    ObjectPool(const ObjectPool&)            = delete;
    ObjectPool& operator=(const ObjectPool&) = delete;

    template<typename... Args>
    [[nodiscard]] T* acquire(Args&&... args) {  // C++17: [[nodiscard]]
        if (!free_head_) return nullptr;        // pool exhausted

        Slot* slot = free_head_;
        free_head_ = slot->next;
        ++in_use_;
        return ::new(std::addressof(slot->object)) T(std::forward<Args>(args)...);
    }

    void release(T* ptr) noexcept {
        std::destroy_at(ptr);                           // C++17; use ptr->~T() on C++14
        auto* slot = reinterpret_cast<Slot*>(ptr);
        slot->next = free_head_;
        free_head_ = slot;
        --in_use_;
    }

    [[nodiscard]] std::size_t available() const noexcept { return Capacity - in_use_; }
    [[nodiscard]] std::size_t size()      const noexcept { return in_use_; }
};

std::destroy_at (C++17) is equivalent to ptr->~T() but handles arrays cleanly and is less error-prone when T is a typedef for an array type. On C++14 and earlier, call the destructor directly.

The pool is intentionally non-copyable and non-movable: copying slots_ without patching every embedded pointer would immediately corrupt the free list. A movable pool requires a custom move constructor that rebuilds the list with adjusted addresses.

RAII Handle

Manually tracking release calls is the first step toward a bug. Wrap the raw pointer in a move-only handle:

cpp
template<typename T, std::size_t N>
class ManagedPool {
    ObjectPool<T, N> pool_;

public:
    struct Handle {
        T*           ptr_  = nullptr;
        ManagedPool* pool_ = nullptr;

        Handle() = default;
        Handle(T* p, ManagedPool* mp) noexcept : ptr_{p}, pool_{mp} {}

        ~Handle() { if (ptr_) pool_->pool_.release(ptr_); }

        Handle(const Handle&)            = delete;
        Handle& operator=(const Handle&) = delete;

        Handle(Handle&& o) noexcept
            : ptr_{std::exchange(o.ptr_, nullptr)},   // C++14: std::exchange
              pool_{std::exchange(o.pool_, nullptr)} {}

        Handle& operator=(Handle&& o) noexcept {
            if (this != &o) {
                if (ptr_) pool_->pool_.release(ptr_);
                ptr_  = std::exchange(o.ptr_,  nullptr);
                pool_ = std::exchange(o.pool_, nullptr);
            }
            return *this;
        }

        T* operator->() const noexcept { return ptr_; }
        T& operator*()  const noexcept { return *ptr_; }
        explicit operator bool() const noexcept { return ptr_ != nullptr; }
    };

    template<typename... Args>
    [[nodiscard]] Handle acquire(Args&&... args) {
        return {pool_.acquire(std::forward<Args>(args)...), this};
    }
};

ManagedPool<std::vector<int>, 32> pool;

{
    auto h = pool.acquire();
    if (!h) throw std::runtime_error("pool exhausted");
    h->push_back(42);
}  // slot returned automatically — no explicit release

Move assignment is easy to overlook. Without it, h2 = std::move(h1) fails to compile (deleted copy) unless you later add it — and adding it later is error-prone because the old value of h2 must be released first.

Thread-Safe Pool

Add a mutex and condition variable (both C++11) to serialize access across threads:

cpp
template<typename T, std::size_t N>
class ThreadSafePool {
    ObjectPool<T, N>        pool_;
    mutable std::mutex      mtx_;   // C++11
    std::condition_variable cv_;    // C++11

public:
    template<typename... Args>
    T* try_acquire(Args&&... args) {
        std::lock_guard g{mtx_};    // C++17: CTAD on lock_guard
        return pool_.acquire(std::forward<Args>(args)...);
    }

    template<typename... Args>
    T* acquire_blocking(Args&&... args) {
        std::unique_lock lock{mtx_};
        cv_.wait(lock, [this]{ return pool_.available() > 0; });
        return pool_.acquire(std::forward<Args>(args)...);
    }

    void release(T* ptr) noexcept {
        { std::lock_guard g{mtx_}; pool_.release(ptr); }
        cv_.notify_one();
    }
};

For high-contention scenarios, a lock-free free list using std::atomic<Slot*> with compare_exchange_weak (C++11) reduces contention. The naive single-word CAS is vulnerable to the ABA problem: a second thread can pop and repush the same slot between your load and CAS, causing the exchange to succeed with a stale next pointer. Mitigations include tagged pointers (packing a version counter into the low bits, relying on alignment) or std::atomic<std::pair<Slot*, uint64_t>> with a 128-bit compare-exchange.

std::pmr vs Object Pool (C++17)

C++17's <memory_resource> provides std::pmr::unsynchronized_pool_resource and std::pmr::synchronized_pool_resource — standard-library pool allocators that distribute fixed-size chunks from pre-allocated blocks:

cpp
#include <memory_resource>  // C++17

std::pmr::unsynchronized_pool_resource pool_res;
std::pmr::vector<int> v{&pool_res};  // allocations come from pool_res
v.reserve(1024);

PMR pools and object pools solve different problems. pmr::pool_resource is a general-purpose allocator that integrates with PMR-aware containers via std::pmr::polymorphic_allocator. Use it when you need containers or allocator-parameterized types to live in a pool. Use the custom ObjectPool above when you need typed acquire/release semantics, RAII handles, and explicit per-object construction arguments.

Best Practices

Pre-size from measurements, not guesses. Profile the peak simultaneous live count under representative load, then add 20–30% headroom. Guessing leads to either wasted memory or silent nullptr returns under production load.

Ensure the pool outlives all handles. Handle::~Handle() calls back into the pool. If the pool is destroyed first, the destructor dereferences a dangling pointer. Pools should be static, global, or owned by an object with strictly longer lifetime than any borrower.

Mark acquire with [[nodiscard]] (C++17). Discarding the return value orphans a slot permanently. The attribute makes this a compiler warning.

Test the exhaustion path. acquire returning nullptr is a legitimate steady-state condition. Callers that unconditionally dereference the result will crash under load. Write tests that fill the pool to capacity.

Common Pitfalls

Use-after-release. Calling release(ptr) runs the destructor and makes ptr a dead pointer. Any subsequent dereference is undefined behaviour. The RAII handle prevents this entirely; raw pointer APIs cannot. In debug builds, overwrite the released slot with a sentinel byte pattern to catch stale reads early.

Double-release. Releasing the same pointer twice corrupts the free list, typically manifesting as a crash or silent data corruption far from the source. Instrument release in debug builds with an std::unordered_set<T*> of live pointers and assert before each release.

Cross-pool release. Two ObjectPool instances hold separate slots_ arrays. Releasing a pointer obtained from pool A into pool B inserts a foreign address into B's free list and corrupts it on the next acquire. Ownership must be unambiguous; the RAII handle carries the pool pointer to prevent this.

Destroying the pool with live handles. All pending Handle destructors will call release on a destroyed pool's storage. Make destruction order explicit — either through RAII scope ordering or by asserting size() == 0 in the pool's destructor.

See Also

  • std::pmr::pool_resource (C++17) — standard pool allocator for PMR-aware containers
  • std::construct_at (C++20) — placement-new wrapper that participates in constant evaluation
  • std::destroy_at (C++17) — explicit destruction without deallocation
  • RAII — the ownership model that makes pool handles leak-safe
  • std::allocator_traits (C++11) — interface contract for writing custom allocators