Troubleshooting

How to Fix Application Scope Memory Leaks

Overview of the Problem

An application-scope memory leak occurs when objects that should be short-lived remain referenced by long-lived structures (such as singletons, static fields, global caches, long-running threads, or framework registries). Because these objects never become eligible for Garbage collection, your process’s memory footprint grows over time, leading to slowdowns, increased garbage-collection (GC) pressure, and eventually out-of-memory errors or container restarts.

This happens when:

  • Long-lived holders (Application scope) retain references to short-lived data (request/session scope).
  • Resources (connections, buffers, file handles) are not explicitly released.
  • Listeners, callbacks, or background tasks outlive their intended lifecycle.

Symptoms include rising RSS/heap, frequent full GC, latency spikes under load, OOM errors, or Kubernetes “OOMKilled” events. The fixes require pinpointing what’s retaining memory, breaking those reference chains, and preventing recurrence with better lifecycle management, bounded caches, and automated profiling in CI.

Symptoms and Impact

  • Gradual increase in process memory (RSS) or heap usage after each batch of traffic.
  • Longer and more frequent GC cycles; elevated GC pause times.
  • Latency and throughput degradation as memory pressure rises.
  • Errors such as:
    • Java: OutOfMemoryError: Java Heap space / Metaspace
    • .NET: System.OutOfMemoryException
    • Node.js: FATAL ERROR: Reached heap limit Allocation failed – JavaScript heap Out of memory
  • Containers being restarted with OOMKilled.
  • Unbounded growth of threads, buffers, or caches.

Example logs:

[ERROR] java.lang.OutOfMemoryError: Java Heap space
Dumping heap to java_pid12345.hprof …
Heap dump file created [528763481 bytes] in /dumps

Unhandled exception. System.OutOfMemoryException: Exception of type ‘System.OutOfMemoryException’ was thrown.

<— Last few GCs —>
[1844674:0x55d1…] Allocation failed – JavaScript heap Out of memory

In Kubernetes:

State: Terminated
Reason: OOMKilled
Exit Code: 137

These are classic signals of an application-scope leak rather than a transient spike.

Possible Causes

Typical Leak Patterns

  • Static collections / singletons retaining request-scoped objects.
  • Unbounded caches without TTL/size limits.
  • Event listeners / callbacks never removed.
  • ThreadLocal mismanagement (not clearing in thread pools).
  • Schedulers/timers where tasks never cancel or references never drop.
  • Observables/streams (Rx, event emitters) that aren’t disposed/unsubscribed.
  • Object pools that grow unbounded or never release broken objects.
  • Classloader leaks in hot-deploy/plug-in scenarios (JDBC drivers, loggers, MBeans).
  • Native resource leaks (file descriptors, direct buffers, pinned arrays).
  • Improper use of strong references where weak/soft references are more appropriate.

Code smell (Java – static map holding per-request data):
java
public class GlobalState {
private static final Map<String, UserContext> USERS = new HashMap<>();
// USERS grows forever if not cleaned up
}

See also  How to Resolve CFImage Processing Errors

