본문으로 건너뛰기
Previous
Next
C++ Memory Leaks: Causes, Detection, and Prevention,

C++ Memory Leaks: Causes, Detection, and Prevention,

C++ Memory Leaks: Causes, Detection, and Prevention,

이 글의 핵심

Heap leaks, tools (Valgrind memcheck, LeakSanitizer, Massif, Heaptrack, VS diagnostics), smart pointers, STL pitfalls, false positives, suppressions, CI, and production monitoring.

Related: leak detection walkthrough · Valgrind guide · smart pointers · circular references.

Introduction: when memory only grows

I think of a memory leak in C++ (and in other manually or partly manually managed systems) as more than a single missed delete—it is a class of lifetime bugs where heap-allocated memory becomes unreachable through any pointer I can still use to delete or delete[] it, while the process still needs that virtual address range for new work. The practical consequence, which I have felt in ops pages, is a monotonic rise in resident set size (RSS) in long runners, or allocation failure when the process hits limits or the system is under memory pressure.

This post is an expanded entry in the C++ error series. I mean it to sit next to a concrete workflow for running tools, not to replace a debugger write-up on use-after-free. Scope (mine): heap leaks, reference-counting cycles, and the diagnostics I actually reach for; I am not trying to cover GPU memory, every custom pool that never calls new, or every OS allocator quirk—only to point to where I would peel off to a specialist write-up.

I cover:

  1. What memory leaks are (lost memory, heap exhaustion, OS point of view).
  2. Common causes with code for each: forgotten delete, exception before delete, lost pointers, shared_ptr cycles, and reassignment without freeing.
  3. Detection tools: Valgrind memcheck, AddressSanitizer + LeakSanitizer, Massif and heaptrack (and related heap profilers), Visual Studio diagnostic tools.
  4. Prevention with RAII: unique_ptr, shared_ptr / weak_ptr, std::make_unique / std::make_shared.
  5. STL containers and ownership of elements.
  6. Custom allocators for tracking allocations.
  7. Leak patterns in real code.
  8. False positives and terminology (reachable vs definitely lost, indirect leaks).
  9. Suppression files to silence known issues in CI.
  10. CI integration for automated detection.
  11. Production monitoring (RSS, profilers, sampling).
  12. Worked examples (complex scenarios and fixes).
  13. Unit testing patterns.
  14. Performance impact of sanitizer and Valgrind.
  15. How I hunt leaks and a troubleshooting workflow.
  16. Platform differences (Linux, Windows, macOS).

Table of contents (quick)


What are memory leaks?

At the C++ language level, I still reach for new and delete (or new[] / delete[]) for dynamic class storage. If I can no longer name a heap block through any live pointer, reference, or smart pointer that will run its destructor, that block is leaked to me: there is no valid program operation I can use to deallocate it, short of process end, when the operating system reclaims the entire address space.

At the operating system level, a leak increases commit or RSS depending on the allocator. The C++ runtime (often glibc malloc on Linux, or msvc CRT on Windows) requests pages from the OS as needed. Heap exhaustion occurs when a new allocation request cannot be satisfied: std::bad_alloc for new, or nullptr from new (nothrow) depending on the form. In long-running services, slow leaks produce gradual pressure; in batch jobs, a leak might be “benign” if the process exits before pressure matters—but it still masks ownership bugs and breaks tests that run LeakSanitizer or Valgrind, which expect no unfreed direct leaks at normal exit (depending on your policy).

Lost memory is a useful informal phrase: the program lost the last way to name that allocation. A dangling pointer to freed memory is a different bug (use-after-free). A leak is the opposite situation: the memory is still allocated, but no correct pointer to it remains.

Heap exhaustion in C++ is not only “bad_alloc immediately.” Many services crawl toward limits: the kernel cgroup memory cap, the per-process ulimit on address space, or swapping to death on mis-sized VMs. A leak can also interact with fragmentation so that a new of size N fails even though the summary of free lists suggests enough bytes in aggregate—malloc and operator new are not “sum of free = can allocate max block.”

Process memory: RSS versus heap size

RSS (resident set size) is OS-level: pages the process has in RAM (simplified; exact definitions vary by ps flags and OS). The C++ heap is a sub-account inside that world: glibc may not return freed pages to the OS for a long time, so after you fix a leak, RSS can stall high until the allocator returns arenas or the process restarts. In SRE postmortems, say “leak fixed in code, RSS normalized after deploy; allocator retained part of the heap until restart” when that pattern appears.

Virtual size can grow even when RSS is flat if you mmap or reserve large arenas; not every “big virtual” line item is a C++ new leak. Cross-check with pmap / /proc/self/smaps on Linux or VMMap-style tools on Windows when sanitizers and leak tools disagree with ops graphs.

Why leaks hurt production

  • OOM killer (Linux) or commit failures (Windows) under sustained growth.
  • Noisy monitoring: “memory is high” without a single obvious stack.
  • False confidence if my dev box never held the process long enough, or I only profile short runs.

Production story: memory brought down our service

