Memory Leaks Occur in Garbage-Collected Languages

Can Memory Leaks Occur in Garbage-Collected Languages?

Yes. And understanding why requires unlearning one of the most persistent myths in software development.

The myth: garbage collection prevents memory leaks. The reality: garbage collection prevents one specific type of memory problem, the dangling reference, where a program loses all pointers to a block of memory and has no way to free it. What garbage collection does not and cannot prevent is the opposite problem: a program that keeps a reference to an object it no longer needs. The garbage collector sees a live reference and correctly leaves the object alone. The developer intended to stop using that object. The memory grows. The effect is identical to a traditional leak, unbounded memory growth, eventual OOM errors, service restarts, but the mechanism is different.

This distinction matters because it changes where you look for the problem and how you fix it. In C and C++, a memory leak means a missing free() or delete. In Java, Python, C#, Go, or JavaScript, it means a reference that should have been cleared but wasn’t. The garbage collector is not malfunctioning. It is correctly respecting a reference that the program should not have kept.

Leak Detection Across Every Language

SMART TS XL maps long-lived reference chains and unbounded data structures across your full codebase.

More Info

What Garbage Collectors Actually Do

A garbage collector’s job is to identify and reclaim memory that is no longer reachable from the program’s roots, global variables, stack variables, and thread-local state. Any object reachable from a root is considered live. Any object not reachable is eligible for collection.

The three dominant GC strategies each define reachability differently:

