Finding C++ Memory Leaks: Valgrind and AddressSanitizer
Key takeaways
A leak that grows a little per request only shows up after days in production. This post compares what each tool reports, explains Valgrind's definitely lost versus still reachable, lists ten leak patterns including containers of raw pointers, and shows how RAII and production memory monitoring keep leaks from coming back.
Tooling: Valgrind · ownership in C++: smart pointers · Rust contrast: ownership.
Introduction: “Memory grows until OOM”
A memory leak means you lose the last pointer to heap memory without freeing it. Long-running services eventually OOM.
void handle() {
int* p = new int[1000];
// forgot delete[]
}
This article covers:
- Five major leak causes
- Valgrind (Linux)
- AddressSanitizer + LeakSanitizer
- Visual Studio / CRT helpers
- Ten leak patterns and RAII fixes
- Production monitoring ideas
What is a memory leak?
Heap memory no longer reachable from any pointer in the program → cannot be freed → grows until limits. Contrast with intentional growth (e.g. a cache) where memory is still reachable.
The distinction matters because the tools only see the first kind. Leak detectors work like a garbage collector’s mark phase: at exit (or on demand), they scan globals, stacks and registers for anything that looks like a pointer into a heap block, and report blocks nothing points to. A cache that grows forever, a std::map of sessions that are never erased, or a queue whose consumer stalled is still reachable, so no leak detector reports it, yet the process runs out of memory just the same. In long-running services, I find that this “logical leak” is at least as common as the classic lost pointer, and it has to be found with heap profiling rather than leak checking.
The other thing to know up front is that a leak in a short-lived program is often harmless, because the operating system reclaims all memory when the process exits. Leaks matter when the leaking code runs repeatedly (per request, per frame, per message) in a process that lives for days.
Five major causes (overview)
- new/delete mismatch or missing
delete - Exception paths skipping manual cleanup
shared_ptrcycles- Containers of raw owning pointers without cleanup
- Singletons with raw
newand no teardown
All five come down to the same thing: memory whose ownership is not tied to an object’s lifetime. When ownership lives in a comment (“caller must delete”) or in the programmer’s head, every early return, every exception and every refactor is a chance to lose it. That is why the fixes in this article almost always replace a manual delete with an owning type rather than adding more delete calls.
Valgrind
g++ -g -std=c++17 -o myapp main.cpp
valgrind --leak-check=full --show-leak-kinds=all ./myapp
Interpret definitely lost vs still reachable (globals may be “reachable” at exit).
A typical report for the handle() function above looks like this:
==12345== 4,000 bytes in 1 blocks are definitely lost in loss record 1 of 1
==12345== at 0x4849013: operator new[](unsigned long) (vg_replace_malloc.c:640)
==12345== by 0x109156: handle() (main.cpp:2)
==12345== by 0x10916B: main (main.cpp:6)
The stack shows where the block was allocated, not where it should have been freed, so the job is to follow ownership from that line and find the path on which the pointer is dropped. Valgrind runs the unmodified binary on a synthetic CPU, which is why it needs no rebuild and why it is slow (commonly tens of times slower). Build with -g for file and line numbers and preferably -O0 or -O1, since inlining at -O2 makes stacks harder to read. --track-origins=yes helps with uninitialized-value errors, not leaks, and slows things further. For programs that are too slow under Valgrind, run a representative subset of the workload rather than the full test.
AddressSanitizer / LeakSanitizer
g++ -g -fsanitize=address -std=c++17 -o myapp main.cpp
export ASAN_OPTIONS=detect_leaks=1
./myapp
Visual Studio: enable AddressSanitizer in project settings (version-dependent).
AddressSanitizer instruments the code at compile time, so the slowdown is usually around 2x, small enough to leave on in developer builds and CI test runs. Leak detection is performed by LeakSanitizer when the process exits, and it is on by default with ASan on Linux; on macOS it has to be enabled explicitly, and MSVC’s ASan does not include leak detection. -fsanitize=leak alone gives leak checking without ASan’s other checks and overhead. The output format resembles Valgrind’s (Direct leak of 4000 byte(s) in 1 object(s) allocated from: ...), where “direct” corresponds to definitely lost and “indirect” to blocks reachable only through leaked ones.
Two things trip people up. First, LSan reports at normal exit, so a program killed by a signal or ending with _exit() prints nothing; a test that hangs and times out also hides its leaks. Second, ASan and Valgrind cannot be combined: running an ASan binary under Valgrind produces errors about the shadow memory mapping. Pick one per run. Known leaks in third-party code can be silenced with a suppression file (LSAN_OPTIONS=suppressions=lsan.supp) rather than turning detection off.
Visual Studio / CRT
_CrtSetDbgFlag with _CRTDBG_LEAK_CHECK_DF in debug builds for leak dumps on exit.
#define _CRTDBG_MAP_ALLOC
#include <stdlib.h>
#include <crtdbg.h>
int main() {
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
// _CrtSetBreakAlloc(152); // break when allocation #152 happens
int* p = new int[1000];
}
At exit, the debug CRT prints each unfreed block to the Output window with an allocation number, for example {152} normal block at 0x00A1B2C8, 4000 bytes long. Setting _CrtSetBreakAlloc(152) and rerunning stops the debugger exactly when that block is allocated, which is the fastest way to find its owner as long as the allocation order is deterministic. The _CRTDBG_MAP_ALLOC macro adds file and line information for malloc, but not for new, so C++ allocations usually show only the number. Because the dump runs when the CRT shuts down, static objects that are destroyed later (for example a global std::string) can appear as false positives. For release-like builds and for tracking growth rather than leaks at exit, the Visual Studio Diagnostic Tools heap snapshots, which let you compare two points in time, are more useful.
Ten patterns
- Raw
newwithoutdelete - Exception before
delete→ RAII new[]paired with wrongdeletevector<T*>without deleting elementsshared_ptrcycle → weak_ptr- Conditional
deletepaths - Rule-of-five violations (double free / leak)
- Factory returning raw owning pointer without documented ownership
- Globals with manual
new - Thread stacks leaking thread-local
new
The ones that survive code review most often are 2, 4, 5 and 7, so they are worth seeing in code.
Exception before delete. The function looks balanced, but any throwing call between the allocation and the delete skips the cleanup:
void load(const std::string& path) {
char* buf = new char[4096];
parse(path, buf); // throws on a malformed file -> buf leaks
delete[] buf;
}
// Fix: std::vector<char> buf(4096); or auto buf = std::make_unique<char[]>(4096);
Containers of raw owning pointers. std::vector<Widget*> destroys the pointers, not the objects they point to. clear(), erase() and the vector’s destructor all leak every element unless you loop and delete first, and the loop is easy to forget in one of the erase paths. std::vector<std::unique_ptr<Widget>> or, if polymorphism is not needed, std::vector<Widget> removes the problem.
shared_ptr cycles. Reference counting cannot free a cycle:
struct Node {
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev; // cycle: a->next = b, b->prev = a
};
// Fix: std::weak_ptr<Node> prev;
When the last outside shared_ptr goes away, each node still holds a count of 1 on the other, so neither destructor runs. Leak detectors may report these blocks as “indirectly lost” or not at all if something else still points into them. The same cycle forms when an object stores a lambda that captures its own shared_ptr (self = shared_from_this()) in a member callback; capture a weak_ptr and lock it inside the lambda instead.
Rule-of-three/five violations. A class that owns a raw pointer and has a destructor but default copy operations will either double-free (two copies delete the same pointer) or, if someone “fixes” the crash by removing the delete, leak. Making the member a std::unique_ptr deletes the copy operations automatically and turns the mistake into a compile error.
Pattern 10 deserves a note too: an object allocated with new and stored in a thread_local raw pointer leaks once per thread when threads are created and destroyed repeatedly, as in a thread pool that resizes. A thread_local std::unique_ptr is destroyed when each thread exits.
RAII
Acquire in constructor / release in destructor — unique_ptr, vector, file handles, locks.
RAII works because C++ guarantees that destructors of fully constructed local objects run whenever a scope is left, whether by return, break, or an exception. Tying each resource to an object therefore covers every exit path at once, including those added by future changes. In practice the rules are: std::make_unique for single ownership, std::make_shared only when ownership is genuinely shared, containers of values or smart pointers, and raw pointers or references only for non-owning access. For C APIs, std::unique_ptr with a custom deleter wraps handles without writing a class:
std::unique_ptr<FILE, decltype(&std::fclose)> f(std::fopen("data.txt", "r"), &std::fclose);
A codebase that follows these rules can grep for new and delete and treat each hit as something to justify.
Production monitoring
/proc/self/status VmRSS on Linux, custom allocation counters (carefully—global operator new hooks affect everything), periodic sanity checks against baselines.
Resident memory is a noisy signal. Allocators such as glibc malloc keep freed memory for reuse instead of returning it to the OS, so RSS often rises to a plateau and stays there even without a leak, and fragmentation can hold it higher still. What indicates a leak is steady growth under a steady load: memory that keeps climbing over hours at constant traffic. When the graph shows that, a heap profiler tells you where the memory is allocated without needing the process to exit: heaptrack on Linux, jemalloc or tcmalloc heap profiles (which can be enabled in production with sampling), or Visual Studio heap snapshots on Windows. Comparing two profiles taken an hour apart shows which call stacks own the growth, which also catches the logical leaks that Valgrind and LSan cannot see.
Summary
Tool comparison
| Tool | Platform | Speed | Rebuild |
|---|---|---|---|
| Valgrind | Linux (limited macOS) | Slow | No |
| ASan/LSan | Linux, macOS; MSVC ASan without leak checks | ~2× | Yes |
| VS CRT debug heap | Windows | Fast | Debug build |
| heaptrack / jemalloc profiles | Linux | Low to moderate | No / allocator swap |
Rules
- Prefer
make_unique/make_sharedover rawnew. - Containers should usually own values or smart pointers, not raw pointers.
- Break shared_ptr cycles with weak_ptr.
- Run ASan/Valgrind in CI on tests.
Related posts (internal)
Catching leaks before release
The cheapest place to catch a leak is the test suite. Running unit and integration tests with -fsanitize=address in CI makes every new leak on a tested path fail the build, with the allocation stack in the log, before anyone has to look at a memory graph. A periodic Valgrind run over a longer scenario catches what the sanitizer build misses, for example leaks in code compiled without instrumentation. In code review, any function returning a raw owning pointer, and any new without an immediately enclosing smart pointer, deserves a question.
Closing
Leaks are common but preventable: RAII, smart pointers, no cycles, and automated leak checks in CI. Next: Use-after-free and deeper Valgrind articles.
More related posts
Frequently Asked Questions (FAQ)
Q. What do Valgrind’s “definitely lost” and “still reachable” mean?
A. “Definitely lost” means no pointer to the block exists anymore, which is a real leak and the first thing to fix. “Indirectly lost” blocks are reachable only through a definitely lost block and usually disappear when you fix it, while “possibly lost” means only a pointer into the middle of the block was found. “Still reachable” memory was still pointed to at exit, typically globals or singletons that were never freed; it is often harmless, unless that reachable set keeps growing in a long-running process.
Related Articles
- Valgrind for C++
- C++ Smart Pointers
- Rust Ownership | Ownership, Borrowing, and Lifetimes
- Tracking Down C++ Memory Leaks