Memory Pools & Arenas
Reviewed & published by Brayan K
By the end of this lesson you'll understand why per-object new/delete is slow in a hot loop, and you'll be able to build a fixed-block pool allocator and a bump-pointer arena by hand, recycle objects with an object pool, and reach for std::pmr when you want the speed without writing the allocator yourself.
Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Explain why per-object new/delete is costly in hot paths (overhead + fragmentation)
- Build a fixed-block pool allocator with an O(1) free list
- Reuse objects safely with an object pool and placement new
- Build a bump-pointer arena that frees everything in O(1) with reset()
- Use std::pmr (monotonic_buffer_resource) to get arena speed for free
- Decide when a custom allocator is worth it — and when it isn't
💡 Real-World Analogy
Calling new for every object is like driving to the store for one ingredient each time you cook — generic, flexible, and slow because each trip has fixed overhead. A pool is a tray of identical lunchboxes by the door: grab one when you need it, drop it back when you're done, and an empty box is instantly reusable — no searching, no oddly-shaped gaps. An arena is a whiteboard: you keep writing left to right (bumping a pointer), and when the meeting ends you wipe the whole board in one stroke instead of erasing each note. General new/delete is the all-purpose store; pools and arenas are purpose-built and far faster for the right pattern.
📊 Why default new/delete hurts in hot paths
| Cost | What happens on every new/delete |
|---|---|
| Bookkeeping | Searches free lists for a big-enough block, may split/merge blocks. |
| Locking | The global heap is shared, so allocation often takes a lock — contention under threads. |
| Fragmentation | Mixed sizes leave gaps too small to reuse; memory grows and cache behaviour worsens. |
| Cache misses | Objects land far apart in memory, so iterating them thrashes the CPU cache. |
A pool or arena removes nearly all of this for a known pattern: allocation becomes "pop one node" or "bump a pointer", and the objects sit packed together for cache-friendly iteration. That's why game engines, network servers, and compilers lean on them in their hottest loops.
1. The Fixed-Block Pool Allocator
A pool carves one big block into equal-sized slots and hands them out one at a time. The clever bit is the free list: while a slot is unused, you reuse its own bytes to store a pointer to the next free slot. Allocation pops the head of that list and deallocation pushes a slot back onto it — both are O(1) with zero extra memory. Read this worked example carefully; every non-obvious line is commented with what it does.
#include <iostream>
using namespace std;
// A POOL hands out fixed-size SLOTS from one big block.
// Trick: while a slot is free we reuse its bytes to store a
// pointer to the NEXT free slot -> a "free list" with zero extra memory.
class BytePool {
static const int SLOT = 32; // bytes per slot
static const int COUNT = 4; // how many slots
char buffer[SLOT * COUNT]; // one contiguous block
void* freeList = nullptr; // head of the free list
public:
BytePool() {
// Link every slot into the free list, back to front.
for (int i = 0; i < COUNT; i++) {
void* slot = buffer + i * SLOT;
*reinterpret_cast<void**>(slot) = freeList; // store "next" in the slot
freeList = slot; // push it on the list
}
cout << "Pool ready: " << COUNT << " slots of " << SLOT << " bytes\n";
}
void* allocate() {
if (!freeList) { cout << "Pool empty!\n"; return nullptr; }
void* slot = freeList; // pop the head -> O(1)
freeList = *reinterpret_cast<void**>(freeList); // head = its "next"
return slot;
}
void deallocate(void* slot) {
*reinterpret_cast<void**>(slot) = freeList; // push back -> O(1)
freeList = slot;
}
};
int main() {
BytePool pool;
void* a = pool.allocate(); // takes a slot
void* b = pool.allocate(); // takes another
cout << "a and b differ? " << (a != b) << "\n"; // 1 (true)
pool.deallocate(a); // return a's slot to the list
void* c = pool.allocate(); // reuses the SAME slot a had
cout << "c reused a's slot? " << (c == a) << "\n"; // 1 (true)
pool.allocate(); // 4th slot
pool.allocate(); // pool now empty -> prints "Pool empty!"
return 0;
}
// ✅ Expected output:
// Pool ready: 4 slots of 32 bytes
// a and b differ? 1
// c reused a's slot? 1A raw byte pool is fine for learning, but in real code you want a typed object pool that constructs a real object in the slot. That needs placement new — new (ptr) T(args) runs a constructor on memory you already own, without allocating anything. Because placement new never pairs with delete, you must call the destructor yourself with ptr->~T() before recycling the slot.
#include <iostream>
#include <new> // placement new
using namespace std;
struct Bullet {
int id; float x;
Bullet(int i, float px) : id(i), x(px) {
cout << " Bullet " << id << " spawned at x=" << x << "\n";
}
~Bullet() { cout << " Bullet " << id << " destroyed\n"; }
};
template <typename T, int N>
class ObjectPool {
// A slot is raw, correctly-sized, correctly-aligned bytes for ONE T,
// OR a pointer to the next free slot when the slot is empty.
union Slot { T value; Slot* next; Slot() {} ~Slot() {} };
Slot storage[N];
Slot* freeList = nullptr;
public:
ObjectPool() {
for (int i = 0; i < N; i++) { // build the free list
storage[i].next = freeList;
freeList = &storage[i];
}
}
// allocate = pop a slot, then construct T in place with placement new
template <typename... Args>
T* create(Args&&... args) {
if (!freeList) { cout << "Pool full!\n"; return nullptr; }
Slot* s = freeList;
freeList = s->next;
return new (&s->value) T(forward<Args>(args)...); // placement new
}
// destroy = call the destructor BY HAND, then push the slot back
void destroy(T* obj) {
obj->~T(); // placement new never calls delete
Slot* s = reinterpret_cast<Slot*>(obj);
s->next = freeList;
freeList = s;
}
};
int main() {
ObjectPool<Bullet, 3> pool;
Bullet* b1 = pool.create(1, 10.0f);
Bullet* b2 = pool.create(2, 20.0f);
cout << "b1 id = " << b1->id << ", b2 id = " << b2->id << "\n";
pool.destroy(b1); // frees the slot (and runs ~Bullet)
Bullet* b3 = pool.create(3, 30.0f); // reuses b1's slot
cout << "b3 reused b1's slot? " << (reinterpret_cast<void*>(b3)
== reinterpret_cast<void*>(b1)) << "\n"; // 1
pool.destroy(b2);
pool.destroy(b3);
return 0;
}
// ✅ Expected output:
// Bullet 1 spawned at x=10
// Bullet 2 spawned at x=20
// b1 id = 1, b2 id = 2
// Bullet 1 destroyed
// Bullet 3 spawned at x=30
// b3 reused b1's slot? 1
// Bullet 2 destroyed
// Bullet 3 destroyed2. The Arena (Bump) Allocator
An arena (also called a bump allocator) is even simpler than a pool. It keeps one cursor — an offset — and every allocation just hands back the current position then moves the cursor forward. You never free individual objects; instead you reset() the whole arena, which rewinds the cursor to 0 and reclaims everything at once in O(1). This is ideal for data with a clear phase: per-frame game state, per-request server scratch memory, or a compiler's AST nodes. Now you finish one.
The program below is almost complete — fill in the two blanks marked ___ using the // 👉 hints, then run it and check it against the expected output.
#include <iostream>
using namespace std;
// 🎯 YOUR TURN — finish this ARENA (bump) allocator.
// An arena hands out memory by moving "offset" forward. Freeing
// everything is just resetting offset back to 0 -> O(1).
class Arena {
char buffer[256];
size_t offset = 0;
public:
void* allocate(size_t bytes) {
if (offset + bytes > sizeof(buffer)) return nullptr;
void* ptr = buffer + offset;
// 1) Move the cursor forward by 'bytes' so the next
// allocation starts after this one.
offset = ___; // 👉 add bytes to the current offset
return ptr;
}
// 2) "Free everything at once" by rewinding the cursor.
void reset() {
___; // 👉 set offset back to 0
}
size_t used() const { return offset; }
};
int main() {
Arena arena;
arena.allocate(40);
arena.allocate(60);
cout << "Used: " << arena.used() << " bytes\n"; // Used: 100 bytes
arena.reset();
cout << "After reset: " << arena.used() << " bytes\n"; // After reset: 0 bytes
return 0;
// ✅ Expected output:
// Used: 100 bytes
// After reset: 0 bytes
}3. std::pmr — Standard Allocators Without the Boilerplate
Hand-writing allocators is great for understanding, but C++17 gives you a ready-made framework: std::pmr (polymorphic memory resources). A std::pmr::memory_resource is an object that knows how to hand out memory, and pmr containers like pmr::vector take their memory from it. Two come built in: monotonic_buffer_resource is an arena (bump, reset on destruction), and unsynchronized_pool_resource is a pool. You get custom-allocator speed without writing the allocator — and you can swap strategies without changing the container's type.
#include <iostream>
#include <vector>
#include <memory_resource> // std::pmr (C++17)
using namespace std;
int main() {
// Give a fixed stack buffer to a monotonic_buffer_resource:
// it's an ARENA -> bump allocate, never reuses freed memory,
// releases it all when the resource dies. No heap calls at all.
char buffer[1024];
pmr::monotonic_buffer_resource arena{buffer, sizeof(buffer)};
// A pmr::vector takes its memory FROM that resource.
pmr::vector<int> v{&arena};
for (int i = 1; i <= 5; i++) v.push_back(i * 10);
cout << "pmr vector: ";
for (int n : v) cout << n << " "; // 10 20 30 40 50
cout << "\n";
// Strings in the same arena -> still no global new/delete.
pmr::vector<pmr::string> names{&arena};
names.emplace_back("Ada");
names.emplace_back("Linus");
for (auto& s : names) cout << s << " "; // Ada Linus
cout << "\n";
return 0;
}
// ✅ Expected output:
// pmr vector: 10 20 30 40 50
// Ada Linus🔎 Deep Dive: placement new, destructors & alignment
Pools and arenas hand out raw bytes, not constructed objects. Placement new bridges that gap: new (ptr) T(args) calls T's constructor at the address ptr and allocates nothing. The price is symmetry — there is no "placement delete", so you must run the destructor yourself: ptr->~T();. Forget that, and any work the destructor does (closing files, freeing inner buffers) silently leaks.
Alignment matters too: a double usually must sit on an 8-byte boundary. A union-of-T slot (as in the object pool) is automatically aligned for T; a raw char buffer is not, so a real arena rounds each offset up to the right boundary before handing it out.
void* mem = pool.raw_slot(); // raw, owned bytes
T* obj = new (mem) T(args); // placement new: construct in place
// ... use obj ...
obj->~T(); // run the destructor BY HAND
pool.recycle(mem); // now the slot is safe to reusePro Tips
- 💡 Measure first: only replace new/delete after a profiler proves allocation is the bottleneck. Most code should keep the default.
- 💡 Match the pattern to the allocator: same-size objects → pool; a whole phase freed together → arena; strict reverse order → a stack allocator.
- 💡 Free lists are free memory: storing the "next" pointer inside the unused slot means a pool needs no side table — the slot is the bookkeeping.
- 💡 Prefer std::pmr when you can: it gives arena/pool behaviour to standard containers with far less code to get wrong.
Common Errors (and the fix)
- Forgetting the destructor: after placement new you must call ptr->~T(); before recycling. Skipping it leaks whatever the destructor would clean up. Never call delete on placement-new memory.
- Calling delete on a pooled pointer: the pool owns the block, so delete e; on an object that came from a pool corrupts the heap. Return it with pool.deallocate(e) instead.
- Use-after-free across reset: after arena.reset(), every pointer the arena handed out is dangling. Reading *p afterwards is undefined behaviour — drop those pointers when you reset.
- Misaligned allocation: handing a char* offset straight to a double* can crash or run slowly. Round the offset up to alignof(T) before constructing.
- 'memory_resource' file not found: std::pmr needs #include <memory_resource> and C++17 (compile with -std=c++17 or newer).
📋 Quick Reference
| Allocator | Allocate | Free | Best for |
|---|---|---|---|
| Pool | O(1) pop free list | O(1) push back | Many same-size objects |
| Arena | O(1) bump offset | O(1) reset all | Per-frame / per-request |
| Stack | O(1) bump | O(1) LIFO pop | Scoped temporaries |
| pmr::monotonic | O(1) bump | on destruction | Standard containers, arena |
| new / delete | O(1)–O(n) | O(1)–O(n) | General purpose |
Key calls: new (ptr) T(args) (placement new), ptr->~T() (manual destructor), #include <memory_resource> for std::pmr.
Mini-Challenge: Build a Token Pool
No blanks this time — just a brief and an outline. Build a fixed-block pool for a tiny Token type, hand out and recycle slots, and prove a freed slot gets reused by comparing pointers. This is the exact pattern a real engine uses for particles, entities, and packets.
#include <iostream>
#include <new>
using namespace std;
struct Token { int kind; };
int main() {
// 🎯 MINI-CHALLENGE: a tiny fixed-block Token pool
// 1. Make a union Slot holding either a Token or a Slot* next.
// 2. Make an array of, say, 4 Slots and link them into a free list.
// 3. Write create(): pop the free list, placement-new a Token,
// return the pointer (or nullptr if the pool is empty).
// 4. Write destroy(Token*): call ~Token(), push the slot back.
// 5. In main(): create 2 tokens, destroy 1, create 1 more, and
// show the new one reused the freed slot (compare the pointers).
//
// ✅ Example output:
// reused freed slot? 1
// your code here
return 0;
}🎉 Lesson Complete
- ✅ Default new/delete pays for bookkeeping, locking, and fragmentation on every call
- ✅ A pool hands out fixed-size slots via an O(1) free list stored inside the slots themselves
- ✅ An object pool uses placement new to construct in a slot, and ptr->~T() to recycle it
- ✅ An arena bumps a cursor and frees everything in O(1) with reset()
- ✅ std::pmr gives standard containers arena/pool speed with almost no boilerplate
- ✅ Reach for custom allocators only in proven hot paths — measure first
Practice quiz
Why is per-object new/delete slow in a hot loop compared to a pool?
- It searches free lists, may split/merge blocks, and often takes a lock
- It always zero-initialises the memory it returns
- It compresses the heap on every call
- It runs the constructor twice for safety
Answer: It searches free lists, may split/merge blocks, and often takes a lock. A general allocator does bookkeeping (find/split/merge a block) and is usually thread-safe (a lock), while a pool just pops one node.
In a fixed-block pool's free list, where is the 'next free slot' pointer stored?
- In a separate side table indexed by slot number
- Inside the unused slot's own bytes
- In a global hash map keyed by address
- At the end of the buffer, after all slots
Answer: Inside the unused slot's own bytes. While a slot is free its bytes are reused to hold the next pointer, so the pool needs zero extra bookkeeping memory.
What are the time complexities of pool allocate and deallocate?
- O(n) allocate, O(1) deallocate
- O(log n) for both
- O(1) for both — pop the head, push it back
- O(n) for both
Answer: O(1) for both — pop the head, push it back. Allocation pops the head of the free list and deallocation pushes a slot back, both O(1).
What does placement new — new (ptr) T(args) — do?
- Allocates fresh memory and constructs T in it
- Runs T's constructor on memory you already own, allocating nothing
- Frees ptr and then reallocates T
- Only zeroes the bytes at ptr
Answer: Runs T's constructor on memory you already own, allocating nothing. Placement new constructs an object at an address you provide; it allocates no memory itself.
After constructing an object with placement new, how do you destroy it before recycling the slot?
- Call delete on the pointer
- Call delete[] on the pointer
- Call the destructor by hand: ptr->~T()
- Nothing — the slot is freed automatically
Answer: Call the destructor by hand: ptr->~T(). There is no placement delete, so you must run the destructor yourself with ptr->~T(); never call delete on placement-new memory.
How does an arena (bump) allocator free everything?
- It calls delete on each object in reverse order
- It walks a free list and frees each node
- It resets the offset cursor back to 0 in O(1)
- It cannot free — the OS reclaims it at exit
Answer: It resets the offset cursor back to 0 in O(1). An arena hands out memory by bumping an offset; reset() rewinds the offset to 0, reclaiming everything at once.
In the arena's allocate(), how is the cursor advanced after handing back the current position?
- offset = offset + bytes;
- offset = 0;
- offset = bytes - offset;
- offset = sizeof(buffer);
Answer: offset = offset + bytes;. Each allocation returns buffer + offset, then moves offset forward by the requested number of bytes.
Which std::pmr resource behaves like an arena (bump-allocate, reclaim on destruction)?
- std::pmr::synchronized_pool_resource
- std::pmr::monotonic_buffer_resource
- std::pmr::new_delete_resource
- std::pmr::null_memory_resource
Answer: std::pmr::monotonic_buffer_resource. monotonic_buffer_resource bump-allocates and never reuses freed memory, releasing it all when the resource dies.
Which header must you include to use std::pmr, and from which standard?
- <memory_resource>, C++17
- <memory>, C++11
- <pmr>, C++20
- <allocator>, C++14
Answer: <memory_resource>, C++17. std::pmr (polymorphic memory resources) lives in <memory_resource> and was added in C++17.
When is it appropriate to reach for a custom pool or arena allocator?
- Always — it is faster than new/delete in every program
- Only in a proven hot path after a profiler shows allocation is the bottleneck
- Whenever a class has a destructor
- Only when the program is single-threaded
Answer: Only in a proven hot path after a profiler shows allocation is the bottleneck. Default new/delete is fine for most code; measure first and use a custom allocator only where allocation is genuinely the bottleneck.
Continue this course
- Previous: Building Custom Iterators & Using Ranges Efficiently
- Next: Game Development Essentials in C++ (Architecture, Components, Timing) — Entity-component systems, game loops, and timing in C++ game engines
- Quick reference: C++ cheat sheet
Frequently asked questions
Why is new/delete slow if it's just allocating memory?
Because a general-purpose allocator has to do real work on every call: find a free block big enough, split or merge blocks, update internal bookkeeping, and stay thread-safe (often a lock). A pool or arena skips almost all of that — it just bumps a pointer or pops one node off a list — so it can be many times faster in a hot loop.
What is memory fragmentation and why do pools avoid it?
Fragmentation is when free memory exists but is split into gaps too small to satisfy a request, so allocation fails or slows down even though there is 'enough' memory. A pool gives out fixed-size slots that are all interchangeable, so a freed slot can always be reused for the next object — there are no oddly-sized gaps to strand.
When should I NOT write a custom allocator?
Most of the time. Default new/delete is fine for ordinary code, and a custom allocator adds complexity and bugs. Reach for a pool or arena only when a profiler shows allocation is a real bottleneck, or in a hot path like a game frame loop, a packet handler, or a request handler where you allocate and free many objects of the same shape.
What is placement new and why do pools use it?
Placement new — new (ptr) T(args) — runs a constructor on memory you already own instead of allocating fresh memory. Pools and arenas hand out raw bytes, so they use placement new to construct the object in those bytes. The catch: placement new does not call delete, so you must call the destructor yourself with ptr->~T().
What is std::pmr and how is it different from writing my own pool?
std::pmr (polymorphic memory resources, C++17) is a standard framework that lets containers like std::vector take their memory from a memory_resource you choose at run time — for example a monotonic_buffer_resource (an arena) or an unsynchronized_pool_resource (a pool). You get the speed of a custom allocator without hand-writing one, and you can swap strategies without changing the container's type.