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.
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
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.
// 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.
+---------------------------------------------------------------+
| 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
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.
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.
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.
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
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)
// 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
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.
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
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".
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.
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 |
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.
"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
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.
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.
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
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.
+---------------------------------------------------+----------------------+
| 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) --^
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
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.
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
+---+---+---+---+---+---+---+---+
| 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
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.
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 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
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
// -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 |
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.
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.
// 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.
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.
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.
// 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)
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.
# --- 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.
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.
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?"
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.
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 ·
ThreadLocalin 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:MaxRAMPercentagein 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.