JVM Internals — Complete Notes

Heap vs stack, class loading, garbage collection, and the errors that wake you at 3am — enough to debug a real production problem and answer "why is your service using 4GB?" with something better than a shrug.

00. What the JVM actually is

The Java Virtual Machine is a program that pretends to be a computer. Your code is compiled for that imaginary machine, not for yours — and the JVM translates, on the fly, to whatever real CPU it's standing on.

The journey from source to CPU
Hello.java          your source
    |  javac                        <- compiles ONCE, at build time
    v
Hello.class         BYTECODE — platform-independent instructions for the imaginary machine
    |  java Hello
    v
   JVM              loads it, verifies it, runs it
    |  interpreter (immediately)  +  JIT compiler (for the hot parts)
    v
machine code        actual x86 / ARM instructions for THIS CPU
"Write once, run anywhere" — this is the mechanism

javac does not produce machine code. It produces bytecode, which is identical on every platform. The JVM is the part that's platform-specific — there's a different JVM binary for Linux/x86, macOS/ARM, and so on. Your .class file doesn't change; the thing interpreting it does.

Bytecode is real and readable — go look at it
// Source:
int add(int a, int b) { return a + b; }

// javap -c Hello.class  ->
//   0: iload_1        // push local var 1 (a) onto the operand stack
//   1: iload_2        // push local var 2 (b)
//   2: iadd           // pop both, add, push result
//   3: ireturn        // pop and return it

// Note there are no registers — the JVM is a STACK machine. Everything is
// push/pop on an operand stack. That's what makes it portable.

Bytecode is a recipe written in metric units. It doesn't matter whether the kitchen is American or British — each kitchen has its own converter (the JVM) that turns "200ml" into whatever its local measuring cups understand. One recipe, every kitchen.

01. The JVM's three parts

Every JVM is the same three subsystems: something that loads classes, somewhere to put data, and something that executes.

The whole machine on one screen
+---------------------------------------------------------------+
|                    1. CLASS LOADER SUBSYSTEM                  |
|      Loading  ->  Linking (Verify/Prepare/Resolve)  ->  Init  |
+---------------------------------------------------------------+
                              |
                              v
+---------------------------------------------------------------+
|                    2. RUNTIME DATA AREAS                      |
|                                                               |
|   SHARED BY ALL THREADS        |    PER-THREAD                |
|  +-------------------------+   |   +----------------------+   |
|  |         HEAP            |   |   |    JVM Stack         |   |
|  |  Young  |     Old       |   |   |  (frames: locals +   |   |
|  | E S0 S1 |               |   |   |   operand stack)     |   |
|  +-------------------------+   |   +----------------------+   |
|  +-------------------------+   |   +----------------------+   |
|  |  Metaspace (native mem) |   |   |   PC Register        |   |
|  |  class metadata         |   |   +----------------------+   |
|  +-------------------------+   |   +----------------------+   |
|  +-------------------------+   |   |  Native Method Stack |   |
|  |  Code Cache (JIT output)|   |   +----------------------+   |
|  +-------------------------+   |                              |
+---------------------------------------------------------------+
                              |
                              v
+---------------------------------------------------------------+
|                    3. EXECUTION ENGINE                        |
|      Interpreter   |   JIT Compiler (C1/C2)   |   GC          |
+---------------------------------------------------------------+
                              |
                    JNI  ->  Native libraries
The line that matters most

Shared (heap, metaspace, code cache) is where threads can collide — everything in the Concurrency notes exists because of this box. Per-thread (stack, PC register) is private by construction, which is why local variables are automatically thread-safe.

02. Class loading

Classes aren't loaded when the program starts. Each one is loaded lazily, the first time it's genuinely needed, and goes through three phases before it can be used.

Loading → Linking → Initialization
1. LOADING
     Find Foo.class (disk / jar / network), read the bytes,
     create a java.lang.Class object representing it. Metadata -> Metaspace.

2. LINKING
     a. VERIFICATION   — is this bytecode legal and safe? (no stack overflows, valid types,
                          no jumping into the middle of an instruction). This is why the JVM
                          can safely run untrusted bytecode. Also why it rejects a corrupt .class.
     b. PREPARATION    — allocate static fields and set them to DEFAULT values.
                          static int x = 5;  ->  x becomes 0 here. NOT 5. Not yet.
     c. RESOLUTION     — turn symbolic references ("the class named com.Foo") into direct
                          references (a pointer). May be lazy — often deferred to first use.

