Skip to main content
C++ · lesson 16 of 24
C++ advanced Lesson 16 of 24

Memory Management in C++

Learn stack vs heap, RAII, new/delete pitfalls, struct padding, placement new and arenas, and catch memory bugs with Valgrind and AddressSanitizer.

By SkillByExample · Updated

C++ gives you direct control over memory. That control is what makes C++ fast — and what makes memory bugs possible. Understanding where objects live, how long they live, and who is responsible for cleaning them up is foundational to writing correct, efficient C++. The tools in this tutorial help you prevent, detect, and debug them.

Stack vs Heap

The stack holds each function call’s local variables. Allocating there costs almost nothing, because the compiler just moves the stack pointer, but the stack is small (typically 1–8 MB per thread) and everything on it dies when its function returns.

The heap (free store) is a large pool that new and malloc take memory from and delete and free give it back to. Heap allocation does more bookkeeping, but heap objects can be large and can outlive the function that created them.

#include <iostream>

int* make_value() {
    int x = 42;                   // stack: destroyed when this function returns
    int* heap_int = new int(x);   // heap: lives until delete
    std::cout << "x at " << &x << ", heap_int at " << heap_int << "\n";
    return heap_int;
}

int main() {
    int* p = make_value();
    int* heap_arr = new int[1024]();   // heap array, () zero-initializes it
    std::cout << "*p = " << *p << ", heap_arr[1023] = " << heap_arr[1023] << "\n";
    delete p;
    delete[] heap_arr;                 // new[] must be paired with delete[]
}
$ g++ -std=c++17 -Wall -o lifetimes lifetimes.cpp && ./lifetimes
x at 0x7ffe134123b4, heap_int at 0xc0f2b0
*p = 42, heap_arr[1023] = 0

The stack address is near the top of the address space and the heap address far below it. Your numbers will differ, since Linux randomizes both on every run. x is gone once make_value returns, but the int copied onto the heap survives, so main can still read 42 through p. The () after new int[1024] is what makes the last element 0; without it, the array’s contents are indeterminate.

Large objects, objects that outlive a function, and objects whose size is unknown at compile time belong on the heap.

new/delete and Their Pitfalls

Every new needs exactly one matching delete, no more and no less. Each mistake below is undefined behavior, and the dangerous part is how differently they fail. A double free is the loud one:

#include <iostream>

int main() {
    int* p = new int(1);
    delete p;
    std::cout << "deleted once" << std::endl;
    delete p;   // UB: p was already freed
    std::cout << "deleted twice" << std::endl;
}
$ g++ -std=c++17 -Wall -o double_free double_free.cpp && ./double_free
deleted once
free(): double free detected in tcache 2
bash: line 1:    12 Aborted                 (core dumped) ./double_free

It compiled without a warning. At run time, glibc’s allocator noticed the block was already in its cache of freed blocks (the tcache) and aborted the process. That check is a convenience of this allocator, not a guarantee: a different sequence of frees can slip past it and quietly corrupt the heap.

A use-after-free is often completely silent:

#include <iostream>

int main() {
    int* q = new int(2);
    delete q;
    *q = 5;   // UB: writes to freed memory
    std::cout << "*q = " << *q << "\n";
}
$ g++ -std=c++17 -Wall -o uaf uaf.cpp && ./uaf
*q = 5

No warning and no crash, and the “expected” value comes back. The memory still belongs to the process, so nothing stops the write. In a larger program, that block may already have been handed to another object, which now has a 5 written into it. The Finding Memory Bugs section runs this same program under Valgrind and AddressSanitizer.

A leak needs no bug on the happy path, just an early return that skips the delete:

#include <iostream>

bool parse(const char* s) {
    int* scratch = new int[16]();
    if (s[0] == '\0') return false;   // early return: scratch is never deleted
    scratch[0] = s[0];
    delete[] scratch;
    return true;
}

int main() {
    std::cout << parse("x") << parse("") << "\n";
}
$ g++ -std=c++17 -Wall -g -fsanitize=address -o leak_path leak_path.cpp && ./leak_path
10

