Sitemap

Java Memory Management Interview Questions — Heap, Stack, and GC (2026 Edition)

35 min readMay 17, 2026

--

Memory management is the interview topic that never gets old — and never stops evolving. Most developers can recite that the heap stores objects and the stack stores local variables. Interviewers at senior level know that. What they’re actually testing is whether you understand how the GC decides what to collect, why PermGen disappeared, what ZGC is doing differently from G1 in Java 25, and whether you can diagnose a memory problem in production.

Press enter or click to view image in full size

This article covers the full spectrum — from fundamentals to the latest GC changes through Java 25 — written from the perspective of someone who has tuned JVMs in production and debugged OOMErrors at 3am.

📘 Java Backend Interview Questions 300+ questions across all experience levels — with the depth interviewers are actually looking for.

📘 Complete Backend Interview Questions 1000+ questions covering core Java, concurrency, JVM internals, Spring, and system design.

📘 Springboot Interview Questions 200+ questions across all experience levels — with the depth interviewers are actually looking for.

Section 1: JVM Memory Architecture

Q1. What are the memory areas managed by the JVM? Walk me through each one.

The JVM divides its memory into several distinct regions:

┌─────────────────────────────────────────────────────────────┐
│ JVM Memory │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ HEAP (GC-managed) │ │
│ │ ┌─────────────────────────────┐ ┌────────────────┐ │ │
│ │ │ Young Generation │ │ Old Generation │ │ │
│ │ │ ┌──────┐ ┌────┐ ┌────┐ │ │ (Tenured) │ │ │
│ │ │ │ Eden │ │ S0 │ │ S1 │ │ │ │ │ │
│ │ │ └──────┘ └────┘ └────┘ │ └────────────────┘ │ │
│ │ └─────────────────────────────┘ │ │
│ └───────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌────────────┐ ┌─────────────────────┐ │
│ │ Metaspace │ │ Stack │ │ Code Cache (JIT) │ │
│ │ (native mem)│ │ (per-thread│ │ (native memory) │ │
│ └──────────────┘ └────────────┘ └─────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Direct Memory (off-heap — ByteBuffer.allocateDirect)│ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

Heap — The GC-managed region where all object instances live. Divided into Young and Old generations. Configured with -Xms (initial size) and -Xmx (maximum size).

Young Generation — Where new objects are born. Divided into Eden (initial allocation) and two Survivor spaces (S0 and S1). Most objects die here — never reaching Old Gen. Minor GC runs here frequently and quickly.

Old Generation (Tenured) — Long-lived objects promoted from Young Gen after surviving several GC cycles. Major GC (or Full GC) collects here — less frequent but more expensive.

Metaspace (Java 8+) — Stores class metadata: class definitions, method bytecode, constant pool, annotations. Lives in native memory, not the heap. Has no fixed upper limit by default (unlike PermGen) — bounded by OS memory. Configurable with -XX:MaxMetaspaceSize.

Stack — One per thread. Stores stack frames: local variables, method parameters, return values, and partial results. Not GC-managed — frames are pushed on method call and popped on return. Configured with -Xss.

Code Cache — Stores JIT-compiled native code (HotSpot compiled methods). Native memory. Bounded by -XX:ReservedCodeCacheSize.

Direct Memory — Off-heap memory allocated via ByteBuffer.allocateDirect() or NIO. Used heavily by network frameworks (Netty) and file I/O. Not GC-managed directly — collected when the DirectByteBuffer object is GC'd. Bounded by -XX:MaxDirectMemorySize.

Q2. What is the difference between heap and stack memory?

Heap Stack Stores Object instances, arrays Local variables, method frames, primitive values, object references Managed by GC JVM automatically (push/pop) Lifetime Until GC collects Duration of method execution Shared Yes — all threads share one heap No — each thread has its own stack Size Configured via -Xmx Configured via -Xss (default: 512KB-1MB) Error when full OutOfMemoryError: Java heap space StackOverflowError Access speed Slower (GC pressure, fragmentation) Faster (LIFO, cache-friendly)

public void processOrder(int orderId) {
// orderId — stored on the stack (primitive)
// order reference — stored on the stack
// Order object itself — stored on the heap
Order order = orderRepository.findById(orderId);
    // localPrice — stored on the stack (primitive double)
double localPrice = order.getPrice() * 1.18;
}
// When this method returns: stack frame is popped, localPrice and order reference vanish
// The Order object on the heap remains until GC determines it's unreachable

Q3. What was PermGen and why was it replaced by Metaspace in Java 8?

PermGen (Permanent Generation) was a fixed-size heap region (pre-Java 8) that stored class metadata. Its critical flaw: it had a fixed upper limit (-XX:MaxPermSize, default 64-256MB depending on JVM). As applications grew — especially with class-heavy frameworks like Spring, Hibernate, and web containers — PermGen would fill up:

java.lang.OutOfMemoryError: PermGen space

This was especially common in development environments that did repeated hot-deploys (each deploy loaded new class versions, filling PermGen without releasing the old classes).

Metaspace (Java 8+) replaced PermGen with native memory allocation:

  • No fixed upper limit by default — grows to whatever native memory is available
  • Classes are collected when their classloader becomes unreachable (e.g., after an undeploy)
  • More efficient memory layout for class metadata
  • GC works better because class metadata doesn’t compete with object allocation for heap space
# Old PermGen flags (obsolete — use them and JVM will warn you)
-XX:PermSize=256m
-XX:MaxPermSize=512m
# Modern Metaspace flags
-XX:MetaspaceSize=256m # Triggers first GC when Metaspace reaches this size
-XX:MaxMetaspaceSize=512m # Cap native memory used by Metaspace (prevents runaway growth)

When to cap Metaspace: In containerized environments with strict memory limits, set -XX:MaxMetaspaceSize to prevent Metaspace from consuming memory that the container doesn't know about, causing OOM kills from the OS.

Q4. What is the Young Generation and how does Minor GC work?

The Young Generation is split into three spaces:

  • Eden: Where new objects are allocated. Fast — allocation is just a pointer bump in most cases.
  • Survivor S0 and S1: Objects that survive one GC cycle move here. Only one survivor space is active at a time — the other is always empty. After each Minor GC, live survivors move from the active survivor space to the empty one (this is why you always see one survivor space nearly full and one completely empty).

Minor GC flow:

Step 1: Eden fills up → Minor GC triggers
Step 2: GC traces from GC roots → finds all live objects in Eden + active Survivor (S0)
Step 3: Live objects copied to empty Survivor (S1), with age incremented
Step 4: Objects that reached the age threshold (default: 15) are promoted to Old Gen
Step 5: Eden and S0 are cleared entirely (no sweeping needed — all garbage, compacted by copying)
Step 6: S0 and S1 roles swap

Why copying collection? Copying leaves no fragmentation — allocation in Eden is always a simple pointer bump. The cost is that live objects must be copied, but since most objects die young (≈90%+ in typical Java apps), the volume of live objects to copy is small.

Objects that bypass Eden entirely:

  • Very large objects (exceeding -XX:PretenureSizeThreshold) go directly to Old Gen
  • G1GC has a concept of humongous objects — objects exceeding 50% of a G1 region go directly to the humongous region (part of Old Gen)

Q5. What triggers a Full GC? Why is it dangerous in production?

A Full GC collects both Young and Old generations (and Metaspace) simultaneously. It is a stop-the-world event for most GC algorithms — all application threads are paused for the duration.

Triggers:

  • Old Generation fills up
  • Metaspace fills up (if no -XX:MaxMetaspaceSize set, this is rare but possible with class-heavy deployments)
  • Explicit System.gc() call (unless -XX:+DisableExplicitGC is set)
  • JVM is unable to create a new object and cannot promote from Young to Old (promotion failure)
  • CMS concurrent mode failure (CMS is now removed in modern JVM)

Why it’s dangerous:

  • Pause duration scales with heap size and object graph complexity — a 32GB heap can pause for 10–30+ seconds with older collectors
  • During the pause, all user requests are frozen — timeouts cascade, load balancers mark the instance as unhealthy, distributed systems detect the node as down
  • A series of Full GCs (GC thrashing) means the JVM spends more time collecting than running application code — CPU spikes to 100%, throughput collapses
# Detect Full GCs in logs — enable GC logging
-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50m
# In the log, look for:
# [GC pause (G1 Evacuation Pause) ...] ← Minor GC, fast
# [Full GC (Ergonomics) ...] ← ALERT — application pause incoming

Section 2: Garbage Collection Algorithms

Q6. What are the GC algorithms available in modern Java (Java 21+) and when is each appropriate?

GC Default? Pause Model Best For Java Version G1GC Yes (Java 9+) Short STW pauses, predictable General purpose — 4GB–16GB heaps Java 9+ ZGC No Sub-millisecond STW Ultra-low latency, any heap size Java 15+ (prod), Generational: Java 21+, Exclusively Generational: Java 24+ Shenandoah No Low STW Large heaps, latency-sensitive Java 12+ (Red Hat); Generational: Java 25+ Serial GC No Full STW Single-core, small JVMs, serverless containers All Parallel GC No (was default pre-Java 9) Full STW Batch processing, maximum throughput All

G1GC is the right default for most production applications. It divides the heap into fixed-size regions (1MB–32MB each) and collects the regions with the most garbage first — the “Garbage First” name. Highly configurable pause target: -XX:MaxGCPauseMillis=200 (soft target, not a hard guarantee).

ZGC (Java 24+) is exclusively Generational ZGC — the non-generational mode was removed via JEP 490. Choose ZGC when your SLA requires consistent sub-millisecond GC pauses regardless of heap size. Ideal for real-time data pipelines, financial trading systems, large in-memory caches.

Shenandoah performs compaction concurrently alongside application threads (unlike G1 which compacts during STW pauses). As of Java 25, Generational Shenandoah is no longer experimental and requires no special flags. Best for large heaps (64GB+) where G1’s compaction pauses become noticeable.

Serial GC for containers with tight CPU limits or memory-constrained serverless functions — its low overhead and simplicity win when concurrency is not needed.

Parallel GC for batch jobs and data processing where throughput matters more than pause time (nightly ETL jobs, report generation, scientific computing).

Q7. How does G1GC work? What makes it different from older collectors?

G1GC divides the heap into a configurable number of equal-sized regions (typically 2048 regions, 1MB–32MB each). Regions are dynamically assigned roles — Eden, Survivor, Old, or Humongous — rather than having fixed-size generational areas.

Heap with G1GC — simplified:
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ E │ E │ S │ O │ E │ H │ H │ O │
├────┼────┼────┼────┼────┼────┼────┼────┤
│ O │ O │ E │ E │ S │ O │ E │ O │
└────┴────┴────┴────┴────┴────┴────┴────┘
E=Eden S=Survivor O=Old H=Humongous (large objects > 50% of region size)

GC cycle types in G1:

  1. Young GC (Minor GC): Collects all Eden and Survivor regions. Stop-the-world. Parallel. Usually 10–200ms. Eden regions promoted or moved to Survivor; Survivors promoted or moved to Old.
  2. Concurrent Marking: When Old Gen occupancy exceeds the Initiating Heap Occupancy Percent (-XX:InitiatingHeapOccupancyPercent, default 45%), G1 starts concurrent marking — runs alongside application threads to identify live objects in Old regions. No pause (except a brief initial mark piggy-backed on a Young GC).
  3. Mixed GC: After concurrent marking, G1 collects a mix of Young regions plus the most profitable Old regions (highest garbage ratio — hence “Garbage First”). Pause similar to Young GC.
  4. Full GC (fallback): If G1 cannot keep up with allocation, it falls back to a single-threaded Full GC. In Java 10+, the Full GC fallback is parallel (-XX:+UseG1GC + Java 10 made it parallel). Still expensive — design to avoid it.

Key G1 tuning levers:

-XX:MaxGCPauseMillis=200     # Soft pause target (G1 adjusts region set to meet this)
-XX:InitiatingHeapOccupancyPercent=45 # When to start concurrent marking
-XX:G1HeapRegionSize=16m # Region size (1MB-32MB, must be power of 2)
-XX:G1NewSizePercent=5 # Min % of heap for Young Gen
-XX:G1MaxNewSizePercent=60 # Max % of heap for Young Gen

Q8. How does ZGC achieve sub-millisecond pause times? What is load barrier?

ZGC’s core innovation: all heavy GC work runs concurrently with application threads. Stop-the-world pauses in ZGC are limited to:

  • Root scanning (stack roots, static references) — typically < 1ms
  • In Java 24+: monitor (synchronized) ownership resolution — no longer pins carrier threads

How ZGC achieves concurrent relocation (the hard part):

Moving objects while the application is running means: after moving object A from address 0x1000 to 0x2000, any existing reference still points to 0x1000. ZGC uses colored pointers — the 64-bit object reference encodes metadata (GC state bits) directly in unused high bits of the pointer. When a thread reads a reference, a load barrier — a small piece of code inserted by the JIT compiler at every object reference load — checks the metadata bits.

Normal pointer:  0x00000001F4A20000   (address only)
ZGC pointer: Metadata bits | Address
↑ ↑
Remapped/Marked Object location

If the metadata indicates the object has been relocated, the load barrier:

  1. Looks up the forwarding table to find the new address
  2. Updates the reference to point to the new address
  3. Returns the new address

This means every thread that touches a relocated object self-heals its reference — eliminating the need for a global stop-the-world remapping phase.

Generational ZGC (Java 21+, exclusively from Java 24+):

Non-generational ZGC treated all objects equally — it had to scan the entire heap on each cycle. Generational ZGC brings the generational hypothesis: most objects die young. Young Gen collections run more frequently on a smaller region, dramatically improving throughput (~10% improvement over non-generational ZGC) while keeping the same ultra-low pause characteristics.

ZGC evolution timeline:

  • Java 21: Generational ZGC introduced as an opt-in feature — NOT the default
  • Java 23: Generational ZGC becomes the default mode; non-generational deprecated
  • Java 24: Non-generational ZGC code entirely removed (JEP 490)
# Java 21-22: Generational ZGC available but requires explicit opt-in
-XX:+UseZGC -XX:+ZGenerational # Opt-in to generational mode
# Java 23: Generational is now the default; non-generational deprecated
-XX:+UseZGC # Generational by default; -XX:-ZGenerational still worked but warned
# Java 24+: ZGenerational=false is obsoleted; non-gen code removed (JEP 490)
-XX:+UseZGC # Generational ZGC — only mode available

Q9. What is Shenandoah GC and how does Generational Shenandoah (Java 25) improve it?

Shenandoah (developed by Red Hat, open-sourced in OpenJDK 12) takes a different approach from ZGC: it performs concurrent compaction using Brooks forwarding pointers.

Brooks Pointer: Every Shenandoah object has an extra word prepended to its header — a self-forwarding pointer. When an object is to be relocated, Shenandoah:

  1. Copies the object to a new location
  2. Updates the Brooks pointer in the old object to point to the new location
  3. Application threads reading through the old pointer find the new location via the Brooks pointer
  4. A concurrent update phase eventually updates all references to point directly to the new location

Unlike ZGC’s colored pointer approach (metadata in reference bits), Shenandoah uses an extra word in each object — meaning every object is 8 bytes larger. This is a space-vs-complexity tradeoff.

Generational Shenandoah (Java 25):

JEP 404 introduced Generational Shenandoah as experimental in Java 24. In Java 25 (LTS), it graduated to production-ready with no experimental flags required.

Before generational mode, Shenandoah scanned the entire heap on each GC cycle — expensive for long-lived object populations. Generational mode splits the heap into Young and Old generations, letting the collector focus most effort on Young Gen where objects die quickly:

# Java 24 — experimental, required flag
-XX:+UseShenandoahGC -XX:ShenandoahGCMode=generational # Experimental
# Java 25+ — production-ready, no experimental flag needed
-XX:+UseShenandoahGC # Non-generational is still available; generational is the focus

Benefits of Generational Shenandoah in Java 25:

  • Better throughput for typical workloads (most allocations die in Young Gen)
  • Shorter average pause times — Young Gen collections are smaller
  • Improved resilience to sudden allocation bursts

Q10. What is the GC Root? How does the GC decide what is garbage?

GC roots are the starting points for reachability tracing. Any object reachable by following references from a GC root is considered live. Anything not reachable is garbage.

GC root categories:

  1. Stack references — local variables and method parameters in all active stack frames across all threads
  2. Static fields — class-level (static) references in all loaded classes
  3. JNI references — objects referenced by native (C/C++) code via the JNI
  4. Thread objects — all active threads and their associated objects
  5. Class loaders — all loaded class loaders (and transitively, all classes they loaded)
  6. Synchronization monitors — objects held in synchronized blocks
public class GcDemo {
static Object staticRef = new Object(); // GC root — static field
    public void method() {
Object localRef = new Object(); // GC root — local variable (while method is active)
Object innerObj = new Object();
localRef = null; // innerObj is now unreachable — eligible for GC
// localRef still keeps staticRef's referent alive — static ref wins
}
}

Reachability is transitive: If Root → A → B → C, all of A, B, and C are live. If the reference Root → A is broken, all three become garbage (assuming no other paths to them).

Important: The GC does not use reference counting (unlike Python). Java uses tracing GC — it traces from roots. This means circular references are NOT a memory leak in Java:

// This does NOT cause a memory leak in Java
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a;
a = null;
b = null;
// Both nodes are unreachable from any GC root — they will be collected
// Reference counting would fail here; tracing GC handles it correctly

Q11. What is object promotion and what causes premature promotion?

Promotion is when an object in Young Gen survives enough Minor GC cycles (the tenuring threshold, default 15) and is moved to Old Gen. Once in Old Gen, it is only collected during Major GC or Full GC — much less frequently.

Premature promotion (also called premature tenuring) happens when objects that are actually short-lived end up in Old Gen because:

  1. Survivor space is too small: If the active Survivor space fills up during a Minor GC, the JVM promotes all remaining live objects to Old Gen regardless of their age. You see this as rapid Old Gen growth even when application semantics don’t require long-lived objects.
  2. High allocation rate: Short bursts of allocation flood Eden, trigger frequent Minor GCs, and objects accumulate age faster than they die.
  3. Large objects bypassing Young Gen: Objects exceeding the PretenureSizeThreshold or humongous object threshold in G1 go directly to Old Gen.

Diagnosis:

# Enable GC details to see survivor space utilization
-Xlog:gc+age*=debug
# In the GC log, look for:
# Desired survivor size 536870912 bytes, new threshold 1 (max threshold 15)
# ← threshold of 1 means almost everything is going directly to Old Gen

Fix: Increase survivor space ratio (-XX:SurvivorRatio=8 means Eden:Survivor = 8:1) or increase heap size to give Young Gen more room. With G1, tune -XX:G1NewSizePercent and -XX:G1MaxNewSizePercent.

Q12. What is the difference between Minor GC, Major GC, and Full GC?

These terms are overloaded in the Java ecosystem — even the JVM specification is imprecise about “Major GC.” Here is the practical distinction:

Minor GC Major GC Full GC Collects Young Gen only Old Gen (may include Young) Young + Old + Metaspace Trigger Eden fills up Old Gen fills up / concurrent GC falls behind Explicit System.gc(), promotion failure, Metaspace exhaustion STW duration Short (ms) Longer (hundreds of ms) Longest (potentially seconds) Frequency Frequent (every few seconds under load) Infrequent (minutes/hours ideally) Should be rare or never

Practical note: In G1GC terminology, there is no “Major GC” — there are Young GC, Mixed GC (which includes some Old regions), and Full GC. “Major GC” as a term comes from the Parallel and Serial collectors where Old Gen was collected separately.

# Monitoring GC frequency from JVM logs
-Xlog:gc:file=gc.log:time,uptime
# From logs, healthy patterns:
# Young GC every 2-10 seconds, pause 10-100ms — NORMAL
# Mixed GC occasionally — NORMAL
# Full GC more than once per day — INVESTIGATE
# Full GC every few minutes — CRITICAL — application will degrade

Section 3: Reference Types and Object Lifecycle

Q13. What are the four reference types in Java? How do they interact with GC?

Java has four reference strengths, in order from strongest to weakest:

// 1. Strong reference — default; object is never GC'd while strongly reachable
Object strongRef = new Object();
// 2. Soft reference — GC'd only when JVM is low on memory
SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = cache.get(); // null if GC collected it
// 3. Weak reference — GC'd at next GC cycle, regardless of memory pressure
WeakReference<ExpensiveObject> weakRef = new WeakReference<>(new ExpensiveObject());
ExpensiveObject obj = weakRef.get(); // null if collected
if (obj == null) obj = rebuildExpensiveObject();
// 4. Phantom reference — GC'd; reference.get() always returns null
// Used for post-mortem cleanup actions
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);

