Skip to main content
    Courses/C++/Memory Pools & Arenas

    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

    💡 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

    CostWhat happens on every new/delete
    BookkeepingSearches free lists for a big-enough block, may split/merge blocks.
    LockingThe global heap is shared, so allocation often takes a lock — contention under threads.
    FragmentationMixed sizes leave gaps too small to reuse; memory grows and cache behaviour worsens.
    Cache missesObjects 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.

    Try it Yourself »
    C++
    #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 newnew (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.

    Try it Yourself »
    C++
    #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.

    Try it Yourself »
    C++
    #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.

    Try it Yourself »
    C++
    #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/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

    AllocatorAllocateFreeBest for
    PoolO(1) pop free listO(1) push backMany same-size objects
    ArenaO(1) bump offsetO(1) reset allPer-frame / per-request
    StackO(1) bumpO(1) LIFO popScoped temporaries
    pmr::monotonicO(1) bumpon destructionStandard containers, arena
    new / deleteO(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.

    Try it Yourself »
    C++
    #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/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
    • 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.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service