Loading content...
When garbage-collected languages emerged, many developers believed memory management problems were a thing of the past. After all, if the runtime automatically reclaims unused memory, how could memory leaks occur?
The reality is more nuanced. While garbage collection eliminates entire classes of bugs—dangling pointers, double-frees, and simple forgets—it doesn't prevent logical memory leaks. These occur when objects remain reachable (and thus uncollectable) even though the application no longer needs them. The garbage collector is doing its job perfectly; the problem is that the application hasn't communicated its intent.
Memory leaks in managed languages are often subtler and harder to detect than their counterparts in manual memory management. They accumulate silently, degrading performance over time, until the application exhausts available memory and crashes—often in production, at the worst possible moment.
By the end of this page, you will understand the difference between true leaks and logical leaks, recognize common patterns that cause memory leaks in GC'd languages, learn detection and debugging strategies, and master prevention techniques that keep your applications memory-healthy.
In traditional systems programming (C, C++), a memory leak is straightforward: memory that was allocated but never freed, with no remaining pointer to access it. The memory is truly 'lost'—unavailable to the program and unrecoverable without restarting.
In managed languages, the definition shifts:
A memory leak in a GC'd language occurs when objects remain reachable (and thus cannot be collected) but are no longer needed by the application. The garbage collector sees these objects as legitimate live data. The bug isn't in the GC—it's in the application logic that maintains unnecessary references.
| Type | Cause | GC's View | Detection Difficulty |
|---|---|---|---|
| True Leak (C/C++) | Allocated memory never freed | N/A (no GC) | Tools like Valgrind detect easily |
| Logical Leak (Managed) | Unneeded objects still reachable | Objects appear legitimate | Requires application knowledge |
| Unbounded Growth | Data structures grow without limit | All objects are 'in use' | Manifests as OOM over time |
| External Resource Leak | Native handles, file descriptors, etc. | Wrapper object may be collected | Often missed by memory tools |
The Sneaky Nature of Logical Leaks:
Logical leaks are insidious because:
Many memory leaks only manifest in production. Development cycles are short—you restart frequently, never noticing gradual growth. Production runs for days or weeks, and a 1MB/hour leak becomes a 24MB/day problem. After a month, you're missing 720MB. After a few months, OOM.
Certain coding patterns repeatedly cause memory leaks across different managed languages. Recognizing these patterns is the first step to prevention.
Pattern 1: Unbounded Collections
The most common leak: adding to a collection without ever removing.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// ❌ MEMORY LEAK: Cache with no eviction policy class UserSessionCache { private sessions: Map<string, UserSession> = new Map(); addSession(userId: string, session: UserSession): void { this.sessions.set(userId, session); // Sessions are never removed! // After months of operation: hundreds of thousands of entries } getSession(userId: string): UserSession | undefined { return this.sessions.get(userId); } // No method to remove expired sessions // No size limit // No eviction policy} // ✅ FIXED: Bounded cache with TTL and eviction class UserSessionCacheFixed { private sessions: Map<string, { session: UserSession; expiresAt: number }> = new Map(); private readonly maxSize = 10000; private readonly ttlMs = 30 * 60 * 1000; // 30 minutes addSession(userId: string, session: UserSession): void { this.evictExpired(); // Clean up on every write if (this.sessions.size >= this.maxSize) { this.evictOldest(); // Enforce size limit } this.sessions.set(userId, { session, expiresAt: Date.now() + this.ttlMs }); } getSession(userId: string): UserSession | undefined { const entry = this.sessions.get(userId); if (!entry) return undefined; if (Date.now() > entry.expiresAt) { this.sessions.delete(userId); // Lazy expiration return undefined; } return entry.session; } private evictExpired(): void { const now = Date.now(); for (const [key, entry] of this.sessions) { if (now > entry.expiresAt) { this.sessions.delete(key); } } } private evictOldest(): void { const oldest = [...this.sessions.entries()] .sort((a, b) => a[1].expiresAt - b[1].expiresAt)[0]; if (oldest) { this.sessions.delete(oldest[0]); } }}Pattern 2: Event Listener / Observer Leaks
Subscribing to events without unsubscribing keeps both the subscriber and publisher alive.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// ❌ MEMORY LEAK: Event handlers not removed class DataVisualization { private chart: ChartInstance; constructor(private dataSource: DataSource) { // Adding listener creates reference from dataSource -> this this.dataSource.on('dataUpdate', this.handleDataUpdate.bind(this)); this.chart = createChart(); } private handleDataUpdate(data: ChartData): void { this.chart.update(data); } // No cleanup method! // When DataVisualization should be "discarded", the listener // keeps it alive as long as dataSource exists} // Usage that leaks:function createAndDestroyCharts() { for (let i = 0; i < 1000; i++) { const viz = new DataVisualization(globalDataSource); // viz goes out of scope but is NOT garbage collected // because globalDataSource still holds reference via listener } // 1000 DataVisualization objects still in memory!} // ✅ FIXED: Explicit cleanup with dispose pattern class DataVisualizationFixed { private chart: ChartInstance; private boundHandler: (data: ChartData) => void; private disposed = false; constructor(private dataSource: DataSource) { this.boundHandler = this.handleDataUpdate.bind(this); this.dataSource.on('dataUpdate', this.boundHandler); this.chart = createChart(); } private handleDataUpdate(data: ChartData): void { if (this.disposed) return; this.chart.update(data); } dispose(): void { if (this.disposed) return; this.disposed = true; // CRITICAL: Remove the listener to break the reference this.dataSource.off('dataUpdate', this.boundHandler); this.chart.destroy(); }} // Usage without leaks:function createAndDestroyChartsSafe() { const visualizations: DataVisualizationFixed[] = []; for (let i = 0; i < 1000; i++) { visualizations.push(new DataVisualizationFixed(globalDataSource)); } // When done, clean up for (const viz of visualizations) { viz.dispose(); // Now they can be garbage collected } visualizations.length = 0;}Pattern 3: Closures Capturing Context
Closures invisibly capture their surrounding scope. If that scope contains large objects, they're retained as long as the closure exists.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
// ❌ MEMORY LEAK: Closure captures large object unnecessarily function processLargeDataset(data: HugeDataset): () => number { // 'data' is captured by the closure even though we only need length const processingSummary = expensiveComputation(data); // This closure captures 'data' (the entire dataset!) // even though it only uses processingSummary return function getResultCount(): number { console.log(`Processed dataset with ${data.length} items`); // 'data' captured! return processingSummary.resultCount; };} const getSummary = processLargeDataset(loadGigabyteDataset());// The gigabyte dataset is now stuck in memory forever,// referenced by the closure returned and stored in getSummary // ✅ FIXED: Extract only what's needed before closure function processLargeDatasetFixed(data: HugeDataset): () => number { const dataLength = data.length; // Extract primitive const processingSummary = expensiveComputation(data); // Now 'data' is not referenced by the closure return function getResultCount(): number { console.log(`Processed dataset with ${dataLength} items`); // Only primitive captured return processingSummary.resultCount; };} // ✅ ALTERNATIVE: Nullify after use function processLargeDatasetAlt(inputData: HugeDataset): () => number { let data: HugeDataset | null = inputData; const dataLength = data.length; const processingSummary = expensiveComputation(data); data = null; // Explicitly break reference return function getResultCount(): number { console.log(`Processed dataset with ${dataLength} items`); return processingSummary.resultCount; };}Pattern 4: Static Fields and Singletons
Static fields live for the entire lifetime of the application. Any object reachable from a static field is never collected.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// ❌ MEMORY LEAK: Static collection accumulates forever class MetricsCollector { private static instance: MetricsCollector; private metrics: Metric[] = []; // Never cleaned! static getInstance(): MetricsCollector { if (!this.instance) { this.instance = new MetricsCollector(); } return this.instance; } recordMetric(metric: Metric): void { this.metrics.push(metric); // Grows forever }} // Every metric ever recorded stays in memory // ✅ FIXED: Use circular buffer or flush/aggregate pattern class MetricsCollectorFixed { private static instance: MetricsCollectorFixed; private metrics: Metric[] = []; private readonly maxMetrics = 10000; private readonly flushInterval = 60000; // 1 minute private constructor() { setInterval(() => this.flush(), this.flushInterval); } static getInstance(): MetricsCollectorFixed { if (!this.instance) { this.instance = new MetricsCollectorFixed(); } return this.instance; } recordMetric(metric: Metric): void { if (this.metrics.length >= this.maxMetrics) { this.flush(); // Force flush when buffer full } this.metrics.push(metric); } private flush(): void { if (this.metrics.length === 0) return; // Send metrics to external system sendToMetricsBackend(this.metrics); // Clear the buffer this.metrics = []; // New array, old one becomes garbage }}Pattern 5: Thread Local Storage Without Cleanup
Thread-local data survives as long as the thread lives. In thread pools, threads are reused, and their thread-locals accumulate.
123456789101112131415161718192021222324252627282930313233343536
// ❌ MEMORY LEAK: ThreadLocal not cleaned in pooled threads public class RequestProcessor { private static final ThreadLocal<RequestContext> contextHolder = new ThreadLocal<>(); public void processRequest(HttpRequest request) { // Set context for this request contextHolder.set(new RequestContext(request)); try { doProcessing(); } finally { // MISSING: contextHolder.remove(); // In a thread pool, this thread will be reused // The RequestContext stays attached to the thread } }} // ✅ FIXED: Always remove ThreadLocal in finally block public class RequestProcessorFixed { private static final ThreadLocal<RequestContext> contextHolder = new ThreadLocal<>(); public void processRequest(HttpRequest request) { contextHolder.set(new RequestContext(request)); try { doProcessing(); } finally { contextHolder.remove(); // CRITICAL: Clean up! } }}Pattern 6: Inner Class Implicit References
In languages like Java, non-static inner classes hold an implicit reference to their outer class instance.
1234567891011121314151617181920212223242526272829303132333435363738394041
// ❌ MEMORY LEAK: Inner class keeps outer class alive public class LargeContainer { private byte[] hugeData = new byte[100 * 1024 * 1024]; // 100MB // Non-static inner class has implicit reference to LargeContainer public class SmallCallback implements Callback { @Override public void onComplete() { System.out.println("Done!"); // This callback keeps 'LargeContainer.this' and its 100MB alive } } public Callback createCallback() { return new SmallCallback(); }} // Usage that leaks:Callback callback = new LargeContainer().createCallback();// The LargeContainer goes "out of scope" but 100MB stays// because callback holds implicit reference to it // ✅ FIXED: Use static inner class or lambda without captures public class LargeContainerFixed { private byte[] hugeData = new byte[100 * 1024 * 1024]; // Static inner class: NO implicit reference to outer class public static class SmallCallback implements Callback { @Override public void onComplete() { System.out.println("Done!"); } } public Callback createCallback() { return new SmallCallback(); // Safe - no reference to this }}Finding memory leaks requires a systematic approach combining monitoring, profiling, and analysis. Different techniques are appropriate for different stages of development and production.
Detection Strategy Overview:
| Approach | When to Use | Tools | Effectiveness |
|---|---|---|---|
| Heap usage monitoring | Production, development | APM tools, metrics dashboards | Detects symptom, not cause |
| Heap dump analysis | Investigation, debugging | VisualVM, Eclipse MAT, dotMemory | Highly effective for diagnosis |
| Allocation profiling | Development, testing | JFR, async-profiler, Chrome DevTools | Identifies allocation hot spots |
| GC log analysis | Production investigation | GCViewer, GCEasy | Shows GC behavior over time |
| Automated leak detection | CI/CD, regression testing | LeakCanary (Android), Jest leaks | Catches regressions early |
Step-by-Step Leak Investigation:
12345678910111213141516171819202122232425
# JVM: Trigger heap dump on OutOfMemoryErrorjava -XX:+HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath=/var/dumps/ \ -jar application.jar # JVM: Manually trigger heap dump (using jcmd)jcmd <pid> GC.heap_dump /tmp/heapdump.hprof # JVM: Alternative with jmapjmap -dump:format=b,file=/tmp/heapdump.hprof <pid> # Node.js: Generate heap snapshotnode --inspect app.js# Then use Chrome DevTools -> Memory -> Take snapshot # Node.js: Programmatic heap dumpconst v8 = require('v8');const fs = require('fs');const snapshotPath = v8.writeHeapSnapshot(); // Returns path # .NET: Using dotnet-dumpdotnet-dump collect -p <pid> -o /tmp/coredump # Analyze with Eclipse Memory Analyzer (MAT)# Open .hprof file -> Leak Suspects Report -> Dominator Tree12345678910111213141516171819202122232425262728
LEAK SUSPECTS REPORT════════════════════ Problem Suspect 1:──────────────────The class "com.example.SessionCache", loaded by "sun.misc.Launcher$AppClassLoader @ 0x725c70058", occupies 256,453,216 (45.23%) bytes. Keywords: com.example.SessionCache java.util.HashMap$Node[] com.example.UserSession Shortest Paths from GC Roots:──────────────────────────── com.example.SessionCache @ 0x72601fd00 ↑ sessions (field) of com.example.Application$1 @ 0x7260202e8 ↑ <Java Local> (Thread: main) Interpretation:───────────────- SessionCache holds 45% of heap- Contents are HashMap entries containing UserSession objects- Reachable from main thread via Application singleton- Likely cause: Sessions added but never removed/expiredThe dominator tree shows which objects keep others alive. Object A dominates object B if freeing A would make B unreachable. Look for objects with unexpectedly high 'retained size' (the memory that would be freed if this object were removed)—these are your leak suspects.
Catching memory leaks before they cause outages requires proactive monitoring. Set up metrics and alerts that surface problems early.
Key Metrics to Monitor:
| Metric | What It Indicates | Alert Threshold |
|---|---|---|
| Heap usage (absolute) | Current memory consumption | 80% of max heap |
| Heap usage trend | Growing over time = potential leak | Increasing baseline over 24h |
| GC frequency | How often GC runs | N collections/minute |
| GC pause time | Application stalls | 200ms pause time (for latency-sensitive) |
| GC time ratio | % time in GC vs application | 5% time in GC |
| Old Gen usage after Full GC | Memory that survives Full GC | Increasing over time |
| Object allocation rate | How fast memory is consumed | Anomalous spikes |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667
// Node.js memory monitoring example import { PerformanceObserver, performance } from 'perf_hooks'; class MemoryMonitor { private readonly intervalMs = 30000; // 30 seconds private heapHistory: number[] = []; private readonly historySize = 120; // 1 hour of data start(): void { setInterval(() => this.collectMetrics(), this.intervalMs); } private collectMetrics(): void { const memUsage = process.memoryUsage(); // Emit metrics (to your monitoring system) this.emit('memory.heap_used', memUsage.heapUsed); this.emit('memory.heap_total', memUsage.heapTotal); this.emit('memory.external', memUsage.external); this.emit('memory.rss', memUsage.rss); // Track trend this.heapHistory.push(memUsage.heapUsed); if (this.heapHistory.length > this.historySize) { this.heapHistory.shift(); } // Check for leak pattern if (this.detectLeakPattern()) { this.alert('Potential memory leak detected: heap consistently increasing'); } } private detectLeakPattern(): boolean { if (this.heapHistory.length < 20) return false; // Compare first half to second half average const mid = Math.floor(this.heapHistory.length / 2); const firstHalf = this.heapHistory.slice(0, mid); const secondHalf = this.heapHistory.slice(mid); const firstAvg = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length; const secondAvg = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length; // Alert if second half is >20% higher than first half return secondAvg > firstAvg * 1.2; } private emit(metric: string, value: number): void { // Send to Prometheus, Datadog, CloudWatch, etc. console.log(`METRIC: ${metric} = ${value}`); } private alert(message: string): void { // Send to PagerDuty, Slack, etc. console.error(`ALERT: ${message}`); }} // GC observation (Node.js 12+)const gcObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(`GC: ${entry.name} took ${entry.duration}ms`); }});gcObserver.observe({ entryTypes: ['gc'] });Healthy GC behavior shows a 'sawtooth' pattern: memory rises as objects allocate, drops sharply when GC runs, rises again. A leak shows the baseline (post-GC level) rising over time—each GC reclaims less because more objects are retained.
Prevention is far better than detection after the fact. Following certain architectural patterns and coding practices dramatically reduces leak risk.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
// Pattern: Centralized resource lifecycle management interface Disposable { dispose(): void;} class ResourceManager implements Disposable { private resources: Set<Disposable> = new Set(); private disposed = false; register<T extends Disposable>(resource: T): T { if (this.disposed) { throw new Error('ResourceManager already disposed'); } this.resources.add(resource); return resource; } dispose(): void { if (this.disposed) return; this.disposed = true; for (const resource of this.resources) { try { resource.dispose(); } catch (error) { console.error('Error disposing resource:', error); } } this.resources.clear(); }} // Usage: Request-scoped resource managementasync function handleRequest(request: Request): Promise<Response> { const resources = new ResourceManager(); try { // All resources registered here will be cleaned up const dbConnection = resources.register(await getConnection()); const fileHandle = resources.register(await openTempFile()); const eventSub = resources.register( eventEmitter.subscribe('data', handler) ); // Process request... const result = await processWithResources(dbConnection, fileHandle); return new Response(result); } finally { // Guaranteed cleanup, even on exception resources.dispose(); }}123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// Using WeakMap/WeakRef for caches that shouldn't prevent GC // WeakMap: Keys can be garbage collectedclass ComputationCache { // When the key object is GC'd, the cache entry disappears private cache = new WeakMap<object, ComputedResult>(); getOrCompute(key: object, compute: () => ComputedResult): ComputedResult { let result = this.cache.get(key); if (!result) { result = compute(); this.cache.set(key, result); } return result; }} // WeakRef: Values can be garbage collected (more explicit)class SoftCache<K, V extends object> { private cache = new Map<K, WeakRef<V>>(); private cleanupRegistry = new FinalizationRegistry<K>((key) => { // Called when the value is garbage collected this.cache.delete(key); }); set(key: K, value: V): void { const ref = new WeakRef(value); this.cache.set(key, ref); this.cleanupRegistry.register(value, key); } get(key: K): V | undefined { const ref = this.cache.get(key); if (!ref) return undefined; const value = ref.deref(); // May return undefined if GC'd if (!value) { this.cache.delete(key); // Clean up stale entry } return value; }} // LRU Cache with strict size limitclass LRUCache<K, V> { private cache = new Map<K, V>(); constructor(private readonly maxSize: number) {} get(key: K): V | undefined { const value = this.cache.get(key); if (value !== undefined) { // Move to end (most recently used) this.cache.delete(key); this.cache.set(key, value); } return value; } set(key: K, value: V): void { if (this.cache.has(key)) { this.cache.delete(key); } else if (this.cache.size >= this.maxSize) { // Evict oldest (first entry) const oldestKey = this.cache.keys().next().value; this.cache.delete(oldestKey); } this.cache.set(key, value); }}WeakRef and FinalizationRegistry are powerful but complex. The GC makes no guarantees about when finalization callbacks run—they may be significantly delayed. Don't rely on them for resource cleanup (use explicit dispose patterns for that); use them for memory optimization in caches.
Integrate memory-awareness into your code review process. The following checklist helps catch leaks before they reach production.
add() without corresponding remove()on() without corresponding off()ThreadLocal without try/finallydispose() or close() methodsWe've explored how memory leaks manifest in garbage-collected languages and how to combat them. Let's consolidate the key insights:
What's Next:
We've covered traditional memory management and its pitfalls. The next page examines Weak References—a specialized tool that enables sophisticated memory patterns like caches, canonicalization maps, and observer lists that don't prevent garbage collection.
You now understand how memory leaks occur in managed languages, can recognize common patterns, and know how to prevent, detect, and diagnose them. This knowledge is essential for building applications that remain healthy over extended production lifetimes.