3. INITIALIZATION
     Run the static initialisers and static field assignments, in source order —
     the compiler bundles them into a method called <clinit>.
     NOW  static int x = 5;  actually assigns 5.
Preparation vs Initialization — the classic gotcha

static int x = 5; happens twice. During preparation x is set to its default 0. Only during initialization is 5 assigned. That gap is what lets circular static initialisation read a 0 or a null that "can't possibly" be there.

Initialization is lazy — and precisely specified
class Holder {
    static { System.out.println("Holder initialised"); }
    static int VALUE = 42;
    static final int CONSTANT = 99;         // compile-time constant!
}

// TRIGGERS initialization:
new Holder();                  // instantiating
Holder.VALUE;                  // reading a non-final static
Holder.someStaticMethod();     // calling a static method
Class.forName("Holder");       // reflection (by default)

// Does NOT trigger it:
Holder[] arr = new Holder[10];  // creating an array of them
Holder.CONSTANT;                // static final with a constant initialiser is INLINED by javac
                                // at compile time — the class is never even loaded!
Sub.inheritedStaticField;       // only the DECLARING class is initialised, not the subclass

The three loaders and parent delegation

The hierarchy
Bootstrap ClassLoader        (written in C++, shows as `null` from Java)
    |                        loads the core JDK: java.lang.*, java.util.*  (java.base module)
    v
Platform ClassLoader         (called "Extension" before Java 9)
    |                        loads JDK modules that aren't core
    v
Application ClassLoader      loads YOUR classes from the classpath / modulepath
    |
    v
(your own custom loaders — Tomcat, Spring Boot, OSGi, plugin systems)
Parent delegation — always ask upward first
// When asked to load "com.example.Foo", a loader:
//   1. Have I already loaded it? -> return the cached Class
//   2. Ask my PARENT to load it  -> recursion, all the way to Bootstrap
//   3. Only if every ancestor failed do I try to load it myself

System.out.println(String.class.getClassLoader());        // null  -> Bootstrap
System.out.println(MyClass.class.getClassLoader());       // jdk...AppClassLoader
Why delegate upward? Security.

Write your own java.lang.String and put it on the classpath. Delegation means the request reaches Bootstrap first, which finds the real one and returns it — your impostor is never loaded. Without delegation, any jar could replace core classes and own your process. It also guarantees core classes are loaded exactly once.

A class's identity = its name AND its loader

The same Foo.class file loaded by two different loaders produces two distinct types. They are not assignment-compatible, and casting between them throws ClassCastException: Foo cannot be cast to Foo — one of the most confusing messages in Java. This is exactly how an app server isolates two deployed apps that each ship a different version of the same library.

03. Runtime memory areas

Five regions. Two of them (heap, metaspace) are shared and are where your memory problems live; three are per-thread.

Area Shared? Holds Error when full
Heap Shared All objects & arrays, the String pool OutOfMemoryError: Java heap space
Metaspace Shared Class metadata, method bytecode, static fields OutOfMemoryError: Metaspace
Code cache Shared JIT-compiled native code Falls back to the interpreter (much slower)
JVM stack Per thread Frames: locals, operand stack, return address StackOverflowError
PC register Per thread Address of the current instruction
Native stack Per thread Frames for JNI/native calls StackOverflowError

Metaspace — and the PermGen it replaced

Java 8 deleted PermGen

Before Java 8, class metadata lived in PermGen, a fixed-size region inside the heap. It was a constant source of OutOfMemoryError: PermGen space — every app server redeploy leaked classloaders until it filled up. Java 8 moved metadata to Metaspace, which lives in native memory and grows automatically. That's why the error you see today is Metaspace, not PermGen — and why the fix is usually "stop leaking classloaders", not "raise the limit".

Metaspace is unbounded by default

It grows until it exhausts system RAM. That's usually the right default, but a classloader leak will now eat the whole machine instead of failing fast. Setting -XX:MaxMetaspaceSize=256m turns a slow server death into a clear, early error.

04. Heap vs stack — the interview classic

The stack holds the bookkeeping of method calls. The heap holds the objects. The single sentence: references live on the stack, objects live on the heap.