Reference GC behavior Use case Strong Never collected while reachable Default — most object references Soft Collected when JVM needs memory Memory-sensitive caches Weak Collected at next GC Canonicalizing maps (WeakHashMap), event listeners Phantom Collected; always returns null Post-GC cleanup; replacing finalize()

Critical Phantom reference fact (frequently tested): Unlike soft and weak references which are enqueued in the ReferenceQueue after the object is finalized, phantom references are enqueued before finalization. Calling phantomRef.get() always returns null — you cannot access the referent. You use phantom references only to detect when an object has been GC-queued and run cleanup logic.

Q14. Why is finalize() deprecated and what replaced it?

finalize() was Object's cleanup hook — called by the GC before collecting an object. It was deprecated in Java 9 and marked for removal in Java 18 (@Deprecated(forRemoval = true)).

Why finalize() is problematic:

  1. Unpredictable timing: The GC decides when to finalize — may be immediately or never (e.g., if the JVM exits). No guarantee it runs at all.
  2. Resurrection risk: finalize() can create a new strong reference to this, preventing collection. The object must then be GC'd again — and finalize() is only called once per object.
  3. Performance: Objects with finalize() methods require two GC cycles to collect: one to identify them as finalizable, one more after finalization. This doubles GC pressure.
  4. Exception swallowing: Uncaught exceptions in finalize() are silently ignored.
  5. Thread safety: finalize() is called by the Finalizer thread — any shared state access requires synchronization.

