Skip to content
C++
Library
since C++11
Intermediate

Memory Management

C++ memory management — RAII, smart pointers, arenas, PMR allocators, object pools, placement new, and avoiding heap pitfalls.

C++ Memory Managementsince C++11

C++ gives the programmer direct control over object lifetime and storage duration — stack, heap, and static — with ownership expressed explicitly through RAII wrappers and smart pointers standardized in C++11.

Overview

C++ exposes three primary storage regions:

  • Stack — automatic storage; objects destroyed on scope exit, no allocator overhead, typically 1–8 MB.
  • Heap (free store) — dynamic storage; new/delete invoke the global allocator, which involves a lock and bookkeeping on every call.
  • Static — program-lifetime storage; initialized before main, no explicit deallocation.

The critical design decision before any allocation is ownership: who creates the object, who destroys it, and who may observe it without owning it. Raw pointers do not encode ownership; RAII wrappers and smart pointers do.

RAII — The Foundation

Resource Acquisition Is Initialization ties resource lifetime to object lifetime through constructors and destructors. The Rule of Five (C++11) holds: if you declare any of destructor, copy constructor, copy assignment, move constructor, or move assignment, declare all five explicitly — otherwise the compiler silently generates broken defaults.

cpp
class FileHandle {
    FILE* f_ = nullptr;
public:
    explicit FileHandle(const char* path, const char* mode)
        : f_{fopen(path, mode)} {
        if (!f_) throw std::system_error(errno, std::system_category());
    }
    ~FileHandle() { if (f_) fclose(f_); }

    // Move-only — transfers ownership (C++11 move semantics)
    FileHandle(FileHandle&& o) noexcept
        : f_{std::exchange(o.f_, nullptr)} {}     // std::exchange: C++14

    FileHandle& operator=(FileHandle&& o) noexcept {
        if (this != &o) {
            if (f_) fclose(f_);
            f_ = std::exchange(o.f_, nullptr);    // C++14
        }
        return *this;
    }

    FileHandle(const FileHandle&)            = delete;  // = delete: C++11
    FileHandle& operator=(const FileHandle&) = delete;

    FILE* get() const noexcept { return f_; }
};

Smart Pointers

The <memory> header (C++11) provides three ownership archetypes:

cpp
// Exclusive ownership — zero overhead over a raw pointer
auto widget = std::make_unique<Widget>(arg1, arg2);  // make_unique: C++14
widget->render();
// destroyed when widget leaves scope; no delete needed

// Shared ownership — atomic reference count
auto session  = std::make_shared<Session>(config);   // make_shared: C++11
auto observer = session;  // ref-count = 2

// Non-owning observer — does not prevent destruction; breaks cycles
std::weak_ptr<Session> weak = session;
if (auto locked = weak.lock()) {   // lock(): C++11
    locked->heartbeat();
}
// If session was destroyed, lock() returns nullptr — no crash

std::make_unique (C++14) avoids the two-step "allocate then construct" exception hazard present with raw new. std::make_shared performs a single heap allocation for both the control block and the managed object, halving allocator overhead compared to std::shared_ptr<T>(new T{...}).

Ownership guide:

  • unique_ptr — 99% of heap allocations; no atomic operations, no overhead.
  • shared_ptr — only when ownership genuinely outlives any single scope and cannot be expressed through nesting or move.
  • Raw pointer / reference — non-owning access into already-managed storage.

Stack and Small-Buffer Optimization

Stack allocation is O(1) with no lock contention — prefer it for short-lived objects:

cpp
void transform(std::span<const float> input) {   // std::span: C++20
    std::array<float, 256> scratch{};             // zero heap calls
    for (std::size_t i = 0; i < input.size() && i < scratch.size(); ++i)
        scratch[i] = input[i] * 2.0f;
}

Many standard types implement Small Buffer Optimization (SBO) internally. std::string avoids heap for short strings (typically ≤ 15 chars, implementation-defined). std::function (C++11) embeds small callables inline. C++26 will standardize std::inplace_vector<T, N> for fixed-capacity stack-friendly sequences.

Never use alloca — it is non-standard, skips destructors, and silently corrupts the stack on overflow.

