The stack and the heap
Two ways to get memory, with very different costs and rules.
By the end of this chapter you can
- Explain why stack allocation is nearly free and heap allocation is not
- Use new and delete correctly, then explain why you should not
- Predict when a stack overflow occurs
Your program has two pools of memory available to it while it runs, and they behave so differently that choosing between them is one of the recurring decisions in C++.
The stack is a single contiguous region per thread, used in strict last-in-first-out order. The heap (the standard calls it the free store) is a general pool you can take arbitrary pieces from, in any order, and return in any order.
Why the stack is nearly free
A stack allocation is one arithmetic operation. The processor keeps a register pointing at the top of the stack; making room for local variables means subtracting from it, and releasing them means adding back.
int sum_three(int a, int b, int c) {
int local = a + b;
return local + c;
}Press Assembly, and switch to -O0 to see it before optimisation. Even
unoptimised, there is no call to an allocator — the compiler decided at compile
time exactly how many bytes this function needs, and reserving them is part of
entering the function. At -O2 the local disappears entirely into a register.
That is the stack’s bargain: allocation is free, but the size must be known at compile time and the lifetime must nest. You get memory in the order you asked and give it back in exactly the reverse order.
Why the heap is not
A heap allocation is a function call into an allocator that has to find a free block of the right size, possibly ask the operating system for more memory, update its bookkeeping, and stay correct while other threads do the same.
#include <chrono>
#include <iostream>
#include <memory>
int main() {
constexpr int rounds = 200'000;
using clock = std::chrono::steady_clock;
auto start = clock::now();
long long sink = 0;
for (int i = 0; i < rounds; ++i) {
int on_stack = i; // automatic storage
sink += on_stack;
}
auto mid = clock::now();
for (int i = 0; i < rounds; ++i) {
auto on_heap = std::make_unique<int>(i); // dynamic storage
sink += *on_heap;
}
auto finish = clock::now();
using us = std::chrono::microseconds;
std::cout << "stack: " << std::chrono::duration_cast<us>(mid - start).count() << " us\n";
std::cout << "heap: " << std::chrono::duration_cast<us>(finish - mid).count() << " us\n";
std::cout << "(checksum " << sink << ")\n";
}The gap is large, and it is larger than it looks: this measurement runs with sanitizers on and optimisation off, and it allocates the friendliest possible size in the friendliest possible pattern. Real allocation patterns fragment the heap and miss cache.
The stack has a hard limit
Typically 1–8 MB per thread, fixed when the thread starts. Exceed it and the program dies immediately:
#include <iostream>
int main() {
std::cout << "about to ask for 64 MB of stack\n";
int huge[16'000'000]; // ~64 MB — far beyond the limit
huge[0] = 1;
huge[15'999'999] = 2;
std::cout << "never reached: " << huge[0] << '\n';
}The same data on the heap is unremarkable:
#include <iostream>
#include <vector>
int main() {
std::vector<int> huge(16'000'000); // ~64 MB, heap-allocated
huge[0] = 1;
huge[15'999'999] = 2;
std::cout << "fine: " << huge.size() << " elements, "
<< huge.size() * sizeof(int) / (1024 * 1024) << " MB\n";
}The other way to exhaust the stack is recursion without a base case — each call adds a frame, and a few hundred thousand frames is enough. That is what a “stack overflow” almost always is in practice.
new and delete, once
You should almost never write these. But you need to recognise them, because they are what smart pointers and containers are doing underneath, and because you will meet them in existing code.
#include <iostream>
int main() {
int* single = new int(42); // one int
int* array = new int[5]{1, 2, 3, 4, 5}; // five ints
std::cout << *single << ' ' << array[2] << '\n';
delete single; // matches new
delete[] array; // matches new[]
}Three rules, all of which are easy to break:
- Every
newneeds exactly onedelete. Zero is a leak; two is a double-free, which corrupts the allocator. new[]pairs withdelete[]. Mixing them is undefined behaviour, not a style issue — the array form has to run destructors for every element and often reads a stored count from before the block.- The pointer must survive to reach the
delete. Which means every early return, everybreak, and every thrown exception between the two is a leak.
Rule 3 is the one that makes manual memory management genuinely hard:
#include <iostream>
#include <stdexcept>
void process(bool fail) {
int* buffer = new int[1000];
if (fail) {
throw std::runtime_error("failed"); // buffer is never deleted
}
delete[] buffer;
}
int main() {
try {
process(true);
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
std::cout << "leaked 4000 bytes; LeakSanitizer reports it at exit\n";
}Nothing in process looks wrong. The delete[] is right there. But the throw
jumps over it, and there is no arrangement of delete statements that fixes
this in general — you would need one before every exit path, including ones
added later by someone else.
LeakSanitizer catches it here and names the allocation. In production, this is a process that grows until it is killed.
What to write instead
The last diagram step is the answer, and it is the answer for essentially all new code:
#include <iostream>
#include <memory>
#include <stdexcept>
#include <vector>
void process(bool fail) {
std::vector<int> buffer(1000); // owns its memory
if (fail) {
throw std::runtime_error("failed"); // buffer's destructor still runs
}
}
int main() {
try {
process(true);
} catch (const std::exception& e) {
std::cout << "caught: " << e.what() << '\n';
}
std::cout << "no leak: the sanitizer has nothing to report\n";
auto single = std::make_unique<int>(42);
std::cout << "and a single object: " << *single << '\n';
}Identical structure, no delete, no leak — on any exit path, including ones
nobody has written yet. The destructor of an automatic object runs no matter
how the scope is left, so putting ownership in an automatic object makes cleanup
unskippable.
That idea has a name, and it is the subject of Chapter 3.2.
std::vector<T>for a run-time number of elements.std::unique_ptr<T>for one heap object with a single owner.std::shared_ptr<T>when ownership is genuinely shared — rarer than its popularity suggests.std::stringfor text.
Reach for new only when implementing one of these yourself.