The replacement: java.lang.ref.Cleaner (Java 9+)

public class NativeResource implements AutoCloseable {
private static final Cleaner CLEANER = Cleaner.create();
    private final long nativeHandle;
private final Cleaner.Cleanable cleanable;
public NativeResource() {
this.nativeHandle = allocateNativeMemory();
// Cleanable runs the action when NativeResource becomes phantom-reachable
// The action is a SEPARATE object — no strong reference to NativeResource
this.cleanable = CLEANER.register(this, new CleanAction(nativeHandle));
}
@Override
public void close() {
cleanable.clean(); // Explicit cleanup — preferred path
}
// Static inner class — CRITICAL: must not reference outer NativeResource
// If it did, the outer object would never become phantom-reachable
private static class CleanAction implements Runnable {
private final long handle;
CleanAction(long handle) { this.handle = handle; }
@Override
public void run() {
freeNativeMemory(handle); // Cleanup logic
}
}
}

Cleaner uses phantom references internally — the clean action runs when the registered object becomes phantom-reachable. The action class must not hold a reference to the registered object (that would prevent it from ever becoming unreachable — a classic bug).

Best practice: Always use try-with-resources and AutoCloseable. Use Cleaner only as a safety net for cases where close() might not be called (defensive programming for resource leaks).

