C++ Memory Leaks — 1.5 GB After 50 Tab Cycles
RSS grew to 1.5 GB after 50 tab cycles from circular refs.
20+ years shipping performance-critical C and C++ systems. Written from production experience, not tutorials.
- Memory leak: allocated heap memory that is never freed
- Detection tools: Valgrind (memcheck), AddressSanitizer (ASan)
- Prevention: RAII, smart pointers (unique_ptr, shared_ptr, weak_ptr)
- Performance impact: RSS growth ~1-10 MB/hour typical, OOM kill at 3-5 GB
- Production gotcha: leak detection tools add 2-10x slowdown, disable in production
- Biggest mistake: relying on manual new/delete instead of RAII wrappers
Imagine you're renting storage units. Every time you need to store something, you rent a new unit. A memory leak is when you forget to return the key after you've emptied the unit — the unit stays rented, the bill keeps growing, and eventually you run out of storage units entirely. In C++, your program rents memory from the heap using 'new' and must return it using 'delete'. When it forgets to return that memory, it leaks — silently, invisibly — until your server crashes at 3am.
Memory leaks are the silent killers of C++ applications. Unlike a crash that screams at you immediately, a memory leak is a slow bleed — your process's RSS (Resident Set Size) climbs steadily over hours or days, swap starts thrashing, and eventually the OOM killer on Linux terminates your process with no warning. This is exactly what happened in a famous 2015 Firefox bug where tab-related allocations were never freed, causing hundreds of megabytes of leak per browsing session. The C++ runtime has no garbage collector watching your back. Every byte you allocate is your responsibility to free.
The core problem is the gap between WHEN you allocate memory and WHEN you need to free it. Throw exceptions, early returns, complex ownership semantics, or circular references into the mix, and that gap becomes a minefield. Raw pointers in C++ carry no metadata about who owns them, who should free them, or whether they're still valid. The language gives you a chainsaw — powerful, but it doesn't stop you from cutting your own hand off.
By the end of this article, you'll understand exactly how the heap allocator works under the hood, why leaks happen even in 'careful' code, how to use Valgrind and AddressSanitizer to pinpoint leaks down to the exact line, and how to architect your code with RAII and smart pointers so that leaks become structurally impossible. You'll also leave with the exact vocabulary and depth to ace memory management questions in senior C++ interviews.
What Is a Memory Leak and Why Does It Happen?
A memory leak occurs when a program allocates memory from the heap (using new, malloc, or calloc) and never deallocates it. The leaked memory remains allocated until the process exits. The operating system reclaims it on process termination, but during the process lifetime, the leak gradually reduces available heap space.
Leaks happen because C++ does not have automatic garbage collection. The programmer must explicitly pair every allocation with a deallocation. Real-world causes include:
- Exception thrown before
deleteis reached. - Early return paths that skip cleanup.
- Overwriting the only pointer to allocated memory (pointer reassignment without delete).
- Circular references involving
shared_ptr. - Missing virtual destructors, causing derived resource not to be released.
- Every allocation grabs a contiguous block from the free list.
- Every deallocation returns the block to the free list (coalescing neighbors).
- A leak means the block stays in use but is no longer reachable — the free list shrinks permanently.
- Fragmentation makes the problem worse: small leaks fragment the heap, causing future allocations to fail even if total free memory is large.
new must be paired with a delete or ownership transferred to a smart pointer.Heap Allocator Internals: How Leaks Steal Memory
The heap allocator (glibc's malloc, tcmalloc, jemalloc) manages a pool of memory blocks. When you call new, it finds a free block via the free list, marks it as used, and returns the address. When you call delete, it returns the block to the free list. Coalescing merges adjacent free blocks into larger ones.
A leak does not just lose memory — it also fragments the heap. If many small blocks are leaked in the middle of the heap, the free list contains many small holes. A request for a larger contiguous block may fail even though total free memory is sufficient. This is called external fragmentation.
Production allocators also maintain thread caches to reduce lock contention. Leaked memory in thread caches is even harder to track because it may appear as "in use" but not actually reachable by any pointer. Tools like Valgrind intercept every malloc/free call to track reachable pointers.
--enable-prof to track fragmentation.RAII and Smart Pointers: Making Leaks Impossible
RAII (Resource Acquisition Is Initialization) ties resource lifetime to object lifetime. When an object goes out of scope, its destructor automatically frees the resource. This makes leaks structurally impossible if the resource is owned by an RAII wrapper.
std::unique_ptr: exclusive ownership, no copy, move-only. Leak-free because the destructor callsdelete.std::shared_ptr: shared ownership via reference counting. Leak only if circular references exist.std::weak_ptr: non-owning observer that breaks reference cycles.
Rule: Never use raw new/delete outside of a RAII wrapper or low-level allocator. The only exceptions are when writing custom containers or interfacing with C libraries.
- Destructors are called for stack unwinding on exceptions, returns, and normal scope exit.
- No manual cleanup needed — reducing cognitive load.
- unique_ptr is zero-overhead over raw pointer in release builds.
- shared_ptr has reference counting overhead (atomic increments) but often acceptable.
- weak_ptr breaks cycles: use when two objects reference each other.
shared_ptr in a graph-like structure where nodes hold pointers to each other. This creates a reference cycle. Neither node's reference count drops to zero and both leak.weak_ptr for one direction of the cycle.Smart Pointer Comparison Table: When to Use Which
Choosing the right smart pointer is critical for leak-free C++. Here's a side-by-side comparison of ownership semantics, overhead, and use cases.
| Pointer Type | Ownership | Copy Semantics | Overhead | Circular Reference Risk | Typical Use Case |
|---|---|---|---|---|---|
std::unique_ptr | Exclusive | Move-only | None (zero overhead) | No | Owning a resource that has a single owner (e.g., a container element, a factory product) |
std::shared_ptr | Shared (reference counted) | Copy increments ref count | Atomic increment on copy, control block | Yes (if cycles) | Shared ownership in DAGs or caches where no single owner exists |
std::weak_ptr | Non-owning observer | Copy does not affect ref count | Minimal | Breaks cycles | Breaking circular references; caching; optional back-pointers |
Raw pointer (T*) | None (must manage externally) | Copy just copies address | Zero | Depends on external management | Only for non-owning, non-owning aliases in performance-critical code or when interfacing with C |
Key Rules: - Default to unique_ptr. It's zero-cost and prevents leaks by design. - Only use shared_ptr when ownership is truly shared (multiple entities need to keep the object alive independently). - Always use weak_ptr when you need a back-reference: think parent->child hierarchies. - Avoid raw new/delete entirely. If you must use a raw pointer for performance, document ownership clearly.
Using Valgrind and AddressSanitizer to Find Leaks
Two dominant tools for finding memory leaks:
Valgrind (memcheck): Runs the program under a synthetic CPU. Intercepts all malloc/free/new/delete calls. Tracks reachability by scanning memory for pointer values. Reports: - definitely lost: pointer to block overwritten, no way to free. - indirectly lost: block is only referenced by other lost blocks. - possibly lost: pointer to interior of block exists, but start address might be lost. - still reachable: pointer still exists but block not freed before exit (technically not a leak, but often suggests design issue).
AddressSanitizer (ASan): Compile-time instrumentation. For every allocation, it records a shadow byte that tracks access permissions. At runtime, it detects heap-use-after-free, buffer overflow, and memory leaks (with detect_leaks=1). Much faster than Valgrind (2-4x slowdown vs 10-50x).
Both tools should be used during development and in stress testing. They are too slow for production. Use core dumps and heap profiling in production.
Windows-Specific: Using CRT Debug Heap to Detect Leaks
On Windows, the C Runtime (CRT) provides a built-in debug heap that can detect memory leaks without external tools. It is enabled in debug builds by default and reports leaks to the Output window and via _.CrtDumpMemoryLeaks()
Key functions: - _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF) — enable leak detection at program exit. - _CrtDumpMemoryLeaks() — dump all unreleased allocations at any point. - _CrtSetBreakAlloc(long) — break into debugger when a specific allocation number occurs (useful for tracking down a leak after seeing its allocation number in the report). - _CrtMemCheckpoint / _CrtMemDifference — compare snapshots to detect memory growth between two points.
Limitations: - Only works in debug builds (can be enabled in release with _DEBUG defined, but with performance impact). - Does not track all memory (e.g., allocations by third-party libraries using different runtimes). - Reports are not as detailed as Valgrind's stack traces (only allocation number, size, and file/line if source info is available).
Setup steps: 1. Add #define _CRTDBG_MAP_ALLOC before including <crtdbg.h> to map new to with file/line info. 2. Include new()<crtdbg.h> and call _CrtSetDbgFlag early in . 3. Optionally call main()_ before exit. 4. For per-thread tracking, use CrtDumpMemoryLeaks()_CrtMemCheckpoint in each thread's main function.
_DEBUG and CRT heap enabled, run it on a canary server, and monitor the Output window for leak reports. This is a low-cost way to catch leaks before they reach production._CrtSetDbgFlag, _CrtDumpMemoryLeaks, and _CrtSetBreakAlloc to catch leaks during development.Production Debugging Without Tools: Core Dumps and Heap Analysis
When a process is OOM-killed, you lose the process. But if you enable core dumps (ulimit -c unlimited), the OS saves the entire memory image to a file. You can analyze it post-mortem: - Use gdb to inspect pointers and see what blocks are reachable. - Use pstack to get thread stacks. - Use malloc_info(0, stderr) to dump glibc's internal allocation statistics.
Heap profiling tools like gperftools (Google Performance Tools) or jemalloc's built-in profiler can be enabled at runtime with minimal overhead (~5%). They generate heap snapshots that you can diff over time to find growing allocations.
In production, never run Valgrind or ASan. Instead, enable lightweight heap profiling continuously. Set a threshold: if RSS grows more than 10% in an hour, alert the pager with a heap snapshot attached.
coredumpctl (systemd) to compress and manage cores. Alternatively, limit by ulimit -c 2097152 (2 GB max).Common Leak Patterns and How to Fix Them
- Missing virtual destructor: Base class pointer to derived object. If base destructor is not virtual, derived destructor never runs, leaking derived members.
- Circular shared_ptr references: A->B and B->A both hold shared_ptr. Neither reference count reaches zero. Fix: use weak_ptr in one direction.
- Exception in constructor: If constructor throws after
newbut before assigning to a smart pointer, the raw allocation is leaked. Fix: use RAII wrappers for all allocations in constructor initializer lists. - Unhandled allocation failure:
newmay throwstd::bad_alloc. If not caught, the code after it may never execute, potentially leaking prior allocations. Fix: always handle exceptions or use nothrow new. - Forgetting to delete in setter/reassignment:
ptr = new X;overwrites old pointer without delete. Fix: always delete before reassigning, or use smart pointers. - Thread-local storage leaks: If a thread exits without cleaning up its TLS blocks, those blocks remain allocated until process death. Fix: register cleanup functions with
pthread_key_createdestructor.
Common Leak Patterns — Quick Checklist
Use this checklist to quickly identify and fix the most frequent memory leak patterns in C++ code.
| Pattern | Symptom | Fix |
|---|---|---|
| Missing virtual destructor | Derived resources not freed when deleting via base pointer | Declare base destructor virtual; use override in derived |
Circular shared_ptr | Two objects hold shared_ptr to each other; neither is freed | Replace one direction with weak_ptr |
| Exception in constructor | new allocates before smart pointer assignment; leak if exception thrown | Use make_unique or make_shared; allocate inside constructor initializer lists with RAII |
| Reassignment without delete | Overwriting a pointer without freeing old memory | Always delete before reassignment, or use smart pointers that handle assignment |
| Thread-local storage leak | TLS blocks not freed on thread exit | Register destructor via pthread_key_create or use thread_local RAII objects |
| Missed deallocation in error path | Early return before delete (e.g., validation failure) | Use RAII or smart pointers; never rely on manual cleanup after returns |
| Container holding raw pointers | STL container of raw pointers not deleted before container destruction | Use vector<unique_ptr<T>> or vector<shared_ptr<T>> |
| Global/static scope memory | Memory allocated at module load time, never freed (still reachable) | Re-evaluate ownership; consider destructors via atexit or RAII singletons |
| Custom allocator mismatch | malloc/free mismatch with new/delete (e.g., using free on new'd array) | Use matching deallocation; prefer new/delete consistently |
Forgotten delete[] vs delete | Using scalar delete on array allocated with new[] | Use delete[] for array allocations; better yet, use vector or unique_ptr<T[]> |
Static Analysis and Coding Standards: Prevent Leaks at Compile Time
The best tool for memory leaks is prevention. C++ has evolved to make leaks harder to write: - -Wall -Wextra -Werror flags catch some cases. - clang-tidy with cppcoreguidelines-no-malloc, modernize-use-auto, modernize-use-equals-default. - cppcheck for static analysis: detects missing delete, mismatched allocation/deallocation, leaky containers. - -fsanitize=address as part of CI pipeline.
- Never use
new/deletedirectly (exception: custom allocators). - Use
std::make_uniqueandstd::make_shared. - Use RAII wrapper for every resource (file handle, socket, mutex).
- Prefer containers over raw arrays.
- Use C++17's
std::shared_ptrwithstd::make_sharedfor exception safety. - Mark classes with
finalif not intended for inheritance to avoid virtual destructor issues.
clang-tidy and cppcheck to your code review CI. Reject PRs that introduce any new new or delete outside of low-level modules.cppcoreguidelines checks and banning raw new/delete in code reviews, the leak rate dropped to zero within one release cycle.Manual Leak Hunting: What the Tools Don't Tell You
Valgrind and ASan are your first line of defense, but they're not magic. When you're dealing with a 500k-line codebase that links against third-party shared objects you can't rebuild, static analysis and runtime tools will miss things. You need to know how to read a core dump's heap fragments and trace allocation chains by hand.
The first rule: never allocate memory at module load time. Static initialization order fiasco will hide leaks that only manifest during shutdown. Always allocate when first needed, not when the DLL loads. Second rule: tag your allocations. Use the new operator with a filename and line number macro. In production builds, I wrap every new call in a macro that stores the allocation site in a thread-local ring buffer. When the process OOMs, I dump that buffer to find the offender.
Third rule: audit your destructors. Virtual destructors in base classes aren't optional if you're deleting through a base pointer. Miss one, and you leak an entire derived object's memory silently. I've seen this crash production services after three days of uptime.
Heap Snapshots: Finding Leaks Without Tearing Down the Process
Sometimes you can't restart the application. Maybe it's a payment processing gateway with an SLA of 99.999%. Or a medical device firmware that boots for months. You need to diagnose memory bloat while the process keeps running. That's where heap snapshots come in.
On Linux, you can dump the heap mappings directly from /proc/<pid>/maps and examine all [heap] regions with gdb --pid <pid>. In gdb, use info malloc-stats if you're running with glibc's mtrace. For a raw approach, attach with gdb, run call to print summary stats to stderr, then inspect specific allocation sizes that grow over time.malloc_stats()
On Windows, you can call _ at intervals in a background thread and compare state dumps. The CRT debug heap stores a linked list of all live allocations — you can walk it with CrtMemCheckpoint()_ on a timer. Pair this with a metric like 'total heap bytes' exported to Prometheus and you'll catch the drift before it kills the node.CrtMemDumpAllObjectsSince()
Custom Allocator Hooks: When You Need Surgical Precision
Valgrind instruments every load and store — that's 5-20x slowdown. Unacceptable for latency-sensitive code. The solution is a lightweight custom allocator with built-in leak detection. I've shipped this pattern in trading systems where every microsecond counts.
The idea is simple: override operator new and operator delete at the translation unit level (don't touch global new unless you own every allocation). Your custom allocator maintains a concurrent hash map keyed by the allocation address. On allocation, store the call stack (use on Linux, backtrace()CaptureStackBackTrace() on Windows) and the allocation size. On deallocation, remove the entry. Once per minute, dump any entries older than N seconds — those are leaks.
The performance cost is one hash map insert per allocation (~50ns with a good hash), zero cost on deallocation (just a lookup and erase). That's 100x faster than AddressSanitizer. The tradeoff: you only catch allocations through your custom allocator, so malloc or mmap calls slip through. But for internal subsystems, this is production-grade leak detection that doesn't kill performance.
False Positives: The Tools Are Wrong, Now What?
Valgrind and AddressSanitizer are not omniscient. They report allocations that are reachable but never freed as leaks. That's correct for the heap, but it doesn't mean your code is broken. The most common false positive: a global singleton that lives for the entire process lifetime. The memory is freed by the OS on exit — there's zero practical leak. Yet the tool screams at you.
Why does this matter? Because rookie devs waste hours chasing ghosts. They add destructors to globals, introduce static order-of-initialization bugs, and generally make a mess. The senior move: classify each leak report. Green: actual leak (allocation grows over time). Yellow: reachable-on-exit (benign). Red: tool artifact (e.g., STL internal caches).
Suppress false positives explicitly. Valgrind has --suppressions, ASan has __attribute__((no_sanitize("address"))). Document every suppression with a reason. That turns a noisy report into a signal you can trust in CI.
Heap Snapshots: Find Leaks Without Killing Your Process
You can't restart production every time you suspect a leak. Heap snapshots let you compare the heap state at two points in time — without restarting. The core idea: take a baseline snapshot, run a suspect code path, take another snapshot, diff them. Any allocation that exists in the second snapshot but not the first is either a leak or a long-lived object.
On Linux, malloc_hook or LD_PRELOAD libraries can capture heap state. But the production-friendly method: use or mallinfo() to track arena size deltas over time. If arena size grows monotonically while RSS stays flat, you have internal fragmentation — not a leak.mallinfo2()
For surgical work, Google's GWP-ASan or heapprofd (Android) give you real-time allocation stacks. They sample high-frequency allocations and dump stack traces on every malloc. The trick: run them at 1% sample rate in production. The performance hit is ~2%. You get leak candidates without crashes. That's senior-engineer territory.
mallinfo2() on glibc 2.33+. It returns size_t instead of int — no overflow on 64-bit heaps. Pair with a periodic timer in a debug build: log arena delta every 5 minutes. A steady upward slope is your leak fingerprint.The Firefox Tab Leak That Ate 400 MB Per Session
- Never assume GC handles circular references across C++/JavaScript boundaries.
- Always add explicit destructors or cleanup handlers for long-lived objects.
- Profile RSS growth over hours, not minutes — small leaks compound to megabytes.
- Use heap profiling tools early in development; retrofitting is expensive.
valgrind --tool=memcheck --leak-check=full --show-leak-kinds=all ./myappASAN_OPTIONS=detect_leaks=1:abort_on_error=1 ./myappKey takeaways
Common mistakes to avoid
5 patternsForgetting to delete in early return paths
Missing virtual destructor in base class
override and = default in derived. Enable -Wsuggest-override compiler flag.Circular shared_ptr references
Reassigning a pointer without deleting the old value
Assuming memory is freed on thread exit without TLS cleanup
pthread_setspecific may leak if the thread exits without calling pthread_setspecific(key, NULL) first.pthread_key_create that frees the TLS block. Or use thread_local variables with RAII objects.Interview Questions on This Topic
Explain the difference between a memory leak and memory fragmentation. How would you diagnose each in a production system?
malloc_info(0, stderr) for glibc). Use mallctl for jemalloc. Look for many small free blocks. Also monitor RSS vs. virtual memory (VSZ) — large VSZ with moderate RSS often indicates fragmentation.
Fix: Leaks are fixed by adding missing deletes or using RAII. Fragmentation is mitigated by using memory pools, slab allocators, or jemalloc's background coalescing.Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Written from production experience, not tutorials.
That's C++ Advanced. Mark it forged?
13 min read · try the examples if you haven't