Skip to content
C++
Library
since C++17
Advanced

Custom Allocators and PMR

C++ custom allocators and C++17 polymorphic memory resources: arena, pool, PMR containers, allocator propagation, and zero-heap hot paths.

std::pmr::memory_resourcesince C++17

An abstract base class whose concrete implementations supply memory to polymorphic allocators, enabling runtime-swappable allocation strategies β€” arena, pool, logging β€” without changing container types.

Overview

C++ has had allocators since C++98, but the original design embeds the allocator type into the container's template signature. std::vector<int, MyAlloc> and std::vector<int, OtherAlloc> are unrelated types; swapping strategies requires a recompile. C++11's std::allocator_traits improved introspection and enabled stateful allocators, but the type-signature problem remained.

C++17's std::pmr resolves this with polymorphic dispatch. All PMR containers β€” std::pmr::vector<T>, std::pmr::map<K,V>, etc. β€” are type aliases that fix the allocator parameter to std::pmr::polymorphic_allocator<T>. That allocator stores a pointer to a std::pmr::memory_resource. Swapping the resource at construction time changes allocation behavior at runtime without touching the container type.

The memory_resource virtual interface has exactly three methods:

cpp
// C++17 β€” <memory_resource>
class memory_resource {
protected:
    virtual void* do_allocate(std::size_t bytes, std::size_t alignment) = 0;
    virtual void  do_deallocate(void* p, std::size_t bytes, std::size_t alignment) = 0;
    virtual bool  do_is_equal(const memory_resource& other) const noexcept = 0;
};

do_is_equal must return true if and only if memory allocated by *this can be safely deallocated by other. For most stateful resources this means return this == &other;. Getting it wrong causes silent UB.

Syntax

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

// --- Singleton global resources ---
std::pmr::memory_resource* std::pmr::new_delete_resource();   // delegates to ::operator new/delete
std::pmr::memory_resource* std::pmr::null_memory_resource();  // throws std::bad_alloc unconditionally
std::pmr::memory_resource* std::pmr::get_default_resource();
std::pmr::memory_resource* std::pmr::set_default_resource(std::pmr::memory_resource*); // returns old

// --- Bump allocator (monotonic_buffer_resource) ---
// O(1) allocate, zero per-object dealloc cost; release() resets cursor without freeing buffer.
// NOT thread-safe.  When fixed buffer exhausts, falls through to upstream.
std::pmr::monotonic_buffer_resource mono{buffer_ptr, size};               // fixed buffer, upstream = new_delete
std::pmr::monotonic_buffer_resource mono{buffer_ptr, size, upstream_ptr}; // explicit upstream
std::pmr::monotonic_buffer_resource mono{initial_bytes};                  // heap-backed, grows geometrically

// --- Pool resources ---
std::pmr::pool_options opts{
    .max_blocks_per_chunk       = 64,   // batch size before requesting upstream
    .largest_required_pool_block = 512, // objects larger than this bypass pool entirely
};
std::pmr::unsynchronized_pool_resource upool{opts};  // single-threaded
std::pmr::synchronized_pool_resource   spool{opts};  // internally mutex-protected; use only when needed

Examples

Arena allocation for per-request processing

The defining use case: all memory for one logical unit of work comes from a stack buffer; the entire arena is discarded atomically.

cpp
#include <memory_resource>
#include <vector>
#include <map>
#include <string>

void handle_request(std::string_view raw) {
    // Align the buffer so monotonic_buffer_resource can serve any alignment
    alignas(std::max_align_t) std::byte buf[65536];                    // 64 KB on stack
    std::pmr::monotonic_buffer_resource arena{                          // C++17
        buf, sizeof(buf), std::pmr::null_memory_resource()             // hard cap β€” no silent heap overflow
    };

    std::pmr::map<std::pmr::string, std::pmr::string> headers{&arena};
    std::pmr::vector<std::pmr::string>                tokens{&arena};

    // All string storage lives inside buf β€” zero heap allocation
    headers.emplace("Content-Type", "application/json");
    headers.emplace("X-Request-Id", "abc-123");

    tokenize(raw, tokens, &arena);
    dispatch(headers, tokens);
    // arena is destroyed β€” stack unwinds, no per-object free() needed
}

Passing null_memory_resource() as upstream converts overflow into an immediate std::bad_alloc rather than silently falling back to the heap. Size your buffer to handle the 99th-percentile workload; if it throws in production you have a sizing problem worth fixing, not hiding.