Q15. What is WeakHashMap and what are its pitfalls?

WeakHashMap stores keys as weak references. When a key has no other strong references, the GC collects it, and the corresponding entry is automatically removed from the map.

WeakHashMap<Object, String> map = new WeakHashMap<>();
Object key = new Object();
map.put(key, "value");
System.out.println(map.size()); // 1
key = null; // No more strong references to the key object
System.gc();
System.out.println(map.size()); // 0 — entry was removed automatically

Practical use: Caching computed results where you don’t want the cache to prevent GC of the keys (e.g., caching metadata about objects that may be short-lived).

Pitfalls:

  1. String literal keys are never collected: String literals are interned and referenced by the string pool (a GC root). map.put("key", value) — the String "key" will never be GC'd, so the entry will never be removed.
  2. Entry removal is not immediate: Entries are removed lazily — when the map is next accessed (get, put, size, etc.). Between GC and the next map operation, the map may appear larger than its effective size.
  3. Not thread-safe: Like HashMap, WeakHashMap is not thread-safe. No ConcurrentWeakHashMap exists in the standard library — use Guava's CacheBuilder.newBuilder().weakKeys().build() for a concurrent weak-keyed cache.

Section 4: Memory Leaks and Diagnosis

Q16. What causes memory leaks in Java? Is garbage collection a complete solution?

Java’s GC collects unreachable objects. A memory leak in Java means an object is reachable (not eligible for GC) but no longer needed by the application. GC is not a complete solution because it only collects unreachable objects.

Common Java memory leak patterns:

1. Static collections growing without bound:

public class EventBus {
// Static list — lives as long as the class
private static final List<EventListener> listeners = new ArrayList<>();
    public static void register(EventListener listener) {
listeners.add(listener); // Added but never removed — classic leak
}
}

2. Unclosed resources:

// Connection never closed — connection pool exhausted, then OOM from buffered data
Connection conn = dataSource.getConnection();
ResultSet rs = conn.createStatement().executeQuery(sql);
// Missing: conn.close() in finally block / try-with-resources

3. ThreadLocal not cleaned up in thread pools:

// Request handler sets ThreadLocal
threadLocalContext.set(new RequestContext());
// Handler returns without calling threadLocalContext.remove()
// Thread returns to pool — next request gets previous request's context
// Over time: unbounded context objects accumulate, pinned by thread pool threads

4. Listeners registered but never deregistered:

eventBus.register(new MyListener());  // MyListener is a GC root via eventBus
// MyListener holds a reference to a large model
// If MyListener is never unregistered, neither it nor the model is collected

5. Cache without eviction:

private static final Map<String, byte[]> cache = new HashMap<>();
// Add items indefinitely — cache size is unbounded
// Fix: Use LinkedHashMap with removeEldestEntry(), or Caffeine/Guava Cache with size eviction

6. Inner class holding outer class reference:

public class Outer {
private byte[] largeData = new byte[10_000_000];
    public class Inner {  // Non-static inner class holds implicit reference to Outer
public void doWork() { }
}
}
Inner inner = new Outer().new Inner();
// inner is still reachable → Outer is still reachable → 10MB largeData cannot be collected
// Fix: use static inner class or lambda

Q17. How do you diagnose a memory leak in production? Walk through the process.

Step 1: Confirm it’s a memory leak (not just working memory)

# Monitor heap usage over time
jstat -gcutil <pid> 5000 20 # Sample every 5s, 20 times
# Healthy: Old Gen usage grows, then drops after GC — stable baseline
# Leak: Old Gen usage grows monotonically, never drops back to baseline

Step 2: Enable GC logging (if not already enabled)

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

Look for: Old Gen usage floor rising with each Full GC — definitive sign of a leak.

Step 3: Capture a heap dump

# jcmd (preferred over jmap in modern Java)
jcmd <pid> GC.heap_dump /tmp/heapdump.hprof
# Or configure automatic dump on OOM
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps/
# jmap (still works but jcmd is preferred)
jmap -dump:format=b,file=/tmp/heapdump.hprof <pid>

