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++11An 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
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:
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 releaseMove 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:
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:
#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 containersstd::construct_at(C++20) — placement-new wrapper that participates in constant evaluationstd::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