Watch where each piece goes
void method() {
    int x = 5;                          // the VALUE 5 -> stack (primitive local)
    String s = "hello";                 // the REFERENCE -> stack; the String object -> heap (pool)
    Person p = new Person("Ann");       // the REFERENCE p -> stack; the Person OBJECT -> heap
    int[] a = new int[1000];            // reference -> stack; the 1000-int array -> heap
}
// When method() returns, its whole frame is popped:
//   x, s, p, a all vanish instantly — no GC involved.
//   The Person and int[] on the heap are now unreachable, and become GC's problem.
Stack Heap
Stores Primitives & references (locals), frames Objects, arrays, instance fields
Scope One thread — private All threads — shared
Lifetime Exactly the method call Until unreachable, then GC'd
Managed by Automatic push/pop — free The garbage collector
Speed Very fast (just move a pointer) Slower (allocate, then collect later)
Size Small (-Xss, ~512KB–1MB) Large (-Xmx, GBs)
Runs out → StackOverflowError OutOfMemoryError
Thread-safe? Inherently No — you must synchronise
A stack frame — what one method call costs
main() calls a(), which calls b():

        |  b()'s frame   |  <- top: local variable array, operand stack, return address
        |  a()'s frame   |
        |  main()'s frame|
        +----------------+
// Each call PUSHES a frame; each return POPS it.
// Infinite recursion = push forever = StackOverflowError.
// This stack of frames is EXACTLY what gets printed as a stack trace.

The stack is a spring-loaded plate dispenser: plates only go on and off the top, in strict order, and it's instant. The heap is a warehouse: things get put anywhere there's room, anyone with the address can reach them, and someone has to come round periodically and clear out what nobody references anymore.

The nuance: escape analysis

"Objects always live on the heap" is the right interview answer, but the JIT can prove that an object never escapes its method and then scalar-replace it — exploding it into plain locals on the stack, with no allocation and nothing for GC to collect. So the specification says heap; the implementation often quietly does better.

05. Garbage collection

The GC finds objects nobody can reach anymore and reclaims their memory. It is not reference counting, and it does not care whether a variable "went out of scope" — the only question is reachability.

GC roots and reachability

An object is alive if a root can reach it. That's the entire rule.
GC ROOTS (the starting points, definitionally alive):
  - local variables & parameters in every live thread's stack frames
  - static fields of loaded classes
  - active threads themselves
  - JNI references from native code
  - objects used as monitors (locked)

Then: mark everything reachable from a root, transitively.
      Everything NOT marked is garbage — no matter how many references
      those garbage objects hold to EACH OTHER.
Why Java has no reference-counting leak

Two objects pointing at each other, with nothing else pointing at them, are both garbage — because no root reaches them. A reference-counting system (like old Python or C++ shared_ptr) would see counts of 1 each and leak them forever. Tracing from roots makes cycles a non-issue.

Cycles are collected fine
class Node { Node other; }

void f() {
    Node a = new Node();
    Node b = new Node();
    a.other = b;
    b.other = a;      // circular reference
}
// After f() returns: a and b are gone from the stack. Nothing reaches either node.
// BOTH are collected. The cycle is irrelevant.

The generational hypothesis

The observation the whole heap layout is built on

Most objects die young. Empirically, the overwhelming majority of objects become garbage almost immediately (loop temporaries, string builders, DTOs). A small minority survive and then tend to live a very long time. So: don't scan the whole heap every time. Scan the young part often and cheaply; scan the old part rarely.

The heap layout that follows from it
+---------------------------------------------------+----------------------+
|                  YOUNG GENERATION                 |    OLD GENERATION    |
|                                                   |      (Tenured)       |
|  +-------------------+  +--------+  +--------+    |                      |
|  |       Eden        |  |   S0   |  |   S1   |    |  long-lived objects  |
|  |  new objects here |  |survivor|  |survivor|    |                      |
|  +-------------------+  +--------+  +--------+    |                      |
+---------------------------------------------------+----------------------+
        ~80%                 ~10%       ~10%              usually ~2/3 of heap

Minor GC (frequent, fast)  ------^                   Major/Full GC (rare, slow) --^
An object's life story
1. `new Foo()`            -> allocated in EDEN (just bump a pointer — allocation is nearly free)

