33 min read
Engineering7,052 words

Memory Leaks in Java and Kotlin

What this is: a deep, practical reference for reasoning about, finding, and preventing memory leaks on the JVM — Java, Kotlin, and Spring Boot — with the trade-offs, failure modes, real tooling, and "how to sound senior" framing an interviewer at Staff+ level is actually probing for. Every example is meant to be sketchable on a whiteboard and defensible under follow-up questions. Code is Kotlin + Java, Spring Boot where it earns it.

Version anchor (facts change — say the year): Java 25 LTS (released 16 Sep 2025). PermGen was removed in Java 8 → class metadata now lives in native Metaspace. Virtual threads GA since Java 21 (JEP 444); Scoped Values finalized in Java 25 (JEP 506) — the ThreadLocal successor that matters for the leak story. JFR Old Object Sample (jdk.OldObjectSample) is the production leak-hunter. Container awareness (UseContainerSupport, cgroup-aware heap) is on by default since JDK 10+. Kotlin 2.x / kotlinx.coroutines 1.10+. Tool flags below are HotSpot; pin your JDK when you quote them.


0. The mental model (everything below is a corollary of this)#

One sentence carries the whole topic. Internalize it and you can derive every rule — and diagnose any leak — live in an interview:

A garbage collector frees the unreachable. A memory leak is therefore reachable garbage: an object you are logically done with, that the GC is nonetheless forbidden to reclaim because some live GC root still has a reference path to it. Every leak reduces to one question — "what root still points at this, and why?" — and every fix is the same shape: sever the edge, or bound its lifetime.

This reframes the entire topic away from the C/C++ intuition (malloc without free). On the JVM you almost never leak by forgetting to free; you leak by accidentally keeping alive. So the three things you must be fluent in are: (1) what counts as a GC root, (2) what a reference path from a root looks like, and (3) the tools that answer "show me the path from a root to this object" — because that is literally what a heap dump analyzer computes. Everything else is a catalog of the handful of ways an unwanted reference path gets created and stays created.

The one-liner to open with in an interview: "Java can't leak the way C leaks — the GC handles reclamation. What Java leaks is reachability: something you don't need is still reachable from a GC root. Find the root, cut the edge." Saying this first signals you understand the problem is about the object graph, not about free().


1. Core concepts — the foundation nobody can skip#

1.1 Reachability and GC roots#

An object is live (uncollectable) if and only if it is reachable — there exists a chain of references from some GC root to it. The GC starts from the roots, marks everything reachable, and reclaims the rest. So a leak is always "an unwanted reference chain from a root." The roots you must be able to name:

  • Local variables and operand stacks of live threads — anything on the stack of a running thread (including a parked/blocked one).
  • Active threads themselves — a Thread object that is alive is a root. This is why a leaked, never-shut-down ExecutorService or a runaway thread keeps its whole task graph alive and stops the JVM from exiting.
  • Static fields — a static field roots its value for the lifetime of the class, and a class lives as long as its class loader. Static collections are the #1 textbook leak for exactly this reason.
  • Loaded classes and their class loaders — pinning one class pins its loader, which pins every class that loader loaded. This is the mechanism behind Metaspace / redeploy leaks.
  • JNI / native references — global refs held by native code.
  • Monitors — objects currently used as locks by a thread, and thread-local values (via the thread, see §2.3).

The practical upshot: when you find a leaking object in a heap dump, you ask the tool for its path to GC roots, and the answer always terminates at one of the above. In practice the three that account for the overwhelming majority of real leaks are static fields, live (pooled) threads' ThreadLocals, and class loaders. Memorize those three families — your three canonical examples in §8 map one-to-one onto them.