Pool resource for homogeneous node allocations

unsynchronized_pool_resource maintains size-segregated free lists internally. It shines when many same-sized objects are allocated and freed repeatedly: linked list nodes, tree nodes, message objects.

cpp
#include <memory_resource>
#include <list>

struct Packet {
    uint32_t           seq;
    uint8_t            ttl;
    std::array<char, 128> payload;
};

void pump(Channel& ch) {
    std::pmr::unsynchronized_pool_resource pool{{   // C++17
        .max_blocks_per_chunk        = 256,
        .largest_required_pool_block = sizeof(Packet) + 8,
    }};

    std::pmr::list<Packet> inflight{&pool};

    while (ch.has_data()) {
        inflight.push_back(ch.recv());  // node from pool β€” sequential memory, good prefetch behaviour
        if (inflight.front().ttl == 0)
            inflight.pop_front();       // node returned to pool for reuse, not freed to heap
    }
}

Note: unsynchronized_pool_resource does not coalesce adjacent free blocks. A workload with widely varying sizes can fragment the upstream even with many free blocks available.

Custom PMR resource

cpp
#include <memory_resource>
#include <atomic>

class ProfilingResource : public std::pmr::memory_resource {  // C++17
    std::pmr::memory_resource* upstream_;
    std::atomic<std::size_t>   total_bytes_{0};
    std::atomic<std::size_t>   call_count_{0};

public:
    explicit ProfilingResource(
        std::pmr::memory_resource* up = std::pmr::new_delete_resource()) noexcept
        : upstream_{up} {}

    std::size_t bytes_allocated()  const noexcept { return total_bytes_.load(std::memory_order_relaxed); }
    std::size_t allocation_count() const noexcept { return call_count_.load(std::memory_order_relaxed); }

protected:
    void* do_allocate(std::size_t bytes, std::size_t align) override {
        total_bytes_.fetch_add(bytes, std::memory_order_relaxed);
        call_count_.fetch_add(1,     std::memory_order_relaxed);
        return upstream_->allocate(bytes, align);
    }

    void do_deallocate(void* p, std::size_t bytes, std::size_t align) override {
        upstream_->deallocate(p, bytes, align);
    }

    bool do_is_equal(const memory_resource& other) const noexcept override {
        return this == &other;
    }
};

Allocator-aware types and scoped_allocator_adaptor

For a type to fully participate in PMR β€” so that its internal pmr::string and pmr::vector members also allocate from the outer arena β€” it must declare allocator_type and provide the standard extended constructors. std::uses_allocator<T, A> (C++11) is specialized automatically when allocator_type is a member.

cpp
#include <memory_resource>
#include <scoped_allocator>  // C++11

class Document {
public:
    using allocator_type = std::pmr::polymorphic_allocator<std::byte>;  // C++17

    explicit Document(std::string_view title, allocator_type alloc = {})
        : title_{title, alloc}, sections_{alloc} {}

    // Extended copy constructor required for allocator-aware containers
    Document(const Document& o, allocator_type alloc)
        : title_{o.title_, alloc}, sections_{o.sections_, alloc} {}

private:
    std::pmr::string              title_;
    std::pmr::vector<std::pmr::string> sections_;
};

void build_corpus() {
    std::byte buf[1 << 20];
    std::pmr::monotonic_buffer_resource arena{buf, sizeof(buf)};

    // scoped_allocator_adaptor (C++11) automatically forwards the arena
    // into each Document's constructor β€” without it, Document members
    // would silently use get_default_resource() instead.
    using Alloc = std::scoped_allocator_adaptor<
        std::pmr::polymorphic_allocator<Document>>;

    std::vector<Document, Alloc> corpus{Alloc{&arena}};
    corpus.emplace_back("Concepts");
    corpus.emplace_back("Ranges");
    // Every string inside every Document is allocated from arena
}

Assert-no-allocation scope (testing)

cpp
struct NoAllocScope {
    std::pmr::memory_resource* saved_;
    NoAllocScope()  : saved_{std::pmr::set_default_resource(std::pmr::null_memory_resource())} {}
    ~NoAllocScope() { std::pmr::set_default_resource(saved_); }
};

// In a test:
{
    NoAllocScope guard;  // any std::bad_alloc thrown here = unexpected allocation
    result = hot_path(input);
}

This only intercepts allocations that go through the PMR default resource. Direct ::operator new calls are invisible to it.

