C++ Memory Management


  • Description: A note on the C++ memory management — stack vs heap, malloc/free vs new/delete, smart pointers (incl. weak_ptr), RAII, copy/move semantics, and polymorphic allocators (PMR)
  • My Notion Note ID: K2A-B1-6
  • Created: 2018-09-27
  • Updated: 2026-02-28
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Stack vs Heap

Understanding where objects live in memory is fundamental to writing correct and efficient C++ code.

Aspect Stack Heap
Allocation Automatic, at compile time Manual, at runtime
Management System-managed (LIFO) Programmer-managed (or via smart pointers)
Lifetime Scoped to the enclosing block Until explicitly freed (or smart pointer destructs)
Speed Fast (pointer bump) Slower (allocator overhead, possible fragmentation)
Size Limited (typically 1–8 MB) Limited only by available system memory
void example() {
    int x = 10;              // Stack allocation
    int* p = new int(20);    // Heap allocation
    // ...
    delete p;                // Must manually free heap memory
}   // x is automatically destroyed when scope ends

2. Manual Memory Management: malloc/free vs new/delete

Both allocate and deallocate heap memory, but new/delete are C++ operators that understand object lifecycles.

Aspect malloc/free new/delete
Language C and C++ C++ only
Type safety Returns void*; requires cast Returns typed pointer T*
Constructors/Destructors Does not call them Calls constructor/destructor
What it does Allocates/frees raw memory Creates/destroys objects
Implementation OS-level allocation Typically uses malloc/free internally
Failure Returns nullptr Throws std::bad_alloc (by default)
// C-style (avoid in modern C++)
int* arr1 = (int*)malloc(10 * sizeof(int));
free(arr1);

// C++ style
int* arr2 = new int[10];
delete[] arr2;  // Note: delete[] for arrays, delete for single objects

// Modern C++ (preferred)
auto arr3 = std::make_unique<int[]>(10);  // No manual delete needed

3. Smart Pointers

Smart pointers (C++11) wrap raw pointers and manage their lifetime automatically via RAII. They eliminate most manual delete calls and prevent memory leaks.

3.1 Common Operations

#include <memory>

auto sp = std::make_shared<int>(42);
auto up = std::make_unique<int>(42);

*sp;        // Dereference: access the value
sp->member; // Member access (for class types)
sp.get();   // Get the raw pointer (use sparingly)

3.2 std::shared_ptr

Shared ownership via reference counting. The managed object is destroyed when the last shared_ptr owning it is destroyed or reset.

#include <memory>
#include <iostream>

auto sp1 = std::make_shared<std::string>("hello");
auto sp2 = sp1;  // Reference count: 2

std::cout << sp1.use_count() << std::endl;  // 2

3.3 std::unique_ptr

Exclusive ownership. Cannot be copied, only moved. Lighter weight than shared_ptr (no reference counting overhead).

#include <memory>

auto up = std::make_unique<std::string>("hello");
// auto up2 = up;              // ERROR: cannot copy unique_ptr
auto up2 = std::move(up);      // OK: transfer ownership
// up is now nullptr

3.4 std::weak_ptr

std::weak_ptr<T> is a non-owning observer of an object managed by std::shared_ptr. It does not contribute to the reference count, so it cannot keep the object alive on its own. There are two main reasons to reach for it:

  1. Breaking cycles — two objects holding shared_ptr to each other will never be destroyed (their reference counts never reach zero). Replacing one direction with weak_ptr breaks the cycle.
  2. Caches and observers — store a reference that should not extend the lifetime of the target.

To access the underlying object, call lock(), which atomically checks if the object is still alive and returns a (possibly empty) shared_ptr. Never use weak_ptr like a raw pointer; always go through lock().

#include <memory>
#include <iostream>

auto sp = std::make_shared<int>(42);
std::weak_ptr<int> wp = sp;