2. Eden fills up         -> MINOR GC ("young collection"):
                              - mark live objects in Eden + the in-use survivor space
                              - COPY the survivors to the OTHER survivor space, age += 1
                              - wipe Eden and the old survivor space ENTIRELY — instantly
                              - S0 and S1 swap roles

   Key insight: cost is proportional to how many objects SURVIVE, not how many died.
   If 99% of Eden is garbage, a minor GC is almost free. Dead objects cost nothing.

3. Survives ~15 minor GCs (-XX:MaxTenuringThreshold) -> PROMOTED to the Old generation

4. Old generation fills   -> MAJOR / FULL GC: expensive, may pause everything

Also: an object too big for Eden is allocated straight into Old ("humongous" in G1).

Eden is the arrivals hall — everyone lands there. Most leave within minutes. Periodically security sweeps the hall and moves the few people still around into a waiting lounge (survivor space), stamping their pass each time. Get stamped fifteen times and you clearly live here — you're moved to the residential district (old gen), which is only swept on rare occasions.

Mark-Sweep-Compact

The three phases
1. MARK     — walk from the GC roots, flag everything reachable.
              Cost is proportional to LIVE data.

2. SWEEP    — reclaim everything unmarked.
              Fast, but leaves the heap full of holes -> FRAGMENTATION.
              You can then have 500MB free and still fail to allocate a 1MB array.

3. COMPACT  — slide the survivors together so free space is one contiguous block.
              Expensive (objects move, so every reference must be updated),
              but makes the next allocation a simple pointer bump.

The young generation instead uses COPYING: survivors are copied out and the whole
region is wiped. Compaction is a free side effect — which is why minor GCs are so cheap.
Stop-the-world

To move objects safely, the GC must freeze every application thread — otherwise a thread could use a reference the GC is busy relocating. This is a stop-the-world pause. Every GC has them; modern collectors work to make them short and predictable rather than to eliminate them. A "GC pause" in your latency graph is this.

06. The collectors

Every collector trades between throughput (total work done), latency (longest pause), and footprint (memory used). You cannot win all three; you pick two.

Collector Flag Optimised for Use when
Serial -XX:+UseSerialGC Tiny footprint Small heaps, containers, single core, CLI tools
Parallel -XX:+UseParallelGC Throughput Batch jobs where a 2s pause is fine (default in Java 8)
G1 -XX:+UseG1GC Balance The default since Java 9. Almost always right.
ZGC -XX:+UseZGC Latency Huge heaps, sub-millisecond pauses required
Shenandoah -XX:+UseShenandoahGC Latency Same goal as ZGC, different design
Epsilon -XX:+UseEpsilonGC Nothing — never collects Benchmarking & testing only. It will OOM. Deliberately.

G1 — the one you're actually running

G1 divides the heap into equal regions instead of fixed generations
+---+---+---+---+---+---+---+---+
| E | O | S | E | O | O | H | E |    ~2048 regions, each 1-32MB
+---+---+---+---+---+---+---+---+
| O | E | O | S | E | O | E | O |    E=Eden  S=Survivor  O=Old  H=Humongous
+---+---+---+---+---+---+---+---+

// A region's ROLE is dynamic — young and old are just labels, not fixed address ranges.
// G1 tracks how much garbage each region holds, then collects the FULLEST-OF-GARBAGE
// regions first. Hence "Garbage First".
//
// Crucially, it collects only AS MANY regions as fit in your pause budget:
-XX:MaxGCPauseMillis=200          // "keep pauses under 200ms" — a goal, not a guarantee
Why G1 is the default

You give it a pause target instead of tuning generation sizes by hand. It collects a partial, budget-sized set of regions each cycle, compacts as it goes (so no fragmentation), and degrades gracefully. It gives up some raw throughput versus Parallel GC in exchange for predictable pauses — the right trade for almost every service.

CMS is gone

The Concurrent Mark Sweep collector was the low-pause option for years. It never compacted, so it fragmented until it hit a "concurrent mode failure" and fell back to a single-threaded full GC — a multi-second pause at the worst possible moment. It was deprecated in Java 9 and removed in Java 14. If you see -XX:+UseConcMarkSweepGC in a config, that config predates 2020.

ZGC's trick