=================================================================
==24==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 64 byte(s) in 1 object(s) allocated from:
    #0 0x7fe4c09c4718 in operator new[](unsigned long) (/usr/local/lib64/libasan.so.8+0xf5718)
    #1 0x4011cb in parse(char const*) /src/leak_path.cpp:4
    #2 0x40134b in main /src/leak_path.cpp:12
    #3 0x7fe4c0380ca7 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58

SUMMARY: AddressSanitizer: 64 byte(s) leaked in 1 allocation(s).

parse("x") returned 1 and cleaned up; parse("") returned 0 and leaked its 16 ints, 64 bytes. ASan’s leak checker runs at exit and names the new[] on line 4. Every extra return, and every exception, is another path that has to remember the delete.

The fourth classic mistake, allocating with new[] and releasing with plain delete, is Practice task 3. Modern C++ avoids all four by not writing delete at all: smart pointers and standard containers free memory in their destructors. Reserve raw new/delete for low-level infrastructure like allocators.

RAII — The Cornerstone of Resource Safety

RAII ties a resource’s lifetime to an object’s lifetime: the constructor acquires it, the destructor releases it. Destructors run on every way out of a scope, including exceptions, so the release can’t be skipped. std::ofstream and std::lock_guard are both RAII types:

#include <cstdio>
#include <fstream>
#include <iostream>
#include <mutex>
#include <stdexcept>
#include <string>

std::mutex log_mutex;

void write_log(const std::string& msg) {
    std::lock_guard<std::mutex> lock(log_mutex);   // unlocks on every exit path
    std::ofstream file("app.log", std::ios::app);   // closes on every exit path
    file << "log: " << msg << "\n";
    if (msg.empty()) throw std::invalid_argument("empty message");
}

int main() {
    std::remove("app.log");
    write_log("started");
    try { write_log(""); }
    catch (const std::exception& e) { std::cout << "caught: " << e.what() << "\n"; }
    std::cout << "mutex free again: " << std::boolalpha << log_mutex.try_lock() << "\n";
    log_mutex.unlock();
    std::cout << std::ifstream("app.log").rdbuf();
}
$ g++ -std=c++17 -Wall -o raii_log raii_log.cpp && ./raii_log
caught: empty message
mutex free again: true
log: started
log: 

The second call threw halfway through write_log, and there is no unlock() or close() anywhere in it. Yet try_lock succeeds, so the lock was released, and the file contains the line written just before the throw, so it was flushed and closed. Both happened in destructors as the exception left the function.

Writing your own RAII type takes a constructor and a destructor. This one measures how long its scope lasted:

#include <chrono>
#include <iostream>
#include <string>
#include <thread>

class ScopedTimer {
    std::chrono::steady_clock::time_point start_;
    std::string name_;
public:
    explicit ScopedTimer(std::string name)
        : start_(std::chrono::steady_clock::now()), name_(std::move(name)) {}

    ~ScopedTimer() {
        auto elapsed = std::chrono::steady_clock::now() - start_;
        auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed);
        std::cout << name_ << ": " << ms.count() << "ms\n";
    }
};

int main() {
    {
        ScopedTimer t("sleep 20ms");
        std::this_thread::sleep_for(std::chrono::milliseconds(20));
    }
    std::cout << "after the block\n";
}
$ g++ -std=c++17 -Wall -o timer timer.cpp && ./timer
sleep 20ms: 20ms
after the block

Nothing calls the timer’s “stop” method; the report appears at the closing brace, before after the block, because that brace is where t is destroyed. The same pattern works for any resource that has to be given back.

Memory Layout: Padding and Alignment

Each type must sit at an address that is a multiple of its alignment, so the compiler inserts padding between struct members. The same members in a different order can produce a different sizeof.

#include <cstddef>
#include <iostream>

struct Padded {
    char   a;   // offset 0, then 3 bytes padding so b is 4-aligned
    int    b;   // offset 4
    char   c;   // offset 8, then 7 bytes padding so d is 8-aligned
    double d;   // offset 16
};

struct Packed {
    double d;   // offset 0
    int    b;   // offset 8
    char   a;   // offset 12
    char   c;   // offset 13, then 2 bytes tail padding
};