A memory leak brought down our service—not with a single spike, but with a weeks-long climb in RSS until the cgroup cap started killing replicas. I still remember the graph: a gentle ramp that looked like “maybe traffic grew,” then a hard knee where latency exploded and the pager would not stay quiet. The binary had passed LSan in CI on a five-minute test suite; the bug lived on a long-lived connection path that our integration tests only brushed.

What I did first was embarrassingly familiar: I stared at dashboards that could tell me the process was dying of memory pressure but not which allocation had gone feral. I reproduced the slope in a staging cluster with a soak script, then pulled ASan+LSan and heaptrack on a Linux build that matched production’s allocator family. The allocation stack I finally trusted pointed at a half-built structure on an error return—classic exception path, raw new, no owner. After I landed the unique_ptr fix and redid the soak, the ramp flattened. RSS still looked padded for a day until the allocator gave pages back; I wrote that into the postmortem so nobody declared victory too early. That week taught me to treat “clean exit in CI” and “safe for a month in prod” as different games.


Common causes

Each subsection below shows a minimal broken pattern, then a typical fix (often RAII). I use these as archetypes; in the wild I have seen them stack—exceptions, containers, and background threads in one ticket.

Forgetting delete or delete[]

The most direct leak: allocate and return without a paired free.

void leak_basic() {
    int* p = new int(42);
    (void)p;
    // missing: delete p;
}

delete[] must pair with new[]:

void leak_mismatch() {
    int* a = new int[10];
    delete a; // undefined behavior; also often leaks depending on platform
}

Fix: one dynamic object → delete once; array → delete[] once, or use std::vector, std::unique_ptr<T[]>, or std::array when size is known at compile time.

Exception before delete (or early return)

If control leaves a scope that allocated with raw new and no RAII, every path must free. Exceptions and early returns are easy to miss.

void maybe_throw(bool fail);

void leak_on_exception() {
    int* p = new int(1);
    maybe_throw(true);     // if this throws, delete never runs
    delete p;
}

Fix: std::unique_ptr<int> p{new int(1)}; (or auto p = std::make_unique<int>(1);).

The same story applies to multiple return statements without a cleanup section.

Lost pointers (reassigning the only copy)

The program still “has a pointer” in the variable name, but you reassign it before delete, orphaning the old block if it was the only handle.

void lost_pointer() {
    int* p = new int(1);
    p = new int(2); // first allocation leaked
    delete p;
}

Fix: again, a single owner type (std::unique_ptr) or a container that disposes the old value when the slot is overwritten, depending on design.

Circular references with std::shared_ptr

shared_ptr is not a silver bullet. If object A holds a shared_ptr to B and B holds a shared_ptr back to A, the reference count never drops to zero when the external world releases its shared_ptrs, because the cycle keeps counts ≥ 1. That is a logical leak of the whole subgraph.

A minimal pattern:

#include <memory>

struct B;

struct A {
    std::shared_ptr<B> b;
};

struct B {
    std::shared_ptr<A> a; // cycle: A -> B -> A
};

Fix: at least one direction should be std::weak_ptr, or you redesign ownership to a tree/DAG, or you use a parent context object with clear lifetime. See Circular references / weak_ptr.

Assignment without delete (raw pointer members)

A copy assignment or reassignment for a class that owns raw new memory must delete[] the old buffer before replacing the pointer, or your rule of three/five/zero is violated.

Broken sketch:

class BadBuf {
    int* p_;
    size_t n_;
 public:
    BadBuf() : p_(nullptr), n_(0) {}
    void reset(size_t n) {
        p_ = new int[n]{};  // old p_ leaked
        n_ = n;
    }
    // ...
};

Fixes: std::vector<int> data_; (preferred), or follow rule of zero with a single owning member, or std::unique_ptr<int[]> with std::exchange in reset to delete[] the previous buffer explicitly while keeping exception safety in mind (again, vector is simpler).


Detection tools

What follows is the practical kit I keep in rotation. Flags drift with each Clang/GCC/MSVC release, so I re-read the release notes when I upgrade. The mental model stays the same: Valgrind instruments at runtime, ASan+LSan inject checks at compile time, and heap profilers earn their keep when “something is growing” but the fault is indirect or the growth is not one obvious new.

Valgrind memcheck (--leak-check=full)

Valgrind on Linux (typical) runs your program in a synthetic CPU; memcheck tracks every heap block and reports definitely / indirectly / possibly lost and still reachable at process exit, depending on your summary settings.

Typical incantation:

valgrind --leak-check=full --show-leak-kinds=all \
    --track-origins=yes --verbose ./your_program arg1
  • --leak-check=full: more detailed per-leak reports, stack traces for allocation site.
  • --show-leak-kinds=all: do not only show “definitely lost”; helps you triage.
  • --track-origins=yes: helps with uninit use in some cases; not always needed for pure leak triage, adds cost.
  • For children: you may need --trace-children=yes if your test fork/execs.

Interpreting output: you see definitely lost (no pointer in any register/stack/global, according to the tool’s model), indirect (a root pointer lost but blocks hanging off it are “indirectly” lost from that root), and still reachable (pointers still exist at exit—often one-time init caches, sometimes intentional). Valgrind is slow (order of magnitude), but needs no special build flags—handy for release binaries (with debug symbols for stacks: -g).