ZGC does nearly all its work concurrently with your application, using coloured pointers and load barriers — metadata is stored in unused bits of each pointer, so a thread reading a reference to a relocating object transparently fixes it up on the fly. Pauses are sub-millisecond and, remarkably, independent of heap size — 8GB or 8TB, same pause. The cost is throughput and memory overhead.

07. OutOfMemoryError vs StackOverflowError

Both are Errors, not exceptions — meaning do not catch them. They tell you the JVM has run out of a resource you can't conjure from a catch block.

StackOverflowError — one thread's stack is full

Almost always recursion
void recurse() { recurse(); }      // no base case -> frames pile up -> boom
recurse();
// Exception in thread "main" java.lang.StackOverflowError
//     at Main.recurse(Main.java:3)
//     at Main.recurse(Main.java:3)
//     ... the same line, thousands of times

// Real-world causes:
//  - a missing/incorrect recursion base case
//  - accidental mutual recursion: a() -> b() -> a()
//  - toString() calling itself:  return "Foo" + this;   // implicit this.toString()
//  - equals()/hashCode() recursing through a cyclic object graph
//  - a genuinely too-deep (but correct) recursion on a deep tree
The fix is a bigger algorithm, not a bigger stack
// -Xss2m   raises the per-thread stack size.
// This is a legitimate fix ONLY when the recursion is correct and genuinely deep.
// For a bug, raising it just means the crash takes slightly longer.
//
// Note: Java has NO tail-call optimisation. A tail-recursive method still
// pushes a frame per call. Deep recursion must be rewritten as a loop
// with an explicit ArrayDeque as your stack.

OutOfMemoryError — the heap (or another region) is full

Message Means Usual cause
Java heap space Can't allocate; GC can't free enough A leak, or -Xmx genuinely too small
GC overhead limit exceeded >98% of time in GC, <2% heap recovered Same as above, caught earlier
Metaspace Too much class metadata Classloader leak, or runtime class generation
unable to create new native thread OS refused a thread Thread leak — not a heap problem; -Xmx won't help
Requested array size exceeds VM limit Array bigger than Integer.MAX_VALUE-ish A bug in a size calculation
Direct buffer memory Off-heap NIO buffers exhausted Netty/NIO leak — again, outside -Xmx
An OOM is a leak until proven otherwise

The instinct is to raise -Xmx. That only buys time if the cause is a leak — you'll simply OOM later, with a bigger heap dump and a longer full GC on the way down. First take a heap dump and find what is holding the memory.

A real memory leak — the static collection
class Cache {
    private static final Map<String, byte[]> CACHE = new HashMap<>();   // static = a GC ROOT

    public static void put(String k, byte[] v) {
        CACHE.put(k, v);     // nothing is ever removed. This map is reachable forever.
    }
}
// Every entry is permanently reachable from a root, so GC can never touch it.
// Java "can't leak" only in the C sense — you can absolutely retain objects forever.

// Fixes: an eviction policy (Caffeine, Guava), a bounded LinkedHashMap with
// removeEldestEntry, or WeakHashMap if the key's lifetime should drive it.
The other classic leaks
// 1. Listeners you register and never remove
button.addListener(this);      // the button now holds `this` forever

// 2. ThreadLocal in a thread pool — the thread never dies, so the value never clears
private static final ThreadLocal<BigThing> TL = new ThreadLocal<>();
TL.set(big);                   // must TL.remove() in a finally!

// 3. Unclosed resources holding native memory
// 4. A HashMap key whose hashCode changes after insertion -> the entry is
//    unreachable by lookup but still strongly referenced by the map

08. The JIT compiler

The JVM starts by interpreting bytecode — slow, but instant to start. Meanwhile it watches which methods run often, and compiles those to native code. This is why Java is slow for the first few seconds and fast afterwards.

Tiered compilation — the escalator
Level 0: INTERPRETER   — execute bytecode directly. Starts instantly, ~20x slower.
                         Counts invocations and loop iterations as it goes.
   |  called often enough
   v
Level 1-3: C1 (client)  — compile fast, optimise lightly. Good code in milliseconds.
                          Also inserts PROFILING counters (which branches? which types?).
   |  still hot (~10,000 invocations)
   v
Level 4: C2 (server)    — compile slowly, optimise aggressively, USING that profile data.
                          This is the code that runs at "Java speed" — often at or near C.
