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 report