A comprehensive collection of 24 Java memory leak examples for hands-on workshop training. Each leak demonstrates a real-world pattern that causes memory issues in production Java applications.
mvn spring-boot:run
# Open http://localhost:8080Requires: Java 21+, Maven 3.8+
Take a heap dump while the app is running:
jmap -dump:format=b,file=heap.hprof $(jps -l | grep MemoryApp | awk '{print $1}')Open in Eclipse MAT (Memory Analyzer Tool) or use the MAT MCP server in mat-mcp/ for AI-assisted analysis.
JVM flags for automatic dumps on OutOfMemoryError:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/
A 100MB DTO is fetched from an external API. Only a small field is needed, but the entire DTO stays on the stack during a 30-second processing step.
Root cause: Large objects held in local variables longer than necessary. Fix: Extract only what you need into a smaller object. Call external APIs through Adapter classes that return minimal DTOs.
A Calculator inner class implicitly holds a reference to its enclosing CalculatorFactory, which owns a 20MB field. Even though only Calculator is passed around, the factory (and its 20MB) stays alive.
Root cause: Non-static inner classes hold an implicit this$0 reference to the enclosing instance. Anonymous classes (new Interface() {}) and double-brace initialization (new HashMap<>() {{ put(...); }}) do the same.
Fix: Use static nested classes. Move classes to separate .java files. Prefer lambdas over anonymous classes (lambdas only capture what they use, not the whole enclosing instance).
MAT finding: path_to_gc_roots shows: Big20MB -> CalculatorFactory -> Calculator -> Thread
A library registers a shutdown hook via Runtime.addShutdownHook() that captures a 20MB object. Shutdown hooks are held by the JVM until process exit.
Root cause: Shutdown hooks are GC roots — anything they reference lives forever. Fix: Avoid libraries that register shutdown hooks in server-side apps. If unavoidable, use reflection to clear the hooks.
MAT finding: dominator_tree shows java.lang.Class retaining 105MB (76.9% of heap).
A "last 10 items" list is maintained using list.subList(1, list.size()). SubList returns a view backed by the original list. As new items are added and subList is called again, a chain of views keeps all historical data alive.
Root cause: List.subList() does not copy — it creates a view referencing the original list.
Fix: Use new ArrayList<>(list.subList(...)) to copy. Or use a LinkedList/ArrayDeque for sliding window patterns.
MAT finding: OQL query SELECT s.parent.size FROM java.util.ArrayList$SubList s reveals parent lists growing to hundreds of elements despite only 10 being "visible."
DOM parser creates a full document tree. When individual Node objects are extracted and stored in a list, each node retains a reference to its parent Document, keeping the entire XML tree in memory.
Root cause: DOM Node objects reference their parent Document. Storing extracted nodes prevents the Document from being GC'd. Fix: Extract text values (Strings) from nodes instead of storing Node objects. Consider SAX/StAX parsing for large XML.
A ThreadLocal<RequestContext> stores a 20MB object per request. Tomcat reuses threads from a pool — the ThreadLocal data from a previous request stays attached to the thread forever.
Root cause: ThreadLocal data is bound to the thread, not the request. Pooled threads are never destroyed, so ThreadLocal data accumulates.
Fix: Always clean up with try { ... } finally { threadLocal.remove(); }. Prefer framework-managed context: MDC, SecurityContextHolder, Baggage.
MAT finding: thread_overview shows 10 Tomcat threads each retaining ~21MB with 7-10 ThreadLocal entries. path_to_gc_roots: Big20MB -> RequestContext -> ThreadLocalMap$Entry -> ThreadLocalMap -> TaskThread
An external library creates a ThreadLocal cache on first use. With Virtual Threads (one per request), each virtual thread gets its own 100KB cache — defeating the purpose of lightweight threads.
Root cause: Libraries designed for pooled threads assume ThreadLocal data is reused. Virtual Threads break this assumption. Fix: Upgrade the library. If not possible, use reflection to clean up the library's ThreadLocal after each call.
Executors.newFixedThreadPool(2) is created per request but never shutdown(). Worker threads live forever even after the executor is no longer referenced.
Root cause: Thread pools keep their worker threads alive. Without shutdown(), threads are GC roots.
Fix: Use try-with-resources on ExecutorService (Java 19+). Better: inject a singleton Spring-managed ThreadPoolTaskExecutor.
Each request downloads 10MB of data and submits it to CompletableFuture.runAsync() which uses ForkJoinPool.commonPool(). The common pool has an unbounded work queue — when requests arrive faster than workers process them, 10MB payloads pile up in the queue.
Root cause: ForkJoinPool.commonPool() has no queue size limit. Large objects retained in queued tasks.
Fix: Use a dedicated ExecutorService with a bounded queue and rejection policy. Offload large payloads to disk/S3 before async processing.
MAT finding: dominator_tree shows 5 ForkJoinTask[] arrays retaining 15%, 12%, 6%, 6%, 6% of heap = 157MB in queued tasks.
Two services with synchronized methods call each other. Thread A holds lock on Service1, waits for Service2. Thread B holds lock on Service2, waits for Service1. Classic circular deadlock.
Root cause: Bidirectional synchronized calls between objects.
Fix: Avoid synchronized methods on service classes. Use explicit locks with timeout. Eliminate bidirectional coupling.
Two ConcurrentHashMap instances with compute() calls that reference each other. Deadlock when both threads try to modify both maps simultaneously.
Root cause: ConcurrentHashMap.compute() holds a lock on the bucket — passing lambdas that access other synchronized structures creates lock ordering issues.
Fix: Don't pass complex lambdas to synchronized collection methods.
All Tomcat worker threads blocked on a 20-second sleep. New requests (including liveness probes) queue up. Kubernetes kills the container because the health check times out.
Root cause: Long-running synchronous operations exhaust the thread pool. Fix: Use a Semaphore to limit concurrent long operations. Consider Virtual Threads. Move long work to async processing.
An async method running on a 3-thread executor submits 3 parallel sub-tasks to the same executor. All 3 worker threads are now waiting for their sub-tasks, but no threads are available to run them.
Root cause: Submitting work to the executor you're already running on. Fix: Use a separate executor for sub-tasks, or use Virtual Threads.
An idempotency filter stores every request's key in a Set<String> to prevent duplicate processing. The set grows forever — no eviction, no TTL.
Root cause: Unbounded in-memory cache with no eviction policy. Fix: Use Caffeine or Redis with TTL-based eviction. For idempotency, 30-60 second TTL is typically sufficient.
Four variants of caching gone wrong:
- DIY cache with no eviction —
Map<LocalDate, Big20MB>grows forever - @Cacheable signature change — adding a parameter changes the cache key, creating duplicate entries
- Object key without equals/hashCode — every lookup is a cache miss
- Mutable entity as cache key — entity is modified after being used as key, corrupting the cache
Root cause: Cache key design is critical and subtle. Framework caching (@Cacheable) hides key generation logic.
Fix: Use immutable keys (records). Monitor cache hit/miss ratio. Write tests that verify caching behavior. Set max size and TTL on all caches.
MAT finding: dominator_tree shows Caffeine BoundedLocalManualCache retaining 461MB (94.2% of heap).
Micrometer timer created with a UUID as a tag value. Every unique UUID creates a new metric time series. Metrics never expire.
Root cause: High-cardinality tag values (UUIDs, user IDs, URLs) create unbounded metric expansion. Fix: Only use bounded tag values (status codes, method names, service names). Never use request IDs or user IDs as metric tags.
Streaming entities from the database within a @Transactional method. Hibernate keeps a copy of every loaded @Entity in the 1st-level cache (persistence context). Streaming 600MB of data = 600MB in cache.
Root cause: Hibernate's persistence context is unbounded within a transaction.
Fix: Call entityManager.detach(entity) after processing each entity. Or use a StatelessSession. Process in batches with periodic entityManager.clear().
Dynamic plugin loading creates a new URLClassLoader per upload. Old plugins are never properly unloaded — their classes hold static fields with large arrays, and static Timer threads prevent GC of the classloader.
Root cause: ClassLoader cannot be GC'd if any of its classes are still referenced (by threads, static fields, or other classloaders). Fix: Stop all threads, clear static fields, and null out all references before discarding a classloader.
Database connection obtained but not returned to the pool if an exception occurs before the finally block.
Root cause: Resource not closed on error paths. Fix: Use try-with-resources for all closeable resources (connections, streams, clients).
ByteBuffer.allocateDirect() allocates memory outside the Java heap. Direct buffers are limited by -XX:MaxDirectMemorySize and subject to fragmentation. Not visible in heap dumps.
Root cause: Direct memory managed outside GC. Fragmentation prevents reuse of freed space.
Fix: Pool direct buffers. Monitor with -XX:NativeMemoryTracking=detail. Use jcmd VM.native_memory.
Alternating allocate/free pattern on direct buffers creates gaps that larger subsequent allocations can't fill.
Root cause: External fragmentation in native memory allocator. Fix: Allocate uniform-sized buffers. Use buffer pools. Monitor RSS vs heap size.
XML entity expansion attack. Recursive entity definitions expand exponentially, consuming gigabytes of memory from a tiny XML file.
Root cause: XML parser processes external entities and recursive definitions without limits.
Fix: Disable DTD processing: factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true). Set jdk.xml.entityExpansionLimit.
Anonymous MouseListener added to a main frame captures a reference to a child frame. When the child frame is closed, the listener keeps it alive.
Root cause: Event listeners create hidden references. Closing a window doesn't remove listeners registered on other windows.
Fix: Remove listeners in WindowListener.windowClosed(). Use weak references for observers.
User preferences (100 x 1KB objects) stored in HTTP session. Under load with 4000 concurrent users, server memory is exhausted by session data.
Root cause: Storing large objects in HTTP sessions. Sessions created for every request (including anonymous/API calls). Fix: REST APIs should be stateless — use JWT tokens. If sessions are needed, push them to Redis/database. Set aggressive session timeouts.
The standard tool for heap dump analysis. Key views:
- Histogram — classes sorted by instance count and memory usage
- Dominator Tree — what objects retain the most memory
- Path to GC Roots — why an object can't be garbage collected
- OQL — SQL-like queries against heap objects
An AI-powered heap dump analyzer. See mat-mcp/README.md for setup.
# List Java processes
jps -l
# Take heap dump
jmap -dump:format=b,file=heap.hprof <PID>
# Thread dump (deadlock detection)
jstack <PID>
# Native memory tracking
jcmd <PID> VM.native_memory summary- Retained Heap is the metric that matters — it's "how much memory would be freed if this object were GC'd"
- Dominator Tree is your first stop — it shows the biggest memory retainers instantly
- Path to GC Roots answers "WHY can't this object be garbage collected?"
- ThreadLocal + Thread Pools is the most common production leak pattern
- Unbounded caches are the second most common — always set max size and TTL
- Inner classes create invisible references to enclosing instances
- Off-heap memory (direct buffers, native memory) won't show in heap dumps
- Framework magic (@Cacheable, @Async, @Transactional) hides memory implications