int main() {
    std::cout << "sizeof(Padded) = " << sizeof(Padded) << "\n";
    std::cout << "sizeof(Packed) = " << sizeof(Packed) << "\n";
    std::cout << "offsetof(Padded, c) = " << offsetof(Padded, c)
              << ", offsetof(Padded, d) = " << offsetof(Padded, d) << "\n";
}
$ g++ -std=c++17 -Wall -o padding padding.cpp && ./padding
sizeof(Padded) = 24
sizeof(Packed) = 16
offsetof(Padded, c) = 8, offsetof(Padded, d) = 16

Both structs hold 14 bytes of data. Padded wastes 10 bytes on padding, mostly the 7 after c, which push d to offset 16. Sorting members from largest to smallest alignment packs the small ones together, leaving only 2 bytes of tail padding, so Packed is a third smaller.

alignas goes the other way and asks for more alignment than the type needs. A common use is giving each counter in concurrent code its own 64-byte cache line:

#include <cstdint>
#include <iostream>

struct Counter { long value; };
struct alignas(64) PaddedCounter { long value; };   // one per 64-byte cache line

int main() {
    Counter plain[2];
    PaddedCounter padded[2];
    auto gap = [](const void* a, const void* b) {
        return reinterpret_cast<std::uintptr_t>(b) - reinterpret_cast<std::uintptr_t>(a);
    };
    std::cout << "Counter:       sizeof " << sizeof(Counter) << ", alignof " << alignof(Counter)
              << ", gap " << gap(&plain[0], &plain[1]) << "\n";
    std::cout << "PaddedCounter: sizeof " << sizeof(PaddedCounter) << ", alignof " << alignof(PaddedCounter)
              << ", gap " << gap(&padded[0], &padded[1]) << "\n";
    std::cout << "&padded[0] % 64 = " << reinterpret_cast<std::uintptr_t>(&padded[0]) % 64 << "\n";
}
$ g++ -std=c++17 -Wall -o cacheline cacheline.cpp && ./cacheline
Counter:       sizeof 8, alignof 8, gap 8
PaddedCounter: sizeof 64, alignof 64, gap 64
&padded[0] % 64 = 0

The long inside is still 8 bytes, but alignas(64) rounds the struct’s size up to 64, so neighbouring array elements never share a cache line. Two Counters, by contrast, sit 8 bytes apart. If two threads update them, the cores keep stealing the shared line from each other, a slowdown called false sharing.

Placement New

Placement new constructs an object in memory you already have, instead of allocating. It is how memory pools, arenas and std::vector build objects in pre-allocated storage. Because you didn’t allocate with plain new, you must not delete: call the destructor yourself.

#include <iostream>
#include <new>
#include <string>

struct Widget {
    std::string name;
    Widget(std::string n) : name(std::move(n)) { std::cout << "construct " << name << "\n"; }
    ~Widget() { std::cout << "destroy " << name << "\n"; }
};

int main() {
    alignas(int) unsigned char ibuf[sizeof(int)];
    int* p = new (ibuf) int(42);   // constructs in ibuf: no heap allocation
    std::cout << *p << " at " << static_cast<void*>(p)
              << ", ibuf at " << static_cast<void*>(ibuf) << "\n";
    // int has a trivial destructor, so there is nothing to call

    alignas(Widget) unsigned char wbuf[sizeof(Widget)];
    Widget* w = new (wbuf) Widget("foo");
    w->~Widget();                  // explicit destructor call: never delete w
    std::cout << "wbuf is free to reuse\n";
}
$ g++ -std=c++17 -Wall -o placement placement.cpp && ./placement
42 at 0x7ffdb335732c, ibuf at 0x7ffdb335732c
construct foo
destroy foo
wbuf is free to reuse

p and ibuf are the same address: placement new returned the pointer it was given, with an int built there. For int there is no destructor to call. Writing p->~int() does not even compile, because a keyword like int is not accepted after ~. Through an alias, as in a template (using T = int; p->~T();), it compiles and does nothing, which is why std::destroy_at works for any T. For Widget the destructor matters, since it frees the string’s memory, and it runs only because w->~Widget() asks for it.

Custom Allocators

new and malloc are general-purpose: each call searches the allocator’s free lists, and each delete puts the block back. When a unit of work, such as one request or one frame, makes many small allocations that all die together, an arena does better: it hands out slices of one big buffer and frees them all at once.

#include <cstddef>
#include <iostream>
#include <new>
#include <vector>