Step 4: Analyze with Eclipse Memory Analyzer (MAT)

  • Open MAT → Load heap dump
  • Run “Leak Suspects Report” — MAT identifies objects accumulating unusually
  • Look at the “Retained Heap” column — which objects are keeping the most memory alive?
  • Examine the reference chain from the leak root to GC root — the path reveals why the object can’t be collected
# Quick histogram without MAT
jcmd <pid> GC.class_histogram | head -30
# Shows object counts and retained size by class — high count of unexpected types = leak suspect

Step 5: Reproduce and fix

Common pattern in MAT: you’ll see a HashMap or ArrayList with 500,000 entries, held by a static field or a long-lived service — that's your leak. Trace the reference chain to find what's holding it and either add cleanup, add eviction, or restructure the reference.

Q18. What is OutOfMemoryError? What are the different types?

OutOfMemoryError is thrown by the JVM when it cannot allocate an object. The error message tells you exactly which memory area was exhausted:

Get Vighneshwar Bhat’s stories in your inbox

Join Medium for free to get updates from this writer.

OutOfMemoryError: Java heap space The heap is full. Either the application needs more memory, or there's a memory leak.

-Xmx4g  # Increase heap; or fix the leak

OutOfMemoryError: GC overhead limit exceeded The JVM spent >98% of its time doing GC and recovered <2% of the heap. The JVM throws this to prevent it from running indefinitely doing nothing useful. Almost always indicates a memory leak or severely undersized heap.

-XX:-UseGCOverheadLimit  # Disable the limit (rarely correct — fix the root cause)

OutOfMemoryError: Metaspace Class metadata exhausted Metaspace. Common causes: massive class loading (class-heavy reflection, dynamic bytecode generation with cglib/ASM), or classloader leak (web app undeploy without releasing classloaders).

-XX:MaxMetaspaceSize=512m  # Add a cap and investigate classloader leaks

OutOfMemoryError: Direct buffer memory Off-heap direct memory exhausted. Common with NIO/Netty-based frameworks.

-XX:MaxDirectMemorySize=2g  # Increase limit; check for unreleased DirectByteBuffers

OutOfMemoryError: unable to create native thread OS cannot create more threads. Too many platform threads running (unbounded thread pool, thread leak).

ulimit -u  # Check per-process thread limit
# Fix: bound thread pools, reduce thread stack size (-Xss256k), or use virtual threads

OutOfMemoryError: Compressed class space When running with compressed oops (-XX:+UseCompressedOops, enabled by default for heaps < 32GB), class metadata goes into a fixed-size Compressed Class Space.

-XX:CompressedClassSpaceSize=1g  # Default 1GB, increase if needed

Section 5: GC Tuning and JVM Flags

Q19. What are the essential JVM flags every senior Java developer should know?

Heap sizing:

-Xms4g               # Initial heap size (set equal to -Xmx to avoid resizing pauses)
-Xmx4g # Maximum heap size
-Xss512k # Thread stack size (reduce for environments with many threads)

GC selection:

-XX:+UseG1GC         # G1GC (default Java 9+, explicit is fine for clarity)
-XX:+UseZGC # ZGC (Generational in Java 21+, exclusively in Java 24+)
-XX:+UseShenandoahGC # Shenandoah
-XX:+UseSerialGC # Serial (single-threaded, small JVMs)
-XX:+UseParallelGC # Parallel (throughput-oriented)

G1GC tuning:

-XX:MaxGCPauseMillis=200          # Soft pause target (G1 tries to meet this)
-XX:InitiatingHeapOccupancyPercent=45 # When to start concurrent marking
-XX:G1HeapRegionSize=16m # Region size (tune for humongous object threshold)
-XX:G1NewSizePercent=20 # Min % of heap for Young Gen
-XX:G1MaxNewSizePercent=40 # Max % of heap for Young Gen
-XX:ConcGCThreads=4 # Concurrent GC thread count

Metaspace:

-XX:MetaspaceSize=256m            # Initial size before first resize GC
-XX:MaxMetaspaceSize=512m # Cap (important in containers)

Diagnostics:

-XX:+HeapDumpOnOutOfMemoryError   # Auto heap dump on OOM
-XX:HeapDumpPath=/dumps/ # Where to write the dump
-Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=50m
-XX:+PrintFlagsFinal # Print all JVM flags at startup (verify your flags took effect)

Container awareness (Java 8u191+, Java 10+):

-XX:+UseContainerSupport          # Default on in modern Java — reads container CPU/memory limits
# Without this, the JVM sees the host machine's resources, not the container's limits

Q20. What is Compressed Oops and when does it matter?

Object references (pointers) on a 64-bit JVM are 8 bytes by default. With a 32GB heap or smaller, Java can represent object references using only 35 bits (32GB = 2³⁵ bytes, objects are 8-byte aligned so you can drop 3 bits → 32 bits needed). Compressed Oops (-XX:+UseCompressedOops, enabled by default for heaps ≤ 32GB) stores references as 32-bit values instead of 64-bit, saving ~20-30% of object memory and improving cache efficiency.

# Enabled by default for heaps < 32GB — verify:
java -XX:+PrintFlagsFinal -version | grep UseCompressedOops
# UseCompressedOops = true
# When you set -Xmx33g or larger, compressed oops are automatically disabled
# References switch back to full 8-byte pointers — memory usage jumps

Practical implication: If your heap is at 28GB, adding 4 more GB (to 32GB) might actually increase memory usage because Compressed Oops is disabled. The crossover point is exactly 32GB (-Xmx32g keeps Compressed Oops; -Xmx33g disables it). Many production JVMs deliberately cap at 30-31GB for this reason.

Q21. What is TLAB (Thread-Local Allocation Buffer)?

Object allocation in Eden looks like it requires synchronization — multiple threads can’t safely bump the same pointer. TLAB solves this by giving each thread its own private chunk of Eden:

Eden space:
┌──────────────────────────────────────────────────┐
│ Thread-1 TLAB │ Thread-2 TLAB │ Thread-3 TLAB │
│ [────────────]│ [──────────] │ [─────────────] │
└──────────────────────────────────────────────────┘

Each thread bumps its own TLAB pointer — no synchronization needed. This makes object allocation extremely fast (a few nanoseconds in the common case).

When a thread’s TLAB fills up, it requests a new TLAB from Eden — this is where synchronization briefly occurs. When Eden is exhausted, a Minor GC runs.

-XX:+UseTLAB                    # Default: true
-XX:TLABSize=0 # 0 = JVM auto-sizes based on allocation rate
-Xlog:gc+tlab*=trace # Log TLAB usage (noisy — use for diagnosis only)

Large objects that exceed the TLAB size are allocated directly in Eden (or Old Gen for very large ones) — these require shared Eden locking.

Q22. How does GC interact with containers and Kubernetes?