Polymorphic Allocators — std::pmr (C++17)

C++17 introduced std::pmr (Polymorphic Memory Resources) as a standard hook for custom allocators without infecting every template parameter. PMR-aware containers are aliases in the std::pmr namespace that default their allocator to a std::pmr::memory_resource*.

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

// Bump allocator backed by a stack buffer — zero heap calls
std::byte buf[64 * 1024];
std::pmr::monotonic_buffer_resource arena{buf, sizeof(buf)};

// All allocations come from the stack buffer
std::pmr::vector<std::pmr::string> names{&arena};
names.emplace_back("alpha");
names.emplace_back("beta");
// arena and names destroyed together — no per-element delete

std::pmr::monotonic_buffer_resource never frees individual objects; reset() is the only deallocation. std::pmr::unsynchronized_pool_resource (C++17) adds per-size free lists for mixed-size allocation patterns. Both fall back to an upstream resource (defaulting to the global allocator) when the buffer is exhausted.

Hand-Rolled Arena Allocator

When you need an arena with an inline buffer and no external dependency:

cpp
class Arena {
    alignas(std::max_align_t) std::byte buf_[1 << 20];  // 1 MB inline
    std::byte* ptr_ = buf_;

public:
    void* allocate(std::size_t bytes,
                   std::size_t align = alignof(std::max_align_t)) {
        std::size_t space = static_cast<std::size_t>((buf_ + sizeof buf_) - ptr_);
        void* p = ptr_;
        if (!std::align(align, bytes, p, space))   // std::align: C++11
            throw std::bad_alloc{};
        ptr_ = static_cast<std::byte*>(p) + bytes;
        return p;
    }

    void reset() noexcept { ptr_ = buf_; }
};

std::align (C++11) adjusts the pointer for alignment without undefined behaviour. The inline buffer avoids a second heap allocation for the backing store itself — useful when the arena lifetime is scoped to a single function or request.

Placement new and Low-Level Construction

Placement new constructs an object in caller-supplied storage. The storage must be suitably aligned and sized; you must destroy the object manually — calling delete on a placement-new'd pointer is UB.

cpp
alignas(Widget) std::byte storage[sizeof(Widget)];

Widget* w = ::new(storage) Widget{42};   // placement new: C++98
w->use();
std::destroy_at(w);                      // std::destroy_at: C++17; calls w->~Widget()

// C++20: prefer std::construct_at for constexpr-compatible construction
// std::construct_at(ptr, args...);      // C++20

Placement new is the mechanism underlying std::optional, std::variant, object pools, and every arena allocator.

Object Pool

Pre-allocate N objects; acquire/release without touching the heap:

cpp
template<typename T, std::size_t N>
class Pool {
    union Slot { T obj; Slot* next; ~Slot() {} };
    std::array<Slot, N> slots_;
    Slot* head_ = nullptr;

public:
    Pool() noexcept {
        for (std::size_t i = 0; i + 1 < N; ++i) slots_[i].next = &slots_[i + 1];
        slots_[N - 1].next = nullptr;
        head_ = &slots_[0];
    }

    template<typename... Args>
    [[nodiscard]] T* acquire(Args&&... args) {          // [[nodiscard]]: C++17
        if (!head_) throw std::bad_alloc{};
        Slot* s = head_; head_ = s->next;
        return ::new(&s->obj) T(std::forward<Args>(args)...);  // C++11
    }

    void release(T* p) noexcept {
        std::destroy_at(p);                             // C++17
        auto* s = reinterpret_cast<Slot*>(p);
        s->next = head_; head_ = s;
    }
};

Pool allocation eliminates per-object malloc/free calls and keeps objects in contiguous memory, which is critical for game entities, network connections, and parser nodes that are allocated and freed at high frequency.

Best Practices

  • Decide ownership before writing allocation. Unique, shared, or borrowed — encode that decision in the type, not in comments.
  • Prefer make_unique/make_shared always. A bare new T in application code is a code smell since C++14.
  • Keep shared_ptr rare. Atomic ref-counting is measurably expensive on multi-core systems; unique_ptr with move-based transfer covers most cases people reach for shared_ptr.
  • Size arenas to batch lifetimes. One arena per request or frame; reset() when the batch ends rather than freeing individual objects.
  • Use std::pmr before rolling your own. The C++17 PMR facilities compose correctly with the standard containers and cost nothing to use.
  • Run AddressSanitizer in CI. It catches the majority of memory bugs at near-native speed; there is no reason to ship without it in debug/test builds.