std::cout << wp.use_count() << std::endl;   // 1 (weak_ptr doesn't count)
std::cout << wp.expired() << std::endl;     // false

if (auto locked = wp.lock()) {              // upgrade to shared_ptr
    std::cout << *locked << std::endl;      // 42
}

sp.reset();                                  // destroys the int
std::cout << wp.expired() << std::endl;     // true

if (auto locked = wp.lock()) {
    // not entered: locked is empty
} else {
    std::cout << "object gone" << std::endl;
}

Breaking Cycles: Parent ↔ Child

A common cycle: a parent owns its children, and each child needs a back-pointer to its parent. If both pointers are shared_ptr, the cycle leaks both objects.

#include <memory>
#include <vector>

struct Node {
    std::vector<std::shared_ptr<Node>> children;
    std::weak_ptr<Node> parent;       // NOT shared_ptr — breaks the cycle
};

auto root  = std::make_shared<Node>();
auto child = std::make_shared<Node>();
root->children.push_back(child);
child->parent = root;                  // weak: doesn't keep root alive

// When 'root' goes out of scope, both Node objects are destroyed correctly.

If parent were shared_ptr<Node>, the parent would own the child and the child would own the parent — neither could ever be released.

3.5 Converting unique_ptr to shared_ptr

A unique_ptr can be implicitly converted to a shared_ptr. This is a common pattern in factory functions: return unique_ptr for flexibility, and callers can convert to shared_ptr if they need shared ownership.

#include <memory>
#include <string>

// Factory function returns unique_ptr
std::unique_ptr<std::string> createString() {
    return std::make_unique<std::string>("Hello World");
}

int main() {
    // Implicit conversion from temporary (moved automatically)
    std::shared_ptr<std::string> sp1 = createString();

    // Explicit move from lvalue
    auto up = std::make_unique<std::string>("Hello");
    std::shared_ptr<std::string> sp2 = std::move(up);  // up is now nullptr

    return 0;
}

Note: You cannot convert a shared_ptr back to a unique_ptr. The conversion is one-way.

3.6 unique_ptr<T[]> and Custom Deleters

std::unique_ptr has an array specialization for managing dynamically-allocated arrays:

auto arr = std::make_unique<int[]>(10);   // allocates int[10], value-initialized (zero for int)
                                          // for uninitialized: std::make_unique_for_overwrite<int[]>(10) (C++20)
arr[0] = 42;
arr[9] = 99;
// auto-deletes with delete[] (NOT delete) when arr goes out of scope

Don't use unique_ptr<T[]> for fixed-size arraysstd::array<T, N> is better. Use it only when the size is a runtime value and you can't use std::vector<T>.

Custom deleters

By default, unique_ptr uses delete (or delete[] for the array form). For non-new-allocated resources, supply a custom deleter:

// FILE* from C library: needs fclose
auto file_deleter = [](FILE* f) { if (f) std::fclose(f); };
std::unique_ptr<FILE, decltype(file_deleter)> file{
    std::fopen("data.txt", "r"), file_deleter
};

// Or with a function-pointer deleter (saves storage in C++20):
std::unique_ptr<FILE, decltype(&std::fclose)> file2{
    std::fopen("data.txt", "r"), &std::fclose
};

Custom deleters let unique_ptr wrap any C-style "open/close" handle: file pointers, sockets, OpenGL textures, OS handles, etc.

std::shared_ptr accepts custom deleters too, but they're stored in the control block (so the deleter type isn't part of the shared_ptr type — same shared_ptr<FILE> regardless of deleter).

3.7 std::enable_shared_from_this

A class managed by shared_ptr sometimes needs to hand out shared_ptr<This> from a member function. Constructing a fresh shared_ptr from this is a bug — it creates a separate control block, leading to double-deletion.

class Node {
public:
    std::shared_ptr<Node> badGetSelf() {
        return std::shared_ptr<Node>(this);   // BUG: separate control block!
    }
};