Before Java 8u191 / Java 10, the JVM read resource limits from the host machine, not the container. A JVM running in a 4GB container on a 128GB host would:

  • Set -Xmx to ~25% of 128GB = 32GB (default ergonomics) — far exceeding the container limit
  • Create thread pools sized to 64 vCPUs (host cores) — wildly excessive for a 2-core container
# Java 8u191+ / Java 10+ — container awareness is on by default
-XX:+UseContainerSupport # Reads cgroup memory limits (default: true)
-XX:MaxRAMPercentage=75.0 # Set heap to 75% of container memory limit
# For a 4GB container: heap = 3GB. Leave room for Metaspace, Code Cache, stack, off-heap.
# Verify in a container
java -XX:+PrintFlagsFinal -version | grep MaxRAMPercentage

Kubernetes best practices:

resources:
requests:
memory: "4Gi"
limits:
memory: "4Gi" # Request = limit to prevent OOM kills from bursting
# JVM flags for a 4Gi container
-XX:+UseContainerSupport
-XX:MaxRAMPercentage=75.0 # Heap = 3GB
-XX:MaxMetaspaceSize=256m # Prevent Metaspace from eating native memory
-XX:+ExitOnOutOfMemoryError # Crash-fast (let Kubernetes restart) instead of degrading

-XX:+ExitOnOutOfMemoryError is important in Kubernetes — a JVM that has OOM'd and is running in a degraded state (constant Full GC loops) is worse than a clean restart. Let the container die and be restarted by Kubernetes.

Section 6: Object Lifecycle and Escape Analysis

Q23. What is escape analysis and how does it optimize memory allocation?

Escape analysis is a JIT compiler optimization that determines whether an object’s lifetime is confined to the current method (it “doesn’t escape”). If an object doesn’t escape, the JVM can:

  1. Stack allocate it — instead of heap allocation, put the object directly on the thread stack. No GC needed — it vanishes when the method returns.
  2. Scalar replace it — decompose the object into its primitive fields, eliminating the object header entirely.
  3. Eliminate synchronization — if a lock object doesn’t escape, any synchronized on it is pointless and can be removed.
public double computeDistance(int x1, int y1, int x2, int y2) {
// Point objects don't escape this method
Point p1 = new Point(x1, y1); // May be stack-allocated by JIT
Point p2 = new Point(x2, y2); // Same
    return p1.distanceTo(p2);
// If JIT applies scalar replacement, p1/p2 never exist as objects — just four int locals
}

When escape analysis applies:

  • Object is only used locally — not passed to other methods that leak it
  • Object is not stored in fields or returned from the method
  • Object is small enough for stack allocation
-XX:+DoEscapeAnalysis          # Default: true (JIT performs escape analysis)
-XX:+PrintEscapeAnalysis # Debug output (development use)
-XX:+EliminateAllocations # Default: true (enables scalar replacement)

Practical impact: In tight loops creating and discarding small objects (e.g., stream pipeline intermediate objects, Optional, Point), escape analysis can eliminate allocations entirely — dramatically reducing GC pressure.

Q24. What is the difference between shallow heap and retained heap in memory analysis?

Shallow heap: The memory consumed by one object itself — the object header (mark word + class pointer, typically 12–16 bytes with compressed oops; reduced to 8 bytes in Java 25 via JEP 519 Compact Object Headers) plus all its fields. Does not include the objects that this object references.

Retained heap: The total memory that would be freed if this object were garbage collected — the object itself plus all objects that become unreachable when this object is removed. Retained heap represents the true “weight” of the object.

Example:
Service object (shallow: 32 bytes)
└── Map field (shallow: 48 bytes)
└── 10,000 Entry objects (shallow: 32 bytes each = 320,000 bytes)
└── String values (shallow: ~40 bytes each = 400,000 bytes)
  Service.shallowHeap = 32 bytes
Service.retainedHeap = 32 + 48 + 320,000 + 400,000 = ~720,000 bytes

In MAT and heap dump analysis, sort by retained heap to find the actual memory hogs. An object with tiny shallow heap but huge retained heap is a memory accumulator — it’s holding a large object graph alive. This is exactly the pattern you see with memory leaks: a static reference (small shallow heap) holding an ever-growing collection (huge retained heap).

Section 7: Modern JVM Memory — Java 21 to 25

Q25. What changed in Java 21–25 that affects memory management?

Java 21 (LTS) — September 2023:

  • Generational ZGC available as opt-in: -XX:+UseZGC -XX:+ZGenerational enables generational mode. Non-generational remains the default in Java 21. (Generational ZGC becomes the default in Java 23; non-generational mode is removed in Java 24 via JEP 490.)
  • Virtual threads (Project Loom): Platform threads use ~1MB stack; virtual threads use ~few KB (growing dynamically). A service running 100K concurrent requests that previously needed a large thread pool (and corresponding stack memory) can now handle it with virtual threads at a fraction of the memory.
  • Sequenced collections: Minor API addition, no memory impact.

Java 22 — March 2024:

  • Various GC and runtime performance improvements
  • Region pinning for G1GC: G1 can now pin individual regions during JNI critical sections, avoiding full GC fallbacks when native code holds references.

Java 23 — September 2024:

  • Generational ZGC becomes the default. -XX:+UseZGC now selects generational mode automatically. Non-generational ZGC is deprecated (still available but will be removed).
  • GC and JIT performance improvements
  • GraalVM Native Image: first support for Native Memory Tracking (NMT) in Native Image builds

Java 24 — March 2025:

  • JEP 490: ZGC — Remove the Non-Generational Mode. The non-generational ZGC code is removed. ZGenerational=false is obsoleted. ZGC runs only in generational mode.
  • JEP 491: Virtual Thread pinning fix. synchronized blocks no longer pin virtual threads to carrier platform threads. This eliminates a memory pressure scenario where carrier threads were blocked instead of being reused.

Java 25 (LTS) — September 2025:

  • Generational Shenandoah graduates to production (JEP 521). No experimental flags needed. Full concurrent, low-pause GC with generational support out of the box.
  • JEP 519: Compact Object Headers (production). Object header size reduced from 12–16 bytes down to 8 bytes by combining the mark word and class pointer into a single 64-bit word. Real-world benchmarks show up to 20–30% reduction in heap usage and CPU improvement for object-heavy workloads. First shipped as experimental in Java 24 (JEP 450); graduates to production in Java 25.
  • ZGC Page Cache replaced with Mapped Cache: New self-balancing binary search tree structure for managing unused ZGC memory — more efficiently merges contiguous free ranges, reduces memory fragmentation.
  • G1GC region management improvements for large heaps
  • First LTS with Generational ZGC as the one and only ZGC flavor

Q26. What is off-heap memory and when should you use it?

Off-heap memory is memory allocated outside the Java heap — not managed by the GC. Java accesses it via ByteBuffer.allocateDirect(), the Foreign Function & Memory API (Java 22+, JEP 454), or unsafe operations.