AddressSanitizer and LeakSanitizer (ASan/LSan)

AddressSanitizer (-fsanitize=address on Clang/GCC) finds out-of-bounds, use-after-free, double free, and more. On many toolchains, LeakSanitizer is bundled with Asan: at program exit, it scans the heap and reports leaks with allocation and sometimes deallocation stacks. Enable with a sanitizer build:

# Typical Clang/GCC debug build
c++ -g -O1 -fsanitize=address -fno-omit-frame-pointer -std=c++17 \
    main.cpp -o a.out
./a.out

Notes:

  • LSan is very fast compared to Valgrind, but you must rebuild with the sanitizer. Line numbers in leaks are excellent if -g and -fno-omit-frame-pointer are on.
  • ThreadSanitizer (TSan) and ASan are not always combined the same way on every version; for targeted leak runs, some teams use a dedicated ASan+LSan build in CI, then TSan in another job, rather than a single “everything at once” binary, depending on compiler support and policies.
  • MSVC has similar ideas under AddressSanitizer in supported configurations—check the Visual Studio version you use.

Runtime flags (varies, often via ASAN_OPTIONS):

  • ASAN_OPTIONS=detect_leaks=1 (when applicable on your build).
  • ASAN_OPTIONS=exitcode=0 to avoid failing the test binary when you are only gathering reports (rare; usually you want failure in CI when leaks are present).

For leak reports, read the “direct leak” and “indirect” sections similarly to Valgrind: the top frames of the allocation stack are your first place to add ownership or fix lifetime.

Heap profilers: Massif, heaptrack, and when to use them

Massif (Valgrind tool) samples heap + stack usage over time. It is ideal when the question is how much and where growth happens, not only a single “forgot to delete at exit.”

valgrind --tool=massif ./your_long_running_scenario
ms_print massif.out.12345

You get time-series and peaks; then you look at the heavy allocation stacks.

heaptrack (Linux) is a fast native heap profiler that can attach to processes (depending on setup) and produces chronological views of allocations and leaked or freed blocks with stack traces, often with a GUI (heaptrack_gui). It is a strong middle ground: faster than memcheck for some workloads, and very informative for leaks in loops and cumulative waste.

When to prefer profilers over memcheck/LSan alone: growth over hours in a service, or fragmentation-like symptoms where a simple new is not the whole story, or when you need attribution to hot paths in performance-sensitive code.

A heap-profiler workflow (heaptrack on Linux)

  1. Build with debug symbols (-g); you do not need asan to record (unless you are cross-checking).
  2. Run heaptrack ./service -- --config=stress.ini (arguments after -- to your app), or use heaptrack -p PID to attach in controlled environments.
  3. Open the .zst result in heaptrack_gui: sort by leaked or by trend in “memory over time.”
  4. For each top stack, ask: is this a leak (no path to free until exit), a per-request cache that is unbounded, or a leak in third-party code?
  5. Fix, then re-run the same workload and diff the peak RSS in htop as a sanity check; if the slope is flat but RSS is still high, read the RSS versus heap size section again.

Massif is similar in spirit, but the massif.out.* + ms_print text report is an excellent archival artifact in tickets when you need repro without a GUI on a headless runner.

Instruments (macOS): the Leaks and Allocations templates give time-based and backtrace views comparable to heaptrack for local GUI and iOS-adjacent C++; export or screenshot the heaviest call trees for your bug system.

Visual Studio diagnostic tools (Windows)

On Windows with MSVC, the combination of Debug CRT checks, CRT heap reporting, the diagnostic tools in Visual Studio, and the concurrency visualizer (for less typical growth patterns) is the native front line.

Practical points:

  • Run under the debugger with the default Debug heap, which can fill freed blocks to catch some use-after-frees, and provides leak reporting hooks when configured.
  • The “Memory usage” view (tooling name can vary with VS version) allows heap snapshots and diffs to see what grew between two moments while exercising a feature.
  • AddressSanitizer for MSVC, where available, unifies a large class of memory bug detection; pair with your test runner the same way as on Linux.

CRT debug heap (classic snippet pattern—include only in debug, guard with your macros as appropriate):

#ifdef _DEBUG
#include <crtdbg.h>
// At startup (once):
// _CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_LEAK_CHECK_DF);
#endif

This asks for a leak report at process exit in debug builds. The report lists allocated blocks; map allocation id to file/line when using _CRTDBG_MAP_ALLOC with new macros in debug configuration.

Dr. Memory (Windows/Linux in some modes) is another lightweight native alternative when you need realloc-style and uninitialized read help alongside leak reports; compare against your version matrix before standardizing in CI. Many teams use it when WSL2 is not an option and ASan is not yet in their MSVC path.

Valgrind: machine-readable output and suppressions

For Jenkins, GitLab, or in-house dashboards, emit XML so jobs can fail on definite only:

valgrind --xml=yes --xml-file=valgrind.xml \
    --leak-check=full --show-leak-kinds=definite \
    ./test_runner

--error-exitcode=1 makes the process exit non-zero on any memcheck error (including definite leaks, depending on flags); combine with your CI parser’s policy so still reachable does not fail a gate if you choose to allow it.

Generate a first-cut suppression interactively: run the leak once with --gen-suppressions=all and paste the block the tool prints into a .supp file, then trim wildcards to avoid over-matching. Review every suppression in code review; treat it like a // NOLINT in spirit.

LeakSanitizer: more ASAN_OPTIONS and LSAN_OPTIONS

Typical variables (read your exact clang docs—names evolve):

  • report_objects=1 (when supported): include per-object detail in the report.
  • use_unaligned=0 / allocator quirks: only change when your toolchain documents it.
  • suppressions=path for LSan (parallel to Valgrind’s file) to allow-list allocations in third-party code.

If you use LD_PRELOAD, custom allocators, or substitute malloc, LSan can false-negative or false-positive depending on how the allocator registers regions—stick to the default CRT/malloc in sanitizer test configs unless you are debugging allocator integration on purpose.

AddressSanitizer without LeakSanitizer (when split)

On some embedded or weird build graphs, you might compile ASan without the leak scan at exit to save a few seconds in tight inner loops, then use a dedicated LSan target for exit-time heap accounting. In practice, most desktop/server teams keep them together; splitting is an advanced optimization with paper trail in CMakePresets or Bazel constraint_values.


Smart pointers, RAII, and factories

RAII (Resource Acquisition Is Initialization) is how I keep cleanup tied to scope: an owner’s destructor runs on scope exit and releases heap memory, file handles, locks, and whatever else I bundle. In day-to-day C++, smart pointers are the owners I lean on for heap objects I would otherwise juggle with raw pointers.

std::unique_ptr

Exclusive ownership, move-only, zero or negligible overhead. Prefer std::make_unique<T>(args...) (C++14) to avoid a transient raw pointer and to make exception safety in compound expressions less subtle.

#include <memory>

void good() {
    auto p = std::make_unique<int>(42);
    // p destroyed here; memory freed
}

For arrays, either std::vector, or std::make_unique<Widget[]>(n) (C++14 and up for std::make_unique array forms—verify your standard library implementation), depending on the case.

std::shared_ptr and std::weak_ptr

Shared ownership: the last shared_ptr to a control block destroys the managed object. Cycles are the classic shared_ptr footgun—see Common causes: circular references.

weak_ptr: non-owning observer; you lock() to a temporary shared_ptr if the object still exists. It is the right tool when a child needs to “see” a parent that might go away, or to break back-edges in graphs.

std::make_shared

std::make_shared<T>(args...) (C++11) combines allocation of the object and the control block when possible, improving locality and exception safety compared to std::shared_ptr<T>(new T(args...)), though custom deleters and weak_ptr lifetime subtleties may affect when make_shared is appropriate—verify against your C++ version and the object size if you are extremely allocation-sensitive.

Rule of thumb for leak prevention: no bare new in application code except at low-level library boundaries, and there immediately hand off to a smart pointer or standard container. Enforce with code review and linters where possible.

Custom deleters and C APIs

std::unique_ptr carries a deleter type; use it to pair malloc/free, new[]/delete[], or XFree-style functions without leaking the rule across the codebase.

#include <cstdlib>
#include <memory>

struct MallocDeleter {
    void operator()(void* p) const noexcept { std::free(p); }
};

using CBufferPtr = std::unique_ptr<void, MallocDeleter>;

CBufferPtr take_ownership(void* p) { return CBufferPtr(p); }

shared_ptr also accepts a deleter in its constructor, but the deleter is type-erased and has a small runtime cost. Prefer unique_ptr for sole ownership of non-shared C resources.

std::shared_ptr control blocks: make_shared trade-offs (brief)

std::make_shared can place the T and the control block in a single heap allocation. When the last shared_ptr is destroyed, T runs its destructor, but the block (including sizeof(T)-sized space for a destroyed object) can remain reserved until the last weak_ptr to that control block goes away. That is not a leak in the LSan/Valgrind sense, but in embedded or footprint-critical code it can be surprising. For server projects, the dominant issues remain retained graphs and cycles—not this—unless you profile and see weak_ptr-heavy designs holding large arenas alive.

Arrays: prefer std::vector to raw new[]

A raw new[] of non-trivial Widget is easy to get wrong: any throw between allocation and delete[] (or a missing delete[] on one path) leaks the whole array. std::vector<Widget>, with constructors and destructors run by the container, pairs naturally with RAII and with sanitizers. If you preallocate, use reserve plus push_back/emplace_back, or default-construct N elements only if that is what you need; shrink_to_fit is for rare peak-to-idle footprint tuning, not a primary leak tool.


Container management

STL containers are leak-safety for value types: std::vector<Widget>, std::map<K,V> (values), etc., destroy their elements in destructors. Leaks show up when you store pointers (especially raw pointers) and never delete what they point to.

Bad:

#include <vector>