Why a JIT can beat an ahead-of-time compiler

gcc must produce code that works for every possible input, forever. The JIT knows what actually happened in this run: this call site only ever saw ArrayList, this branch is never taken, this method is always tiny. So it optimises for reality and keeps a guard in case reality changes. That's information a static compiler can never have.

What C2 actually does
// INLINING — the most valuable optimisation, because it enables all the others
int getX() { return x; }
int sum = getX() + getX();      // becomes: int sum = x + x;  — the calls vanish entirely

// MONOMORPHIC DISPATCH — profile says this call site only ever saw ArrayList,
// so devirtualise the interface call into a direct one (with a type guard).
for (Object o : list) { ... }

// ESCAPE ANALYSIS + SCALAR REPLACEMENT — this object never leaves the method,
// so don't allocate it at all; keep its fields in registers.
Point p = new Point(1, 2);
return p.x + p.y;               // becomes: return 1 + 2;  -> return 3;  and nothing is allocated

// Plus: dead code elimination, loop unrolling, constant folding,
// lock elision (removing locks on provably thread-confined objects)
Deoptimization

Every speculative optimisation has a guard. If a call site that only ever saw ArrayList suddenly gets a LinkedList, the guard fails and the JVM deoptimizes: throws the compiled code away, falls back to the interpreter, and later recompiles with the new reality. This is also why Java microbenchmarks are so treacherous — and why you use JMH, which handles warmup and defeats dead-code elimination for you.

09. Flags & debugging a real problem

You will not tune GC by intuition. You take a measurement, form a hypothesis, and change one thing.

The flags worth knowing
# --- sizing ---
-Xms2g                      # initial heap. Set it EQUAL to -Xmx in production:
-Xmx2g                      #   avoids pause-inducing resizes and pre-commits the memory
-Xss1m                      # per-thread stack size
-XX:MaxMetaspaceSize=256m   # cap metaspace so a classloader leak fails fast

# --- collector ---
-XX:+UseG1GC                # default since 9
-XX:MaxGCPauseMillis=200    # G1's pause goal

# --- diagnostics: ALWAYS set these in production ---
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/heap.hprof
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=10M   # Java 9+ unified logging

# --- containers (Java 10+, on by default) ---
-XX:MaxRAMPercentage=75.0   # use 75% of the CONTAINER limit, not the host's RAM
                            # Use this instead of -Xmx in Kubernetes.
The container trap

An old JVM (pre-8u191) reads the host's RAM, not the cgroup limit. Give it a 512MB container on a 64GB node and it sizes its heap for 64GB — then the kernel OOM-kills the pod with exit code 137, and there's no Java stack trace because the JVM never knew. Modern JVMs are container-aware by default; use -XX:MaxRAMPercentage rather than a hardcoded -Xmx.

The toolbox
jps                          # list running JVMs and their pids
jstat -gc <pid> 1s           # live GC stats every second — is it collecting constantly?
jstack <pid>                 # thread dump — hangs, deadlocks (it detects them for you)
jmap -histo <pid>            # object histogram: what's on the heap, by count and bytes
jmap -dump:live,format=b,file=heap.hprof <pid>    # full heap dump
jcmd <pid> VM.native_memory  # off-heap usage (needs -XX:NativeMemoryTracking=summary)
jcmd <pid> GC.heap_info

# Then open heap.hprof in Eclipse MAT: "Leak Suspects" report usually names the culprit outright.
# For CPU/allocation profiling: Java Flight Recorder (JFR) — near-zero overhead, ships with the JDK
jcmd <pid> JFR.start duration=60s filename=rec.jfr

"Why is your service using 4GB?"

The answer that gets you hired

1. Because RSS ≠ heap. Total = heap + metaspace + code cache + thread stacks (1MB × threads!) + direct/NIO buffers + GC structures + the JVM itself. 2. Because a JVM doesn't return memory it isn't asked to — with -Xms4g it will hold 4GB whether it needs it or not. 3. Then check whether it's a leak: watch post-full-GC live-set over time (jstat). Flat sawtooth = healthy, just garbage. A rising floor after every full GC = a real leak → take a heap dump and open MAT.

Reading the sawtooth
HEALTHY — used memory drops back to the same floor every collection:
  used |    /|    /|    /|    /|
       |   / |   / |   / |   / |
       |__/__|__/__|__/__|__/__|____
       floor stays flat -> the live set is constant. This is just normal garbage.