1.2 What a leak actually is (and what it isn't)#

A true leak is unbounded, monotonic retention: the set of reachable-but-useless objects grows without limit as the program runs, so used heap after each full GC trends upward forever until OutOfMemoryError. Contrast three things that look like leaks but aren't, because distinguishing them is a senior signal:

  • A leak — heap-after-GC (the "floor") rises steadily over hours/days. The fix is to cut a reference.
  • Bloat / under-provisioning — footprint is high but bounded; it plateaus. The fix is a bigger heap or a smaller working set, not a code change.
  • Churn / allocation pressure — lots of short-lived garbage. The heap sawtooths hard and GC runs hot (high CPU in GC), but the floor is flat. Nothing is leaking; you have an allocation-rate problem. The fix is to allocate less, not to cut a reference.

The single most useful diagnostic picture: plot used heap immediately after each full GC. A flat (even if jagged) floor = healthy. A rising floor = leak. Get this graph before you touch a profiler; it tells you whether you're even hunting the right animal.

1.3 Reference strengths — your leak-prevention vocabulary#

java.lang.ref gives four reachability strengths. Knowing precisely when each is cleared is what lets you design a structure that can't leak.

StrengthCollected when…Canonical useTrap
Strong (a normal reference)only when unreachableeverythingthe only kind that causes leaks
Soft (SoftReference)under memory pressure, on a policy-driven schedule (not necessarily at the last moment)memory-sensitive cachesnot a real eviction policy — unpredictable (tuned by -XX:SoftRefLRUPolicyMSPerMB), can keep large graphs alive; prefer a real cache (Caffeine)
Weak (WeakReference, WeakHashMap)at the next GC once only weakly reachablecanonical keys, listeners, metadata maps, interningcleared aggressively — never use for data you still need
Phantom (PhantomReference + ReferenceQueue)after finalization, enqueued for post-mortem cleanupdeterministic native-resource cleanup (the Cleaner mechanism)you never get the referent back; only a cleanup signal

The ReferenceQueue ties them together: you register a weak/soft/phantom reference with a queue, and when the GC clears it, the reference is enqueued so you can run cleanup (e.g. remove the now-dead entry from a registry). This is the machinery under WeakHashMap and Cleaner.

kotlin
// Kotlin — a weak reference lets the referent die even while you hold the WeakReference. val ref = java.lang.ref.WeakReference(loadBigThing()) val stillThere: BigThing? = ref.get() // may be null after a GC — you must null-check every time
java
// Java — same semantics. A WeakReference never keeps its referent alive. WeakReference<BigThing> ref = new WeakReference<>(loadBigThing()); BigThing t = ref.get(); // null once GC has reclaimed it

1.4 The WeakHashMap value trap (a favorite depth check)#

WeakHashMap holds its keys weakly, so an entry disappears once the key is otherwise unreachable — perfect for "metadata keyed by an object I don't own." But the classic bug: the value is held strongly, and if the value (directly or transitively) references its own key, the key can never become weakly-reachable-only, so nothing is ever collected. You built a leak on top of the leak-proof collection.

java
WeakHashMap<Session, SessionStats> m = new WeakHashMap<>(); // LEAK if SessionStats holds a back-reference to its Session: m.put(session, new SessionStats(session)); // value.session strongly reaches the key -> never evicted

Fixes: don't let the value reference the key, or wrap the value in a WeakReference too. The interview-grade takeaway: "weak keys only help if the values don't strongly reach the keys."

1.5 Where memory actually lives — heap is not the whole story#

"Memory leak" in a JVM shop means one of several regions, and naming them apart is a strong Staff+ signal because most candidates only picture the Java heap:

  • Java heap — normal objects. Leaks here → OutOfMemoryError: Java heap space. Found with a heap dump.
  • Metaspace (native, replaced PermGen in Java 8) — class metadata. Leaks here → OutOfMemoryError: Metaspace, almost always a class loader leak (redeploys, dynamic proxies, script/JIT-compiled classes, aggressive bytecode generation).
  • Thread stacks (native, ~0.5–1 MB each) — thousands of leaked platform threads → unable to create native thread and large RSS with a flat heap.
  • Direct / off-heap buffers (DirectByteBuffer, memory-mapped files, Unsafe, Netty pooled buffers) — freed only when the owning Java object is GC'd (via a Cleaner), not deterministically. Leaks here → OutOfMemoryError: Direct buffer memory or just growing RSS.
  • Code cache (JIT-compiled code), GC structures, JNI allocations — smaller, but real.

The consequence that trips teams up in production: RSS (what the OS and the Kubernetes OOM-killer see) = heap + Metaspace + thread stacks + direct buffers + code cache + native. If your heap graph is flat but the pod keeps getting OOMKilled, the leak is not on the heap and a heap dump will show you nothing — you need Native Memory Tracking (§5.6). Internalize: heap-flat + RSS-rising ⇒ look off-heap.

1.6 The OutOfMemoryError family — read the message, it names the region#

OutOfMemoryError is not one error; the message tells you which subsystem ran out and therefore which tool to reach for.

MessageWhat it meansUsual causeFirst tool
Java heap spaceheap can't satisfy an allocationheap leak, or under-sized heapheap dump → MAT dominator tree
GC overhead limit exceeded>98% of time in GC, <2% heap recoveredheap nearly full → GC thrash (leak in late stage)heap dump; treat as a heap leak
Metaspaceclass-metadata region fullclass loader leak (redeploys, proxy/class generation)class-loader histogram, "duplicate classes" in MAT
unable to create new native threadOS refused a new threadthread leak (unbounded pools, threads never shut down) or ulimitthread dump, count threads
Direct buffer memorydirect-buffer pool exhaustedleaked DirectByteBuffer / Netty buffers, too-low MaxDirectMemorySizeNMT, BufferPoolMXBean
Requested array size exceeds VM limitsingle allocation > ~2^31a bug computing a size (not a slow leak)stack trace at throw

Two follow-up facts worth knowing: -XX:+HeapDumpOnOutOfMemoryError fires on JVM-thrown heap and Metaspace OOMs, but not on unable to create new native thread or Direct buffer memory (thrown by a different path), and not on a container/cgroup OOMKill (the kernel killing the process by memory — no Java error, no dump). And GC overhead limit exceeded can be disabled but that just converts it into a hang or a plain heap-space OOM; it's a symptom of a full heap, not a separate disease.


2. The classic leak catalog (dual-language, each: the code → which root → the fix)#

These are the patterns interviewers expect you to recognize instantly. For each, the discipline is: name the GC root that's doing the retaining, then fix.

2.1 Obsolete object references — the canonical leak (Effective Java, Item 7)#

A hand-rolled stack that manages its own array is the textbook example. pop() decrements the size but leaves the popped slot pointing at the object — the array (reachable from the live Stack) keeps it alive forever even though it's logically gone.

java
// Java — the leak is the missing null-out. The array element is an obsolete reference. public class Stack { private Object[] elements; private int size = 0; public Stack(int cap) { elements = new Object[cap]; } public void push(Object e) { ensureCapacity(); elements[size++] = e; } public Object pop() { if (size == 0) throw new java.util.EmptyStackException(); Object result = elements[--size]; elements[size] = null; // <-- THE FIX: null the obsolete reference so GC can reclaim it return result; } }
kotlin
// Kotlin — identical shape; the fix is the same explicit null-out. class Stack(capacity: Int) { private var elements = arrayOfNulls<Any?>(capacity) private var size = 0 fun push(e: Any?) { ensureCapacity(); elements[size++] = e } fun pop(): Any? { if (size == 0) throw NoSuchElementException() val result = elements[--size] elements[size] = null // null the obsolete reference return result } // ensureCapacity() omitted }

Root: a static/instance field (the Stack) transitively holds the array, which holds the dead elements. Lesson (say this): "Whenever a class manages its own memory — its own array, its own pool, its own cache — the GC has no idea which slots are obsolete. Null them out on removal." Don't over-apply it: nulling references everywhere is noise; do it only where you own the backing store.

2.2 Unbounded caches and ever-growing static collections#

The most common real-world leak. A Map used as an ad-hoc cache with no maximum size and no expiry, especially keyed by something with an unbounded key space (user IDs, request IDs, generated keys). Every distinct key adds an entry that never leaves.

java
// Java — LEAK: unbounded cache on a singleton bean. One entry per distinct sku, forever. @Service public class PricingService { private final Map<String, Quote> cache = new ConcurrentHashMap<>(); public Quote quote(String sku) { return cache.computeIfAbsent(sku, this::expensiveQuote); } }
java
// Java — FIX: a bounded, expiring cache (Caffeine). Size cap + TTL = a leak with no countdown. @Service public class PricingService { private final com.github.benmanes.caffeine.cache.Cache<String, Quote> cache = com.github.benmanes.caffeine.cache.Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(java.time.Duration.ofMinutes(10)) .build(); public Quote quote(String sku) { return cache.get(sku, this::expensiveQuote); } }
kotlin
// Kotlin — FIX: same Caffeine bound. @Service class PricingService { private val cache: Cache<String, Quote> = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(10)) .build() fun quote(sku: String): Quote = cache.get(sku) { expensiveQuote(it) } }

Root: the static/singleton-held map field. The line to say: "An unbounded cache is a memory leak with a countdown timer. Every cache needs a bound — size, TTL, or both — decided by the access pattern, not left implicit." A static final Map is the same bug one scope up: the class loader roots it for the life of the app.

2.3 ThreadLocal never removed on a pooled thread (the servlet/Spring classic)#

The one that bites request/response frameworks. A ThreadLocal value is stored in the thread's own ThreadLocalMap. In a thread pool (Tomcat, @Async, any ExecutorService) the threads live for the life of the app, so the value survives after the request ends — until the same thread runs a task that overwrites it, or you call remove(). That's both a memory leak and a correctness/security bug (one request seeing another's data).

java
// Java — context stored in a ThreadLocal, cleaned in a finally. remove() is mandatory. public final class RequestContext { private static final ThreadLocal<UserData> CTX = new ThreadLocal<>(); public static void set(UserData u) { CTX.set(u); } public static UserData get() { return CTX.get(); } public static void clear() { CTX.remove(); } // <-- the leak fix AND the correctness fix } @Component public class ContextFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { try { RequestContext.set(extract(req)); chain.doFilter(req, res); } finally { RequestContext.clear(); // ALWAYS remove on the pooled thread } } }
kotlin
// Kotlin — same discipline; try/finally around the chain, remove() at the end. object RequestContext { private val ctx = ThreadLocal<UserData>() fun set(u: UserData) = ctx.set(u) fun get(): UserData? = ctx.get() fun clear() = ctx.remove() } @Component class ContextFilter : OncePerRequestFilter() { override fun doFilterInternal(req: HttpServletRequest, res: HttpServletResponse, chain: FilterChain) { try { RequestContext.set(extract(req)) chain.doFilter(req, res) } finally { RequestContext.clear() } } }

The mechanism (know this cold — it's a great follow-up answer): the ThreadLocalMap.Entry holds the key (the ThreadLocal object) as a WeakReference, but the value strongly. So even if the ThreadLocal itself becomes unreachable, its value lingers until the thread dies or the entry is expunged — and expunging only happens heuristically on later get/set/remove on that thread. On a long-lived pool thread that's effectively "forever." Root: the live pool thread. Fix: remove() in a finally.

Virtual-thread nuance (Staff+): with spring.threads.virtual.enabled=true (Boot 3.2+), each request runs on its own virtual thread that is created and discarded per task — not pooled — so the "stale value on a reused thread" failure mode largely evaporates. But don't lean on that for correctness, and note the flip side: ThreadLocal across millions of virtual threads is its own memory problem (each carries its own copy), which is exactly why ScopedValue (finalized Java 25) exists — an immutable, bounded-lifetime, structured replacement. The senior framing: "ThreadLocal leaks were a pooled-thread artifact; virtual threads change the failure mode, and ScopedValue is the successor for per-task context."

2.4 Listeners, callbacks, and observers never unregistered#

Register a listener with a long-lived subject (an event bus, a PropertyChangeSupport, a cache with removal listeners) but never unregister it, and the subject holds the listener — and everything the listener captures — for the subject's whole life.

java
// Java — FIX pattern: registration returns a handle the caller closes (symmetry by construction). public class PriceTicker { private final List<PriceListener> listeners = new java.util.concurrent.CopyOnWriteArrayList<>(); public AutoCloseable register(PriceListener l) { listeners.add(l); return () -> listeners.remove(l); // try-with-resources / explicit close removes it } }
kotlin
// Kotlin — same handle-based deregistration. class PriceTicker { private val listeners = CopyOnWriteArrayList<PriceListener>() fun register(l: PriceListener): AutoCloseable { listeners.add(l) return AutoCloseable { listeners.remove(l) } } }

Alternative: hold listeners in a WeakHashMap-backed set so an abandoned subscriber can be GC'd — but only if the subject tolerates silent disappearance and the values don't strongly reach the keys (§1.4). Root: the long-lived subject's collection. The line: "Every register needs a matching unregister on a deterministic lifecycle boundary; if you can't guarantee that, hold the listener weakly."

2.5 Unclosed resources — streams, connections, executors#

Not closing Closeable/AutoCloseable resources leaks whatever they hold — and for connection pools it manifests not as heap OOM but as pool exhaustion ("connection is not available"). Use try-with-resources (Java) / use (Kotlin); they close in reverse order even on exception.

java
// Java — try-with-resources closes rs, ps, conn (reverse order) on every path. try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { /* ... */ } } // conn returned to the HikariCP pool here — miss this and the pool bleeds out
kotlin
// Kotlin — `use` is the idiom; nests cleanly and closes on exception. dataSource.connection.use { conn -> conn.prepareStatement(sql).use { ps -> ps.executeQuery().use { rs -> while (rs.next()) { /* ... */ } } } }

Turn on HikariCP leak detection so an un-returned connection is logged with the borrowing stack trace:

properties
spring.datasource.hikari.leak-detection-threshold=20000 # ms a connection may be out before a warning

And always shut down executors — a live ExecutorService keeps non-daemon threads (which are GC roots) alive, leaking the task graph and preventing JVM exit:

java
ExecutorService pool = Executors.newFixedThreadPool(8); try { /* submit work */ } finally { pool.shutdown(); }

Root: the resource's underlying thread/native handle, or the pool. Trap to name: Executors.newFixedThreadPool uses an unbounded LinkedBlockingQueue — a producer faster than the pool piles tasks up in the queue until OOM. Bound your queues.

2.6 Inner classes and lambdas that capture this#

A non-static inner class, an anonymous class, or a lambda that calls an instance method captures an implicit reference to the enclosing instance. Hand that object to something long-lived (a scheduler, a static registry, an executor) and you pin the entire enclosing object graph.

java
// Java — LEAK: the anonymous Runnable captures ReportJob.this, pinning hugeBuffer for the // executor's whole lifetime, long after this job is logically done. public class ReportJob { private final byte[] hugeBuffer = new byte[64 * 1024 * 1024]; void schedule(ScheduledExecutorService ex) { ex.scheduleAtFixedRate(new Runnable() { @Override public void run() { doWork(); } // implicit ReportJob.this }, 0, 1, TimeUnit.HOURS); } // FIX: capture only what you need, so `this` (and hugeBuffer) isn't retained: void scheduleFixed(ScheduledExecutorService ex, long id) { ex.scheduleAtFixedRate(() -> process(id), 0, 1, TimeUnit.HOURS); // captures a long, not `this` } }
kotlin
// Kotlin — a language-level guardrail: nested classes are STATIC by default (no outer reference). class Outer { private val huge = ByteArray(64 * 1024 * 1024) inner class Leaky // `inner` opts IN to holding Outer.this -> can pin `huge` class Safe // default nested class holds NO reference to Outer // But Kotlin lambdas still capture, exactly like Java: fun schedule(ex: ScheduledExecutorService, id: Long) = ex.scheduleAtFixedRate({ process(id) }, 0, 1, TimeUnit.HOURS) // captures id, not `this` }

Root: the long-lived holder → the captured enclosing instance. A nice point to make: "Kotlin's default nested class doesn't capture the outer instance — inner is opt-in — so Kotlin removes one whole footgun Java leaves armed. But lambdas capture in both."

2.7 Mutable keys and broken equals/hashCode#

Put an object in a HashMap/HashSet, then mutate a field that participates in hashCode, and the entry lands in the wrong bucket: you can never find it to remove it, so it accumulates. Same outcome if a key type has no equals/hashCode (identity semantics) and you can't reconstruct the same reference.

kotlin
// Kotlin — mutate-after-insert breaks retrieval; the entry is now unreachable-by-key and leaks. val m = HashMap<MutablePoint, String>() val p = MutablePoint(1, 2) m[p] = "a" p.x = 9 // hashCode changed -> wrong bucket m.remove(p) // fails to find it -> stays forever

Fix: use immutable keys — record (Java) / data class with vals (Kotlin) — and never mutate a key after insertion. Root: the map. This one doubles as a correctness bug, which is why interviewers like it.

2.8 ClassLoader leaks — the advanced, "senior" leak#

The mechanism behind OutOfMemoryError: Metaspace. In an app server that supports hot redeploy (Tomcat, WildFly), redeploying should make the old web app's class loader unreachable so all its classes unload from Metaspace. A single stray reference from something longer-lived pins that class loader → which pins every class it loaded → Metaspace grows by a whole app's worth of classes on each redeploy until it's exhausted. Classic culprits:

  • A JDBC Driver the web app registered in java.sql.DriverManager (loaded by the system class loader) but never deregistered.
  • A ThreadLocal whose value's class comes from the web app, set on a container (pool) thread and never removed — the container thread outlives the app, pinning the loader.
  • A thread the app started and never stopped; a shutdown hook; a JDK cache (e.g. a SimpleDateFormat referenced from a JDK-held singleton) pointing back into app code.
java
// Java — deregister JDBC drivers on shutdown so the app's class loader can be collected. @WebListener public class DriverCleanup implements ServletContextListener { @Override public void contextDestroyed(ServletContextEvent e) { java.sql.DriverManager.drivers().forEach(d -> { try { java.sql.DriverManager.deregisterDriver(d); } catch (java.sql.SQLException ignored) { } }); // also: shut down app-started threads, remove ThreadLocals, cancel timers } }

Root: a long-lived class loader (or a thread/DriverManager reachable from one) → the web app's class loader → its classes. The Staff+ nuance to volunteer: "In a containerized single-app Spring Boot fat-jar you rarely hot-redeploy — you restart the whole JVM — so this specific leak is uncommon there. But it's the canonical class-loader leak, it still bites anything that spins up class loaders at runtime (plugin systems, scripting engines, heavy dynamic-proxy/bytecode generation), and being able to explain it demonstrates you understand loaders, Metaspace, and reachability together." Tomcat even ships a JreMemoryLeakPreventionListener and logs these on undeploy.

2.9 Finalizer backups and String.intern — the footnotes (know them, don't lead with them)#

  • finalize() (deprecated for removal) runs on a single finalizer thread. If finalizers are slow, objects pending finalization pile up on the finalizer queue — reachable via the queue — so a slow finalize is itself a leak plus a latency sink. Use java.lang.ref.Cleaner (Java 9+) or plain try-with-resources instead.
  • String.intern() on unbounded, user-controlled input bloats the JVM's string pool (on the heap since Java 7, formerly PermGen). Interning is for a small, bounded set of repeated literals; don't intern arbitrary input. (Historical trivia to show range: before JDK 7u6, String.substring shared the parent's backing char[], so keeping a tiny substring pinned a huge string — fixed in 7u6 by copying.)

3. Kotlin-specific leaks#

Kotlin runs on the JVM, so every Java leak above applies unchanged (a companion object field is a Java static → §2.2; a captured lambda is a captured lambda → §2.6). But coroutines add a category of their own, and a couple of Kotlin constructs have their own retention edges.

3.1 Coroutines — the GlobalScope / uncancelled-Job / unclosed-Channel family#

A coroutine is a live task; its frame (and everything it captured, plus buffered items) is retained until it completes or is cancelled. Structured concurrency exists precisely to prevent leaks: children are tied to a scope, and cancelling the scope cancels them all. The leak is created by escaping structure — GlobalScope, a detached CoroutineScope you never cancel, or a Channel/Flow that never terminates.

kotlin
// LEAK: GlobalScope is tied to no lifecycle. If this collect never ends (infinite flow), // the coroutine — and everything it holds — lives until the process dies. GlobalScope.launch { priceTicker().collect { update(it) } // never cancelled -> never reclaimed }
kotlin
// FIX: own a scope tied to a real lifecycle, and cancel it. Spring DisposableBean is a clean hook. @Component class TickerWorker : DisposableBean { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) fun start() = scope.launch { priceTicker().collect { update(it) } } override fun destroy() { scope.cancel() // cancels ALL children -> frames, buffers, captured state all released } }

A Channel that a producer never close()s leaves consumers suspended forever (retained), and buffered elements sit in the channel's buffer:

kotlin
val ch = Channel<Event>(capacity = 64) // producer forgets ch.close() -> consumers suspend indefinitely; coroutines + up to 64 items retained

Fix: prefer produce { }/coroutineScope { } so closing and cancellation are structural; if you hand-roll a Channel, close() it in a finally. The line: "A leaked coroutine is a leaked task. Structured concurrency makes the leak impossible by construction; GlobalScope opts out of that guarantee, which is why it's discouraged."

3.2 companion object statics, by lazy, and object singletons#

A companion object's properties compile to static backing fields on the enclosing class — so a growing collection in a companion is exactly the §2.2 static-collection leak. (Its functions are instance methods on the Companion singleton unless annotated @JvmStatic, but the leak lives in the field, not the method.) A top-level object (Kotlin singleton) is initialized once and held for the app's life; anything it accumulates leaks. by lazy holds its computed value strongly once initialized — fine for a bounded value, a leak if it's an unbounded accumulator.

kotlin
object EventLog { // singleton -> app-lifetime GC root private val all = mutableListOf<Event>() // grows forever == leak fun add(e: Event) { all += e } }

Fix: same as §2.2 — bound it, evict it, or stream it out; don't accumulate unbounded state in a singleton.

3.3 The one place Kotlin helps: nested-not-inner by default#

Restating §2.6 because it's a clean interview point: Kotlin nested classes do not hold a reference to the outer instance unless you write inner. Java's inner classes (and anonymous classes) always do. So the "anonymous listener silently pins its enclosing object" leak is opt-in in Kotlin and opt-out in Java. Lambdas, however, capture identically in both languages — so the fix (capture the field, not this) is the same.


4. Spring Boot-specific leaks#

Spring's defaults are mostly leak-safe, but a handful of framework behaviors are reliable interview material because they surprise people.

4.1 @Cacheable with the default cache manager is unbounded#

The trap: if no cache provider is on the classpath, Spring Boot falls back to ConcurrentMapCacheManager, which is a plain ConcurrentHashMap per cache with no eviction and no TTL. @Cacheable on a method keyed by anything high-cardinality then grows without limit — a leak hiding behind an annotation.

java
// Java — LEAK if the backing manager is ConcurrentMapCacheManager: one entry per distinct sku, forever. @Cacheable("quotes") public Quote quote(String sku) { return expensiveQuote(sku); }
yaml
// FIX put Caffeine on the classpath and give the cache a real bound: spring: cache: type: caffeine caffeine: spec: maximumSize=10000,expireAfterWrite=10m
kotlin
// Or configure per-cache bounds with a CacheManager bean (Kotlin). @Configuration @EnableCaching class CacheConfig { @Bean fun cacheManager(): CacheManager = CaffeineCacheManager("quotes").apply { setCaffeine(Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(Duration.ofMinutes(10))) } }

The line: "@Cacheable doesn't imply eviction. The default ConcurrentMapCacheManager is unbounded — always back @Cacheable with a provider that has a size or TTL bound."

4.2 Singleton beans that accumulate state#

Spring beans are singletons by default → app-lifetime GC roots. A mutable collection field that only ever grows is a leak (and a thread-safety bug — the same field is a shared-mutable-state hazard).

kotlin
// Kotlin — LEAK: a singleton bean accumulating unbounded state. @Service class AuditService { private val seen = mutableListOf<AuditEvent>() // never trimmed; lives as long as the app fun record(e: AuditEvent) { seen += e } }

Fix: persist/stream events out, or keep only a bounded window (a ring buffer / bounded cache). If a bean genuinely needs per-request state, that's a @Scope("request")/prototype smell to reconsider — and note the next trap.

4.3 Prototype beans held by a singleton#

Spring does not manage the full lifecycle of prototype beans — it hands you a new instance and forgets it (no destruction callback). If a singleton keeps creating prototype beans and holding them in a field, they accumulate under the singleton (a GC root) forever. Inject a Provider/ObjectFactory and don't retain the results, or rethink the scope.

4.4 ThreadLocal, SecurityContextHolder, and MDC on async boundaries#

Spring's own RequestContextHolder/SecurityContextHolder/SLF4J MDC are ThreadLocal-based and framework-cleaned on the request thread — but custom filters and @Async/ThreadPoolTaskExecutor tasks run on pool threads you're responsible for. Set-without-remove there is the §2.3 leak. Clean in a finally, or use context-propagation helpers (e.g. DelegatingSecurityContextExecutor, Micrometer Context Propagation) that also clear the target thread.

4.5 Connection pools and the Hikari leak detector#

A borrowed Connection (or JPA EntityManager, or jOOQ Cursor) not closed doesn't grow the heap so much as exhaust the pool — symptom: Connection is not available, request timed out. Turn on detection to get the offending stack trace:

properties
spring.datasource.hikari.leak-detection-threshold=20000

4.6 Micrometer / Prometheus high-cardinality tags — the metrics "leak"#

A subtle Staff+ favorite. Every unique tag-value combination creates a distinct time-series (Meter) that the MeterRegistry holds forever. Tag a metric with something unbounded — userId, orderId, raw path, email — and the registry grows without limit: a leak that also melts your monitoring backend.

java
// Java — LEAK: userId is unbounded -> one Meter per user, retained in the registry for the app's life. meterRegistry.counter("orders.placed", "userId", order.userId()).increment(); // FIX: only low-cardinality, bounded tags. meterRegistry.counter("orders.placed", "region", order.region(), "status", order.status().name()) .increment();

The line: "Metric tags must be low-cardinality. IDs as tags turn your metrics registry into an unbounded map — it leaks in-process and blows up the TSDB."

4.7 Reactive / Netty: ByteBuf reference-count leaks (WebFlux, gRPC, Reactor Netty)#

In Netty-based stacks, pooled ByteBufs are reference-counted and must be release()d; a missed release leaks pooled direct memory (off-heap, so a heap dump won't show it). Netty's ResourceLeakDetector samples buffers and logs a LEAK: with the access trace. In Spring WebFlux, release with DataBufferUtils.release(...) when you consume a DataBuffer yourself, and prefer operators that release for you. This is the off-heap cousin of §2.5 and a strong signal you've operated reactive systems.


5. How to identify a leak — the diagnosis toolbox and the method#

This is the section that makes an answer presentable: not "I'd add logging" but a repeatable method with named tools. The flow is always confirm → capture → analyze → localize → prevent regression.

5.1 Confirm it's actually a leak (before you profile anything)#

Watch used heap immediately after full GC over time (GC logs or a Micrometer jvm.memory.used/jvm.gc.pause dashboard). Enable GC logging:

-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

A rising post-GC floor ⇒ heap leak → go to §5.2. A flat heap but rising RSS / repeated OOMKilled ⇒ off-heap/Metaspace/thread leak → go to §5.6. A sawtooth with hot GC but flat floor ⇒ churn, not a leak → allocation-profile instead (§5.5). Deciding which of these you have first is the single highest-leverage step; most wasted leak hunts skip it.

5.2 Capture a heap dump#

bash
# Best: always-on in prod, so a heap OOM leaves you evidence. Point the path at a persistent volume. -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps/heap.hprof # On demand (both do a full GC first, so you dump only LIVE objects): jcmd <pid> GC.heap_dump /dumps/heap.hprof jmap -dump:live,format=b,file=/dumps/heap.hprof <pid> # Cheap first look without a full dump — a class histogram (instance counts + bytes by class): jcmd <pid> GC.class_histogram | head -40 jmap -histo:live <pid> | head -40

5.3 Analyze with Eclipse MAT (the vocabulary you must speak)#

Open the .hprof in Eclipse Memory Analyzer (MAT). The concepts to name:

  • Shallow heap — bytes of the object itself. Retained heap — bytes that would be freed if this object were collected, i.e. it plus everything reachable only through it. Sort by retained heap to find the object whose removal reclaims the most — that's your leak's anchor.
  • Dominator tree — X dominates Y if every path from a GC root to Y passes through X. The top of the dominator tree, sorted by retained size, is a ranked list of "the few objects holding everything." Your leak is almost always a fat dominator (a big map, a big list, a class loader).
  • Path to GC Roots (exclude soft/weak) — right-click the suspect and ask for the path; it shows the exact reference chain from a root, which names the bug (e.g. PricingService.cache → ConcurrentHashMap → …).
  • Leak Suspects report — MAT's automated first pass; good for a fast orientation, not a substitute for reading the dominator tree.

5.4 Localize by comparing two dumps over time#

Under steady load, dump at T0 and again at T1. Compare class histograms (MAT's histogram compare, or diff two jmap -histo outputs): the class whose instance count grows monotonically between dumps is the leak; then use path-to-GC-roots on an instance to find who retains it. Two dumps beat one because a single dump shows what's big, not what's growing.

5.5 Production-safe continuous profiling: JFR and async-profiler#

  • JDK Flight Recorder (JFR) — low-overhead, built in, safe to leave on. Its killer feature for leaks is the Old Object Sample event (jdk.OldObjectSample): JFR tracks a sample of objects that survive and, for each, records the allocation stack trace and the path to GC root — so you can often find a leak without a full heap dump, live in prod. You must pass path-to-gc-roots=true to actually get the root chain (it's off by default because it costs more); without it you get the allocation site but not who's retaining it.
    bash
    -XX:StartFlightRecording=filename=app.jfr,settings=profile,path-to-gc-roots=true,dumponexit=true jcmd <pid> JFR.start name=leak settings=profile path-to-gc-roots=true jcmd <pid> JFR.dump name=leak filename=/dumps/leak.jfr # open in JDK Mission Control
  • async-profiler (-e alloc) — flame-graph of where allocations happen. This is your tool for the churn case (§5.1): it shows the hot allocation sites so you can cut allocation rate. Allocation profiling answers "what's being made," heap dumps answer "what's being kept" — different questions.

5.6 Off-heap and native leaks: NMT, RSS, and the container reality#

When heap is flat but RSS climbs, switch tools entirely:

bash
# Native Memory Tracking: turn it on at launch, then query categories (heap, class/Metaspace, # thread, code, GC, internal, direct buffers...). Baseline + diff shows which region grows. -XX:NativeMemoryTracking=summary jcmd <pid> VM.native_memory summary jcmd <pid> VM.native_memory baseline # ...later... jcmd <pid> VM.native_memory summary.diff pmap -x <pid> | sort -k3 -n | tail # OS view of the address space

For direct buffers specifically, watch the BufferPoolMXBean (jvm.buffer.memory.used in Micrometer) and cap them with -XX:MaxDirectMemorySize. For Metaspace, watch for duplicate classes / many class loaders in MAT and cap with -XX:MaxMetaspaceSize (so a loader leak fails fast with a dump instead of eating all RAM).

The container facts that decide real incidents:

  • RSS = heap + Metaspace + thread stacks + direct buffers + code cache + native. The Kubernetes OOM-killer watches the container working set (≈ RSS + unreclaimable page cache), not the Java heap.
  • OOMKilled = exit code 137 (128 + SIGKILL). A cgroup OOMKill is the kernel killing the process by memory (the working set above) — there is no Java OutOfMemoryError and no automatic heap dump, because -XX:+HeapDumpOnOutOfMemoryError fires only on a JVM-thrown heap/Metaspace OOM, not on this (and not on a native-thread or direct-buffer OOM either). So a native/off-heap leak silently 137s your pod; diagnose it with NMT, not a heap dump.
  • Size the heap relative to the container, not with a fixed -Xmx guessed against the node: -XX:MaxRAMPercentage=75.0 (container support reads the cgroup limit by default since JDK 10+), leaving headroom for the non-heap regions above so the JVM OOMs (with a dump) before the kernel 137s it (without one).

6. Best practices — the prevention checklist#

A leak-resistant service is mostly a set of habits. This is the checklist to recite:

  1. Bound every cache — a maximumSize and/or TTL on every cache, chosen by the access pattern. Never @Cacheable on the default ConcurrentMapCacheManager. An unbounded cache is a leak with a countdown.
  2. Close every resource — try-with-resources (Java) / use (Kotlin) for anything Closeable; return pool connections; shut down every ExecutorService/scope.
  3. remove() every ThreadLocal in a finally on pooled threads. Prefer ScopedValue for per-task context on virtual threads.
  4. Symmetric register/unregister — every listener/callback subscription has a deterministic unsubscribe; hold weakly if you can't guarantee it.
  5. No unbounded static/singleton/companion collections — accumulate to a store, not to the heap; keep only bounded windows.
  6. Immutable map/set keysrecord/data class; never mutate a key after insertion.
  7. Structured concurrency for coroutines — own a scope, cancel it on lifecycle end; avoid GlobalScope; close() channels.
  8. Don't capture this in long-lived lambdas/inner classes — capture the field you need; prefer static nested classes (free in Kotlin).
  9. Bounded queues everywhere — reject/backpressure instead of an unbounded LinkedBlockingQueue that OOMs under load.
  10. Low-cardinality metric tags — never IDs as tags.
  11. Deregister JDBC drivers / stop app-started threads on shutdown if you ever load classes dynamically (redeploy / plugins).
  12. Make production leak-ready-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<persistent volume>, JFR always-on, NMT available, alerts on post-GC heap trend and RSS, heap sized with MaxRAMPercentage.
  13. Soak-test before shipping — run steady load for hours and watch the post-GC floor; a leak shows up as a slope. This is the cheapest place to catch one.

7. Problems & how to solve them (Problem → Root cause → Fix)#

7.1 Heap grows under load and never comes back down after traffic subsides#

  • Problem: used heap after full GC keeps climbing across the day; eventually OutOfMemoryError: Java heap space.
  • Root cause: an unbounded retainer — cache/static map/singleton collection, or a ThreadLocal on a pool thread (§2.2/§2.3).
  • Fix: heap dump → MAT dominator tree → path-to-GC-roots names the field; bound the cache (Caffeine size+TTL) or remove() the ThreadLocal in a finally.

7.2 Pod is OOMKilled (137) every few hours but the heap graph is flat#

  • Problem: Kubernetes restarts the pod on memory; JVM shows healthy heap and no OutOfMemoryError, no heap dump.
  • Root cause: the leak is off-heap — direct buffers / Netty ByteBuf, thread stacks (thread leak), or Metaspace (class-loader leak). RSS > heap (§1.5, §5.6).
  • Fix: NMT baseline+diff to find the growing region; then the region-specific fix (release ByteBufs, bound/shut pools, deregister drivers). Size heap with MaxRAMPercentage so headroom exists for non-heap.

7.3 OutOfMemoryError: Metaspace after N deployments#

  • Problem: Metaspace exhausts after several redeploys/reloads (app server, or a runtime that generates classes).
  • Root cause: a class-loader leak — a DriverManager driver, a ThreadLocal on a container thread, or an app-started thread pins the old class loader → all its classes stay in Metaspace (§2.8).
  • Fix: deregister drivers and stop threads/ThreadLocals on shutdown; in MAT look for duplicate classes / multiple web-app class loaders and path-to-GC-roots the loader. Cap with -XX:MaxMetaspaceSize to fail fast.

7.4 HikariCP - Connection is not available, request timed out#

  • Problem: requests block then fail acquiring a DB connection; pool looks exhausted though DB is healthy.
  • Root cause: connections borrowed and never closed (missing try-with-resources / an exception path skipping close) — a resource leak, not a heap leak (§2.5).
  • Fix: try-with-resources/use on every connection; enable leak-detection-threshold to log the borrowing stack; check for a @Transactional/manual-connection mix leaking outside the managed scope.

7.5 Latency slowly degrades over days, GC CPU keeps rising, then OOM#

  • Problem: p99 creeps up over a week; GC time trends up; finally a heap-space OOM.
  • Root cause: a slow heap leak filling old gen → more frequent/longer full GCs (eventually GC overhead limit exceeded) before the final OOM.
  • Fix: same as §7.1; because it's slow, two heap dumps a day apart + histogram compare (§5.4) localize the growing class faster than staring at one dump.

7.6 Adding @Cacheable made memory worse#

  • Problem: enabling a cache to speed things up introduced a slow OOM.
  • Root cause: default ConcurrentMapCacheManager is unbounded; the cache key is high-cardinality (§4.1).
  • Fix: back the cache with Caffeine (maximumSize/expireAfterWrite) or a real provider; reconsider whether that key space is cacheable at all.

7.7 Micrometer registry is huge / Prometheus scrape is enormous#

  • Problem: in-process MeterRegistry retains a vast number of meters; scrape payloads balloon.
  • Root cause: a high-cardinality tag — an ID/email/path as a tag creates one series per value, held forever (§4.6).
  • Fix: remove the unbounded tag; use only bounded dimensions; if you truly need per-entity counts, that's a logging/analytics job, not a metric.

7.8 Kotlin: coroutines/threads pile up until the process dies#

  • Problem: thread/coroutine count and memory climb; many parked coroutines in a dump.
  • Root cause: GlobalScope/detached scope never cancelled, or a Channel/Flow that never completes (§3.1).
  • Fix: own a CoroutineScope tied to a lifecycle and cancel() it (DisposableBean.destroy()); make production/consumption structural (produce, coroutineScope); close() channels in finally.

7.9 The Staff+ twist: "it's not always your code"#

  • Problem: the leak is in a dependency, a driver, an agent, or the sizing, not your business logic.
  • Root cause: a library holding state (connection/statement caches, interceptors), a JVM/native agent, or simply heap sized against the node instead of the cgroup so the kernel 137s you before the JVM can dump.
  • Fix: path-to-GC-roots still names the retainer even when it's third-party; pin/upgrade the dependency; and treat heap sizing vs container limit as part of "the fix," not an afterthought. Recognizing that a leak can live outside your code — and that a stateless service still dies from a leak because the process is long-lived — is a senior signal.

8. Interview framing — how to sound Staff+#

  • Open with the mental model. "The GC frees the unreachable, so a Java leak is reachable garbage — something I don't need that's still reachable from a GC root. The whole job is: find the root, cut the edge." This one sentence reframes you from "I memorized leak types" to "I understand the object graph."
  • Have three canonical examples, one per root family. Unbounded cache (static-field root), ThreadLocal on a pooled thread (live-thread root), class-loader leak on redeploy (class-loader root). Naming which GC root retains each shows structural understanding, not a list.
  • Distinguish heap from off-heap/Metaspace/native. Most candidates only picture the heap. Saying "if the heap graph is flat but the pod still OOMKills, it's off-heap — NMT, not a heap dump" is a strong differentiator and maps to real production pain.
  • Describe a repeatable method, not tools-as-trivia. confirm (post-GC floor) → capture (dump / JFR) → analyze (MAT dominator tree + path-to-GC-roots) → localize (two-dump compare) → prevent (soak test + alerts). Mention JFR Old Object Sample for finding leaks in production without a full dump — few candidates know it.
  • Separate leak from churn from bloat. Volunteering "first I'd confirm it's a leak and not allocation churn — different graph, different tool" signals you won't waste a day profiling the wrong thing.
  • Show reference-type fluency. When you'd reach for WeakReference/WeakHashMap (keys, listeners) — and the value-strongly-references-key trap that defeats it. Note that SoftReference is not an eviction policy and a real cache (Caffeine) is better.
  • Bring the container angle. RSS vs heap, OOMKilled = 137, -XX:MaxRAMPercentage, heap-dump-to-a-volume. This is where leaks actually get diagnosed today.
  • Kotlin/coroutines maturity. Structured concurrency makes leaks impossible by construction; GlobalScope opts out; ScopedValue is the ThreadLocal successor for virtual threads.
  • Trap answers to avoid (and to gently correct if a peer says them): "Java can't have memory leaks because of GC" (false — reachable garbage), "call System.gc()" (it can't collect reachable objects — no help for a leak), "just increase the heap" (delays the OOM, doesn't fix a leak; for a real leak it only changes when you die), and "use finalizers to clean up" (deprecated; slow finalizers are themselves a leak — use Cleaner/try-with-resources).

9. Cheat-sheets#

OutOfMemoryError → region → likely cause → first tool

MessageRegionUsual causeFirst tool
Java heap spaceheapheap leak / undersized heapheap dump → MAT dominator tree
GC overhead limit exceededheapnear-full heap, GC thrash (late-stage leak)heap dump; treat as heap leak
MetaspaceMetaspace (native)class-loader leakMAT duplicate-classes / loader path-to-roots
unable to create new native threadthread stacks (native)thread leak / ulimitthread dump, count threads
Direct buffer memoryoff-heap directleaked DirectByteBuffer/Netty; low MaxDirectMemorySizeNMT, BufferPoolMXBean
pod OOMKilled (137), heap flatRSS / nativeoff-heap/Metaspace/thread leakNMT baseline+diff, pmap

Reference strengths

TypeCleared whenUse it forDon't
Strongunreachable onlynormal references
SoftReferenceunder memory pressurememory-sensitive cache-ishrely on it as eviction
WeakReference/WeakHashMapnext GC once only-weakly-reachablekeys, listeners, metadatastore data you still need; let value reach key
PhantomReference+ReferenceQueuepost-finalizationdeterministic native cleanup (Cleaner)expect the referent back

Leak source → GC root family → fix

Leak sourceRetaining GC rootFix
Unbounded cache / static mapstatic field (class loader)Caffeine size + TTL
Self-managed array/pool (obsolete refs)instance/static fieldnull out removed slots
ThreadLocal not removed (pool)live pool threadremove() in finally; ScopedValue
Listener never unregisteredlong-lived subjectsymmetric unregister / weak
Unclosed resource / executornative handle / live threadtry-with-resources / use / shutdown()
Inner class / lambda capturing thislong-lived holder → outercapture the field; static nested
Mutable / bad-hashCode keythe mapimmutable keys
Class-loader leak (redeploy)long-lived loader/thread/DriverManagerderegister drivers, stop threads, remove TLs
GlobalScope/unclosed Channeldetached coroutine scopestructured scope + cancel()/close()
High-cardinality metric tagMeterRegistrylow-cardinality tags only

Tools

ToolAnswersWhen
GC logs / jvm.memory.usedis the post-GC floor rising?first — confirm it's a leak
jmap -histo:live / GC.class_histogramwhat classes dominate by count/bytescheap first look
Heap dump + Eclipse MATwhat's retained; path to GC rootslocalize a heap leak
Two dumps + histogram comparewhat's growingslow leaks
JFR Old Object Samplesurviving objects + alloc trace + root pathproduction, no full dump
async-profiler -e allocwhere allocations happenchurn (not a leak)
NMT (VM.native_memory) / pmapwhich native region growsflat heap, rising RSS
HikariCP leak-detection-thresholdwho borrowed a connection and didn't return itpool exhaustion

10. Self-test (say the answers out loud)#

  1. Why is "Java can't have memory leaks" wrong? Define a leak in one sentence using the words reachable and GC root.
  2. Name the GC-root families. Which three account for most real leaks, and give one leak per root.
  3. Distinguish leak vs bloat vs churn by their heap graphs. Which tool fits each?
  4. Exactly when is each of strong/soft/weak/phantom cleared? Why is SoftReference a poor cache eviction policy?
  5. Explain the WeakHashMap value trap. Write a put that leaks despite weak keys.
  6. Walk the ThreadLocal-on-a-pooled-thread leak: the map's key is weak but the value…? Why does it also leak data across requests, and what changes with virtual threads?
  7. Fix the Effective-Java stack leak in one line. Why can't the GC figure it out itself?
  8. Your pod is OOMKilled (137) but the heap graph is flat. What's your hypothesis and which tool — not a heap dump — do you reach for, and why won't HeapDumpOnOutOfMemoryError help?
  9. Define shallow heap, retained heap, and dominator tree. Which do you sort by to find a leak's anchor, and what does path to GC roots give you?
  10. What is the JFR Old Object Sample and why is it special for production leak hunting?
  11. Why is default @Cacheable a leak risk, and what one-line config fixes it?
  12. How does a high-cardinality metric tag leak, and where?
  13. Why does a local heap dump not explain a class-loader / Metaspace leak the way it explains a heap leak — and name two things that pin a class loader.
  14. In Kotlin, why is GlobalScope.launch { } a leak risk, and how does structured concurrency remove the risk by construction?
  15. Give the three trap answers (System.gc(), "increase the heap", finalizers) and say precisely why each fails to fix a leak.

Lineage & sources: reachability/GC-roots per the JVM GC model; "eliminate obsolete object references," the self-managed-stack example, and reference-strength guidance from Bloch, Effective Java (Item 7) and Goetz et al., Java Concurrency in Practice. ThreadLocalMap weak-key/strong-value mechanics per the JDK source. Metaspace replaced PermGen in Java 8; String interning moved to heap in Java 7; substring char[]-sharing removed in 7u6. Diagnostics: Eclipse MAT (dominator tree / retained heap / path-to-GC-roots), JDK Flight Recorder jdk.OldObjectSample + JDK Mission Control, jcmd/jmap, Native Memory Tracking, async-profiler; HikariCP leak detection; Caffeine caching; Micrometer cardinality guidance; Netty ResourceLeakDetector. Container behavior: cgroup-aware JVM (JDK 10+), MaxRAMPercentage, OOMKilled=137. Virtual threads GA JDK 21 (JEP 444); Scoped Values final JDK 25 (JEP 506). Verify version-specific flags against your target JDK.