Code smell (Node.js – never removing listeners):
js
const emitter = new EventEmitter();
function onData(data) { // }
emitter.on(‘data’, onData);
// If not removed, this retains ‘onData’ and captured closures indefinitely

Code smell (.NET – event subscription without unsubscription):
csharp
publisher.SomeEvent += this.Handler;
// If not unsubscribed, ‘this’ may be retained by publisher indefinitely

Step-by-Step Troubleshooting Guide

Prepare Your Environment

  • Enable memory and GC metrics in your observability stack.
  • Reproduce under controlled load; keep a stable baseline (same version, same dataset).
  • Configure safe memory limits:
    • Java: -Xms, -Xmx, GC logs
    • .NET: container memory limit awareness; COMPlus settings if applicable
    • Node.js: –max-old-space-size
  • Ensure heap dump/memory snapshot permissions and disk space.

Java GC logs:
bash
JAVA_TOOL_OPTIONS=”-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags”

Detect and Confirm a Leak

  • Look for monotonic upward trends in heap/RSS between GCs over hours.
  • Compare two or more heap snapshots taken at different times. Leaks show growing dominator sets and retained sizes.
  • Correlate traffic patterns. Memory should return to baseline during idle periods; if not, likely a leak.

Capture Evidence by Platform

Java

  • Trigger heap dump on OOM:
    bash
    -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps

  • On-demand heap dump:
    bash
    jcmd GC.heap_dump /dumps/heap.hprof

    or

    jmap -dump:format=b,file=/dumps/heap.hprof

  • Live metrics:
    bash
    jcmd GC.class_histogram
    jcmd GC.heap_info

  • Analyze with Eclipse MAT, VisualVM, or YourKit (dominator tree, paths to GC roots).

.NET (Core/5+)

  • Capture dumps:
    bash
    dotnet-gcdump collect -p -o app.gcdump
    dotnet-dump collect -p

  • Counters and GC:
    bash
    dotnet-counters monitor System.Runtime –process-id dotnet-trace collect –process-id –providers Microsoft-DotNETCore-SampleProfiler

  • Analyze with dotnet-gcdump, PerfView, Visual Studio.

Node.js

  • Enable inspector and take snapshots:
    bash
    node –inspect=0.0.0.0:9229 server.js

Use Chrome DevTools: Memory > Take heap snapshot; compare growth over time.

  • CLI tools: clinic heapprof, heapdump module to generate .heapsnapshot on signal.

Python

  • Start tracemalloc:
    python
    import tracemalloc
    tracemalloc.start()

snapshot and compare

s1 = tracemalloc.take_snapshot()

… after load …

s2 = tracemalloc.take_snapshot()
for stat in s2.compare_to(s1, ‘lineno’)[:20]:
print(stat)

  • Inspect with objgraph, memory_profiler, and the built-in gc module.

Containers / Kubernetes

  • Check pod memory usage over time (kubectl top pods).
  • Look for OOMKilled and RSS vs heap differences.
  • Confirm cgroup limits align with runtime flags (e.g., Java’s -XX:MaxRAMPercentage).

Analyze Heap Dumps

  • Open the snapshot and inspect:
    • Dominator tree: Which objects retain the most memory?
    • Paths to GC roots: Is the root a static field, thread, classloader, or JNI global?
    • Growth diff: Compare two snapshots to find classes that keep growing.
  • Common red flags:
    • Huge maps keyed by request IDs or user IDs.
    • Loads of pending timers or Scheduled tasks.
    • Many instances of listeners/subscriptions.
    • Multiple classloaders retaining classes/resources after redeploys.

Fix Typical Leaks (Targeted Remediation Steps)

  • Static/singleton holding short-lived data:
    • Replace with scoped lifetimes (DI container: transient/scoped).
    • If caching, enforce max size and TTL.
  • Unbounded caches:
    • Use libraries with eviction:
      • Java: Caffeine/Guava
      • Node.js: lru-cache
      • .NET: MemoryCache with size limit
      • Python: cachetools
  • Event listeners / callbacks:
    • Always unsubscribe or use weak event patterns.
    • Tie subscriptions to lifecycle hooks; auto-dispose on shutdown.
  • ThreadLocal:
    • Always remove() in a finally block in pooled threads.
    • Avoid storing large graphs in ThreadLocal.
  • Schedulers/timers:
    • Cancel tasks on shutdown; ensure ScheduledExecutorService is properly shutdown() in Java.
  • Observables/streams:
    • Use dispose(), takeUntil(), or AutoDispose patterns.
  • Classloader leaks:
    • Deregister JDBC drivers, release MBeans, close loggers/appenders on undeploy.
    • Avoid storing app-classloader instances in static fields of shared libs.
  • Native resources:
    • Close files/sockets/buffers promptly; rely on explicit close rather than finalizers.
  • Large buffers/images:
    • Reuse pooled buffers with bounds; release direct buffers; unmap memory-mapped files.
  • Dependency injection and lifecycle:
    • Ensure singleton services do not capture scoped/transient services or request data.
See also  How to Troubleshoot Out of Memory Errors in ColdFusion

Java cache with size limit:
java
Cache<String, UserContext> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(30))
.recordStats()
.build();