LEAKING — the floor climbs after every full GC:
  used |      /|     /|    /|   /|
       |    _/ |   _/ |  _/ | _/ |
       |  _/   | _/   |_/   |/   |
       |_/_____|/_____|_____|____|__
       floor rising -> something is retained forever -> OOM is coming. Heap dump now.

10. Gotchas — where the JVM surprises you

1. System.gc() is a suggestion, and using it is almost always wrong.

It requests a full GC. The JVM may ignore it entirely (-XX:+DisableExplicitGC makes it a no-op). When it doesn't ignore it, you've forced an expensive stop-the-world pause the collector had deliberately avoided. You cannot out-think G1 from application code.

2. finalize() is deprecated, unpredictable, and dangerous.

It may run late, or never. It runs on an unbounded queue served by one thread, so it can leak; it can resurrect the object; and an exception inside it is swallowed. Deprecated since Java 9. Use try-with-resources, or java.lang.ref.Cleaner.

3. Java absolutely can leak memory.

"No manual free" ≠ "no leaks". Any object reachable from a GC root lives forever — a static collection that only grows, an unremoved listener, a ThreadLocal on a pool thread. GC frees the unreachable; it can't know you meant to forget something.

4. A memory leak and a heap that's simply too small look identical.

Both end in OutOfMemoryError: Java heap space. The distinguishing evidence is the live set after a full GC: constant → you just need a bigger heap; rising → a leak, and a bigger heap only delays it.

5. static final int constants are inlined at compile time.