void leak_in_vector() {
    std::vector<Widget*> v;
    v.push_back(new Widget{});
} // Leaks: vector destroys pointer values, not pointees

Good (often): std::vector<std::unique_ptr<Widget>> or std::vector<Widget> if copy semantics are defined and affordable.

Polymorphic base pointers: it is the owner’s responsibility to delete through a virtual destructor; prefer unique_ptr with custom deleters or shared_ptr with clear ownership story.

POD arrays in containers: std::array and std::vector of trivial types do not leak, but if you resize in ways that keep orphaned indirection, that is a design issue—usually use contiguous values, not new per element, unless a profiling reason forces it.


Custom allocators and tracking

For teaching, tracing, and controlled experiments, a stateful Allocator type passed to std::vector (or a pool type) can log every allocate/deallocate call. A leak in custom logic (double allocate, return wrong pointer) then becomes a mismatch in logs at shutdown.

A minimal C++-style counter pattern (pseudocode-level; align with your standard library’s Allocator requirements if you go production-grade):

#include <cstddef>
#include <memory>

struct tracking_allocator_stats {
    std::size_t live = 0;
};

template <class T>
struct tracking_allocator {
    using value_type = T;
    explicit tracking_allocator(tracking_allocator_stats& s) noexcept : s(&s) {}

    T* allocate(std::size_t n) {
        s->live += n;
        return std::allocator<T>{}.allocate(n);
    }
    void deallocate(T* p, std::size_t n) noexcept {
        s->live -= n;
        std::allocator<T>{}.deallocate(p, n);
    }
    template <class U>
    bool operator==(const tracking_allocator<U>& o) const { return s == o.s; }
    tracking_allocator_stats* s;
};

Use cases: test suites asserting stats.live == 0 at end of a scope, or correlating a feature toggle with unexpected heap growth. Caveat: this does not replace LSan/Valgrind for undefined behavior; it only counts you asked for.


Leak patterns

Patterns I have learned to recognize in reviews and in incident threads:

  • “One per request” allocation without a matching free when the request fails mid-way (half-built structures).
  • Cache with unbounded insert and no eviction—semantic leak (still reachable) but OOM in practice.
  • Singleton holding shared_ptr to graphs that are never released at shutdown; still reachable in Valgrind, memory not returned to OS depending on allocator.
  • Thread exits with detached background work still allocating into structures owned from main thread—lifetime bugs sometimes appear as “leak under stress.”
  • Container of raw pointers from third-party C APIs (malloc)—free in destructors, or wrap in a small RAII guard class.
  • Double ownership: two delete is UB; not a leak, but a paired bug class.

False positives: reachable vs lost

“False positive” is overloaded:

  1. Tool classification: Valgrind can report still reachable blocks at exit. That is not a false report—it is a true unfreed block—but you may not care if it is a global cache freed only on real shutdown (or never in long-lived services where you still want bounded memory).
  2. Still reachable can hide growing caches if the cache is unbounded; it is not “free memory.”
  3. Definitely lost is what you should almost always fix unless you are in a quarantine phase with a suppression for a third-party bug on a path you cannot change yet.
  4. LSan/Valgrind and globals: static objects destruct in an order; some leaks appear only when races and atexit ordering interact—reduce complexity in shutdown paths.

Indirect leak (Valgrind/LSan terminology): a root new is lost, and everything allocated under that object is also unreachable by program rules even if some inner pointers still…—the tool’s model reports indirect loss from the first missing root. The fix is usually at the owning new site.


Suppression files

Suppressions list allow-listed frames and leak types for Valgrind, so CI can stay green for known third-party issues while your code is fixed forward.

Valgrind suppression file example (syntax illustrative; see Valgrind documentation for the exact memcheck:Leak or ... form your version needs):

{
   third-party-lib-ssl-init
   Memcheck:Leak
   fun:malloc
   fun:libSSL_internal_init_*
   ...
}

Workflow:

  1. Reproduce a leak with a known top stack in a foreign library.
  2. Guard upgrades of that library so suppressions are revisited on version bumps.
  3. Do not grow suppressions without an owner and a ticket to remove them.

LeakSanitizer also supports suppression lists via runtime options in several LLVM versions: check LSAN_OPTIONS=suppressions=/path/file in your clang release documentation.

Team policy tip: a suppression file in version control with comments and links to upstream bug trackers beats a silent exitcode=0 hack in CI that hides your own regressions.


CI integration

A typical CI matrix (Linux job shown):

# Example: build with ASan+LSan, run tests, fail on leak
c++ -std=c++20 -g -O1 -fsanitize=address -fno-omit-frame-pointer \
    -c test_main.cpp
c++ -fsanitize=address -fno-omit-frame-pointer test_main.o -o test_runner
ASAN_OPTIONS=detect_leaks=1:halt_on_error=1 \
    ./test_runner --gtest_color=yes

Valgrind in CI is often a nightly or merge-queue job because of speed:

valgrind --error-exitcode=1 --leak-check=full --errors-for-leak-kinds=definite \
    ./test_runner