Common Pitfalls

cpp
// ❌ Use-after-free
auto* p = new int{42};
delete p;
std::cout << *p;         // UB — ASan will abort here

// ❌ Double-free
delete p;
delete p;                // UB — heap corruption or crash

// ❌ Array/scalar mismatch
int* arr = new int[16];
delete arr;              // UB — must be delete[]

// ❌ Exception-unsafe raw new in function arguments
f(new A{}, new B{});     // if B{} throws, A leaks
// ✅ Fix:
auto a = std::make_unique<A>();   // C++14
auto b = std::make_unique<B>();
f(std::move(a), std::move(b));

// ❌ shared_ptr cycle — ref-count never reaches 0
struct Node { std::shared_ptr<Node> next; };
auto x = std::make_shared<Node>();
auto y = std::make_shared<Node>();
x->next = y; y->next = x;        // both leak forever
// ✅ Fix: make one side std::weak_ptr<Node>

// ❌ Stack overflow from large locals
void bad() { char buf[8'000'000]; /* ... */ }   // silent crash
// ✅ Fix: std::vector<char> buf(8'000'000);

Diagnostics

bash
# AddressSanitizer + UBSan — fastest, catches most issues (recompile required)
g++ -fsanitize=address,undefined -fno-omit-frame-pointer -g -O1 -o app src.cpp

# Valgrind — no recompile; 10–50× slower; useful for release binaries
valgrind --leak-check=full --track-origins=yes ./app

# Heap profiling — shows allocation call sites and retained size
heaptrack ./app && heaptrack_gui heaptrack.app.*.gz

# Allocator hot-path profiling on Linux
perf record -g ./app && perf report

Placement new and custom allocation

Placement new separates storage acquisition from object construction. You supply a pre-allocated, suitably-aligned buffer and ::new(ptr) T{args} constructs directly into it — no heap call, no allocator overhead. You are responsible for calling the destructor explicitly before reusing the storage.

cpp
// C++17 — alignas + std::byte for correct alignment
alignas(T) std::byte raw[sizeof(T)];

T* obj = ::new(raw) T{1, 2, 3};     // constructs in-place, no allocation
obj->use();
obj->~T();                           // explicit destructor call required
// raw storage may now be reused or simply abandoned

// C++20 — std::construct_at is constexpr-compatible
#include <memory>
T* obj2 = std::construct_at(reinterpret_cast<T*>(raw), 1, 2, 3); // C++20
std::destroy_at(obj2);              // std::destroy_at: C++17

Custom allocators, std::optional<T>, std::variant, and every arena/pool in the section above all rely on placement new at their core. The matching cleanup primitive is std::destroy_at (C++17) or the raw destructor call p->~T().

cpp
// Pool element recycled via placement new (C++11)
template<typename T>
void reinit(void* storage, T&& value) {
    ::new(storage) T(std::forward<T>(value));  // construct over dead storage
}

Stack vs heap allocation trade-offs

Stack allocation is always faster than heap allocation — but its capacity is fixed and its lifetime is strictly scoped. Choose the storage class that matches the object's natural lifetime, then optimize for throughput.

CriterionStackHeap (new/delete)Arena/Pool
Allocation costO(1), no lockO(log n) + lockO(1), no lock
Capacity1–8 MB (OS default)Virtual address spacePre-committed buffer
LifetimeScope exitManual / smart pointerBatch reset
Cache localitySequentialScatteredSequential (arena)
cpp
// C++20 — stack-friendly fixed-capacity sequence
#include <array>
#include <span>

void process(std::span<const int> input) {  // C++20
    if (input.size() <= 256) {
        std::array<int, 256> tmp{};          // zero heap; cache hot
        for (std::size_t i = 0; i < input.size(); ++i)
            tmp[i] = input[i] * 2;
        // use tmp...
    } else {
        std::vector<int> tmp(input.size()); // heap fallback for large input
        for (std::size_t i = 0; i < input.size(); ++i)
            tmp[i] = input[i] * 2;
        // use tmp...
    }
}