// Direct ByteBuffer — off-heap allocation
ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024 * 1024 * 100); // 100MB off-heap
// GC doesn't collect this — it's freed when the DirectByteBuffer object is GC'd
// (via Cleaner internally)
// Foreign Memory API (Java 22+, JEP 454 — finalized)
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(1024 * 1024); // 1MB off-heap
// Explicit lifetime — freed when arena is closed
} // Arena.close() frees the memory immediately — no GC waiting

When to use off-heap:

  • Large caches where GC pressure from large heaps is unacceptable (e.g., in-memory databases, distributed caches)
  • Network I/O — DirectByteBuffer avoids copying data between heap and native buffers when writing to sockets (Netty uses this extensively)
  • Inter-process communication — shared memory segments (memory-mapped files)
  • Native library integration — passing data to JNI code without copying

Risks:

  • No GC safety net — memory leaks are permanent until process restart
  • No bounds checking by default — foreign memory API provides safer bounds-checked access
  • OutOfMemoryError: Direct buffer memory when exhausted

Q27. What is GraalVM native image and how does it change the memory model?

GraalVM native image compiles a Java application ahead-of-time into a self-contained native executable (no JVM runtime). The memory model is significantly different:

JVM (HotSpot) Native Image (SubstrateVM) Startup 1–10 seconds (JVM + class loading + JIT warmup) Milliseconds Memory at startup 100MB+ (JVM overhead) 10–50MB GC G1GC, ZGC, Shenandoah, etc. Serial GC (default), G1GC (GraalVM Enterprise), Epsilon Dynamic class loading Supported Not supported (classes fixed at build time) JIT compilation Yes (HotSpot C2) No (AOT only) Peak throughput Higher (after JIT warmup) Lower (no runtime optimization) Heap dumps Full detail Supported (GraalVM 23+)

Memory in native image:

  • Heap is managed by SubstrateVM (a JVM implemented in Java itself)
  • Object model is the same as HotSpot — objects have headers, references are 8 bytes (or compressed)
  • GC is simpler — Serial GC by default; G1GC available in GraalVM Enterprise

Why it matters for interviews: Native image is now the target for Spring Boot serverless deployments. Understanding that native image runs with different GC constraints (Serial GC, no dynamic class loading) is essential for 2026 senior Java roles.

Section 8: Practical GC Scenarios

Q28. How do you choose between G1GC, ZGC, and Shenandoah in 2026?

This is the question that shows whether you understand GC trade-offs or just memorized feature lists.

Choose G1GC when:

  • General-purpose application, heap 2GB-32GB
  • Pause tolerance 50–200ms is acceptable (web services, APIs)
  • You want the most mature, most tuned, most documented collector
  • CPU budget is tight — G1 has lowest concurrent overhead

Choose ZGC when:

  • Strict latency SLAs: p99 pause < 1ms regardless of heap size
  • Very large heaps (32GB-TB range)
  • Real-time trading systems, streaming pipelines, gaming backends
  • Java 21+ (Generational ZGC gives throughput comparable to G1)
  • Trade-off: ~5–10% more CPU for GC threads, ~15–30% more memory overhead

Choose Shenandoah when:

  • Large heaps (64GB+) where concurrent compaction matters
  • Java 25+ (Generational Shenandoah is now production-ready)
  • Running Red Hat OpenJDK distributions where Shenandoah has additional tuning

Choose Parallel GC when:

  • Batch jobs where throughput > pause time
  • Nightly ETL, report generation, scientific computing
  • No concurrent GC overhead — all GC threads work in parallel during STW

Never choose Serial GC for production services — use it only for small single-threaded utilities or constrained embedded environments.

Q29. You see GC overhead limit exceeded in production. What do you do?

This error means the JVM spent >98% of time in GC and recovered <2% of the heap. This is always a symptom, not the root cause.

Step 1: Don’t just increase heap

Increasing -Xmx may delay the error but won't fix it if there's a leak. You need to understand why the heap can't be freed.

Step 2: Capture heap dump

# If the app is still running (barely)
jcmd <pid> GC.heap_dump /tmp/heapdump.hprof
# Add to JVM flags for next incident (heap is captured automatically before OOM)
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps/

Step 3: Analyze with MAT

Run “Leak Suspects” report. Sort by retained heap. Find the accumulator.

Step 4: Check for known patterns

  • Static caches without eviction
  • Session objects accumulating in web apps under high load
  • ThreadLocal not being removed
  • Hibernate session-level caches or query result caches growing unbounded

Step 5: Temporary mitigation while fixing

-XX:GCTimeLimit=98             # Default: 98 — lower this to fail faster rather than thrash
-XX:GCHeapFreeLimit=2 # Default: 2 — raise this to tolerate more GC overhead
-XX:+ExitOnOutOfMemoryError # Prefer a clean crash over a degraded JVM

Q30. What is Epsilon GC and when is it useful?

Epsilon GC (introduced in Java 11, JEP 318) is a no-op garbage collector — it allocates memory but never collects. When the heap is exhausted, the JVM exits with OutOfMemoryError.

-XX:+UseEpsilonGC

Use cases:

  1. Performance testing: Eliminate GC as a variable when benchmarking allocation-intensive code. GC pauses add noise to microbenchmarks — Epsilon removes all GC overhead.
  2. Short-lived programs: CLI tools, serverless functions, or batch jobs that run for seconds and exit. If you can size the heap to fit all allocations, GC overhead is pure waste.
  3. Memory pressure analysis: Run with Epsilon to see the true allocation rate of your application before GC reclaims anything.

Never use in production services — as soon as Eden fills up and there’s no GC to reclaim it, the JVM dies.

Q31. What is a safepoint and why does it matter for GC pause duration?

A safepoint is a point in a thread’s execution where the JVM can safely inspect or modify the thread’s state. The JVM requires all threads to reach a safepoint before performing stop-the-world operations (like GC root scanning).

Reaching a safepoint:

  • JVM sets a flag
  • Each thread checks the safepoint flag at regular intervals (back-edge of loops, method returns, etc.)
  • When a thread reaches a safepoint check, it pauses and signals it’s at a safepoint
  • STW begins only when all threads have reached a safepoint

The Time to Safepoint (TTSP) problem:

If one thread is executing a long compiled loop without safepoint checks, the GC must wait for that loop to complete — all other threads are already paused, waiting. The reported GC pause time includes TTSP. A 500ms GC pause could be: 5ms actual GC work + 495ms waiting for one thread to reach a safepoint.

-Xlog:safepoint*=debug:file=safepoints.log  # Log safepoint operations
# Look for: "Application time" and "Stopping threads took" — the latter is TTSP

Java 10+ mitigation: Loop-strip mining inserts safepoint checks at loop back-edges in compiled code, reducing worst-case TTSP.

Q32. What is the G1GC “humongous object” problem and how do you fix it?