Trade-offs: ASan/LSan jobs need sufficient RAM; Valgrind may need a bigger timeout multiplier (×10–50 depending on the binary).

Coverage: if a branch never runs in tests, a leak there will not be reported. Pair leak checks with good path coverage and fuzzing for parsers.

CMake: sanitizer presets

A CMake INTERFACE library keeps flags consistent for every test target that opts in:

# Example sketch — adjust generator expressions for multi-config
add_library(sanitize_address INTERFACE)
target_compile_options(sanitize_address INTERFACE
  $<$<CXX_COMPILER_ID:GNU,Clang>:-fsanitize=address -fno-omit-frame-pointer -g -O1>)
target_link_options(sanitize_address INTERFACE
  $<$<CXX_COMPILER_ID:GNU,Clang>:-fsanitize=address>)
# ...then: target_link_libraries(my_tests PRIVATE sanitize_address)

MSVC uses different flag spellings; guard with MSVC and the /fsanitize=address family (verify VS version). Keep separate targets for ASan and UBSan/TSan when mutually exclusive on your compiler version.

Ninja + ccache makes sanitized rebuilds tolerable; Bazel’s --config=asan pattern is the same idea: one place for cflags and link flags.

GitHub Actions: minimal Linux job

# .github/workflows/cpp-asan-lsan.yml (illustrative)
jobs:
  asan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Configure and test with ASan+LSan
        env:
          ASAN_OPTIONS: detect_leaks=1:abort_on_error=1:print_stats=1
        run: |
          cmake -B build -DCMAKE_BUILD_TYPE=Debug -DENABLE_SANITIZERS=ON
          cmake --build build -j
          ctest --test-dir build --output-on-failure

Set CTEST_OUTPUT_ON_FAILURE to 1 in GitLab or other CI for parity. Double the default timeout for Valgrind jobs compared to ASan jobs on the same test suite.

Docker (Linux CI): install Valgrind and (optionally) heaptrack in the image. Tune ulimit (open file descriptors, address space) for integration tests that mmap large files; otherwise failures can look like memory pressure but are really resource limits.

Bazel (sketch): a --config=asan line in .bazelrc is the Bazel idiom parallel to CMake interface librariesexample:

# .bazelrc
build:asan --copt=-fsanitize=address --copt=-fno-omit-frame-pointer --copt=-g
build:asan --linkopt=-fsanitize=address
build:asan --test_env=ASAN_OPTIONS=detect_leaks=1

Run with bazel test --config=asan //... on a subset first; full tree can exhaust RAM on shared runnersshard or use tags like leak=heavy.

Static analysis (clang-tidy, compiler warnings)

-Wall -Wextra -Wconversion and clang-tidy checks such as cppcoreguidelines-*, clang-analyzer, and bugprone-* do not find all heap leaks, but they do find dangling writes, mismatched ownership, and bad moves that become leaks after refactors. Run tidy on the same translation units you ASan- test.


Production monitoring

The tools above are for dev/CI. When I have to reason about a live system, I usually:

  • Track RSS and private bytes (Windows) per process and per service tier.
  • Correlate with restarts, load, and deploy events.
  • Use sampling profilers (e.g. frame pointers and perf on Linux) to find hot allocation sites when I cannot run sanitizers in prod.
  • For .NET and mixed codebases, additional counters apply—this post stays in native C++.

Caution: jemalloc / mimalloc and glibc may not return memory to the OS as aggressively; RSS can be flat or grow without a C++-level “leak” in the LSan sense—distinguish fragmentation and allocator caching from unreachable blocks using dev-time profilers. A/B a suspected build in staging with the same allocator as prod.

OOM: when the process dies, have core or at least logs and metrics; post-mortem without ASan/Valgrind means reproducing in test is mandatory.


Real examples

Example A: exception-unsafe resource release

Problem: a file descriptor and a heap block are interleaved; an exception in read_payload() skips cleanup.

Symptom: LSan: direct leak, stack at new in read_payload path.

Fix extract:

void handle_stream(std::istream& in) {
    std::string header;
    in >> header; // may throw on bad state
    auto body = std::make_unique<std::vector<char>>(read_size_from(header));
    read_payload(in, *body);
    process(*body);
}

Pair I/O and heap with RAII and one owning abstraction per level.

Example B: graph with shared_ptr cycle

Problem: a document model uses shared_ptr for parent and child both directions; closing the document “should” free everything, but reference count never hits zero.

Fix: one direction is weak_ptr<Parent> from Child, or a central Document owner and raw non-owning child pointers, depending on invariants. See the dedicated circular reference post.

Example C: third-party C API

Problem: you call C functions returning malloc’d buffers. Forgetting the documented free is a classic leak. Wrapping in a small struct with custom deleter on unique_ptr or a unique_ptr with a lambda deleter contains the rule in one place.

#include <cstdlib>
#include <memory>

extern "C" void* c_api_get_buffer(size_t* out_len);

void use_c() {
    size_t n = 0;
    void* raw = c_api_get_buffer(&n);
    std::unique_ptr<void, void(*)(void*)> p(raw, &std::free);
    (void)n;
    // use p.get()
}