Caveat: large stack frames kill threads. The stack for a std::thread or a coroutine frame is typically 64 KB–8 MB. Arrays larger than ~64 KB should go on the heap or in an arena.

Memory alignment: alignas and std::align

Misaligned access is undefined behaviour in C++ and causes hardware faults or silent slowdowns on real CPUs. alignas sets a minimum alignment for a type or object; std::align adjusts a raw pointer to the next aligned address within a buffer.

cpp
#include <memory>   // std::align: C++11
#include <cstddef>

// Force 64-byte alignment (one cache line) for false-sharing prevention
struct alignas(64) CacheLinePadded {  // alignas: C++11
    std::atomic<int> counter{0};
    char pad[60];
};

// Runtime alignment within a pre-allocated buffer
void* align_in_buffer(void* buf, std::size_t buf_size,
                      std::size_t obj_size, std::size_t obj_align) {
    void* p = buf;
    std::size_t space = buf_size;
    return std::align(obj_align, obj_size, p, space);  // C++11
    // returns nullptr if the buffer is too small
}

alignof(T) (C++11) queries the required alignment of any type. alignof(std::max_align_t) gives the strictest fundamental alignment — typically 8 or 16 bytes. Always pass alignof(T) when using std::align or rolling your own arena.

Over-aligned types and aligned_alloc

operator new only guarantees alignof(std::max_align_t) (usually 16 bytes); types with stricter requirements need aligned_alloc or C++17's over-aligned operator new. SIMD types (__m256, __m512) commonly require 32- or 64-byte alignment.

cpp
#include <cstdlib>   // aligned_alloc: C11/C++17
#include <memory>

// C++17 — over-aligned new: compiler selects the right operator new overload
struct alignas(32) Vec8f {  // 256-bit AVX vector element
    float data[8];
};

Vec8f* v = new Vec8f{};     // C++17: invokes operator new(size, align_val_t)
delete v;                   // matching operator delete called automatically

// Manual allocation with aligned_alloc (C++17; available as C11 function)
void* raw = std::aligned_alloc(32, sizeof(Vec8f) * 16);  // C++17
if (!raw) throw std::bad_alloc{};
Vec8f* arr = static_cast<Vec8f*>(raw);
// ... use arr ...
std::free(arr);             // must use free(), NOT delete[]

Prior to C++17, placing an over-aligned type inside a std::vector was UB because the default allocator only guaranteed alignof(std::max_align_t). C++17 mandates that ::operator new(size, std::align_val_t) is called automatically for over-aligned types.

Operator new / delete overloading

Overloading operator new and operator delete is the portable hook for redirecting all heap allocations for a class to a custom allocator. It requires no changes to call sites — every new Widget{} transparently uses your override.

cpp
#include <cstdlib>
#include <new>

class Widget {
public:
    // Class-specific allocation — called by every `new Widget{...}`
    static void* operator new(std::size_t size) {           // C++98
        void* p = my_pool_alloc(size);
        if (!p) throw std::bad_alloc{};
        return p;
    }

    static void operator delete(void* p) noexcept {         // C++98
        my_pool_free(p);
    }

    // C++17: sized deallocation — receives actual size for slab allocators
    static void operator delete(void* p, std::size_t size) noexcept {  // C++17
        my_pool_free_sized(p, size);
    }

    // Array forms — override these separately if array allocation is used
    static void* operator new[](std::size_t size) {         // C++98
        return ::operator new[](size);  // fallback to global
    }
    static void operator delete[](void* p) noexcept {       // C++98
        ::operator delete[](p);
    }
};

Caveat: overloading global ::operator new affects every allocation in the process, including inside the standard library. Prefer class-scope overloads or PMR allocators to limit scope. Always override delete when you override new; a missing delete causes a no-op deallocation and leaks.

C++17 polymorphic memory resources (PMR) overview

