Finding and fixing production memory problems — Java vs C/C++ leaks, static-reference and cache leaks (and why "L1/L2 garbage collection" is really a two-level cache question), detecting leaks in production, heap dumps and MAT, VisualVM, JConsole, JFR and JMC, sampling vs instrumentation profiling, allocation and GC-pressure tracking, classloader leaks, investigating high GC overhead, triaging OutOfMemoryErrors by type, jstack and thread contention, and coding practices that prevent leaks in long-running services.
Published September 25, 2026
Interviewers want a runbook, not a list of tools:
Answer with a real incident if you can.
Short answer:
ThreadLocal, or a class loader) but no longer needed. The GC can't know you won't use them again. Memory grows until performance degrades (GC thrashing), and finally an OutOfMemoryError.Key points to cover:
Learn it in depth → Memory Leaks in Java
Short answer: Static fields live as long as their class (and its class loader), which for application classes means forever. Everything reachable from a static field is pinned:
Map/List caches without eviction, which is the #1 leak;Class objects from other modules, which pins entire applications (redeploy leaks);ThreadLocals whose values are never remove()d in pooled threads.Fix: bounded caches with eviction (Caffeine), explicit unregistration, lifecycle hooks (@PreDestroy), weak-keyed maps where the semantics allow, and ThreadLocal.remove() in finally.
Short answer: There's no L1/L2 garbage collection in the JVM. The question is really about a two-level cache, plus bounded memory:
HashMap caches with bounded ones.equals/hashCode, or mutable keys.SoftReference caches let the GC evict entries under memory pressure. They're not a good primary policy: eviction is unpredictable, and there's GC churn under pressure. WeakHashMap suits metadata keyed by objects whose lifetime is managed elsewhere, not general caching.Cache<String, ProductDto> l1 = Caffeine.newBuilder()
.maximumSize(50_000)
.expireAfterWrite(Duration.ofMinutes(5))
.recordStats()
.build();
ProductDto get(String sku) {
return l1.get(sku, k -> redis.get(k) // L2
.orElseGet(() -> loadAndStoreInRedis(k))); // source of truth
}
Common trap: repeating "L1 and L2 garbage collection" as if they were JVM features.
Short answer:
-XX:+HeapDumpOnOutOfMemoryError (always on, with enough disk).jcmd <pid> GC.heap_dump /dumps/app.hprof. It pauses the JVM, and the file is about the size of the heap, so take it from an instance drained from the load balancer if you can.jdk.OldObjectSample), a low-overhead way to find what's living long, with allocation stack traces.jcmd <pid> GC.class_histogram) taken over time, to see which types grow.Learn it in depth → GC Tuning & Diagnostics
Short answer:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps);jcmd <pid> GC.heap_dump (the live option forces a Full GC first);kubectl cp it out.ParseHeapDump.sh for huge dumps on a big-memory machine), VisualVM, or IntelliJ's profiler.-XX:HeapDumpGzipLevel (Java 17+) to compress it.Short answer:
It's great for development and QA. For production, prefer JFR (low overhead, always-on) and offline dump analysis.
Short answer: JConsole is a JMX client bundled with the JDK:
ThreadMXBean.findDeadlockedThreads() to find cycles of threads waiting on monitors or ownable synchronisers.It's basic but universal. Modern setups export the same MXBeans to Prometheus through Micrometer, and alert on them.
Short answer:
-XX:StartFlightRecording=disk=true,maxage=6h,settings=profile), and be dumped on demand (jcmd <pid> JFR.dump).Since Java 14 there's JFR event streaming (RecordingStream) for live in-process monitoring. Java 25 adds CPU-time sampling on Linux, and method timing and tracing events.
Short answer: JMC turns a JFR recording into answers:
The workflow is: record under realistic load → find the top allocator or lock → fix → record again and compare.
Short answer:
AsyncGetCallTrace and perf events) avoid. It gives no exact call counts.Practice: sample to find the hotspots, then instrument narrowly (or add JFR custom events, or Micrometer timers) to measure specific operations precisely.
Short answer:
jdk.ObjectAllocationSample (Java 16+), a throttled, low-overhead sample of allocations with stack traces;jdk.ObjectAllocationInNewTLAB/OutsideTLAB, in older setups.String.format, stream pipelines, byte arrays from serialisation.jfr print --events jdk.ObjectAllocationSample rec.jfr, or async-profiler -e alloc, with flame graphs.Short answer: The typical symptom is OutOfMemoryError: Metaspace, or growth after redeploys or plugin reloads, with many instances of the same class loader type (WebappClassLoader, RestartClassLoader, plugin loaders).
jcmd <pid> VM.classloader_stats or VM.metaspace show many live loaders, and growing class counts.Runnable pins the loader);ThreadLocal values in container threads;DriverManager;ThreadLocal.remove().Short answer:
System.gc()).-Xlog:gc+phases=debug): object copy (a large survivor set), ref processing (lots of soft or weak references or finalizers), code root scanning, and time-to-safepoint.nr_throttled), swapping, transparent huge pages, noisy neighbours, too few GC threads.OutOfMemoryErrors in your logs?Short answer: First read the message, because each type has a different cause:
| Message | Meaning | First action |
|---|---|---|
Java heap space | The heap is full of live objects | Heap dump → leak, or an oversized load (a huge query result or file) |
GC overhead limit exceeded | Parallel GC is spending ≥ 98% of time in GC | The same as heap space: a leak, or an undersized heap |
Metaspace | Class metadata is exhausted | Classloader leak, or dynamic class generation |
Direct buffer memory | MaxDirectMemorySize hit | Unpooled or unreleased direct buffers (Netty or NIO) |
unable to create native thread | OS or container thread or memory limit | Thread leak, or unbounded pools. Use virtual threads, or bounded executors |
Requested array size exceeds VM limit | Allocating a gigantic array | A bug in the size computation |
| Container OOMKilled (no Java error) | Total RSS above the pod limit | Native memory (NMT), and adjust MaxRAMPercentage |
Then reproduce it (a load or soak test), fix the root cause, and only then resize. Add -XX:+ExitOnOutOfMemoryError, so a broken JVM is restarted cleanly.
Short answer:
ThreadLocal: remove() in finally.Short answer:
@PreDestroy/close(). Prefer scoped constructs (try-with-resources, structured concurrency).ThreadLocal discipline: set and remove in try/finally (filters, interceptors). Prefer ScopedValue (finalised in Java 25) for per-request context.jstack used for?Short answer: jstack <pid> prints a thread dump: every thread's name, state (RUNNABLE, BLOCKED, WAITING, TIMED_WAITING), stack trace, and locks held or awaited. It also reports Java-level deadlocks. Modern equivalents: jcmd <pid> Thread.print, and jcmd <pid> Thread.dump_to_file -format=json, which includes virtual threads (Java 21). You can also send kill -3 <pid> (the dump goes to stdout).
Use it for: hangs, deadlocks, thread-pool exhaustion, and high CPU (map a hot OS thread ID from top -H to the dump's nid, in hex). Take 3–5 dumps a few seconds apart: threads stuck in the same frame across dumps are your suspects.
Short answer:
waiting to lock <0x...> on the same monitor, or parked on the same ReentrantLock/AbstractQueuedSynchronizer. Identify the owner, and what it's doing (often slow I/O inside a lock).jdk.JavaMonitorEnter (with blocked duration and stack traces), jdk.ThreadPark, and JMC's Lock Instances view. Or use async-profiler's -e lock mode.jdk.VirtualThreadPinned events show pinning, which in Java 21–23 was typically caused by blocking inside synchronized or native frames.ConcurrentHashMap, LongAdder).StampedLock optimistic reads.Q: What's the difference between shallow size and retained size in MAT? A: Shallow size is the object's own memory. Retained size is the memory that would be freed if the object were collected: everything only reachable through it. Leak hunting focuses on large retained sizes (the dominator tree).
Q: Can you take a heap dump without stopping the application?
A: Not completely. Heap dumping needs a safepoint for consistency, so the JVM pauses for the dump's duration. Minimise the impact by draining traffic first, dumping to fast local disk, and avoiding the live option when you don't need the Full GC first.
Q: What is Native Memory Tracking (NMT)?
A: A JVM feature (-XX:NativeMemoryTracking=summary|detail) that accounts for the JVM's own native allocations by category (heap, class, thread, code, GC, internal, other). Query it with jcmd <pid> VM.native_memory summary, with a baseline and diff to see growth. It doesn't track allocations by third-party native libraries.
Q: What is async-profiler, and why is it popular?
A: An open-source, low-overhead sampling profiler for HotSpot. It combines AsyncGetCallTrace with Linux perf events, so it has no safepoint bias, covers CPU, allocation, lock and wall-clock modes, includes native and kernel frames, and outputs flame graphs.