static final int X = 5; is copied into the caller's bytecode. If you change the constant and recompile only that class, callers keep the old value until they're recompiled too. This produces genuinely baffling bugs. (It doesn't apply to static final objects — only compile-time constants.)

6. Threads cost ~1MB of stack each, outside the heap.

1,000 threads ≈ 1GB of virtual memory that -Xmx knows nothing about. This is why unable to create new native thread happens on a machine with plenty of "free" heap, and why you size pools rather than spawning per request.

7. The String pool is on the heap (since Java 7), and intern() is a trap.

String literals are automatically pooled and shared. new String("a") deliberately creates a distinct object — which is why == fails on it and equals is mandatory. Calling intern() by hand on many distinct strings is usually slower than the allocation you were avoiding.

8. Two classes with the same name can be different types.

Identity is name + classloader. Loaded by two loaders, they're incompatible — hence ClassCastException: Foo cannot be cast to Foo. If you see that, you have a classloader problem, not a typing problem.

9. Allocation is cheap. Really cheap.

Allocating in Eden is a pointer bump in a thread-local buffer (TLAB) — a few instructions, no locking. Combined with escape analysis, "avoid creating objects" is usually premature optimisation. Object retention is what costs you, not creation.

11. Interview Q&A

Q: Heap vs stack?

Stack is per-thread, holds frames with primitives and references, freed automatically on return, small, fast, inherently thread-safe, overflows with StackOverflowError. Heap is shared, holds all objects and arrays, reclaimed by GC, large, needs synchronisation, exhausts with OutOfMemoryError. References on the stack, objects on the heap.

Q: How does the JVM decide an object is garbage?

Reachability, not reference counting. It traces from GC roots (thread stack locals, statics, active threads, JNI refs, monitors) and marks everything reachable; the rest is garbage. That's why reference cycles are collected without any special handling.

Q: Explain the generational heap.

Built on the observation that most objects die young. New objects go in Eden; a minor GC copies the few survivors between survivor spaces, ageing them; after ~15 survivals they are promoted to Old, which is collected rarely. Minor GC cost is proportional to survivors, not garbage — so a mostly-dead Eden is nearly free to collect.

Q: Minor vs major vs full GC?

Minor = young generation only; frequent, fast, always stop-the-world but brief. Major = the old generation. Full = the whole heap (plus metaspace); rare and the most expensive. Frequent full GCs are a red flag: either the heap is too small or you're leaking.

Q: PermGen vs Metaspace?

PermGen (≤ Java 7) held class metadata inside the heap at a fixed size, and overflowed constantly on redeploys. Java 8 replaced it with Metaspace in native memory, growing dynamically — so OutOfMemoryError: PermGen space no longer exists, but an unbounded Metaspace can now eat the whole machine.

Q: What's G1 and why is it the default?

A region-based collector: the heap is ~2048 equal regions whose generational role is dynamic. It collects the regions with the most garbage first ("garbage first"), only as many as fit your MaxGCPauseMillis budget, and compacts as it copies. You get predictable pauses without hand-tuning generation sizes.

Q: OutOfMemoryError vs StackOverflowError?

OOM = the heap (or metaspace/native memory) can't satisfy an allocation — a leak or an undersized heap. SOE = one thread's stack is full — nearly always runaway recursion. Both are Errors; don't catch them.

Q: How would you find a memory leak in production?

Confirm it first: jstat -gc and watch the live set after full GCs — a rising floor means a leak. Then dump the heap (-XX:+HeapDumpOnOutOfMemoryError or jmap), open it in Eclipse MAT, and run Leak Suspects / dominator tree to find what's retaining the most and which GC root holds it. Usual culprits: an unbounded static cache, unremoved listeners, ThreadLocal on pool threads.

Q: What is the JIT and why is Java fast despite being interpreted?

It compiles hot methods to native code at runtime, using tiered compilation (interpreter → C1 with profiling → C2 with aggressive optimisation). Because it knows the actual runtime profile, it can inline, devirtualise, and scalar-replace based on what really happens, guarding the assumptions and deoptimizing if they break — information no ahead-of-time compiler has.

Q: Explain the class loading process.

Loading (read bytes, make the Class) → Linking: Verification (is the bytecode safe?), Preparation (statics get default values), Resolution (symbolic → direct refs) → Initialization (run <clinit>: static blocks and assignments). It's lazy — triggered on first real use. Loaders use parent delegation (ask upward first) so nobody can spoof java.lang.String.

Q: Why is your service using 4GB?

Because RSS is heap + metaspace + code cache + ~1MB per thread stack + direct buffers + GC metadata + the JVM binary — and because with -Xms4g the JVM reserves it whether it needs it or not, and rarely returns memory to the OS. Whether that's a problem depends on the post-full-GC live set, not on RSS.

12. Cheat sheet

  • Pipeline: .java →(javac)→ bytecode .class →(JVM: interpreter + JIT)→ native. Bytecode is portable; the JVM isn't.
  • Three subsystems: class loader · runtime data areas · execution engine (interpreter + JIT + GC).
  • Class loading: Load → Link (Verify → Prepare[defaults] → Resolve) → Init(<clinit>). Lazy. Parent delegation = ask upward first. Identity = name + loader.
  • Shared: heap, metaspace (native, replaced PermGen in 8), code cache. Per-thread: stack, PC register, native stack.
  • Stack: frames, primitives + references, auto-freed, ~1MB (-Xss) → StackOverflowError. Heap: objects, shared, GC'd (-Xmx) → OutOfMemoryError.
  • GC roots: stack locals · statics · active threads · JNI refs · monitors. Reachable = alive. Cycles collect fine.
  • Generations: Eden → S0/S1 (age++) → Old at ~15. Minor GC cost ∝ survivors, not garbage.
  • Phases: mark → sweep (fragments) → compact. Young gen copies instead. All require a stop-the-world pause.
  • Collectors: Serial (tiny) · Parallel (throughput, 8's default) · G1 (default since 9, region-based, pause target) · ZGC/Shenandoah (sub-ms) · CMS removed in 14.
  • OOM flavours: heap space · GC overhead limit · Metaspace · unable to create native thread (thread leak!) · direct buffer memory.
  • Leaks: static collections · listeners · ThreadLocal in pools · mutated hash keys. GC frees unreachable, not unwanted.
  • JIT: interpreter → C1 (fast + profile) → C2 (aggressive). Inlining · escape analysis · devirtualisation · deopt when a guard fails. Benchmark with JMH.
  • Prod flags: -Xms == -Xmx · -XX:MaxRAMPercentage in containers · -XX:+HeapDumpOnOutOfMemoryError · -Xlog:gc*.
  • Tools: jps · jstat -gc (is it leaking?) · jstack (hangs/deadlock) · jmap -histo/dump · MAT · JFR.
  • Leak test: live set after full GC — flat floor = fine, rising floor = leak.
Last reviewed · July 2026 · part of knowledge-base