Skip to main content
C++ advanced Lesson 16 of 23

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

BugCauseDetection
Leaknew without deleteValgrind, ASan leak sanitizer
Use-after-freeAccess after deleteASan
Double-freedelete called twiceASan
Buffer overflowWrite past end of arrayASan
Stack overflowToo-large stack allocationOS signal, ASan
Uninitialized readUsing value before writingValgrind, 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.

Frequently Asked Questions

What is RAII?
Resource Acquisition Is Initialization. Resources (memory, files, mutexes) are acquired in a constructor and released in the destructor, ensuring cleanup even when exceptions occur.
When would I write a custom allocator?
For performance-critical code where the default allocator's overhead matters: game engines use pool allocators, HFT systems use arena allocators to avoid heap fragmentation.