Thread-local arena (zero-contention per-thread scratch space)

cpp
thread_local std::byte tl_buf[256 * 1024];
thread_local std::pmr::monotonic_buffer_resource tl_arena{
    tl_buf, sizeof(tl_buf), std::pmr::null_memory_resource()
};

void process_task(const Task& t) {
    tl_arena.release();  // resets cursor; does NOT free tl_buf (it is thread_local storage)

    std::pmr::vector<Result> results{&tl_arena};
    for (auto& item : t.items())
        results.push_back(evaluate(item));
    commit(results);
}

Best Practices

  • Chain resources explicitly. Always pass an upstream pointer; relying on ambient get_default_resource() makes your resource's overflow behaviour surprising when the default changes under test.
  • Prefer unsynchronized_pool_resource by default. synchronized_pool_resource acquires a mutex per allocation; the overhead is measurable. Move to the synchronized variant only when you've confirmed multiple threads share the resource.
  • Align your arena buffers. Declare alignas(std::max_align_t) on stack arrays used as monotonic_buffer_resource backing. Without it, the resource may need to waste bytes padding to the first aligned position.
  • Apply scoped_allocator_adaptor whenever you store allocator-aware types in a container. Omitting it silently reroutes inner allocations to the default resource.
  • Implement do_is_equal correctly. For stateful, non-shareable resources, return this == &other is correct. A wrong implementation causes UB when two containers swap or merge and their resources are considered equal when they are not.

Common Pitfalls

PMR allocators do not propagate on copy or move.
std::pmr::polymorphic_allocator sets all propagation traits to false (POCCA, POCMA, POCS). A copy of a PMR container allocates from get_default_resource(), not from the original's resource:

cpp
std::byte buf[4096];
std::pmr::monotonic_buffer_resource arena{buf, sizeof(buf)};

std::pmr::vector<int> a{&arena};
a.push_back(42);

std::pmr::vector<int> b = a;  // b uses get_default_resource() β€” NOT arena

This is intentional: propagating the resource into a copy whose lifetime might exceed the arena's would introduce dangling pointers. It surprises nearly every first-time PMR user.

set_default_resource is process-wide, not thread-local.
Setting null_memory_resource() as the global default in one test thread will cause std::bad_alloc for any other thread that allocates through the default resource concurrently. Always restore via RAII as shown above.

monotonic_buffer_resource is not thread-safe.
Concurrent allocate() calls on the same instance produce data races. Use synchronized_pool_resource or external locking if the resource must be shared.

Classic (C++98) allocator rebind must propagate state.
When a container internally reuses your allocator for a different value type (e.g., a std::map uses rebind<Node> to allocate tree nodes), any state not propagated through rebind is silently lost. This is the primary reason to prefer PMR over rolling your own allocator template.

Writing a minimal allocator

Every STL-compatible allocator requires just allocate, deallocate, and value_type. std::allocator_traits (C++11) fills in the remaining ~15 optional members with sensible defaults, so modern minimal allocators are far smaller than their C++98 counterparts.

cpp
// C++11 β€” minimal conforming allocator
template <typename T>
struct MallocAllocator {
    using value_type = T;

    MallocAllocator() noexcept = default;

    // Required rebind: lets std::map reuse this allocator for its internal node type
    template <typename U>
    MallocAllocator(const MallocAllocator<U>&) noexcept {}

    T* allocate(std::size_t n) {
        if (n > std::numeric_limits<std::size_t>::max() / sizeof(T))
            throw std::bad_alloc{};
        void* p = std::malloc(n * sizeof(T));
        if (!p) throw std::bad_alloc{};
        return static_cast<T*>(p);
    }

    void deallocate(T* p, std::size_t) noexcept {
        std::free(p);
    }
};

// Two MallocAllocators of different T are always interchangeable
template <typename T, typename U>
bool operator==(const MallocAllocator<T>&, const MallocAllocator<U>&) noexcept { return true; }
template <typename T, typename U>
bool operator!=(const MallocAllocator<T>&, const MallocAllocator<U>&) noexcept { return false; }

// Usage
std::vector<int, MallocAllocator<int>> v;
v.push_back(1);

Because operator== returns true unconditionally, the allocator is stateless: any two instances can free each other's memory. A stateful allocator must compare by identity or embedded state.

Scoped allocators (scoped_allocator_adaptor)