ThreadLocal cleanup:
java
private static final ThreadLocal<byte[]> LOCAL = new ThreadLocal<>();
try {
LOCAL.set(new byte[1024 * 1024]);
// use
} finally {
LOCAL.remove(); // critical in thread pools
}

Quick Cause/Solution Reference

  • Static references hold request data
    • Solution: Move to scoped lifetimes; avoid static state for per-request objects.
  • Unbounded cache growth
    • Solution: Add TTL, max size, and eviction; monitor hit/miss and size.
  • Listeners/subscriptions never removed
    • Solution: Unsubscribe in finally/Dispose; use weak listeners where available.
  • ThreadLocal not cleared
    • Solution: Call remove() in finally; minimize stored data.
  • Scheduled tasks persist forever
    • Solution: Cancel on shutdown; use bounded queues and backpressure.
  • Classloader leak on redeploy
    • Solution: Deregister drivers/MBeans; avoid static singletons in shared libs.
  • Native handles/buffers not freed
    • Solution: Close explicitly; use try-with-resources/using/context managers.
  • Object pool retains dead objects
    • Solution: Validate and evict; cap size; prefer modern bounded pools.

Common mistakes and How to Avoid Them

  • Blaming the GC before investigating roots: Always analyze paths to GC roots in a heap dump.
  • Confusing memory bloat with leaks: A big but stable cache is not a leak; look for monotonic growth between GCs.
  • Ignoring native memory: RSS can grow due to native leaks (buffers, JNI, pinned arrays) even if heap is stable.
  • Forgetting container limits: If Java/.NET/Node isn’t tuned for cgroups, the process may exceed memory budgets.
  • Not replicating under representative load: Many leaks appear only after hours/days of typical traffic patterns.
  • Missing lifecycle hooks: Not disposing listeners, timers, or DI-scoped components on shutdown/redeploy.
  • Keeping diagnostics off in prod-like environments: Enable minimal-cost metrics and sampling to catch leaks early.

