Enter your email address below and subscribe to our newsletter

Share your love

SPONSORED

Case File: The "Ghost" Memory Leak in Production (And How We Tracked It Down)

Case File: The "Ghost" Memory Leak in Production (And How We Tracked It Down)

It was 4:30 PM on a Friday—the exact time every developer dreads—when our monitoring dashboard flashed bright red. Server memory usage was climbing steadily in a perfect, unbroken staircase: 60%, 75%, 90%, and then an inevitable OutOfMemoryError: Java heap space.

Restarting the instance gave us a clean slate, but within three hours, the memory leak crept right back. The strange part? Our thread dumps looked completely normal, garbage collection was running constantly, and our active user traffic hadn't spiked.

Here is the exact step-by-step breakdown of how we diagnosed a subtle, invisible memory leak, why automated tools missed it, and how to prevent it in your own applications.

The Symptom: Garbage Collection Working Overtime

When memory leaks occur, the immediate impulse is to blame unclosed database connections or massive file uploads sitting in memory. But our logs painted a stranger picture:

Plaintext

[INFO] [GC (Allocation Failure) [PSYoungGen: 2048K->512K(2048K)] 102400K->101800K(102400K), 0.0152300 secs]

[INFO] [GC (System.gc()) [PSOldGen: 99800K->99750K(102400K)] 101800K->101750K(102400K), 0.0451200 secs]

Notice the PSOldGen numbers: Garbage Collection was firing every few seconds, yet it was only freeing a fraction of a percent of memory. Something was holding tight to millions of small objects in the Old Generation space, preventing the garbage collector from reclaiming them.

The False Lead: Blaming the Database Pool

Our first hypothesis was that our SQL connection pool was leaking open statements. We refactored several query services, wrapped every repository call in strict try-with-resources blocks, and redeployed.

The result? The leak persisted at the exact same rate.

The lesson here: never trust intuition when diagnosing memory issues. Profiling data is the only truth.

The Root Cause: A Hidden ThreadLocal Cache

We generated a heap dump (jmap -dump:format=b,file=heap.hprof <pid>) during the surge and loaded it into an analyzer. Sorting objects by total retained size brought up an immediate red flag: thousands of instances of a UserContext object sitting inside a thread pool.

Here was the problematic code structure inside our custom logging interceptor:

Java

public class UserSessionInterceptor {


// ThreadLocal intended to pass user metadata across layer calls

private static final ThreadLocal<UserContext> contextHolder = new ThreadLocal<>();


public void onRequestStart(HttpRequest request) {

UserContext context = extractContextFromToken(request);

contextHolder.set(context); // Object attached to current thread

}


public void onRequestEnd() {

// BUG: We read the context, but we forgot to invoke contextHolder.remove()!

}

}

Why This Leaked Memory

Application servers (like Apache Tomcat) re-use a fixed pool of threads to handle incoming web requests.

  1. When a request arrived, contextHolder.set() attached a heavy UserContext object to the worker thread executing the request.

  2. When the HTTP request finished, the thread went back into the thread pool to wait for the next incoming request.

  3. Because contextHolder.remove() was never called, the UserContext object remained strongly referenced by the thread's internal ThreadLocalMap.

  4. As hundreds of unique user requests cycled through the worker threads, every thread accumulated dead user objects that could never be garbage collected.

The Fix: Guaranteed Cleanup

Fixing the leak required enforcing a strict cleanup lifecycle using a try-finally block to ensure remove() is executed regardless of whether the request succeeds or throws an exception.

Java

public void processRequest(HttpRequest request) {

try {

UserContext context = extractContextFromToken(request);

contextHolder.set(context);

// Execute business logic...

handleBusinessLogic(request);

} finally {

// ALWAYS clear ThreadLocal data when the execution scope finishes

contextHolder.remove();

}

}

Key Takeaways for Developers

  • Thread Reuse Risk: Whenever you use ThreadLocal in environment with thread pooling (web containers, executor services), failing to clear data means data persists across unrelated tasks.

  • Heap Dumps > Guesswork: When memory steadily grows, capture a .hprof heap dump and inspect the retained heap size of your classes rather than guessing where leaks live.

  • Always Clear in finally: If you introduce context holders or manual resources, ensure the cleanup call happens inside a finally block so runtime exceptions don't bypass it.

Leave a Reply

0/200
Upload Image
Max 100 KB | Passport Ratio
Optional