Skip to content

Memory Management

Memory management is one of C++'s most powerful attributes, giving developers precise control over system resources. While legacy C++ required manual allocations prone to memory leaks, Modern C++ utilizes safety abstractions that automate resource lifetimes with near-zero runtime overhead.


1. The Stack and the Heap

A running C++ program splits its allocated RAM into several areas, most notably the Stack and the Heap.

    High Memory Addresses
    ┌───────────────────────────┐
    │          Stack            │  ◄── Local variables, grows downwards
    │            │              │
    │            ▼              │
    │                           │
    │            ▲              │
    │            │              │
    │          Heap             │  ◄── Dynamic variables (new/malloc), grows upwards
    └───────────────────────────┘
    Low Memory Addresses

The Stack (Automatic Storage)

The stack is a "LIFO" (Last In, First Out) memory structure managed automatically by the CPU. - Speed: Extremely fast. Allocation is just incrementing the stack pointer register. - Lifetime: Managed by scope. When a variable goes out of scope (e.g. at a closing brace }), its memory is popped and automatically freed. - Limitations: Relatively small size (typically 1–8 MB depending on the OS). Allocating massive arrays on the stack causes a Stack Overflow crash.

1
2
3
void calculate() {
    int buffer[1024]; // 4 KB on the stack (Automatic destruction at end of function)
} 

The Heap (Dynamic Storage / Free Store)

The heap is a large pool of system memory managed manually or via runtime libraries. - Speed: Slower. The OS must search for a free block of suitable size. - Lifetime: Controlled entirely by the programmer. Variables persist until explicitly deleted. - Limitations: Large capacity (limited by system RAM). Requires careful management to prevent leaks.


2. Legacy Manual Management: new and delete

Historically, C++ allocated heap memory using new and freed it using delete.

1
2
3
4
5
6
7
8
9
// Allocate an integer on the heap
int* pVal = new int(42); 

// Allocate an array of integers on the heap
int* pArr = new int[100]; 

// Cleanup (Must match! array allocation requires delete[])
delete pVal;
delete[] pArr;

The Dangers of Manual Management

  1. Memory Leaks: Forgetting to call delete keeps memory allocated, slowly consuming RAM until the OS terminates the process.
  2. Dangling Pointers: Accessing a pointer after its memory has been deleted.
  3. Double Free: Calling delete twice on the same memory address, causing heap corruption.
  4. Exception Unsafety: If an exception is thrown between new and delete, the delete statement is bypassed, causing a leak.

3. The RAII Paradigm

RAII (Resource Acquisition Is Initialization) is the single most important design pattern in C++. It binds the lifetime of a resource (heap memory, file handles, network sockets, database connections, or mutex locks) to the lifetime of a stack-allocated object.

  1. Acquire the resource inside the object's constructor.
  2. Release the resource inside the object's destructor.

Since stack-allocated objects are guaranteed to call their destructors when going out of scope—even during exceptions—resources are never leaked.

#include <fstream>
#include <mutex>

void write_to_file() {
    std::mutex file_mutex;

    // RAII Lock: locks mutex on initialization, unlocks automatically at end of scope
    std::lock_guard<std::mutex> lock(file_mutex); 

    // RAII File: opens file on initialization, closes automatically in destructor
    std::ofstream file("log.txt"); 
    file << "Writing secure log data.\n";
} // 'file' and 'lock' go out of scope here. Mutex unlocked, file closed.

4. Modern C++ Smart Pointers

Modern C++ has made manual new and delete obsolete. The standard library provides three smart pointer templates in the <memory> header. They are stack objects that wrap raw pointers and leverage RAII to delete the underlying heap memory automatically.

1. std::unique_ptr<T>

Represents exclusive ownership of a heap resource. Only one unique_ptr can point to the resource at a time. It cannot be copied, only moved.

#include <memory>
#include <iostream>

struct Device {
    void ping() { std::cout << "Device active\n"; }
};