class Arena {   // every allocation comes from one buffer, and is freed with it
    std::vector<char> buf_;
    std::size_t offset_ = 0;
public:
    explicit Arena(std::size_t capacity) : buf_(capacity) {}

    void* allocate(std::size_t size, std::size_t align = alignof(std::max_align_t)) {
        std::size_t aligned = (offset_ + align - 1) & ~(align - 1);   // round up
        if (aligned + size > buf_.size()) throw std::bad_alloc();
        offset_ = aligned + size;
        return buf_.data() + aligned;
    }
    void reset() { offset_ = 0; }   // frees everything at once
};

int main() {
    Arena arena(1024);
    char* first  = static_cast<char*>(arena.allocate(10));
    char* second = static_cast<char*>(arena.allocate(10));
    std::cout << "second starts " << (second - first) << " bytes after first\n";
    arena.reset();
    char* again = static_cast<char*>(arena.allocate(10));
    std::cout << "after reset: " << (again == first ? "same address as first" : "new address") << "\n";
}
$ g++ -std=c++17 -Wall -o arena arena.cpp && ./arena
second starts 16 bytes after first
after reset: same address as first

Only 10 bytes were requested, but the second slice starts 16 bytes in, because the default alignment (alignof(std::max_align_t)) is 16 on x86-64. Allocation is just a round-up and an addition. reset() is a single assignment, after which the arena hands out the same memory again. The trade-off is that you can’t free one object on its own: everything in the arena lives until the reset.

Finding Memory Bugs

The use-after-free program from earlier printed *q = 5 and exited cleanly. Here it is again under the two standard checkers. Build with -g for both, so they can report source lines.

#include <iostream>

int main() {
    int* q = new int(2);
    delete q;
    *q = 5;   // UB: writes to freed memory
    std::cout << "*q = " << *q << "\n";
}

Valgrind runs an ordinary binary on a simulated CPU and checks every memory access:

$ g++ -std=c++17 -Wall -g -O0 -o uaf uaf.cpp && valgrind ./uaf
==70== Memcheck, a memory error detector
==70== Copyright (C) 2002-2024, and GNU GPL'd, by Julian Seward et al.
==70== Using Valgrind-3.24.0 and LibVEX; rerun with -h for copyright info
==70== Command: ./uaf
==70== 
==70== Invalid write of size 4
==70==    at 0x4011A2: main (uaf.cpp:6)
==70==  Address 0x4df0080 is 0 bytes inside a block of size 4 free'd
==70==    at 0x484886D: operator delete(void*, unsigned long) (vg_replace_malloc.c:1181)
==70==    by 0x40119D: main (uaf.cpp:5)
==70==  Block was alloc'd at
==70==    at 0x4844F93: operator new(unsigned long) (vg_replace_malloc.c:487)
==70==    by 0x401167: main (uaf.cpp:4)
==70== 
==70== Invalid read of size 4
==70==    at 0x4011BE: main (uaf.cpp:7)
==70==  Address 0x4df0080 is 0 bytes inside a block of size 4 free'd
==70==    at 0x484886D: operator delete(void*, unsigned long) (vg_replace_malloc.c:1181)
==70==    by 0x40119D: main (uaf.cpp:5)
==70==  Block was alloc'd at
==70==    at 0x4844F93: operator new(unsigned long) (vg_replace_malloc.c:487)
==70==    by 0x401167: main (uaf.cpp:4)
==70== 
*q = 5
==70== 
==70== HEAP SUMMARY:
==70==     in use at exit: 0 bytes in 0 blocks
==70==   total heap usage: 3 allocs, 3 frees, 74,756 bytes allocated
==70== 
==70== All heap blocks were freed -- no leaks are possible
==70== 
==70== For lists of detected and suppressed errors, rerun with: -s
==70== ERROR SUMMARY: 2 errors from 2 contexts (suppressed: 0 from 0)

Valgrind gives three line numbers for each bad access: where it happened (line 6, then line 7), where the block was freed (line 5), and where it was allocated (line 4). It reports and carries on, so the program still prints *q = 5 and the exit status is 0. Check ERROR SUMMARY, or pass --error-exitcode=1 to make CI fail. The heap summary counts 3 allocations, not 1, because the C++ runtime and std::cout make their own.

AddressSanitizer is built into the compiler. It adds a check to every load and store, and stops at the first bad one:

$ g++ -std=c++17 -Wall -g -fsanitize=address -o uaf uaf.cpp && ./uaf 2>&1 | head -n 20
=================================================================
==18==ERROR: AddressSanitizer: heap-use-after-free on address 0x502000000010 at pc 0x00000040126c bp 0x7ffc31741790 sp 0x7ffc31741788
WRITE of size 4 at 0x502000000010 thread T0
    #0 0x40126b in main /src/uaf.cpp:6
    #1 0x7f54734abca7 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
    #2 0x7f54734abd64 in __libc_start_main_impl ../csu/libc-start.c:360
    #3 0x4010f0 in _start (/src/uaf+0x4010f0)

0x502000000010 is located 0 bytes inside of 4-byte region [0x502000000010,0x502000000014)
freed by thread T0 here:
    #0 0x7f5473af04b8 in operator delete(void*, unsigned long) (/usr/local/lib64/libasan.so.8+0xf64b8)
    #1 0x401234 in main /src/uaf.cpp:5
    #2 0x7f54734abca7 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58

previously allocated by thread T0 here:
    #0 0x7f5473aef5b8 in operator new(unsigned long) (/usr/local/lib64/libasan.so.8+0xf55b8)
    #1 0x4011c7 in main /src/uaf.cpp:4
    #2 0x7f54734abca7 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58

SUMMARY: AddressSanitizer: heap-use-after-free /src/uaf.cpp:6 in main

It reports the same three places, line 6 for the write, line 5 for the free and line 4 for the allocation, then exits with status 1, so *q = 5 never prints. head cut the report here; the rest is a dump of ASan’s shadow memory around the address. Because ASan checks are compiled in rather than simulated, an ASan build is usually much faster than running the same binary under Valgrind (the ASan documentation cites a typical slowdown of about 2x). It also catches stack and global buffer overflows that Valgrind cannot see. Valgrind’s advantage is that it needs no rebuild, and it can report reads of uninitialized memory, which ASan does not check.

Enable -fsanitize=address,undefined in debug and CI builds. The overhead is acceptable there, and it catches bugs that would otherwise show up only in production.

Memory Bug Checklist

BugCauseDetection
Leaknew without deleteValgrind, ASan leak sanitizer
Use-after-freeAccess after deleteASan
Double-freedelete called twiceASan
Buffer overflowWrite past end of arrayASan
Stack overflowToo-large stack allocationOS signal, ASan
Uninitialized readUsing value before writingValgrind, MemorySanitizer

The long-term answer to memory bugs is not vigilance — it is RAII and smart pointers, which make the correct behavior the default and the dangerous behavior require explicit effort.

Practice

  1. Find the worst ordering. Put Padded and Packed in one program, then add a third struct with the same four members in the order char, double, char, int. Predict its sizeof before running, then print the offset of every member.
Solution
#include <cstddef>
#include <iostream>

struct Padded { char a; int b; char c; double d; };
struct Packed { double d; int b; char a; char c; };
struct Worst  { char a; double d; char c; int b; };

int main() {
    std::cout << "Padded " << sizeof(Padded) << "\n";
    std::cout << "Packed " << sizeof(Packed) << "\n";
    std::cout << "Worst  " << sizeof(Worst)  << "\n";
    std::cout << "Worst offsets: a=" << offsetof(Worst, a)
              << " d=" << offsetof(Worst, d)
              << " c=" << offsetof(Worst, c)
              << " b=" << offsetof(Worst, b) << "\n";
}
$ g++ -std=c++17 -Wall -o layout layout.cpp && ./layout
Padded 24
Packed 16
Worst  24
Worst offsets: a=0 d=8 c=16 b=20

a sits at 0, and d must start on an 8-byte boundary, which wastes 7 bytes. c lands at 16 and b needs a 4-byte boundary, so it moves to 20 and the struct ends at 24. That ties with Padded, so moving the double doesn’t help on its own. Grouping the two chars together is what lets Packed get down to 16.

  1. Time some real work. Extend the ScopedTimer program to time two blocks, one after the other, that sleep for 100 ms and 50 ms, plus a timer for the whole of main. In what order do the lines print?
Solution
#include <chrono>
#include <iostream>
#include <string>
#include <thread>