std::scoped_allocator_adaptor (C++11) solves the inner-container forwarding problem. Without it, a vector<Document> allocates Document objects from your arena but each Document's own pmr::string members silently fall back to the default resource.

cpp
#include <scoped_allocator>  // C++11
#include <memory_resource>   // C++17
#include <vector>
#include <string>

// An allocator-aware value type
struct Entry {
    using allocator_type = std::pmr::polymorphic_allocator<char>;

    std::pmr::string key;
    std::pmr::string value;

    explicit Entry(std::string_view k, std::string_view v, allocator_type a = {})
        : key{k, a}, value{v, a} {}

    // Extended copy constructor β€” required for scoped propagation
    Entry(const Entry& o, allocator_type a)
        : key{o.key, a}, value{o.value, a} {}
};

void demo() {
    std::byte buf[1 << 16];
    std::pmr::monotonic_buffer_resource arena{buf, sizeof(buf)};  // C++17

    // scoped_allocator_adaptor wraps the outer allocator and forwards it
    // automatically to each Entry constructor as the inner allocator argument.
    using Alloc = std::scoped_allocator_adaptor<
        std::pmr::polymorphic_allocator<Entry>>;

    std::vector<Entry, Alloc> table{Alloc{&arena}};
    table.emplace_back("user", "alice");   // key and value strings live in arena
    table.emplace_back("role", "admin");
}

scoped_allocator_adaptor can also be given a chain of inner allocators for multiple nesting levels; in practice a single outer allocator is almost always sufficient.

PMR allocators (C++17)

std::pmr::polymorphic_allocator is the single allocator type that covers all PMR containers; the runtime strategy lives in the memory_resource it wraps. Because all PMR containers share the same allocator type, you can store them in uniform collections and swap resources between calls.

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

// A function that accepts any pmr vector regardless of backing resource
void process(std::pmr::vector<int>& v) {
    std::sort(v.begin(), v.end());
}

void run_with_arena() {
    alignas(std::max_align_t) std::byte buf[8192];
    std::pmr::monotonic_buffer_resource arena{buf, sizeof(buf)};

    std::pmr::vector<int> a{&arena};
    a.assign({5, 3, 1, 4, 2});
    process(a);  // same call site, different resource
}

void run_with_pool() {
    std::pmr::unsynchronized_pool_resource pool;

    std::pmr::vector<int> b{&pool};
    b.assign({9, 7, 8, 6});
    process(b);  // same call site, different resource
}

std::pmr::polymorphic_allocator<T> exposes a resource() accessor that returns the memory_resource* it holds, useful for propagating the resource into nested allocator-aware members via new_object<T>(args...).

Arena allocator pattern

An arena (also called region or bump allocator) is the simplest and fastest allocator: it advances a cursor through a contiguous buffer and ignores individual frees. The entire region is reclaimed in one shot when the arena is destroyed or release() is called.

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

// Hand-rolled arena to illustrate the concept; prefer monotonic_buffer_resource in production
class BumpArena {
    std::byte* base_;
    std::byte* cursor_;
    std::byte* end_;
public:
    BumpArena(void* buf, std::size_t sz) noexcept
        : base_{static_cast<std::byte*>(buf)}, cursor_{base_}, end_{base_ + sz} {}

    void* allocate(std::size_t bytes, std::size_t align = alignof(std::max_align_t)) noexcept {
        // Round cursor up to alignment
        auto cur = reinterpret_cast<std::uintptr_t>(cursor_);
        auto aligned = (cur + align - 1) & ~(align - 1);
        auto next = aligned + bytes;
        if (next > reinterpret_cast<std::uintptr_t>(end_)) return nullptr;
        cursor_ = reinterpret_cast<std::byte*>(next);
        return reinterpret_cast<void*>(aligned);
    }

    void reset() noexcept { cursor_ = base_; }

    std::size_t used()      const noexcept { return static_cast<std::size_t>(cursor_ - base_); }
    std::size_t remaining() const noexcept { return static_cast<std::size_t>(end_   - cursor_); }
};

void arena_demo() {
    alignas(std::max_align_t) std::byte storage[4096];
    BumpArena arena{storage, sizeof(storage)};

    int*    ints   = static_cast<int*>   (arena.allocate(16 * sizeof(int),   alignof(int)));
    double* floats = static_cast<double*>(arena.allocate(8  * sizeof(double), alignof(double)));
    assert(ints != nullptr && floats != nullptr);

    arena.reset();  // reclaim everything β€” O(1)
}