void run_device() {
    // Preferred creation method (Exception-safe and clean)
    std::unique_ptr<Device> dev = std::make_unique<Device>();
    dev->ping();

    // std::unique_ptr<Device> copy_dev = dev; // Compile Error! Copying is forbidden.

    std::unique_ptr<Device> moved_dev = std::move(dev); // OK. 'dev' is now null.
} // 'moved_dev' goes out of scope here; Device is automatically deleted.
Overhead: Zero. Compiles down to the exact same assembly as a raw pointer.

2. std::shared_ptr<T>

Represents shared ownership of a resource. Multiple shared_ptr objects can point to the same resource. It maintains an internal Reference Count block. - Creating/copying a shared_ptr increments the count. - Destroying a shared_ptr decrements the count. - When the reference count reaches 0, the heap resource is deleted.

#include <memory>
#include <iostream>

void shared_demo() {
    std::shared_ptr<int> p1 = std::make_shared<int>(100); // ref_count = 1
    {
        std::shared_ptr<int> p2 = p1; // ref_count = 2
        std::cout << *p2 << "\n";
    } // p2 goes out of scope, ref_count = 1
} // p1 goes out of scope, ref_count = 0, heap memory is deleted.

Why Use std::make_shared?

Using std::shared_ptr<T>(new T) performs two separate heap allocations: one for the object T, and one for the reference control block. std::make_shared allocates both in a single contiguous block, improving cache locality and performance.

3. std::weak_ptr<T>

A non-owning observer pointing to an object managed by std::shared_ptr. It does not increment the reference count. To access the resource, it must be promoted to a shared_ptr using .lock().

#include <memory>
#include <iostream>

void weak_demo(std::shared_ptr<int> shared) {
    std::weak_ptr<int> weak = shared;

    // Check if the object still exists before accessing
    if (std::shared_ptr<int> locked = weak.lock()) {
        std::cout << "Value: " << *locked << "\n";
    } else {
        std::cout << "Resource has already been deleted.\n";
    }
}

5. Circular Reference Leaks

A major vulnerability of std::shared_ptr is the Circular Reference (or reference cycle). If two objects contain shared_ptrs pointing to each other, their reference counts can never drop to 0, resulting in a permanent memory leak.

1
2
3
4
5
       ┌───────────┐  shared_ptr   ┌───────────┐
       │  Node A   │ ────────────&gt; │  Node B   │
       │           │ &lt;──────────── │           │
       └───────────┘  shared_ptr   └───────────┘
     (Ref Count = 1)             (Ref Count = 1)
#include <iostream>
#include <memory>

struct Node {
    std::shared_ptr<Node> partner;
    ~Node() { std::cout << "Node destroyed\n"; }
};

void create_leak() {
    auto a = std::make_shared<Node>();
    auto b = std::make_shared<Node>();

    a->partner = b; // b ref count = 2
    b->partner = a; // a ref count = 2
} // a and b stack variables go out of scope. 
  // ref counts decrement to 1. Neither destructor runs! Memory is leaked.

The Fix: Use std::weak_ptr

Break the cycle by declaring one of the pointers as a weak_ptr:

1
2
3
4
5
6
7
#include <memory>
#include <iostream>

struct Node {
    std::weak_ptr<Node> partner; // Observer. Resolves cyclic reference.
    ~Node() { std::cout << "Node destroyed\n"; }
};

6. Move Semantics: Performance Optimized

C++11 introduced Move Semantics to eliminate expensive deep copy operations on temporary objects (Rvalues). Instead of copying the underlying memory block, a move constructor "steals" the pointer from the source object, resetting the source's pointer to null.

  • Lvalues: Objects that have an identifiable location in memory (an address) and a name (e.g., int x).
  • Rvalues: Temporary values that do not persist beyond the expression (e.g., the result of x + y, or a temporary string returned by a function).

std::move casts an Lvalue into an Rvalue reference (T&&), signaling to the compiler that the resource can be safely moved.

#include <vector>
#include <iostream>

int main() {
    std::vector<int> source = {1, 2, 3, 4, 5};

    // Moves data from source to destination.
    // Underlying array pointer is copied, and source is set to empty.
    std::vector<int> destination = std::move(source); 

    std::cout << "Source size: " << source.size() << "\n";           // Prints 0
    std::cout << "Destination size: " << destination.size() << "\n"; // Prints 5

    return 0;
}