The right way: derive from std::enable_shared_from_this<T> and use shared_from_this().

class Node : public std::enable_shared_from_this<Node> {
public:
    std::shared_ptr<Node> getSelf() {
        return shared_from_this();           // safe: shares control block
    }
};

auto n = std::make_shared<Node>();
auto same = n->getSelf();                     // points to the same node, ref count = 2

Caveat: shared_from_this() only works after the object is owned by a shared_ptr. Calling it from the constructor or before any shared_ptr exists throws std::bad_weak_ptr.

3.8 make_shared vs make_unique vs new

Three ways to create a smart pointer:

std::unique_ptr<T> a = std::make_unique<T>(args...);   // preferred
std::unique_ptr<T> b{new T(args...)};                   // works, but verbose

std::shared_ptr<T> c = std::make_shared<T>(args...);   // preferred
std::shared_ptr<T> d{new T(args...)};                   // works, but slower

Why prefer make_*:

  1. Exception safety. f(make_unique<A>(), make_unique<B>()) is safe; f(unique_ptr<A>(new A), unique_ptr<B>(new B)) can leak if one new throws after the other allocates.
  2. No naked new. The codebase becomes easier to audit for leaks: just grep for new.
  3. Faster make_shared. make_shared allocates the object and the control block in one allocation. shared_ptr<T>(new T) allocates twice (the object first via new, then the control block when the shared_ptr is constructed).

When NOT to use make_*:

  1. Custom deleters. make_* doesn't accept them; use unique_ptr<T, D>(p, d) directly.
  2. Aliasing constructor of shared_ptr. Use the constructor directly when you need shared_ptr<int> pointing into a member of an existing shared_ptr<Struct>.
  3. Long-lived weak_ptrs to a short-lived object. make_shared keeps the whole allocation alive as long as any weak_ptr exists (the control block is in the same block as the object). For occasional leaks-of-weak-pointers patterns, plain new + shared_ptr releases the object memory earlier.

4. RAII (Resource Acquisition Is Initialization)

RAII is a core C++ idiom: tie resource lifetime to object lifetime. Acquire resources in constructors, release them in destructors. When the object goes out of scope, cleanup happens automatically.

Smart pointers are the most common example, but RAII applies to any resource: file handles, locks, database connections, etc.

#include <fstream>
#include <mutex>

void example(std::mutex& mtx) {
    std::lock_guard<std::mutex> lock(mtx);  // Lock acquired
    // ... critical section ...
}   // Lock automatically released when lock_guard is destroyed

void readFile() {
    std::ifstream file("data.txt");  // File opened
    // ... read from file ...
}   // File automatically closed when ifstream is destroyed

5. Rule of Five and Rule of Zero

For the language-level mechanics of copy/move constructors, lvalue and rvalue references, value categories, and the special member functions table, see K2A-B1-5 C++ Reference, Copy, Move and Forwarding. This section focuses on applying those mechanics when designing classes that own resources.

Classes that own resources (heap memory, file handles, sockets, mutexes) need a clear policy for copy, move, and destruction. The compiler-generated copy/move/destroy do member-wise operations, which is correct when every member already manages itself (smart pointers, STL types) but wrong when a member is a raw pointer to an owned resource.

5.1 The Rule of Five

If your class manages a resource that the compiler cannot copy/move correctly on its own, you typically need to write all five "non-default" special members (destructor + the four copy/move operations) consistently. This is the Rule of Five (Rule of Three before C++11 added move).

#include <algorithm>
#include <cstddef>

class Buffer {
public:
    explicit Buffer(std::size_t n)
        : size_(n), data_(new int[n]) {}

    ~Buffer() { delete[] data_; }

    // Copy constructor: deep copy
    Buffer(const Buffer& other)
        : size_(other.size_), data_(new int[other.size_]) {
        std::copy(other.data_, other.data_ + size_, data_);
    }