class ScopedTimer {
    std::chrono::steady_clock::time_point start_;
    std::string name_;
public:
    explicit ScopedTimer(std::string name)
        : start_(std::chrono::steady_clock::now()), name_(std::move(name)) {}

    ~ScopedTimer() {
        auto elapsed = std::chrono::steady_clock::now() - start_;
        auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(elapsed);
        std::cout << name_ << ": " << ms.count() << "ms\n";
    }
};

int main() {
    ScopedTimer total("total");
    {
        ScopedTimer step("step 1");
        std::this_thread::sleep_for(std::chrono::milliseconds(100));
    }
    {
        ScopedTimer step("step 2");
        std::this_thread::sleep_for(std::chrono::milliseconds(50));
    }
    std::cout << "leaving main\n";
}
$ g++ -std=c++17 -Wall -o timer timer.cpp && ./timer
step 1: 100ms
step 2: 50ms
leaving main
total: 150ms

Each timer prints from its destructor, so each line appears at the closing brace of its scope. total prints after leaving main because it is destroyed as main returns. Your millisecond counts may be one or two higher, since sleep_for waits at least as long as you ask.

  1. Catch a new[] / delete mismatch. This program allocates an array with new[] but releases it with plain delete. Build it once with -Wall and once with AddressSanitizer, then fix it.

    #include <iostream>
    
    int main() {
        int* arr = new int[10]();
        arr[9] = 7;
        std::cout << "arr[9] = " << arr[9] << "\n";
        delete arr;    // wrong: allocated with new[]
    }
Solution
#include <iostream>

int main() {
    int* arr = new int[10]();
    arr[9] = 7;
    std::cout << "arr[9] = " << arr[9] << "\n";
    delete[] arr;  // matches new[]
}
$ g++ -std=c++17 -Wall -o mismatch mismatch.cpp && ./mismatch
mismatch.cpp: In function 'int main()':
mismatch.cpp:7:12: warning: 'void operator delete(void*, std::size_t)' called on pointer returned from a mismatched allocation function [-Wmismatched-new-delete]
    7 |     delete arr;    // wrong: allocated with new[]
      |            ^~~
mismatch.cpp:4:28: note: returned from 'void* operator new [](std::size_t)'
    4 |     int* arr = new int[10]();
      |                            ^
arr[9] = 7
$ g++ -std=c++17 -g -fsanitize=address,undefined -o mismatch mismatch.cpp && ./mismatch 2>&1 | head -n 6
=================================================================
==24==ERROR: AddressSanitizer: alloc-dealloc-mismatch (operator new [] vs operator delete) on 0x504000000010
    #0 0x7fb64da4f4b8 in operator delete(void*, unsigned long) (/usr/local/lib64/libasan.so.8+0xf64b8)
    #1 0x401531 in main /src/mismatch.cpp:7
    #2 0x7fb64cdf7ca7 in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
    #3 0x7fb64cdf7d64 in __libc_start_main_impl ../csu/libc-start.c:360
$ g++ -std=c++17 -g -fsanitize=address,undefined -o mismatch mismatch_fixed.cpp && ./mismatch
arr[9] = 7

The plain build compiled with only a warning and ran normally, which is what makes undefined behaviour dangerous. GCC’s -Wmismatched-new-delete (part of -Wall) warns at compile time, and ASan stops at line 7. arr[9] = 7 is missing from the ASan run because piped output is buffered and ASan exits before it is flushed.

  1. Two widgets, one buffer. Change the placement-new example so one alignas(Widget) buffer holds two Widgets side by side. Print where the second one starts and destroy both correctly.
Solution
#include <iostream>
#include <new>
#include <string>

struct Widget {
    std::string name;
    Widget(std::string n) : name(std::move(n)) { std::cout << "build " << name << "\n"; }
    ~Widget() { std::cout << "destroy " << name << "\n"; }
};

int main() {
    alignas(Widget) unsigned char buf[2 * sizeof(Widget)];

    Widget* first  = new (buf) Widget("first");
    Widget* second = new (buf + sizeof(Widget)) Widget("second");
    std::cout << "second starts " << (reinterpret_cast<unsigned char*>(second) - buf)
              << " bytes into buf\n";

    // Destroy in reverse order of construction, like the compiler does for locals
    second->~Widget();
    first->~Widget();
    std::cout << "buffer is free to reuse\n";
}
$ g++ -std=c++17 -Wall -o placement placement.cpp && ./placement
build first
build second
second starts 32 bytes into buf
destroy second
destroy first
buffer is free to reuse