Example D: callback capturing shared_ptr to a parent

Problem: a timer or network callback uses std::function (or a C-style void* user cookie) and captures shared_ptr<Session> by value to keep the session alive. If the callback is re-registered on every request and old registrations are not removed, you retain more Session state than you intended. In metrics, “open session” counts may climb on reconnect storms; a leak at process exit can be hard to see if a global list still holds one strong reference.

Fix direction: weak_ptr in the callback, ID-based session lookup in a central registry with erase-on-close, and explicit API contracts for unregistration. Add a stress test that connect/disconnects in a loop under ASan+LSan.

Example E: per-thread arena without teardown

Problem: a thread_local arena (for example a std::vector of scratch buffers) grows during request handling but is only cleared on the success path. Aborted or error paths leave the arena unbounded. The memory is “still reachable” from thread-local storage, so some tools will not label it a classic leak, but the process’s RSS can grow without limit.

Fixes: a small RAII guard that clear()s or pops the arena on scope exit, C++20 std::scope_exit where appropriate, and code-review rules that “every control-flow exit from a handler resets TLS scratch state.”


Testing for leaks

Unit test patterns:

  • Build a shared asan config in CMake or Bazel, and link it only for asan test targets.
  • For hermetic cases, a test fixture that runs a function N times in a loop and asserts RSS (fragile) is worse than LSan. Prefer LSan/Valgrind in CI.
  • GoogleTest and others can fork; ensure leak checks are configured for the child in Valgrind if tests spawn.
  • For allocators with tracking_allocator, assert stats.live == 0 at scope exit (white-box).

Fuzzing (libFuzzer + ASan) finds leaks in parsers with inputs that are hard to hand-write. Run a corpus in CI to prevent regressions.

GoogleTest: death tests, forks, and LSan

EXPECT_DEATH, forking harnesses, and out-of-process isolations complicate exit-time leak scanning because the leaking process might be a child with its own heap. Run a subset of tests in a single process under ASan+LSan; use GTEST filters to separate “must be in-process for LSan” from “spawn only when needed.” For Valgrind, --trace-children=yes is often necessary; document it in your README for contributors.

Catch2, doctest, and custom main

Many frameworks define main() or wrap it: ensure ASan+LSan still run the fini path—return from main, not _exit, in tests (unless you intentionally test abnormal exit). std::quick_exit skips destructors; leak checkers at process end may mis-classify or under-report.

Regression: golden allocation counts (use sparingly)

White-box tests can assert that a function does not allocate in a steady state (e.g. reusing a buffer) by hooking operator new in test builds only. This is brittle across stdlib changes; prefer ASan+LSan and module-level invariants first.

Integration tests: run a scripted scenario and assert RSS delta is under a threshold in staging only—noisy, but can correlate with user-visible leaks when tools in dev are green.


Performance impact

Debugging diary — cost of the tools I actually run: For a normal production build (-O2, no sanitizers), I treat CPU and RAM as my baseline. When I flip on ASan+LSan, I budget roughly twice the CPU and twice the memory—that is a finger-in-the-wind rule I use when sizing CI runners, not a guarantee. Valgrind memcheck is a different beast: I expect on the order of 10–50× slowdown and a fat memory footprint, but I do not need a special compile if I already have symbols; I reach for it when I need a release-shaped binary or when ASan and the bug disagree. Massif and heaptrack sit in the middle: sampling cost varies, but both are often much faster than memcheck when my question is “where is the slope coming from?” not only “what is still allocated at exit?”

What I actually do: I keep ASan+LSan on the main test path (per-PR or at least nightly). I pull Valgrind when I need that asymmetric view or a second opinion. I pull heaptrack or Massif when the graph is a ramp in production and I need time on the x-axis, not just a tombstone at process end.


How I hunt leaks

Debugging diary — habits, not a checklist grid: I default ownership to std::unique_ptr, std::vector, and std::string. I only let std::shared_ptr in when shared lifetime is real in the design, not because I am afraid of raw pointers.

For every C API that hands me a malloc’d blob, I wrap the free rule once—a small unique_ptr with a deleter or a tiny guard type—and I write down who frees in the same commit so the next reader does not guess.

I treat exceptions and early returns as hostile to raw new: if there is more than one exit, I refuse to leave a naked new in scope; I promote the owner to unique_ptr or a container before I merge.

On graphs and UI-style back-pointers, I break shared_ptr cycles with weak_ptr or I centralize ownership in one place. I have been burned enough by “helpful” mutual shared_ptrs that I look for cycles in review the way I look for missing break in a switch.

In CI, I want ASan+LSan on the binary I trust most; I schedule Valgrind where speed is less important than depth, often on a fat integration job. For third-party noise, I will use a suppression file, but only with a ticket and an owner—I treat a suppression like a loan, not a permanent hide.

In review, I still flinch at std::vector<T*> without a documented owner, at new in a public API that returns a raw pointer, and at unbounded static shared_ptr caches.