PMR decouples the allocation strategy from the container type via a runtime-polymorphic memory_resource interface, avoiding the template-parameter explosion of C++11 stateful allocators. All standard containers have std::pmr:: aliases that use std::pmr::polymorphic_allocator<T> as their allocator type.

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

// 1. Monotonic arena — fastest; no individual frees
std::byte arena_buf[1 << 16];  // 64 KB on stack
std::pmr::monotonic_buffer_resource mono{arena_buf, sizeof(arena_buf),
                                          std::pmr::null_memory_resource()};
// null upstream: throw std::bad_alloc instead of falling back to heap

// 2. Pool resource — recycles same-size chunks; good for graphs/trees
std::pmr::unsynchronized_pool_resource pool{&mono};  // C++17

// 3. PMR containers — identical API, different allocator
std::pmr::vector<std::pmr::string> words{&pool};
words.emplace_back("hello");
words.emplace_back("world");
// All allocations come from pool → mono → arena_buf

// 4. Chaining resources
std::pmr::synchronized_pool_resource thread_safe_pool{
    std::pmr::get_default_resource()};  // falls back to global heap

The standard ships three concrete memory_resource types: monotonic_buffer_resource, unsynchronized_pool_resource, and synchronized_pool_resource. The global default is std::pmr::new_delete_resource(), which wraps ::operator new/::operator delete. Override the thread-local default with std::pmr::set_default_resource() (affects all subsequent PMR container constructions that omit an explicit resource).

cpp
// Custom memory_resource — implement three virtuals
class LoggingResource : public std::pmr::memory_resource {  // C++17
    std::pmr::memory_resource* upstream_;
public:
    explicit LoggingResource(std::pmr::memory_resource* u =
                             std::pmr::get_default_resource()) : upstream_{u} {}

protected:
    void* do_allocate(std::size_t bytes, std::size_t align) override {
        auto* p = upstream_->allocate(bytes, align);
        // log(bytes, align, p);
        return p;
    }
    void do_deallocate(void* p, std::size_t bytes, std::size_t align) override {
        upstream_->deallocate(p, bytes, align);
    }
    bool do_is_equal(const std::pmr::memory_resource& o)
                     const noexcept override {
        return this == &o;
    }
};

Memory debugging checklist

A systematic checklist catches the class of bugs that sanitizers miss between runs and that code review rarely spots. Apply in order from fastest to slowest.

cpp
// Step 1 — compile-time: enable warnings and sanitizers
// g++ -Wall -Wextra -fsanitize=address,undefined -g -O1 -o app src.cpp
// clang++ -fsanitize=address,undefined,memory -g -O1 -o app src.cpp

// Step 2 — annotate ownership at the API boundary
// ✅ ownership explicit in types
std::unique_ptr<Conn> connect(const char* url);   // C++14 — caller owns
std::shared_ptr<Config> global_config();          // C++11 — shared ownership
const Config& global_config_view();               // non-owning borrow

// ✅ Use [[nodiscard]] on allocating functions (C++17)
[[nodiscard]] std::unique_ptr<Widget> make_widget();

// Step 3 — initialization: always initialize raw memory
std::vector<std::byte> buf(4096, std::byte{0});   // C++17 std::byte
// or
auto arr = std::make_unique<int[]>(1024);         // zero-initialised: C++14

// Step 4 — bounds checking in debug builds
std::vector<int> v(10);
v.at(10);   // throws std::out_of_range — safe alternative to v[10]

// Step 5 — lifetime: avoid dangling references/pointers
std::string_view sv;
{
    std::string s = "hello";
    sv = s;         // ❌ sv dangles after this block
}
// sv is now a dangling string_view — UB on access

Quick-reference: which tool for which bug.

Bug classDetection tool
Use-after-free, heap overflowAddressSanitizer (-fsanitize=address)
Uninitialized readsMemorySanitizer, Valgrind --track-origins
Integer overflow, UBUBSan (-fsanitize=undefined)
Data racesThreadSanitizer (-fsanitize=thread)
Leak at program exitLeakSanitizer (bundled with ASan)
Heap profile / hot sitesheaptrack, gperftools, jemalloc prof

Always enable ASan + UBSan in CI for every test run. Run Valgrind nightly on release binaries where recompilation is not possible.

See Also