Memory Management in C++
Understand stack vs heap, RAII, new/delete pitfalls, memory layout, and tools like Valgrind for leak detection.
C++ gives you direct control over memory. That control is what makes C++ fast — and what makes memory bugs possible. Understanding where objects live, how long they live, and who is responsible for cleaning them up is foundational to writing correct, efficient C++. Most C++ bugs that reach production are memory bugs: use-after-free, double-free, buffer overflow. The tools in this tutorial help you prevent, detect, and debug them.
Stack vs Heap
The stack is a contiguous region of memory managed automatically by the CPU. Every function call pushes a frame; every return pops it. Stack allocation is essentially free — just a pointer decrement. The tradeoff is size: the stack is typically 1–8 MB per thread, and objects on the stack live only as long as the function that created them.
The heap (free store) is a large pool managed by the OS and runtime. new and malloc request memory from it; delete and free return it. Heap allocation involves bookkeeping, potential OS calls, and fragmentation over time — but heap objects can be arbitrarily large and outlive the function that created them.
void demonstrate_lifetimes() {
int x = 42; // stack — destroyed automatically when function returns
int arr[1024] = {}; // stack — 4 KB, be careful with large stack arrays
int* heap_int = new int(99); // heap — must delete manually
int* heap_arr = new int[1024](); // heap array — must use delete[]
delete heap_int;
delete[] heap_arr; // must use delete[] for arrays — delete would be UB
}
Stack size is limited (typically 1–8 MB per thread). Large objects, objects that outlive a function, and objects whose size is unknown at compile time belong on the heap.
new/delete and Their Pitfalls
Raw new and delete are powerful but fragile. Every allocation needs exactly one matching deallocation — no more, no less. In real code with multiple return paths and exceptions, ensuring this manually is error-prone, which is precisely why smart pointers and RAII exist.
// Double free — undefined behavior: may crash or corrupt the allocator's internal state
int* p = new int(1);
delete p;
delete p; // UB — already freed
// Use after free — undefined behavior: reads/writes freed memory
int* q = new int(2);
delete q;
*q = 5; // UB — the memory may have been reallocated to something else
// Array mismatch — undefined behavior
int* arr = new int[10];
delete arr; // wrong: should be delete[] — undefined behavior
// Leak — no matching delete
void leak() {
int* p = new int(42);
if (some_condition()) return; // p is never deleted on this path
delete p;
}
Modern C++ avoids new/delete directly by using smart pointers and standard containers. Reserve raw new/delete for implementing low-level infrastructure like allocators.
RAII — The Cornerstone of Resource Safety
RAII ties a resource’s lifetime to an object’s lifetime. The constructor acquires the resource; the destructor releases it. Because destructors run deterministically — even when exceptions are thrown — RAII makes resource leaks structurally impossible. Every major standard library resource wrapper (fstream, mutex, unique_ptr) is a RAII type.
#include <fstream>
#include <mutex>
#include <stdexcept>
// std::fstream is RAII for file handles — closes automatically on destruction
void write_log(const std::string& msg) {
std::ofstream file("app.log", std::ios::app);
if (!file) throw std::runtime_error("cannot open log");
file << msg << "\n";
// file closes here — even if an exception was thrown above
}
// std::lock_guard is RAII for mutexes — unlocks automatically on destruction
std::mutex mtx;
void thread_safe_op() {
std::lock_guard<std::mutex> lock(mtx); // locks in constructor
// ... do work — exception safe: lock released even if this throws
// unlocks here
}
// Custom RAII wrapper — the pattern works for any resource
class ScopedTimer {
std::chrono::steady_clock::time_point start_;
std::string name_;
public:
explicit ScopedTimer(std::string name)
: start_(std::chrono::steady_clock::now()), name_(std::move(name)) {}
~ScopedTimer() {
auto elapsed = std::chrono::steady_clock::now() - start_;
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed);
std::cout << name_ << ": " << ms.count() << "ms\n";
}
};
Memory Layout: Padding and Alignment
The compiler inserts padding between struct members to satisfy alignment requirements — each type must be placed at an address that is a multiple of its alignment. Member declaration order directly affects struct size, and reordering members from largest to smallest alignment minimizes wasted bytes.
#include <cstddef>
#include <iostream>
struct Padded {
char a; // 1 byte
// 3 bytes padding (to align b to 4-byte boundary)
int b; // 4 bytes
char c; // 1 byte
// 7 bytes padding (to align d to 8-byte boundary)
double d; // 8 bytes
// Total: 24 bytes — 10 bytes padding wasted
};
struct Packed {
double d; // 8 bytes
int b; // 4 bytes
char a; // 1 byte
char c; // 1 byte
// 2 bytes padding (to make size a multiple of 8)
// Total: 16 bytes — same fields, better order, 33% smaller
};
int main() {
std::cout << sizeof(Padded) << "\n"; // 24
std::cout << sizeof(Packed) << "\n"; // 16 — same fields, better order
std::cout << offsetof(Padded, d) << "\n"; // 16
}
For cache-sensitive code, sort members from largest to smallest alignment to minimize padding.
// Control alignment explicitly — useful for cache line alignment in concurrent code
struct alignas(64) CacheLine { // aligned to 64-byte cache line boundary
int data[16];
};
std::cout << alignof(CacheLine) << "\n"; // 64
Placement New
Placement new constructs an object at a specific memory address you provide. It is the mechanism behind memory pools and arena allocators — you allocate a large block once, then construct objects within it without individual heap allocations. You must call the destructor explicitly before the memory is reused or freed.
#include <new>
#include <cstdlib>
alignas(alignof(int)) char buf[sizeof(int)];
// Construct an int in pre-allocated buffer — no heap allocation
int* p = new(buf) int(42);
std::cout << *p << "\n"; // 42
// Must call destructor explicitly — do NOT use delete here
p->~int();
// For non-trivial types, the destructor does real work
struct Widget {
std::string name;
Widget(std::string n) : name(std::move(n)) {}
~Widget() { std::cout << "destroy " << name << "\n"; }
};
alignas(alignof(Widget)) char wbuf[sizeof(Widget)];
Widget* w = new(wbuf) Widget("foo");
w->~Widget(); // explicit destructor call — required before buffer is reused
Custom Allocators
The default allocator calls into the OS for every allocation. For hot paths — game loops, request handlers, HFT order processing — this overhead accumulates. An arena allocator eliminates individual deallocation overhead entirely: all objects share a single large buffer, and the entire arena is freed at once when the work unit completes.
// Simple arena allocator — all allocations from a single buffer, freed as one
class Arena {
std::vector<char> buf_;
std::size_t offset_ = 0;
public:
explicit Arena(std::size_t capacity) : buf_(capacity) {}
void* allocate(std::size_t size, std::size_t align = alignof(std::max_align_t)) {
// Round up offset to required alignment
std::size_t aligned = (offset_ + align - 1) & ~(align - 1);
if (aligned + size > buf_.size()) throw std::bad_alloc();
offset_ = aligned + size;
return buf_.data() + aligned;
}
void reset() { offset_ = 0; } // free everything instantly — O(1), no individual frees
};
// Usage: parse a request, do work, reset — no individual frees needed
Arena arena(1024 * 1024); // 1 MB — single allocation
void* p = arena.allocate(256);
// ... use p ...
arena.reset(); // returns all memory at once — extremely fast
Finding Memory Bugs
Never ship without running through a memory checker. The overhead in development builds is worth it — these tools catch bugs that are nearly impossible to reproduce in production.
Valgrind (Linux/macOS) instruments your binary to detect leaks, use-after-free, and uninitialized reads:
g++ -g -O0 my_program.cpp -o my_program
valgrind --leak-check=full --track-origins=yes ./my_program
AddressSanitizer is faster (2–3x slowdown vs Valgrind’s 20–50x) and catches stack overflows too. It should be enabled for all debug and CI builds:
g++ -g -fsanitize=address,undefined my_program.cpp -o my_program
./my_program
# ASAN reports: heap-use-after-free, heap-buffer-overflow, leaks
Example ASan output for a use-after-free:
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x602000000010
READ of size 4 at 0x602000000010
#0 main my_program.cpp:8
previously freed at:
#0 operator delete my_program.cpp:7
Always develop with -fsanitize=address,undefined enabled. The overhead is acceptable in debug/test builds and catches bugs that would otherwise appear only in production.
Memory Bug Checklist
| Bug | Cause | Detection |
|---|---|---|
| Leak | new without delete | Valgrind, ASan leak sanitizer |
| Use-after-free | Access after delete | ASan |
| Double-free | delete called twice | ASan |
| Buffer overflow | Write past end of array | ASan |
| Stack overflow | Too-large stack allocation | OS signal, ASan |
| Uninitialized read | Using value before writing | Valgrind, MemorySanitizer |
The long-term answer to memory bugs is not vigilance — it is RAII and smart pointers, which make the correct behavior the default and the dangerous behavior require explicit effort.