Mark-and-sweep (used by Java’s HotSpot, Go, C#/.NET) starts from the root set and traverses all reachable object references. Objects not reached during traversal are swept. This correctly handles circular references, two objects pointing to each other but reachable from nowhere else are both collected. What it cannot handle: a root-reachable container (a static HashMap, a class-level List) that grows without bound because entries are added and never removed.

Reference counting (CPython’s primary mechanism) tracks how many references point to each object. When the count reaches zero, the object is freed immediately. The classic failure mode: circular references where A references B and B references A. Both have a reference count of at least 1 and are never freed. CPython addresses this with a supplemental cycle collector, but the cycle collector has edge cases involving objects with __del__ methods.

Generational GC (used by most modern runtimes in combination with the above) divides objects into young and old generations based on how long they have survived. Short-lived objects are collected frequently at low cost. Long-lived objects are collected rarely. The consequence for memory leaks: objects that should have become short-lived but are accidentally retained move into the old generation and are collected less and less frequently, making the leak harder to observe and diagnose.

None of these mechanisms check whether the program should still be using an object, only whether it can reach it.

The Five Mechanisms That Cause Memory Leaks in GC Languages

1. Static and Class-Level Collections

A static field in Java, a class variable in Python, a static property in C#, these live for the duration of the application process. Any collection stored in a static field accumulates objects for the entire lifetime of the program unless explicitly cleared.

java

// Java: static cache that grows without bound
public class SessionManager {
    // Lives for the lifetime of the JVM process
    private static final Map<String, UserSession> sessions = new HashMap<>();

    public void createSession(String userId) {
        sessions.put(userId, new UserSession(userId));
        // Session is added but never removed when it expires
    }
    // Missing: cleanup when session expires or user logs out
}

The sessions map is reachable from the class loader, which is reachable from the root. Every UserSession ever created accumulates. In a long-running service, this exhausts heap over hours or days. The fix requires either active removal (call sessions.remove(userId) on logout or expiry) or switching to a structure with automatic eviction such as a time-bounded cache.

2. Event Listeners and Callbacks

Registering an observer, listener, or callback creates a reference from the event source to the listener. If the listener is never unregistered, the event source keeps the listener alive for as long as the event source exists, which may be much longer than intended.

python

# Python: listener retained by long-lived event source
class DataSource:
    def __init__(self):
        self._listeners = []

    def add_listener(self, listener):
        self._listeners.append(listener)

    # Missing: remove_listener method

class DataProcessor:
    def __init__(self, source: DataSource):
        self.source = source
        source.add_listener(self._on_data)   # strong reference held in source

    def _on_data(self, data):
        pass

# Each DataProcessor created is never collected because
# DataSource._listeners holds a reference to its bound method
source = DataSource()
for _ in range(10000):
    processor = DataProcessor(source)
    # processor goes out of scope here
    # but source._listeners still holds 10,000 bound method references

3. Thread-Local Variables in Thread Pools

Thread-local storage is scoped to a thread, not to any particular task. In applications using thread pools, where threads are reused across many requests, thread-local variables set during one request remain set for every subsequent request handled by the same thread.

java

// Java: ThreadLocal leak in a thread pool
public class RequestContextHolder {
    private static final ThreadLocal<RequestContext> context = new ThreadLocal<>();

    public static void setContext(RequestContext ctx) {
        context.set(ctx);
    }

    public static RequestContext getContext() {
        return context.get();
    }

    // Missing: clearContext() method called after each request
}

// In a servlet or request handler:
public void handleRequest(HttpRequest req) {
    RequestContextHolder.setContext(new RequestContext(req));
    // ... process request
    // Missing: RequestContextHolder.clearContext();
    // The RequestContext stays associated with this thread indefinitely
}

In a thread pool of 50 threads handling millions of requests, each thread eventually holds a RequestContext referencing the last request’s data, connection details, user credentials, request parameters. These are never collected because the threads themselves are never collected.

4. Closures Capturing Large Object Graphs

Closures capture references to variables in their enclosing scope. In JavaScript and Python especially, a small callback or handler can inadvertently capture a large object, an entire DOM tree, a request object, a large dataset, preventing it from being collected long after it should be.

javascript

// JavaScript: closure capturing large object in event listener
function setupHandler() {
    const largeDataset = fetchLargeDataset(); // 50MB of data

    document.getElementById('btn').addEventListener('click', function() {
        // Only uses one field, but captures entire largeDataset in closure
        console.log(largeDataset.summary);
    });

    // largeDataset cannot be collected as long as the button exists
    // because the event listener closure holds a reference to it
}

// Fix: capture only what you need
function setupHandlerFixed() {
    const largeDataset = fetchLargeDataset();
    const summary = largeDataset.summary; // extract only needed data

    document.getElementById('btn').addEventListener('click', function() {
        console.log(summary); // closure captures only the string
    });
    // largeDataset is now eligible for collection
}

5. Unbounded Caches Without Eviction

Caches are one of the most common sources of memory leaks in production services. A cache that grows without an eviction policy is an accumulator of objects that cannot be collected because the cache itself is always reachable.

csharp

// C#: unbounded dictionary used as cache
public class PriceService {
    // Growing forever -- no eviction, no size limit
    private static Dictionary<string, decimal> _priceCache = new();

    public decimal GetPrice(string productId) {
        if (!_priceCache.TryGetValue(productId, out var price)) {
            price = FetchPriceFromDatabase(productId);
            _priceCache[productId] = price;
        }
        return price;
    }
    // After processing 1 million unique product IDs, the cache
    // holds 1 million entries and grows with every new product
}

// Fix: use a cache with eviction policy
using Microsoft.Extensions.Caching.Memory;

public class PriceService {
    private readonly IMemoryCache _cache;

    public PriceService(IMemoryCache cache) {
        _cache = cache;
    }

    public decimal GetPrice(string productId) {
        return _cache.GetOrCreate(productId, entry => {
            entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
            entry.SizeLimit = 1;  // bounded by cache size configuration
            return FetchPriceFromDatabase(productId);
        });
    }
}

Memory Leaks by Language

Java

Java’s most common leak patterns, beyond static collections, involve the finalize() method and inner classes. Non-static inner classes hold an implicit reference to the enclosing outer class instance. An anonymous class defined inside an Activity or Fragment (in Android) holds a reference to that Activity, preventing it from being collected after rotation or navigation.

Java’s WeakHashMap solves the static collection problem for cases where the keys are the meaningful objects: when the key becomes unreachable from elsewhere, the entry is automatically removed. For values that should be released when no longer referenced, WeakReference and SoftReference wrap objects without creating strong references that prevent collection.

Detection tools: Java VisualVM, Eclipse Memory Analyzer (MAT), JProfiler, YourKit, and HeapHero for analyzing heap dumps. Look for byte[], char[], and Object[] growth in heap histograms, these typically back the data structures in expanding collections.

Python

Python’s GC has two layers: reference counting (primary) and a cycle collector (supplemental). The reference counting layer frees objects immediately when the count reaches zero, which is fast and predictable. The cycle collector handles circular references but runs less frequently and has exceptions.

python

# Python circular reference with __del__ -- historically problematic
class Node:
    def __init__(self):
        self.child = None

    def __del__(self):
        print("Node deleted")

a = Node()
b = Node()
a.child = b
b.child = a   # circular reference
del a, b      # neither freed in Python 2; freed by cycle collector in Python 3.4+

Python 3.4+ handles most circular references correctly. The remaining leak patterns are: objects stored in module-level globals, growing defaultdict or dict structures used as registries, and memory in C extension modules that is not exposed to the Python GC.

Detection: tracemalloc (standard library), memory_profiler, objgraph. The objgraph.show_most_common_types() and objgraph.show_growth() functions identify which types are accumulating.

C# and .NET

C# leaks most commonly occur through: event handlers not unsubscribed, static event sources holding references to subscriber instances, and IDisposable objects not disposed.

csharp

// C#: event handler leak
public class Publisher {
    public static event EventHandler DataChanged;  // static event
}

public class Subscriber {
    public Subscriber() {
        Publisher.DataChanged += OnDataChanged;  // strong reference
    }

    private void OnDataChanged(object sender, EventArgs e) { }

    // Missing: Dispose() calling Publisher.DataChanged -= OnDataChanged
}

The Subscriber is never collected as long as Publisher exists (which, being static, is forever), because the static event holds a delegate referencing the subscriber instance. The fix is implementing IDisposable and unsubscribing in Dispose(), or using WeakEventManager.

Detection: .NET Memory Profiler, dotMemory, Visual Studio Diagnostic Tools, and WinDbg with SOS extension for production heap dumps.

Go

Go uses mark-and-sweep GC with a very low stop-the-world pause. Despite this, Go programs develop memory leaks through goroutine leaks, goroutines that are started and never terminate.

go

// Go: goroutine leak -- channel never closed, goroutine blocked forever
func processItems(items []Item) {
    results := make(chan Result)

    go func() {
        for _, item := range items {
            results <- process(item)
        }
        // Missing: close(results)
        // This goroutine blocks on the send if no one reads after items run out
    }()

    for result := range results {
        handleResult(result)
    }
    // If the goroutine blocks and results is never closed,
    // the loop here also blocks -- both goroutines leak
}

Each leaked goroutine holds its stack (initially 2KB, growing as needed) and any heap objects it references. A service that leaks one goroutine per request accumulates thousands over time.

Detection: runtime.NumGoroutine() metric, pprof goroutine profile endpoint (/debug/pprof/goroutine), and goleak for testing. The pprof profile shows every goroutine’s stack trace, making it straightforward to identify which functions are blocking indefinitely.

JavaScript and Node.js

In Node.js, the most common leak patterns involve global variables, event emitter listeners accumulating without removal, and buffers/streams not properly closed.

javascript

// Node.js: EventEmitter listener leak
const EventEmitter = require('events');
const emitter = new EventEmitter();

function createHandler() {
    emitter.on('data', (data) => {
        // This listener is never removed
        // Each call to createHandler adds another listener
        console.log(data);
    });
}

// Called once per request in a server:
// After 11 calls, Node prints MaxListenersExceededWarning
// After thousands of calls, thousands of listeners accumulate

// Fix: use emitter.once() for one-time listeners
// or explicitly remove with emitter.off()

Detection: Chrome DevTools heap snapshots (for browser JS), node --inspect with Chrome DevTools (for Node.js), clinic.js for production Node.js profiling, and heapdump npm package for capturing and diffing heap snapshots over time.

Prevention Patterns Across GC Languages

Rather than debugging leaks after they manifest in production, these structural patterns prevent the most common causes:

Weak references allow an object to be referenced without preventing its collection. When the GC needs memory, weakly-referenced objects are eligible for collection even if references to them exist. Use WeakReference<T> in Java and C#, weakref.ref() in Python, and WeakRef in JavaScript for caches and observer registrations where the referencing object should not extend the lifetime of the referenced one.

Size-bounded caches with eviction replace raw HashMap or dict with purpose-built cache implementations: Guava’s CacheBuilder in Java, functools.lru_cache or cachetools in Python, IMemoryCache with size limits in C#, and node-lru-cache in Node.js.

Explicit lifecycle management ensures that objects with registered listeners, open resources, or thread-local state implement a close(), dispose(), or equivalent method that performs cleanup, and that callers always invoke it, through try-with-resources in Java, with statements in Python, using declarations in C#, and defer in Go.

Goroutine cancellation in Go uses context.Context with cancellation to ensure every goroutine has a path to termination. Goroutines that select on ctx.Done() can be signaled to exit, preventing accumulation.

How Static Analysis Finds Potential Leaks Before Production

Memory profiling finds leaks after they manifest. Static analysis finds the structural patterns that cause them before the code runs.

SMART TS XL’s static code analysis examines the structural characteristics of codebases across Java, Python, C#, JavaScript, and other languages, identifying patterns associated with unintended retention: static collections without documented eviction, event listener registrations without corresponding deregistration, ThreadLocal usage without cleanup, and resource allocations without corresponding disposal.

The application dependency mapping capability surfaces the long reference chains that GC-language memory leaks typically involve, objects that are reachable from program roots through several levels of indirection, where the path to the root passes through a long-lived container that was intended to hold objects temporarily.

For organizations managing large legacy codebases where memory management patterns were established years or decades ago, SMART TS XL’s impact analysis capability makes remediation scoped and systematic: identify every location where a specific anti-pattern (unbounded static cache, unremoved listener) occurs, enumerate the components affected, and plan remediation in order of risk rather than discovering instances one production incident at a time.