Lesson 15 • Advanced
Modern C++ Memory Model
By the end of this lesson you'll know where every variable lives in memory, how long it stays alive, why structs carry hidden padding, what the compiler is allowed to rearrange behind your back, and the first idea behind safe multithreading — the mental model that separates someone who writes C++ from someone who truly understands it.
What You'll Learn
- Map a program's memory into four segments: code, static/data, stack, heap
- Tell apart the four storage durations: automatic, static, dynamic, thread
- Reason about object lifetime — when constructors and destructors fire
- Explain alignment and padding, and measure them with alignof / sizeof
- Use alignas to force a stricter alignment when you need it
- Understand the as-if rule and the happens-before idea behind thread safety
new/delete and know that a destructor runs when an object is destroyed. This lesson zooms out to show where those objects actually live and when they die.💡 Real-World Analogy
Think of your program's memory as a building. The code segment is the printed instruction manual bolted to the wall — fixed, read-only. The static/data segment is the lobby noticeboard: a few items that stay pinned up the entire time the building is open. The stack is a tall stack of trays in the canteen — you can only add or remove from the top, and trays vanish in the exact reverse order you added them. The heap is a giant warehouse: you can request a shelf of any size at any time, but you must remember to hand the shelf back, or it stays reserved forever (a memory leak). Almost everything in this lesson is just "which part of the building does this object live in, and who clears it away?"
📊 The Four Memory Segments
| Segment | Holds | Who clears it | Grows |
|---|---|---|---|
| code / text | Compiled machine code | OS at exit | Fixed |
| static / data | Globals, statics, literals | After main() | Fixed |
| stack | Locals, function calls | Automatically (LIFO) | ↓ down |
| heap | new allocations | You (delete) | ↑ up |
The stack and heap grow toward each other. A stack overflow happens when the stack runs past its limit (often 1–8 MB) — deep or infinite recursion is the usual culprit.
1. Where Variables Live: The Memory Layout
When your program runs, the operating system hands it an address space split into the segments above. Where a variable lives is decided by how you create it: a plain local goes on the stack, a global or static goes in the static/data segment, and anything you create with new goes on the heap. The compiled instructions themselves sit in the read-only code segment. Read this worked example, run it, then check the output against the comments.
Worked example: the four segments
See a global, a local, a heap allocation, and a literal — each in its own segment.
#include <iostream>
using namespace std;
// Lives in the STATIC/DATA segment — exists for the whole program.
int globalCounter = 42;
int main() {
// A local — AUTOMATIC storage on the STACK.
int onStack = 10;
// Allocated with 'new' — DYNAMIC storage on the HEAP.
int* onHeap = new int(99);
// A string literal — read-only DATA segment (and the CODE/text
// segment holds the compiled machine code that runs all this).
const char* literal = "hello";
cout << "glob
...2. Storage Duration & Object Lifetime
Storage duration answers "how long does this object's memory stay alive?" C++ has four kinds. Automatic is the default for locals — the object is born when control reaches it and dies at the end of its { } block, in reverse order of creation. Static (globals and static locals) lasts the whole program. Dynamic (made with new) lasts until you call delete. Thread (thread_local) gives each thread its own copy. The example below makes the constructor and destructor announce themselves so you can watch lifetimes unfold.
Worked example: automatic vs dynamic lifetime
Watch construction and destruction order for stack and heap objects.
#include <iostream>
using namespace std;
struct Beep {
int id;
Beep(int i) : id(i) { cout << "build " << id << endl; }
~Beep() { cout << "destroy " << id << endl; }
};
int main() {
cout << "--- enter main ---" << endl;
{
Beep a(1); // AUTOMATIC: built here...
Beep b(2); // ...and here
} // <- block ends: destroyed in REVERSE order -> destroy 2, destroy 1
Beep* p = new Beep(3); // DYNAMIC: lives past the n
...Your turn. A heap object only dies when you free it — forget the delete and its destructor never runs (a leak). Fill in the blank so the Note is cleaned up properly.
🎯 Your turn: free the heap object
Replace the ___ so the destructor runs and there's no leak.
#include <iostream>
using namespace std;
struct Note {
Note() { cout << "Note created" << endl; }
~Note() { cout << "Note destroyed" << endl; }
};
int main() {
// 🎯 YOUR TURN — this object is on the HEAP, so YOU control its life.
Note* n = new Note(); // dynamic storage duration
cout << "using the note..." << endl;
// 1) Free the heap object so its destructor runs (no leak!)
___; // 👉 delete n;
cout << "done" << endl;
// ✅ Expe
...3. Alignment & Padding
The CPU reads memory fastest when a value sits on an address that's a multiple of its size — this requirement is called alignment. An int usually wants an address divisible by 4, a double one divisible by 8. To honour that inside a struct, the compiler quietly inserts padding bytes between members, which is why sizeof a struct can be bigger than the sum of its parts. You can measure alignment with alignof(T), and demand a stricter one with alignas(N). Reordering members largest-to-smallest often removes padding.
Worked example: alignof, sizeof & padding
See how member order changes a struct's size, and force alignment with alignas.
#include <iostream>
using namespace std;
// Each type wants to START on an address that is a multiple of its
// alignment. The compiler inserts PADDING bytes to make that happen.
struct Padded {
char c; // 1 byte ... then 3 padding bytes so the int aligns
int i; // 4 bytes (must start on a multiple of 4)
char d; // 1 byte ... then 3 padding bytes to round the struct
}; // total: 12 bytes, not 6!
struct Packed {
int i; // 4 bytes
char c; // 1 by
...Now you try. Use alignof and sizeof to ask the compiler directly about a type's alignment and a struct's padded size:
🎯 Your turn: measure alignment & size
Fill in the alignof and sizeof calls, then check the numbers.
#include <iostream>
using namespace std;
struct Pixel {
double x; // 8 bytes (alignment 8)
char tag; // 1 byte ... + 7 padding -> struct is 16 bytes
};
int main() {
// 🎯 YOUR TURN — ask the compiler about alignment & size.
// 1) Print the alignment requirement of a double
cout << "align double: " << ___ << endl; // 👉 alignof(double)
// 2) Print the total size of Pixel (includes padding)
cout << "size Pixel: " << ___ << endl; // 👉 sizeof(Pixel)
...4. The As-If Rule & Happens-Before
The compiler is not obliged to run your statements literally. The as-if rule lets it reorder, combine, or delete operations as long as the program's observable behaviour (its I/O and volatile access) is unchanged — that's how optimised builds get fast. On a single thread you never notice. Across threads, though, reordering can be dangerous, so C++ gives you the happens-before relationship: when a thread publishes data through a std::atomic store and another thread sees it through an atomic load, every write before the store is guaranteed visible after the load. That ordering guarantee is the whole foundation of safe multithreading.
Worked example: as-if rule + happens-before
See single-thread reordering, then a safe cross-thread publish with std::atomic.
#include <iostream>
#include <atomic>
#include <thread>
using namespace std;
atomic<bool> ready{false}; // atomic = safe to touch from many threads
int payload = 0; // plain int, guarded by 'ready'
int main() {
// The AS-IF RULE: the compiler may reorder these two lines because,
// on a single thread, the observable result is identical:
int a = 2 + 3; // the compiler is free to just write 5
int b = a * 10; // ...and fold this too
cout << "a=" << a << " b="
...🔎 Deep Dive: stack vs heap, in one breath
The stack is automatic and fast: allocation is just moving a pointer, and cleanup is free because the object dies with its scope. The catch is size — the stack is small, so huge arrays and deep recursion overflow it. The heap is flexible: any size, any lifetime, but every new needs a matching delete, and forgetting leaks memory. The modern answer is to almost never write raw new/delete yourself — let a std::unique_ptr or container own the heap memory so the destructor frees it for you (RAII).
int x = 5; // stack: freed automatically at scope end int* p = new int(5); // heap: YOU must delete p; or it leaks auto up = make_unique<int>(5); // heap, but freed for you (RAII) ✅
Pro Tips
- 💡 Prefer the stack: if an object can live on the stack, put it there — it's faster and cleans itself up.
- 💡 Let RAII own the heap: wrap
newin aunique_ptror container so you never hand-writedelete. - 💡 Order members big-to-small: placing larger types first often removes padding and shrinks a struct.
- 💡 Default to safe ordering: use a
mutexor defaultatomicfirst; reach for relaxed/acquire/release only once you've measured a need.
Common Errors (and the fix)
- Memory leak: you called
newbut neverdelete, so the destructor never runs and the heap shelf is held forever. Match everynewwith adelete— or better, use aunique_ptrso it's freed automatically. - Dangling pointer (returning a local's address): a local lives on the stack and dies at the end of its block, so a pointer to it points at freed memory. Return by value, or allocate on the heap and pass ownership out via a smart pointer.
- Stack overflow: "Segmentation fault" from deep or infinite recursion means the stack ran past its limit. Add a base case, or move large data to the heap.
- "struct is bigger than I expected": hidden padding inflated
sizeof. Reorder members largest-to-smallest, and remember the size includes alignment padding. - Data race: two threads touch the same plain variable with no synchronisation, so there's no happens-before — the result is undefined. Guard it with a
std::mutexor make itstd::atomic.
📋 Quick Reference
| Concept | Code / Detail | Result |
|---|---|---|
| Stack (automatic) | int x = 5; | freed at scope end |
| Heap (dynamic) | int* p = new int(5); | you delete p; |
| Static duration | static int n = 0; | whole program |
| Alignment | alignof(double) | 8 |
| Force alignment | alignas(16) int a; | 16-byte aligned |
| Padded size | sizeof(MyStruct) | ≥ sum of members |
| Cross-thread publish | flag.store(true) | happens-before |
Frequently Asked Questions
Q: What is the difference between the stack and the heap?
The stack is a fast region the compiler manages for you: every local variable is created on it automatically and destroyed automatically when its block ends. The heap is a larger region you manage by hand (or via smart pointers) with new/delete, where objects live until you free them. Stack allocation is cheap and automatic; heap allocation is flexible but you are responsible for the object's lifetime.
Q: What does 'storage duration' actually mean?
Storage duration is how long the memory for an object stays alive. C++ has four kinds: automatic (a local — lives until its block ends), static (a global or static local — lives for the whole program), dynamic (created with new — lives until you delete it), and thread (a thread_local — one copy per thread). The duration decides when the object is created and destroyed.
Q: Why is there padding between my struct members?
Each type has an alignment requirement — an address it must start on (an int usually wants a multiple of 4, a double a multiple of 8). The compiler inserts padding bytes so every member lands on a valid address, which is why sizeof(struct) can be larger than the sum of its members. Reordering members from largest to smallest often removes padding and shrinks the struct.
Q: What is the as-if rule?
The as-if rule lets the compiler transform your code in any way it likes — reorder, combine, or delete operations — as long as the program's observable behaviour stays the same. Observable means I/O and access to volatile data. It is why optimised builds can be far faster than the literal instructions you wrote, while still producing the same output.
Q: Do I need to learn memory ordering to write threads?
Not at first. For everyday threading, protect shared data with a std::mutex or use std::atomic with its default (sequentially consistent) ordering, and the happens-before guarantees are handled for you. The relaxed/acquire/release orderings are an expert tool for squeezing out performance once you understand the model — reach for them last, not first.
Mini-Challenge: Lifetime Detective
No blanks this time — just a brief and an outline. Predict the destruction order before you run it, then build it and check your output against the comments. Getting this right means you've internalised how lifetimes actually work.
🎯 Mini-Challenge: trace the lifetimes
Create stack and heap objects and predict the construct/destroy order.
#include <iostream>
using namespace std;
struct Tracker {
int id;
Tracker(int i) : id(i) { cout << "open " << id << endl; }
~Tracker() { cout << "close " << id << endl; }
};
int main() {
// 🎯 MINI-CHALLENGE: lifetime detective
// 1. In a { } block, create two stack Trackers: Tracker(1), Tracker(2).
// Predict the destruction order BEFORE you run it (hint: reverse).
// 2. After the block, create one heap Tracker with new Tracker(3).
// 3. delete i
...🎉 Lesson Complete
- ✅ Memory splits into four segments:
code,static/data,stack,heap - ✅ Four storage durations: automatic, static, dynamic, thread
- ✅ Stack objects die in reverse order at scope end; heap objects die when you
deletethem - ✅
alignofmeasures alignment,sizeofincludes padding,alignasforces a stricter alignment - ✅ The as-if rule lets the compiler rearrange code that has the same observable result
- ✅ Happens-before via
std::atomicmakes one thread's writes safely visible to another - ✅ Next lesson: Advanced OOP — virtual tables, polymorphic dispatch, and inheritance
Sign up for free to track which lessons you've completed and get learning reminders.