    // Copy assignment: deep copy with self-assignment guard
    Buffer& operator=(const Buffer& other) {
        if (this == &other) return *this;
        delete[] data_;
        size_ = other.size_;
        data_ = new int[size_];
        std::copy(other.data_, other.data_ + size_, data_);
        return *this;
    }

    // Move constructor: steal the pointer, leave 'other' empty
    Buffer(Buffer&& other) noexcept
        : size_(other.size_), data_(other.data_) {
        other.size_ = 0;
        other.data_ = nullptr;
    }

    // Move assignment: same pattern
    Buffer& operator=(Buffer&& other) noexcept {
        if (this == &other) return *this;
        delete[] data_;
        size_ = other.size_;
        data_ = other.data_;
        other.size_ = 0;
        other.data_ = nullptr;
        return *this;
    }

private:
    std::size_t size_;
    int*        data_;
};

Why noexcept on move? STL containers like std::vector only use move (instead of copy) during reallocation if the move operations are noexcept. Without it, you silently lose performance on vector::push_back reallocations.

Implicit deletion rules. Declaring any of the five disables some of the others. Most importantly, declaring a custom destructor suppresses the implicit move constructor and move assignment. That is why "Rule of Five" exists: once you write one, you should consider all five together.

5.2 The Rule of Zero (Preferred)

The best resource-managing class is one that doesn't manage resources directly. Use existing RAII types (std::unique_ptr, std::shared_ptr, std::vector, std::string) as members, and the compiler-generated copy/move/destroy will do the right thing automatically — no special member functions needed.

#include <vector>

class Buffer {
    std::vector<int> data_;             // RAII member: manages itself
public:
    explicit Buffer(std::size_t n) : data_(n) {}
    // No destructor, no copy/move ctor or assignment — defaults are correct.
};

The Rule of Five is for the rare case where you have to wrap a non-RAII resource (a C library handle, an OS primitive, hardware). The Rule of Zero is the default in modern C++.

5.3 Quick Decision Guide

  1. Class doesn't own anything → Rule of Zero. Don't write any of the five.
  2. Class owns a single resource you can wrap in a smart pointer or container → Rule of Zero. Use the wrapper (unique_ptr, vector, …).
  3. Class wraps a raw, non-RAII resource (C handle, hardware, custom protocol) → Rule of Five. Write all five carefully, mark moves noexcept.
  4. Class is non-copyable but movable (e.g., a unique_ptr-like type) → Default the destructor and move operations, =delete the copy operations.
  5. Class is non-copyable and non-movable (e.g., std::mutex, types pinning to a memory address) → =delete both copy and move; default the destructor.

6. Polymorphic Allocators (PMR)

std::pmr (C++17, <memory_resource>) is a unified allocator framework that lets containers select an allocation strategy at runtime instead of at compile time.

6.1 Mental Model: Allocator = Handle, Resource = Behavior

  1. std::pmr::polymorphic_allocator<T> is an STL-compatible allocator that forwards every allocation to a runtime-selected std::pmr::memory_resource.
  2. The resource provides the actual allocation strategy (pooling, monotonic, tracking, limits, etc.).
#include <memory_resource>

std::pmr::monotonic_buffer_resource R;
std::pmr::polymorphic_allocator<int> a{&R};
auto* r = a.resource();  // points to R
// a.allocate(n) will allocate via R

6.2 Why It's Called Polymorphic

PMR is "polymorphic" because allocations go through a runtime polymorphic interface:

  1. std::pmr::memory_resource is an abstract base class with virtual allocation functions.
  2. polymorphic_allocator stores a memory_resource* and calls it virtually.

The key outcome: the container type stays the same while the allocation strategy changes at runtime.

Contrast with classic allocators (compile-time). Classic allocators are part of the container's type:

  1. std::vector<int, MyAlloc> and std::vector<int, YourAlloc> are different types.