A Widget is just a std::string, which takes 32 bytes in GCC’s library, so the second object starts at offset 32. sizeof already rounds up to a multiple of the alignment, so buf + sizeof(Widget) is correctly aligned. Nothing calls the destructors for you, and skipping them would leak any heap memory the strings own.

  1. Watch the arena align. Add a used() accessor to Arena and make a 64-byte arena. Allocate 1 byte, then 8 bytes aligned to 8, then 3 bytes, then 16 bytes with the default alignment, printing used() after each step. Then request 40 more bytes, and request them again after reset().
Solution
#include <cstddef>
#include <iostream>
#include <new>
#include <vector>

class Arena {
    std::vector<char> buf_;
    std::size_t offset_ = 0;
public:
    explicit Arena(std::size_t capacity) : buf_(capacity) {}

    void* allocate(std::size_t size, std::size_t align = alignof(std::max_align_t)) {
        std::size_t aligned = (offset_ + align - 1) & ~(align - 1);
        if (aligned + size > buf_.size()) throw std::bad_alloc();
        offset_ = aligned + size;
        return buf_.data() + aligned;
    }
    std::size_t used() const { return offset_; }
    void reset() { offset_ = 0; }
};

int main() {
    Arena arena(64);
    arena.allocate(1, 1);
    std::cout << "after 1 byte:            used=" << arena.used() << "\n";
    arena.allocate(8, 8);
    std::cout << "after 8 bytes, align 8:  used=" << arena.used() << "\n";
    arena.allocate(3, 1);
    std::cout << "after 3 bytes, align 1:  used=" << arena.used() << "\n";
    arena.allocate(16);
    std::cout << "after 16 bytes, default: used=" << arena.used() << "\n";
    try {
        arena.allocate(40);
    } catch (const std::bad_alloc&) {
        std::cout << "40 more bytes: bad_alloc, used still " << arena.used() << "\n";
    }
    arena.reset();
    arena.allocate(40);
    std::cout << "after reset + 40 bytes:  used=" << arena.used() << "\n";
}
$ g++ -std=c++17 -Wall -o arena arena.cpp && ./arena
after 1 byte:            used=1
after 8 bytes, align 8:  used=16
after 3 bytes, align 1:  used=19
after 16 bytes, default: used=48
40 more bytes: bad_alloc, used still 48
after reset + 40 bytes:  used=40

The 8-byte allocation skipped from offset 1 to 8 before taking its bytes. The default alignment, alignof(std::max_align_t), is 16 on x86-64, so the 16-byte block was rounded up from offset 19 to 32. That left 13 bytes unused, which is the price of alignment. The failed request leaves offset_ unchanged, and reset() reclaims everything in one step.

Frequently Asked Questions

What is RAII?
RAII stands for Resource Acquisition Is Initialization. A resource such as memory, a file or a mutex lock is acquired in an object's constructor and released in its destructor. Because destructors run on every exit from a scope, including exceptions, the release cannot be forgotten. std::ofstream, std::lock_guard and std::unique_ptr all work this way.
When would I write a custom allocator in C++?
When profiling shows allocation itself is a cost, typically because a unit of work makes many small allocations that all die together. Game engines use pool allocators for fixed-size objects, and request handlers use arena allocators that free a whole request's memory with one reset. For most code, the default allocator is fast enough.
Why doesn't a use-after-free in C++ crash?
Freed memory usually still belongs to the process, so reading or writing it often succeeds and returns the expected value. The bug only shows when that block has been reused for another object, which may be far from the faulty line. Build with -fsanitize=address to stop the program at the exact read or write.
Should I use Valgrind or AddressSanitizer for C++ memory bugs?
Both report use-after-free and leaks with the lines that allocated and freed the memory. AddressSanitizer needs a rebuild with -fsanitize=address, but is typically far faster than Valgrind (its documentation cites about 2x) and also catches stack buffer overflows. Valgrind works on an unmodified binary and detects uninitialized reads, but runs the program much more slowly.