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++17An 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:
// 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
#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 neededExamples
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.
#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.
#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
#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.
#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)
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)
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_resourceby default.synchronized_pool_resourceacquires 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 asmonotonic_buffer_resourcebacking. Without it, the resource may need to waste bytes padding to the first aligned position. - Apply
scoped_allocator_adaptorwhenever you store allocator-aware types in a container. Omitting it silently reroutes inner allocations to the default resource. - Implement
do_is_equalcorrectly. For stateful, non-shareable resources,return this == &otheris 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:
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 arenaThis 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.
// 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.
#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.
#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.
#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.
// 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:
| Trait | true_type | false_type |
|---|---|---|
| POCCA (copy assign) | destination takes source's allocator | destination 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 exchanged | allocators 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.
// 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.
#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).
// 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
std::allocator_traitsβ the C++11 policy shim that standardizes allocator introspection and provides defaultsstd::scoped_allocator_adaptorβ propagates an allocator through multiple levels of nested containersstd::uses_allocatorβ trait and construction helper for allocator-aware types- RAII and resource management
std::unique_ptrand custom deleters