In G1GC, an object is “humongous” if it exceeds 50% of the G1 region size. Humongous objects are allocated directly in the Old Generation (in contiguous regions). This creates problems:

  1. Fragmentation: Old Gen can have contiguous regions of humongous objects separated by regular objects. If no contiguous span of free regions exists for a new humongous object, a Full GC may be triggered even though plenty of total memory is free.
  2. Early GC pressure: Humongous regions are collected during concurrent marking cycles, not during efficient Young GC.
  3. Skipped Young GC optimization: Humongous allocations don’t benefit from TLAB or Eden-based allocation.

Diagnosis:

-Xlog:gc+humongous*=debug  # Log humongous allocations
# Output: [info][gc,humongous] Object allocation forced to humongous: size 2048K

Fix 1: Increase G1 region size

-XX:G1HeapRegionSize=32m  # Default auto-sized (1-32MB based on heap)
# With 32m regions, objects must exceed 16MB to be humongous
# Good if large arrays are unavoidable (e.g., large image buffers)

Fix 2: Reduce object sizes at application level

Large byte arrays are the most common culprit — JSON serialization, image data, query result sets. Streaming data instead of buffering it all in memory prevents humongous allocations.

Fix 3: Switch to ZGC

ZGC doesn’t have the humongous object concept — large objects are handled efficiently without special-casing.

Q33. How does Java handle String memory? What is the String pool?

Strings are immutable in Java. The JVM maintains a String pool (also called the intern pool or string table) — a set of unique string instances stored in the heap (Metaspace before Java 7, heap since Java 7).

// String literals are automatically interned — same pool reference
String a = "hello";
String b = "hello";
System.out.println(a == b); // true — same object from the pool
// new String creates a heap object outside the pool
String c = new String("hello");
System.out.println(a == c); // false — different objects
System.out.println(a.equals(c)); // true — same content
// intern() adds to pool (or returns existing pool entry)
String d = c.intern();
System.out.println(a == d); // true — d is the pool entry

Memory implications:

The pool prevents duplicate String objects for literals — a "hello" literal used in 10,000 places is one object, not 10,000. This is a significant memory saving in applications with many string constants.

When the pool can cause problems:

If you call intern() on millions of runtime strings (e.g., column names from a CSV parser), the pool grows unboundedly. Use it only for strings that are truly constant across the application.

Java 7 change: Moving the pool to the heap (from PermGen) means pool strings are now subject to GC — if no string literal references an interned string, it can be collected. This eliminated the PermGen-exhaustion-via-intern() antipattern from Java 6.

String deduplication (G1GC):

-XX:+UseStringDeduplication  # G1GC feature — background thread deduplicates equal strings

This is different from interning: it makes multiple String objects with the same content share the same underlying char[] backing array, saving heap space even for non-interned strings. Works transparently at the GC level.

Q34. What is the difference between System.gc() and actually triggering GC?

System.gc();  // Requests GC — JVM may or may not comply

System.gc() is a hint, not a command. The JVM can (and sometimes does) ignore it. Under -XX:+DisableExplicitGC (often set when using DirectByteBuffers or when profiling), it is silently ignored.

When it’s used (despite being a hint):

  • Some JVM implementations (HotSpot) usually honor it in normal operation — but “usually” is not a guarantee
  • RMI (Remote Method Invocation) used to call System.gc() periodically — a source of unexpected Full GCs in RMI-using applications

Runtime.getRuntime().gc() is identical to System.gc() — just a different calling convention.

Why calling System.gc() explicitly is usually wrong:

  1. GC has its own optimal timing — calling it when the heap has room wastes CPU and causes unnecessary pauses
  2. It forces a Full GC in most implementations, pausing the application when it may not need it
  3. The right solution to memory pressure is fixing the code, not forcing GC

When explicit GC is legitimate:

  • After a large bulk deletion (e.g., purging a cache), to reclaim memory promptly before the next allocation wave
  • In performance tests, to get a clean heap baseline before the measured section
  • Triggering DirectByteBuffer cleanup (System.gc() causes the associated Cleaners to run, freeing direct memory — though System.runFinalization() + System.gc() combo is needed for reliability — and even better, use the Cleaner API or explicit ByteBuffer management)

Q35. What monitoring tools do you use for JVM memory in production?

Command-line:

# jstat — live GC statistics (lightweight)
jstat -gcutil <pid> 2000 # Print GC stats every 2 seconds
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0 45 78 23 96 90 142 4.2 1 0.8 5.0
# jcmd — Swiss Army knife for JVM diagnostics
jcmd <pid> VM.uptime
jcmd <pid> GC.heap_info
jcmd <pid> GC.class_histogram | head -20
jcmd <pid> Thread.print # Thread dump
jcmd <pid> GC.heap_dump /tmp/hd.hprof
# jmap (older, jcmd preferred)
jmap -histo:live <pid> | head -30

Off-process:

  • Prometheus + Micrometer: Spring Boot Actuator exposes /actuator/prometheus — JVM memory metrics (heap used/committed/max, GC count/time, Metaspace size) scraped by Prometheus, visualized in Grafana
  • Elastic APM / Dynatrace / New Relic: Full APM with automatic JVM memory monitoring
  • async-profiler: Allocation profiling — flame graphs showing which code paths allocate the most
# Spring Boot Actuator — expose JVM metrics for Prometheus scraping
management:
endpoints:
web:
exposure:
include: health,info,prometheus,metrics
metrics:
export:
prometheus:
enabled: true

Key metrics to alert on:

  • Old Gen usage: alert at 80% utilization (sustained, not spikes)
  • GC pause duration: alert if p99 GC pause exceeds your SLA threshold
  • GC time ratio: alert if >10% of time is spent in GC (severe: >50% = GC thrashing)
  • jvm_memory_used_bytes{area="heap"} climbing monotonically = memory leak

The Questions That Separate Senior Answers

At senior level, memory management questions are never just about definitions. Here is what interviewers are actually probing:

“What GC should we use?” → They want to hear: it depends on heap size, pause tolerance, and CPU budget — not just “ZGC because it’s newest.”

“We’re seeing 10-second pauses in production” → They want: GC log analysis, Full GC detection, humongous object diagnosis, not “increase the heap.”

“How does ZGC achieve sub-millisecond pauses?” → They want: colored pointers, load barrier, concurrent relocation — not “it’s a low-pause GC.”

“What changed in Java 25 for GC?” → They want: Generational ZGC is exclusively the mode (JEP 490 removed non-generational in Java 24), Shenandoah’s generational mode graduated to production in Java 25, ZGC Mapped Cache replaces Page Cache.

The mechanism behind the behavior — that’s the answer.

📘 Java Backend Interview Questions 300+ questions across all experience levels — with the depth interviewers are actually looking for.

📘 Complete Backend Interview Questions 1000+ questions covering core Java, concurrency, JVM internals, Spring, and system design.

📘 Springboot Interview Questions 200+ questions across all experience levels — with the depth interviewers are actually looking for.

--

--