Lesson 24 • Advanced
Memory Pools & Arenas
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.
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
new/delete, classes, and templates. If raw pointers feel shaky, revisit Pointers & References first. This lesson works at a low level — that's the whole point.💡 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.
Worked example: a minimal byte pool
See the free list pop on allocate and push on deallocate, and a freed slot get reused.
#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() {
...A 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.
Worked example: a typed object pool
Placement new constructs objects in pooled slots; destroy() runs the destructor by hand and recycles 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 S
...2. 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.
🎯 Your turn: finish the bump allocator
Fill in the two ___ blanks so allocate() advances the cursor and reset() rewinds it.
#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
// alloc
...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.
Worked example: a pmr::vector backed by an arena
A monotonic_buffer_resource over a stack buffer feeds a pmr::vector — fast allocation, no global new.
#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}
...🔎 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 reuse
Pro Tips
- 💡 Measure first: only replace
new/deleteafter 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::pmrwhen 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 calldeleteon placement-new memory. - Calling
deleteon a pooled pointer: the pool owns the block, sodelete e;on an object that came from a pool corrupts the heap. Return it withpool.deallocate(e)instead. - Use-after-free across reset: after
arena.reset(), every pointer the arena handed out is dangling. Reading*pafterwards is undefined behaviour — drop those pointers when you reset. - Misaligned allocation: handing a
char*offset straight to adouble*can crash or run slowly. Round the offset up toalignof(T)before constructing. - 'memory_resource' file not found:
std::pmrneeds#include <memory_resource>and C++17 (compile with-std=c++17or 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.
Frequently Asked Questions
Q: 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.
Q: 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.
Q: 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.
Q: 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().
Q: 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.
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.
🎯 Mini-Challenge: a fixed-block Token pool
Write the union slot, free list, create() with placement new, and destroy() — then show a slot is reused.
#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.
...🎉 Lesson Complete
- ✅ Default
new/deletepays 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)withreset() - ✅
std::pmrgives standard containers arena/pool speed with almost no boilerplate - ✅ Reach for custom allocators only in proven hot paths — measure first
- ✅ Next lesson: Game Development — put these allocators to work in a real frame loop
Sign up for free to track which lessons you've completed and get learning reminders.