With PMR:

  1. std::pmr::vector<int> is one type, and the resource is chosen at runtime.

6.3 Default Resource Pitfalls

A default-constructed PMR allocator or container uses std::pmr::get_default_resource(), which is typically new_delete_resource() (i.e. plain heap). This is a common source of unexpected heap allocations.

#include <memory_resource>
#include <vector>

std::pmr::vector<int> v;  // uses get_default_resource() — heap!

Testing trick to catch accidental allocations: temporarily set the default resource to std::pmr::null_memory_resource() so any allocation throws std::bad_alloc and fails fast.

std::pmr::set_default_resource(std::pmr::null_memory_resource());

6.4 Automatic Rebinding

Even if you store or accept polymorphic_allocator<std::byte>, it can be converted ("rebound") to allocate other types as needed. This is why allocator-aware classes often use:

using allocator_type = std::pmr::polymorphic_allocator<std::byte>;

6.5 Allocator Propagation Into Nested PMR Objects

If you construct a PMR container with a resource, nested allocator-aware elements (like std::pmr::string) will automatically use the same resource. This is called uses-allocator construction.

#include <memory_resource>
#include <string>
#include <vector>

std::pmr::monotonic_buffer_resource R;

std::pmr::vector<std::pmr::string> v(&R);
v.emplace_back("hello");  // the string's allocation comes from R

6.6 Copy Construction Surprise

std::pmr::polymorphic_allocator::select_on_container_copy_construction() is specified to return a default-constructed allocator (using the default resource) — so a copy-constructed PMR container always uses the default resource, not the source's resource.

std::pmr::monotonic_buffer_resource R1;
std::pmr::vector<int> a(&R1);

std::pmr::vector<int> b = a;  // b uses the DEFAULT resource, not R1

The safer explicit copy with a chosen resource:

std::pmr::vector<int> b{a, &R1};  // copy using R1

Practical guideline: avoid copy-by-value patterns with PMR-heavy types unless you're deliberate about the resource.

6.7 Move Can Degrade to Copy

Allocator-aware move operations may fall back to allocating and copying when the source and destination resources differ.

std::pmr::monotonic_buffer_resource R1, R2;
std::pmr::vector<int> a(&R1);
std::pmr::vector<int> b(&R2);

b = std::move(a);  // may copy if R1 != R2

Guideline: keep objects that are moved between each other on the same resource to ensure moves stay cheap.

6.8 Returning PMR Containers by Value

Returning by value isn't forbidden, but you usually need to pass the resource explicitly or use output parameters.

  1. Take a resource parameter:

    std::pmr::vector<int> make_vec(std::pmr::memory_resource* r) {
        std::pmr::vector<int> v(r);
        v.push_back(1);
        return v;  // OK: v already uses r
    }
    
  2. Output parameter (often preferred in strict PMR codebases):

    void make_vec(std::pmr::vector<int>& out) {
        out.clear();
        out.push_back(1);
    }
    

Guideline: avoid pass-by-value and return-by-value if it risks creating temporaries that use the default resource.

6.9 Making Your Own Type Allocator-Aware

To participate in allocator propagation, define allocator_type and provide allocator-aware constructors that pass the allocator to PMR members.

#include <memory_resource>
#include <string>

class MyType {
public:
    using allocator_type = std::pmr::polymorphic_allocator<std::byte>;

    MyType() = default;

    explicit MyType(allocator_type alloc)
        : name_(alloc), alloc_(alloc) {}

    MyType(MyType const& other, allocator_type alloc = {})
        : name_(other.name_, alloc), alloc_(alloc) {}

    MyType(MyType&& other, allocator_type alloc)
        : name_(std::move(other.name_), alloc), alloc_(alloc) {}

    MyType& operator=(MyType const& other) {
        name_ = other.name_;             // do NOT change alloc_
        return *this;
    }

