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

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/free heap. new/delete understand object lifecycles; malloc/free don't.
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) — RAII-wrap raw pointers, auto-manage lifetime. Eliminate most manual delete, prevent 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 refcount. Object destroyed when last shared_ptr 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. Move-only, no refcount overhead. Lighter than shared_ptr.
#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>non-owning observer of a shared_ptr-managed object.
  • Doesn't contribute to refcount → can't keep object alive on its own.

2 main uses:

  1. Breaking cycles — two objects with mutual shared_ptr never destroyed (refcount never 0). Replace one direction with weak_ptr.
  2. Caches/observers — reference that should not extend lifetime.
  • Access via lock() — atomically checks alive + returns shared_ptr (possibly empty). Never use weak_ptr like a raw pointer; always 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

  • Parent owns children + each child has parent back-pointer. Both shared_ptr → cycle leaks both.
#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> — parent owns child and child owns parent → neither releasable.

3.5 Converting unique_ptr to shared_ptr

  • unique_ptr implicitly convertible to shared_ptr.
  • Common factory pattern: return unique_ptr for flexibility; callers convert if needed.
#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: can't go shared_ptrunique_ptr. One-way conversion.

3.6 unique_ptr<T[]> and Custom Deleters

  • std::unique_ptr has array specialization:
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 only when size is a runtime value and std::vector<T> won't fit.

Custom deleters

  • Default: delete (or delete[] for array form).
  • Non-new resources → 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 wrap any C-style "open/close" handle — files, sockets, OpenGL textures, OS handles.
  • shared_ptr accepts deleters too — stored in control block, so deleter type isn't part of shared_ptr type (same shared_ptr<FILE> regardless of deleter).

3.7 std::enable_shared_from_this

  • Class managed by shared_ptr needs to hand out shared_ptr<This> from a member fn.
  • Constructing fresh shared_ptr from this is a bug — separate control block → double-delete.
class Node {
public:
    std::shared_ptr<Node> badGetSelf() {
        return std::shared_ptr<Node>(this);   // BUG: separate control block!
    }
};
  • Right way — derive from std::enable_shared_from_this<T>, 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: only works after object is owned by a shared_ptr. Calling from ctor or before any shared_ptr exists → std::bad_weak_ptr.

3.8 make_shared vs make_unique vs new

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 safetyf(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 — codebase greppable for leaks.
  3. Faster make_shared — single allocation (object + control block together). shared_ptr<T>(new T) allocates twice.

When NOT to use make_*:

  1. Custom deletersmake_* doesn't accept them; use unique_ptr<T, D>(p, d).
  2. Aliasing constructor of shared_ptr — for shared_ptr<int> pointing into a member of an existing shared_ptr<Struct>.
  3. Long-lived weak_ptrs to short-lived objectsmake_shared keeps whole allocation alive while any weak_ptr exists (control block in same block as object). Plain new + shared_ptr releases object memory earlier.

4. RAII (Resource Acquisition Is Initialization)

  • Core C++ idiom — tie resource lifetime to object lifetime.
  • Acquire in ctor, release in dtor. Scope exit → automatic cleanup.
  • Smart pointers = common example. Applies to any resource — file handles, locks, DB connections.
#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

  • Language-level mechanics of copy/move ctors, lvalue/rvalue references, value categories, special member fns table → see K2A-B1-5 C++ Reference, Copy, Move and Forwarding.
  • This section: applying those when designing resource-owning classes.
  • Resource-owning classes (heap memory, file handles, sockets, mutexes) need clear copy/move/destroy policy.
  • Compiler-generated ops do member-wise — correct when members self-manage (smart pointers, STL types), wrong when a member is a raw pointer to an owned resource.

5.1 The Rule of Five

  • If your class manages a resource the compiler can't copy/move correctly, write all 5 special members (dtor + 4 copy/move ops) consistently.
  • Rule of Three before C++11 added move → Rule of Five.
#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 (std::vector) use move (vs copy) on reallocation only when moves are noexcept. Forgetting → silent perf loss on push_back realloc.
  • Implicit deletion: declaring any of the 5 disables some others. Custom dtor suppresses implicit move ctor + move assignment → why Rule of Five exists.

5.2 The Rule of Zero (Preferred)

  • Best resource class — doesn't manage resources directly.
  • Use existing RAII members (unique_ptr, shared_ptr, vector, string) → compiler-generated ops do the right thing. No special members 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.
};
  • Rule of Five = rare case wrapping non-RAII resource (C library handle, OS primitive, hardware).
  • Rule of Zero = default in modern C++.

5.3 Quick Decision Guide

  1. Class doesn't own anything → Rule of Zero. None of the 5.
  2. Class owns single resource wrappable in smart pointer/container → Rule of Zero. Use wrapper.
  3. Raw non-RAII resource (C handle, hardware, custom protocol) → Rule of Five. All 5, moves noexcept.
  4. Non-copyable but movable (unique_ptr-like) → default dtor + moves, =delete copies.
  5. Non-copyable + non-movable (std::mutex, types pinned to memory) → =delete both copy + move; default dtor.

