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
ThreadLocalsuccessor 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.coroutines1.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
Threadobject that is alive is a root. This is why a leaked, never-shut-downExecutorServiceor a runaway thread keeps its whole task graph alive and stops the JVM from exiting. - Static fields — a
staticfield 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.
| Strength | Collected when… | Canonical use | Trap |
|---|---|---|---|
| Strong (a normal reference) | only when unreachable | everything | the only kind that causes leaks |
Soft (SoftReference) | under memory pressure, on a policy-driven schedule (not necessarily at the last moment) | memory-sensitive caches | not 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 reachable | canonical keys, listeners, metadata maps, interning | cleared aggressively — never use for data you still need |
Phantom (PhantomReference + ReferenceQueue) | after finalization, enqueued for post-mortem cleanup | deterministic 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 — 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 — 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.
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 threadand 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 aCleaner), not deterministically. Leaks here →OutOfMemoryError: Direct buffer memoryor 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.
| Message | What it means | Usual cause | First tool |
|---|---|---|---|
Java heap space | heap can't satisfy an allocation | heap leak, or under-sized heap | heap dump → MAT dominator tree |
GC overhead limit exceeded | >98% of time in GC, <2% heap recovered | heap nearly full → GC thrash (leak in late stage) | heap dump; treat as a heap leak |
Metaspace | class-metadata region full | class loader leak (redeploys, proxy/class generation) | class-loader histogram, "duplicate classes" in MAT |
unable to create new native thread | OS refused a new thread | thread leak (unbounded pools, threads never shut down) or ulimit | thread dump, count threads |
Direct buffer memory | direct-buffer pool exhausted | leaked DirectByteBuffer / Netty buffers, too-low MaxDirectMemorySize | NMT, BufferPoolMXBean |
Requested array size exceeds VM limit | single allocation > ~2^31 | a 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 — 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 — 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 — LEAK: unbounded cache on a singleton bean. One entry per distinct sku, forever.
public class PricingService {
private final Map<String, Quote> cache = new ConcurrentHashMap<>();
public Quote quote(String sku) {
return cache.computeIfAbsent(sku, this::expensiveQuote);
}
}
// Java — FIX: a bounded, expiring cache (Caffeine). Size cap + TTL = a leak with no countdown.
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 — FIX: same Caffeine bound.
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 — 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
}
public class ContextFilter extends OncePerRequestFilter {
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 — 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()
}
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 — 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 — 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 — 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 — `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:
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:
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 — 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() {
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 — 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 — 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
Driverthe web app registered injava.sql.DriverManager(loaded by the system class loader) but never deregistered. - A
ThreadLocalwhose value's class comes from the web app,seton 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
SimpleDateFormatreferenced from a JDK-held singleton) pointing back into app code.
// Java — deregister JDBC drivers on shutdown so the app's class loader can be collected.
public class DriverCleanup implements ServletContextListener {
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 slowfinalizeis itself a leak plus a latency sink. Usejava.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.substringshared the parent's backingchar[], 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.
// 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
}
// FIX: own a scope tied to a real lifecycle, and cancel it. Spring DisposableBean is a clean hook.
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:
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.
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 — LEAK if the backing manager is ConcurrentMapCacheManager: one entry per distinct sku, forever.
public Quote quote(String sku) { return expensiveQuote(sku); }
// FIX — put Caffeine on the classpath and give the cache a real bound:
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m
// Or configure per-cache bounds with a CacheManager bean (Kotlin).
class CacheConfig {
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 — LEAK: a singleton bean accumulating unbounded state.
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:
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 — 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#
# 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 passpath-to-gc-roots=trueto 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.-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:
# 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
OutOfMemoryErrorand no automatic heap dump, because-XX:+HeapDumpOnOutOfMemoryErrorfires 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
-Xmxguessed 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:
- Bound every cache — a
maximumSizeand/or TTL on every cache, chosen by the access pattern. Never@Cacheableon the defaultConcurrentMapCacheManager. An unbounded cache is a leak with a countdown. - Close every resource — try-with-resources (Java) /
use(Kotlin) for anythingCloseable; return pool connections; shut down everyExecutorService/scope. remove()everyThreadLocalin afinallyon pooled threads. PreferScopedValuefor per-task context on virtual threads.- Symmetric register/unregister — every listener/callback subscription has a deterministic unsubscribe; hold weakly if you can't guarantee it.
- No unbounded
static/singleton/companion collections — accumulate to a store, not to the heap; keep only bounded windows. - Immutable map/set keys —
record/data class; never mutate a key after insertion. - Structured concurrency for coroutines — own a scope, cancel it on lifecycle end; avoid
GlobalScope;close()channels. - Don't capture
thisin long-lived lambdas/inner classes — capture the field you need; prefer static nested classes (free in Kotlin). - Bounded queues everywhere — reject/backpressure instead of an unbounded
LinkedBlockingQueuethat OOMs under load. - Low-cardinality metric tags — never IDs as tags.
- Deregister JDBC drivers / stop app-started threads on shutdown if you ever load classes dynamically (redeploy / plugins).
- 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 withMaxRAMPercentage. - 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/
staticmap/singleton collection, or aThreadLocalon 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 afinally.
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 withMaxRAMPercentageso 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
DriverManagerdriver, aThreadLocalon 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:MaxMetaspaceSizeto 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/
useon every connection; enableleak-detection-thresholdto 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
ConcurrentMapCacheManageris 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
MeterRegistryretains 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 aChannel/Flowthat never completes (§3.1). - Fix: own a
CoroutineScopetied to a lifecycle andcancel()it (DisposableBean.destroy()); make production/consumption structural (produce,coroutineScope);close()channels infinally.
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),
ThreadLocalon 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 thatSoftReferenceis 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;
GlobalScopeopts out;ScopedValueis 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 — useCleaner/try-with-resources).
9. Cheat-sheets#
OutOfMemoryError → region → likely cause → first tool
| Message | Region | Usual cause | First tool |
|---|---|---|---|
Java heap space | heap | heap leak / undersized heap | heap dump → MAT dominator tree |
GC overhead limit exceeded | heap | near-full heap, GC thrash (late-stage leak) | heap dump; treat as heap leak |
Metaspace | Metaspace (native) | class-loader leak | MAT duplicate-classes / loader path-to-roots |
unable to create new native thread | thread stacks (native) | thread leak / ulimit | thread dump, count threads |
Direct buffer memory | off-heap direct | leaked DirectByteBuffer/Netty; low MaxDirectMemorySize | NMT, BufferPoolMXBean |
pod OOMKilled (137), heap flat | RSS / native | off-heap/Metaspace/thread leak | NMT baseline+diff, pmap |
Reference strengths
| Type | Cleared when | Use it for | Don't |
|---|---|---|---|
| Strong | unreachable only | normal references | — |
SoftReference | under memory pressure | memory-sensitive cache-ish | rely on it as eviction |
WeakReference/WeakHashMap | next GC once only-weakly-reachable | keys, listeners, metadata | store data you still need; let value reach key |
PhantomReference+ReferenceQueue | post-finalization | deterministic native cleanup (Cleaner) | expect the referent back |
Leak source → GC root family → fix
| Leak source | Retaining GC root | Fix |
|---|---|---|
Unbounded cache / static map | static field (class loader) | Caffeine size + TTL |
| Self-managed array/pool (obsolete refs) | instance/static field | null out removed slots |
ThreadLocal not removed (pool) | live pool thread | remove() in finally; ScopedValue |
| Listener never unregistered | long-lived subject | symmetric unregister / weak |
| Unclosed resource / executor | native handle / live thread | try-with-resources / use / shutdown() |
Inner class / lambda capturing this | long-lived holder → outer | capture the field; static nested |
Mutable / bad-hashCode key | the map | immutable keys |
| Class-loader leak (redeploy) | long-lived loader/thread/DriverManager | deregister drivers, stop threads, remove TLs |
GlobalScope/unclosed Channel | detached coroutine scope | structured scope + cancel()/close() |
| High-cardinality metric tag | MeterRegistry | low-cardinality tags only |
Tools
| Tool | Answers | When |
|---|---|---|
GC logs / jvm.memory.used | is the post-GC floor rising? | first — confirm it's a leak |
jmap -histo:live / GC.class_histogram | what classes dominate by count/bytes | cheap first look |
| Heap dump + Eclipse MAT | what's retained; path to GC roots | localize a heap leak |
| Two dumps + histogram compare | what's growing | slow leaks |
| JFR Old Object Sample | surviving objects + alloc trace + root path | production, no full dump |
async-profiler -e alloc | where allocations happen | churn (not a leak) |
NMT (VM.native_memory) / pmap | which native region grows | flat heap, rising RSS |
HikariCP leak-detection-threshold | who borrowed a connection and didn't return it | pool exhaustion |
10. Self-test (say the answers out loud)#
- Why is "Java can't have memory leaks" wrong? Define a leak in one sentence using the words reachable and GC root.
- Name the GC-root families. Which three account for most real leaks, and give one leak per root.
- Distinguish leak vs bloat vs churn by their heap graphs. Which tool fits each?
- Exactly when is each of strong/soft/weak/phantom cleared? Why is
SoftReferencea poor cache eviction policy? - Explain the
WeakHashMapvalue trap. Write aputthat leaks despite weak keys. - 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? - Fix the Effective-Java stack leak in one line. Why can't the GC figure it out itself?
- 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'tHeapDumpOnOutOfMemoryErrorhelp? - 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?
- What is the JFR Old Object Sample and why is it special for production leak hunting?
- Why is default
@Cacheablea leak risk, and what one-line config fixes it? - How does a high-cardinality metric tag leak, and where?
- 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.
- In Kotlin, why is
GlobalScope.launch { }a leak risk, and how does structured concurrency remove the risk by construction? - 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.