C++ Memory Management
- Description: A note on the C++ memory management — stack vs heap,
malloc/freevsnew/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
- 2. Manual Memory Management:
malloc/freevsnew/delete - 3. Smart Pointers
- 4. RAII (Resource Acquisition Is Initialization)
- 5. Rule of Five and Rule of Zero
- 6. Polymorphic Allocators (PMR)
- 6.1 Mental Model: Allocator = Handle, Resource = Behavior
- 6.2 Why It's Called Polymorphic
- 6.3 Default Resource Pitfalls
- 6.4 Automatic Rebinding
- 6.5 Allocator Propagation Into Nested PMR Objects
- 6.6 Copy Construction Surprise
- 6.7 Move Can Degrade to Copy
- 6.8 Returning PMR Containers by Value
- 6.9 Making Your Own Type Allocator-Aware
- 6.10 Resource Selection: What to Use When
- 6.11 Practical Checklist for Introducing PMR
- 6.12 One-Liner Summary
- 7. Advanced Allocation: Placement New and
std::launder
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/deleteunderstand object lifecycles;malloc/freedon'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_ptris 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 ashared_ptr-managed object.- Doesn't contribute to refcount → can't keep object alive on its own.
2 main uses:
- Breaking cycles — two objects with mutual
shared_ptrnever destroyed (refcount never 0). Replace one direction withweak_ptr. - Caches/observers — reference that should not extend lifetime.
- Access via
lock()— atomically checks alive + returnsshared_ptr(possibly empty). Never use weak_ptr like a raw pointer; alwayslock().
#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
parentwereshared_ptr<Node>— parent owns child and child owns parent → neither releasable.
3.5 Converting unique_ptr to shared_ptr
unique_ptrimplicitly convertible toshared_ptr.- Common factory pattern: return
unique_ptrfor 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_ptr→unique_ptr. One-way conversion.
3.6 unique_ptr<T[]> and Custom Deleters
std::unique_ptrhas 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 arrays —std::array<T, N>is better. Use only when size is a runtime value andstd::vector<T>won't fit.
Custom deleters
- Default:
delete(ordelete[]for array form). - Non-
newresources → 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_ptraccepts deleters too — stored in control block, so deleter type isn't part ofshared_ptrtype (sameshared_ptr<FILE>regardless of deleter).
3.7 std::enable_shared_from_this
- Class managed by
shared_ptrneeds to hand outshared_ptr<This>from a member fn. - Constructing fresh
shared_ptrfromthisis 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>, useshared_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 anyshared_ptrexists →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_*:
- 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 onenewthrows after the other allocates. - No naked
new— codebase greppable for leaks. - Faster
make_shared— single allocation (object + control block together).shared_ptr<T>(new T)allocates twice.
When NOT to use make_*:
- Custom deleters —
make_*doesn't accept them; useunique_ptr<T, D>(p, d). - Aliasing constructor of
shared_ptr— forshared_ptr<int>pointing into a member of an existingshared_ptr<Struct>. - Long-lived weak_ptrs to short-lived objects —
make_sharedkeeps whole allocation alive while anyweak_ptrexists (control block in same block as object). Plainnew+shared_ptrreleases 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
noexcepton move: STL containers (std::vector) use move (vs copy) on reallocation only when moves arenoexcept. Forgetting → silent perf loss onpush_backrealloc. - 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
- Class doesn't own anything → Rule of Zero. None of the 5.
- Class owns single resource wrappable in smart pointer/container → Rule of Zero. Use wrapper.
- Raw non-RAII resource (C handle, hardware, custom protocol) → Rule of Five. All 5, moves
noexcept. - Non-copyable but movable (
unique_ptr-like) → default dtor + moves,=deletecopies. - Non-copyable + non-movable (
std::mutex, types pinned to memory) →=deleteboth 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
std::pmr::polymorphic_allocator<T>— STL-compatible allocator that forwards every allocation to a runtime-selectedstd::pmr::memory_resource.- 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:
std::pmr::memory_resource— abstract base with virtual allocation fns.polymorphic_allocatorstoresmemory_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()— typicallynew_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 throwsstd::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.
-
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 } -
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
std::pmr::monotonic_buffer_resource— per-iteration scratch ("allocate a bunch, free all at once"). Very fast.std::pmr::unsynchronized_pool_resource— many small allocations, single-thread.std::pmr::synchronized_pool_resource— pooled + thread-safe (slower than unsynchronized).std::pmr::null_memory_resource— tests/guards; fail fast on unexpected allocations.- Custom
memory_resource— tracking, limits, logging.
- Lifetime rule: PMR container must not outlive its resource.
6.11 Practical Checklist for Introducing PMR
- Identify hot allocations —
vector,string, maps, temporary buffers. - Convert to PMR —
std::pmr::vector,std::pmr::string, etc. - Create suitable
memory_resourceat right scope (per-cycle, per-call, per-component). - Construct top-level objects with that resource; rely on propagation for nested.
- 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:
- Custom allocators —
std::vectoruses placement new internally for constructing elements in allocated capacity. - Object pools — pre-allocate big buffer, construct in slots.
- Embedded — direct control over object location (specific memory regions, hardware buffers).
- Low-level. Most code →
std::vector,std::pmr(see § 6), orstd::construct_at(C++20).
7.2 std::launder (C++17)
std::launder<T>(p)— tells compiler "I just constructed a new object atp's address, even thoughphas 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
launderonly when reused storage for different object AND old object isn't "transparently replaceable" through old pointer. - Replacement is not transparent when:
- Original type contained
constor reference subobjects. - Original was a base-class subobject.
- Original was complete +
const-qualified.
- Most code → just use new pointer (
qabove).launderfor 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
constexprcontexts (placement new wasn't usable pre-C++20) + allocator implementations.