6. Polymorphic Allocators (PMR)

  • std::pmr (C++17, <memory_resource>) — unified allocator framework. Containers select allocation strategy at runtime, not compile time.

6.1 Mental Model: Allocator = Handle, Resource = Behavior

  1. std::pmr::polymorphic_allocator<T> — STL-compatible allocator that forwards every allocation to a runtime-selected std::pmr::memory_resource.
  2. Resource = actual allocation strategy (pooling, monotonic, tracking, limits).
#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

  • Allocations go through runtime polymorphic interface:
  1. std::pmr::memory_resource — abstract base with virtual allocation fns.
  2. polymorphic_allocator stores memory_resource*, calls virtually.
  • Outcome: container type stays the same; allocation strategy changes at runtime.

vs classic allocators (compile-time):

  • Classic allocators part of container type:
    • std::vector<int, MyAlloc>std::vector<int, YourAlloc>.
  • PMR:
    • std::pmr::vector<int> = one type, resource chosen at runtime.

6.3 Default Resource Pitfalls

  • Default-constructed PMR allocator/container uses std::pmr::get_default_resource() — typically new_delete_resource() (plain heap).
  • Common source of unexpected heap allocations.
#include <memory_resource>
#include <vector>

std::pmr::vector<int> v;  // uses get_default_resource() — heap!
  • Testing trick: set default resource to null_memory_resource() → any allocation throws std::bad_alloc, fails fast.
std::pmr::set_default_resource(std::pmr::null_memory_resource());

6.4 Automatic Rebinding

  • polymorphic_allocator<std::byte> can be "rebound" to allocate other types as needed.
  • Allocator-aware classes commonly use:
using allocator_type = std::pmr::polymorphic_allocator<std::byte>;

6.5 Allocator Propagation Into Nested PMR Objects

  • Construct PMR container with resource → nested allocator-aware elements (std::pmr::string) auto-use same resource.
  • This = 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

  • polymorphic_allocator::select_on_container_copy_construction() returns default-constructed allocator (default resource) → copy-constructed PMR container always uses default resource, not source's.
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
  • Safer explicit copy:
std::pmr::vector<int> b{a, &R1};  // copy using R1
  • Guideline: avoid copy-by-value patterns with PMR-heavy types unless intentional about resource.

6.7 Move Can Degrade to Copy

  • Allocator-aware moves may fall back to allocate-and-copy when source/dest 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 mutually-moved objects on same resource → cheap moves.

6.8 Returning PMR Containers by Value

  • Allowed, but usually need explicit resource or output params.
  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/return-by-value if it risks default-resource temporaries.

6.9 Making Your Own Type Allocator-Aware

  • Define allocator_type, provide allocator-aware ctors that pass 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.
  3. std::pmr::synchronized_pool_resource — pooled + thread-safe (slower than unsynchronized).
  4. std::pmr::null_memory_resource — tests/guards; fail fast on unexpected allocations.
  5. Custom memory_resource — tracking, limits, logging.
  • Lifetime rule: PMR container must not outlive its resource.

6.11 Practical Checklist for Introducing PMR

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

6.12 One-Liner Summary

  • PMR keeps container types stable but makes allocation strategy runtime-pluggable via polymorphic memory_resource → perf gains, tracking, strict "no accidental heap" guarantees.

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

7.1 Placement New

  • Placement new — construct object in pre-allocated memory, no allocation. Header <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 for constructing elements in allocated capacity.
  2. Object pools — pre-allocate big buffer, construct in slots.
  3. Embedded — direct control over object location (specific memory regions, hardware buffers).
  • Low-level. Most code → std::vector, std::pmr (see § 6), or std::construct_at (C++20).

7.2 std::launder (C++17)

  • std::launder<T>(p) — tells compiler "I just constructed a new object at p's address, even though p has the type of the previous object." Refreshes compiler's object-identity tracking.
#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
  • Need launder only when reused storage for different object AND old object isn't "transparently replaceable" through old pointer.
  • Replacement is not transparent when:
  1. Original type contained const or reference subobjects.
  2. Original was a base-class subobject.
  3. Original was complete + const-qualified.
  • Most code → just use new pointer (q above). launder for library code exposing pre-reuse pointers.

7.3 std::construct_at and std::destroy_at

  • C++20 — constexpr-friendly wrappers for placement new + explicit dtor calls.
#include <memory>

std::byte buf[sizeof(Widget)];
Widget* p = std::construct_at(reinterpret_cast<Widget*>(buf), 1);
std::destroy_at(p);
  • Use in constexpr contexts (placement new wasn't usable pre-C++20) + allocator implementations.