Memory Management
C++ memory management — RAII, smart pointers, arenas, PMR allocators, object pools, placement new, and avoiding heap pitfalls.
C++ Memory Managementsince C++11C++ 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/deleteinvoke 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.
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:
// 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 crashstd::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:
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*.
#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 deletestd::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:
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.
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++20Placement 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:
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_sharedalways. A barenew Tin application code is a code smell since C++14. - Keep
shared_ptrrare. Atomic ref-counting is measurably expensive on multi-core systems;unique_ptrwith move-based transfer covers most cases people reach forshared_ptr. - Size arenas to batch lifetimes. One arena per request or frame;
reset()when the batch ends rather than freeing individual objects. - Use
std::pmrbefore 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
// ❌ 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
# 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 reportPlacement 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.
// 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++17Custom 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().
// 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.
| Criterion | Stack | Heap (new/delete) | Arena/Pool |
|---|---|---|---|
| Allocation cost | O(1), no lock | O(log n) + lock | O(1), no lock |
| Capacity | 1–8 MB (OS default) | Virtual address space | Pre-committed buffer |
| Lifetime | Scope exit | Manual / smart pointer | Batch reset |
| Cache locality | Sequential | Scattered | Sequential (arena) |
// 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.
#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.
#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.
#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.
#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 heapThe 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).
// 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.
// 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 accessQuick-reference: which tool for which bug.
| Bug class | Detection tool |
|---|---|
| Use-after-free, heap overflow | AddressSanitizer (-fsanitize=address) |
| Uninitialized reads | MemorySanitizer, Valgrind --track-origins |
| Integer overflow, UB | UBSan (-fsanitize=undefined) |
| Data races | ThreadSanitizer (-fsanitize=thread) |
| Leak at program exit | LeakSanitizer (bundled with ASan) |
| Heap profile / hot sites | heaptrack, gperftools, jemalloc prof |
Always enable ASan + UBSan in CI for every test run. Run Valgrind nightly on release binaries where recompilation is not possible.