In production code, std::pmr::monotonic_buffer_resource does exactly this with standard-library polish: overflow handling, automatic growth via an upstream resource, and full PMR integration.

Allocator propagation on copy/move

Whether an allocator travels with its container during copy, move, or swap is controlled by three traits in std::allocator_traits. Getting these wrong causes silent re-allocation from the wrong resource after a container moves into a different scope.

cpp
// C++11 β€” controlling propagation traits
template <typename T>
struct StickyAllocator {
    using value_type = T;
    using propagate_on_container_copy_assignment = std::true_type;  // copy carries allocator
    using propagate_on_container_move_assignment = std::true_type;  // move carries allocator
    using propagate_on_container_swap            = std::true_type;  // swap exchanges allocators

    // ... allocate / deallocate ...
};

template <typename T>
struct IsolatedAllocator {
    using value_type = T;
    // All three default to std::false_type when omitted via allocator_traits
    // Copy/move/swap use the destination's existing allocator
};

The table of behaviors:

Traittrue_typefalse_type
POCCA (copy assign)destination takes source's allocatordestination keeps its own
POCMA (move assign)destination takes source's allocator; move is O(1)allocator mismatch forces element-wise move
POCS (swap)allocators are exchangedallocators stay put; elements are exchanged

std::pmr::polymorphic_allocator sets all three to false_type intentionally β€” see the pitfall in the Common Pitfalls section for why.

Stateless vs stateful allocators

A stateless allocator holds no instance data; any two instances are interchangeable and compare equal. std::allocator<T> is stateless. A stateful allocator carries a pointer, handle, or mutex and therefore must implement operator== based on that state.

cpp
// C++11 β€” stateless allocator: sizeof(MyAlloc<T>) == 1 (or 0 with EBO)
template <typename T>
struct PageAllocator {
    using value_type = T;
    // No data members β€” stateless
    T*   allocate(std::size_t n)          { return static_cast<T*>(::operator new(n * sizeof(T))); }
    void deallocate(T* p, std::size_t)    { ::operator delete(p); }
};
template <typename T, typename U>
bool operator==(const PageAllocator<T>&, const PageAllocator<U>&) noexcept { return true; }

// C++11 β€” stateful allocator: carries a pointer to a shared pool
template <typename T>
struct PoolAllocator {
    using value_type = T;
    Pool* pool_;  // state

    explicit PoolAllocator(Pool* p) noexcept : pool_{p} {}
    template <typename U> PoolAllocator(const PoolAllocator<U>& o) noexcept : pool_{o.pool_} {}

    T*   allocate(std::size_t n)       { return static_cast<T*>(pool_->alloc(n * sizeof(T), alignof(T))); }
    void deallocate(T* p, std::size_t) { pool_->free(p); }
};
template <typename T, typename U>
bool operator==(const PoolAllocator<T>& a, const PoolAllocator<U>& b) noexcept {
    return a.pool_ == b.pool_;  // same pool β†’ can free each other's memory
}

The standard library applies empty-base optimisation (EBO) for stateless allocators, so std::vector<int> with a stateless allocator pays no size penalty over raw storage.

Allocators in unordered containers

Unordered containers (unordered_map, unordered_set) use an allocator for both the node storage and the internal bucket array. This means the bucket array β€” a contiguous block of size_type pointers β€” also allocates from your resource, which is critical for cache-locality when using an arena.

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

void cache_demo() {
    alignas(std::max_align_t) std::byte buf[1 << 17];  // 128 KB
    std::pmr::monotonic_buffer_resource arena{buf, sizeof(buf)};  // C++17

    // Reserve buckets up front so the initial rehash bucket array
    // also comes from the arena (not from the default resource).
    std::pmr::unordered_map<std::pmr::string, int> freq{16, &arena};

    for (std::string_view word : get_words())
        ++freq[std::pmr::string{word, &arena}];

    // Both the nodes and the bucket array live in buf β€” maximally local
}

Pre-reserve buckets with the count constructor argument to avoid an early rehash that would allocate a new bucket array from the arena (advancing the cursor and wasting the old one β€” monotonic arenas never reclaim individual blocks).

cpp
// C++17 β€” inspecting allocator propagation in an unordered_map
std::pmr::unordered_map<int, int> m{&arena};
auto alloc = m.get_allocator();                 // returns pmr::polymorphic_allocator<...>
auto* res  = alloc.resource();                  // returns the underlying memory_resource*
assert(res == &arena);

See Also