    MyType& operator=(MyType&& other) {
        name_ = std::move(other.name_);  // do NOT change alloc_
        return *this;
    }

private:
    std::pmr::string name_;
    allocator_type alloc_;
};

Key rule: if you store the allocator, assignment must not change which allocator the object uses.

6.10 Resource Selection: What to Use When

  1. std::pmr::monotonic_buffer_resource — per-iteration scratch ("allocate a bunch, free all at once"); very fast.
  2. std::pmr::unsynchronized_pool_resource — many small allocations, single-thread only.
  3. std::pmr::synchronized_pool_resource — pooled and thread-safe (slower than unsynchronized).
  4. std::pmr::null_memory_resource — tests and guards; fail fast on unexpected allocations.
  5. Custom memory_resource — for tracking, limits, logging, etc.

Lifetime rule: a PMR container must not outlive the resource it allocates from.

6.11 Practical Checklist for Introducing PMR

  1. Identify hot allocations: std::vector, std::string, maps, temporary buffers.
  2. Convert to PMR versions: std::pmr::vector, std::pmr::string, etc.
  3. Create a suitable memory_resource at the right scope (per-cycle, per-call, or per-component).
  4. Construct top-level objects with that resource; rely on allocator propagation for nested objects.
  5. Add tests that set the default resource to null_memory_resource() to catch accidental default allocations.

6.12 One-Liner Summary

PMR keeps container types stable but makes their allocation strategy runtime-pluggable via a polymorphic memory_resource, enabling performance gains, tracking, and strict "no accidental heap allocations" guarantees.


7. Advanced Allocation: Placement New and std::launder

7.1 Placement New

The "placement new" operator constructs an object in pre-allocated memory without allocating on its own. The header is <new>.

#include <new>
#include <cstddef>

alignas(int) std::byte buffer[sizeof(int)];
int* p = new (buffer) int{42};   // construct an int in 'buffer'

std::cout << *p;                  // 42

std::destroy_at(p);               // explicit destroy (no-op for int, but mandatory for non-trivial types)
// no delete: 'buffer' is automatic storage

Common uses:

  1. Custom allocatorsstd::vector uses placement new internally to construct elements in its allocated capacity.
  2. Object pools — pre-allocate a big buffer, construct objects in slots.
  3. Embedded systems — direct control over where an object lives (specific memory regions, hardware buffers).

Placement new is low-level. Most code should use std::vector, std::pmr (see § 6), or std::construct_at (C++20) instead.

7.2 std::launder (C++17)

std::launder<T>(p) tells the compiler "I just constructed a new object at p's address, even though p has the type of the previous object that was there." It refreshes the compiler's tracking of object identity.

#include <new>

struct Widget { const int id; };

alignas(Widget) std::byte buf[sizeof(Widget)];
Widget* p = new (buf) Widget{1};

p->~Widget();                                  // destroy
Widget* q = new (buf) Widget{2};               // construct a new Widget in same place

// p still points to the address but to a DIFFERENT object now.
// Reading *p directly is technically UB because of the const member.
// std::launder fixes that:
std::cout << std::launder(p)->id;              // 2

You only need std::launder when you've reused storage for a different object AND the old object isn't "transparently replaceable" through the old pointer. Replacement is not transparent in any of these cases:

  1. The original object's type contained const or reference subobjects.
  2. The original object was a base-class subobject.
  3. The original object was a complete object that's const-qualified.

For most code, just use the new pointer (q above). std::launder is for library code that has to expose pointers obtained before reuse.

7.3 std::construct_at and std::destroy_at

C++20 added std::construct_at and std::destroy_at as constexpr-friendly wrappers around placement new and explicit destructor calls.

#include <memory>

std::byte buf[sizeof(Widget)];
Widget* p = std::construct_at(reinterpret_cast<Widget*>(buf), 1);
std::destroy_at(p);

Use these in constexpr contexts (where placement new wasn't usable before C++20) and in allocator implementations.