When RSS climbs in prod but LSan is quiet, I do not assume victory: I look for fragmentation, allocator caching, unbounded caches (still reachable but fatal), or memory that never went through new (GPU, mmap) before I declare the investigation over.


Troubleshooting workflow

  1. I reproduce the growth or exercise the suspected path in a dev build with -g, ASan+LSan, and one thread when I can—fewer moving parts, cleaner stacks.
  2. When ASan+LSan fires, I read the allocation stack, fix the owner, rebuild, and re-run. If the report says indirect, I walk up until I find the root object that should have died.
  3. When LSan is clean but Valgrind shows still reachable, I decide with the product: one-time init cache, or a structure that grows per user?
  4. When tools disagree with observed RSS, I run heaptrack or Massif on a workload that matches prod; I map the slope to stacks and to queues, caches, and per-connection state.
  5. For Heisenbugs under load, I spin a ThreadSanitizer build in parallel—races often masquerade as “weird lifetime,” not a classic leak.
  6. I close the loop: I add a regression test that would have failed LSan before the fix.

Escalation: If the leak rate tracks a specific input, I add that input to the regression bag; if the surface is parser-shaped and security-adjacent, I add fuzzing to the same story.


Platform differences: Linux, Windows, macOS

  • Linux: I reach for Valgrind, ASan/LSan, heaptrack, and perf first—this is where I have lived most often for servers.
  • Windows: I lean on Visual Studio heap diagnostics, CRT debug flags, and WSL2 to run Linux tools on ELF builds if my pipeline produces them; I verify AddressSanitizer for MSVC against the matrix I actually support.
  • macOS: I script ASan/LSan on Clang; I open Xcode Instruments for Leaks and Allocations when I need a GUI. Valgrind is usually not my first stop there—I default to ASan+Instruments.
  • Cross-platform: I keep one sanitizer job per OS on the nightly grid, and I align the allocator in staging with prod when I am interpreting RSS.

Windows without WSL: MSVC, ASan, and Dr. Memory

Recent Visual Studio releases have improved AddressSanitizer for native code. If I cannot produce ELF binaries for WSL2, I still use heap snapshots, Debug CRT reporting, or an ASan-enabled CI configuration for Windows-native DLLs and executables before I trust a release-only manual pass.

Dr. Memory can help on Win32 when I am not on ASan or Valgrind and WSL is out of scope for a particular hunt. I still treat it like any single-vendor tool: I confirm with a second signal when stacks are noisy after inlining.

WSL2 and “Linux tools for Windows” teams

WSL2 runs a real Linux kernel. If production is also Linux, I often get the fastest match to server behavior by running Valgrind or heaptrack on a glibc build inside WSL. I keep glibc and compiler versions roughly aligned with production: malloc behavior and definitely lost counts can shift between distros on the same ISA.

macOS: ASan+LSan vs Instruments

Command-line ASan+LSan is what I script in CI. Xcode Instruments (Leaks, Allocations) adds rich context for AppKit run loops and GCD queues. For portable C++ libraries, I reproduce the issue in a headless test with ASan first, then I use Instruments when the leak only shows up inside a GUI or system framework.


Classifying tool output (Valgrind/LSan cheat sheet)

Debugging diary — words I use in tickets: When I see definitely lost in Valgrind or a direct leak in LSan, I assume the last strong path to the block is gone; my first move is to find the owning new on the allocation stack and give that block a real owner.

Indirectly lost / indirect leak means I probably leaked a container or parent object; fixing a child pointer rarely helps until I fix the root owner.

Possibly lost in Valgrind usually sends me back to pointer arithmetic—an interior pointer still “in range” that confuses the tool about the base pointer I must pass to delete or delete[].

Still reachable is where I slow down: it can be innocent one-time init, or it can be a cache that grows with every user. If the reachable set grows without a cap, I still call it a production leak even if the label says “reachable.”

When LSan prints , I rebuild with -g, fix stale symbols, and make sure llvm-symbolizer is on my PATH if the binary uses dlopen heavily.

On MSVC in Debug, the CRT’s end-of-process heap dump lists raw block ids; I pair _CRTDBG_MAP_ALLOC with my new macros so I can map an id back to a line.


Conclusion

I think of memory leaks in C++ as broken ownership and lifetime: a heap block is no longer deletable under the invariants I thought I had, so it survives until the process ends—sometimes slowly enough to bite only long-running systems. My default is to pair every allocation with a clear ownerunique_ptr, value-semantic containers, and careful shared_ptr graphs with weak_ptr on back-edges—and to backstop with ASan+LSan in development and CI, Valgrind when I need depth or a release-shaped binary, and heap profilers when growth is the symptom. I use suppression files as governance, not as a rug to sweep my own regressions under; I lean on production monitoring for what CI never stressed.

Further reading on this blog: detection in practice, Valgrind, circular shared_ptr issues, and the series post on real leak patterns for narrative-style case studies.

When I start new C++ in 2026, I still default to values, unique_ptr, and vector; I reach for shared_ptr only when the design truly shares lifetime—not as a “make the leak report go away” button—and I keep the workflow above within reach the next time RSS climbs in prod.