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

See Also