Prevention Tips / Best practices

  • Enforce coding guidelines: No request/session state in singletons or static fields.
  • Review cache policies: All caches must have max size and TTL; monitor sizes and eviction rates.
  • Adopt lifecycle-aware design: Dispose/unsubscribe in framework hooks (IHostedService StopAsync, Spring @PreDestroy, Node process signals).
  • Use weak references appropriately:
    • Java: WeakReference for listener registries; SoftReference sparingly for caches.
    • Node: WeakRef/FinalizationRegistry with caution and tests.
  • Integrate leak detection into CI:
    • Run long soak tests with heap snapshot diffs and automated leak suspects.
    • Track memory budgets per service with alerts.
  • Instrument observability:
    • Export heap, GC, and RSS metrics; alert on sustained upward trends.
  • Practice resource hygiene:
    • try-with-resources (Java), using (C#), context managers (Python).
  • Validate dependency injection scopes: Avoid capturing scoped services in singletons.
  • Container alignment:
    • Set JVM/Node/.NET to respect cgroup limits; right-size memory requests/limits.
  • Regular dependency updates: Some leaks come from libraries; follow release notes and CVEs.

Example Fixes by Platform

Java

Unregister listeners:
java
class Listener implements SomeListener {
public void close() { source.removeListener(this); }
}

// On shutdown:
listener.close();

Deregister JDBC drivers on undeploy:
java
Enumeration drivers = DriverManager.getDrivers();
while (drivers.hasMoreElements()) {
Driver d = drivers.nextElement();
if (d.getClass().getClassLoader() == appClassLoader) {
DriverManager.deregisterDriver(d);
}
}

See also  How to Fix CFDocument PDF Rendering Issues

Scheduler cleanup:
java
ScheduledExecutorService ses = Executors.newScheduledThreadPool(2);
// on shutdown:
ses.shutdown();
ses.awaitTermination(30, TimeUnit.SECONDS);

.NET

Unsubscribe and dispose:
csharp
public sealed class Worker : IDisposable {
private readonly Publisher _publisher;
public Worker(Publisher p) {
_publisher = p;
_publisher.SomeEvent += OnSomeEvent;
}
private void OnSomeEvent(object? sender, EventArgs e) { // }
public void Dispose() {
_publisher.SomeEvent -= OnSomeEvent;
}
}

Bounded MemoryCache:
csharp
var options = new MemoryCacheOptions { SizeLimit = 10000 }; // units defined by entries
var cache = new MemoryCache(options);
cache.Set(“key”, value, new MemoryCacheEntryOptions { Size = 1, SlidingExpiration = TimeSpan.FromMinutes(30) });

Node.js

Remove event listeners; prefer WeakMap for metadata:
js
const {EventEmitter} = require(‘events’);
const emitter = new EventEmitter();
function handler(msg) { // }

emitter.on(‘data’, handler);
try {
// work
} finally {
emitter.off(‘data’, handler); // ensure removal
}

const meta = new WeakMap();
function attach(obj, info) { meta.set(obj, info); }

LRU cache with eviction:
js
const LRU = require(‘lru-cache’);
const cache = new LRU({ max: 10000, ttl: 1000 60 30 });

Python

Context managers and weak references:
python
from contextlib import closing
import weakref

with closing(open(‘data.txt’)) as f:
data = f.read()

cache = weakref.WeakKeyDictionary()
class Foo: pass
obj = Foo()
cache[obj] = “metadata” # does not prevent obj from being GC’d

Bounded cache:
python
from cachetools import LRUCache
cache = LRUCache(maxsize=10000)

Key Takeaways or Summary Points

  • Application-scope leaks arise when long-lived holders retain references to short-lived data, preventing GC.
  • Confirm with evidence: repeated heap snapshots, dominator trees, and paths to GC roots—don’t guess.
  • Fixes focus on lifecycle hygiene: unsubscribe, dispose, cancel, clear ThreadLocals, and bound caches.
  • Native memory and classloader issues are common in production; monitor RSS and not just heap.
  • Bake prevention into design and CI: enforce cache limits, use weak references judiciously, and automate soak tests and alerts.

FAQ

How do I tell a memory leak from legitimate cache growth?

Watch for monotonic growth across idle periods and after full GCs. A healthy cache stabilizes near a steady-state size and responds to eviction policies. A leak shows increasing retained size and growing dominator sets when you compare multiple snapshots over time.

Why does memory not drop after GC even after fixing the leak?

Allocators and runtime heaps may not return memory to the OS immediately. RSS can remain elevated due to fragmentation or retained arenas. Validate that heap usage stabilizes between GCs, and allow time or restart during Maintenance windows if needed. Also check native memory (direct buffers, images).

Can a memory leak happen without increased heap usage?

Yes. Native Memory leaks (direct ByteBuffers, file handles, unmanaged libraries) can increase RSS while the managed heap looks stable. Monitor process RSS, open file descriptors, and native buffer allocations alongside managed heap metrics.

What monitoring thresholds should I alert on?

Alert when sustained used heap exceeds, for example, 70–80% of max for more than N minutes, GC pause time exceeds SLOs, RSS approaches 90% of container limit, or cache size approaches configured max repeatedly. Calibrate thresholds with real traffic patterns.

Do weak references solve all listener leaks?

No. Weak references help avoid retaining objects solely by listener registries, but they can introduce subtle bugs if the listener is collected unexpectedly. Prefer explicit unsubscribe/dispose tied to lifecycle, using weak references selectively where appropriate.

About the author

Aaron Longnion

Aaron Longnion

Hey there! I'm Aaron Longnion — an Internet technologist, web software engineer, and ColdFusion expert with more than 24 years of experience. Over the years, I've had the privilege of working with some of the most exciting and fast-growing companies out there, including lynda.com, HomeAway, landsofamerica.com (CoStar Group), and Adobe.com.

I'm a full-stack developer at heart, but what really drives me is designing and building internet architectures that are highly scalable, cost-effective, and fault-tolerant — solutions built to handle rapid growth and stay ahead of the curve.