JVM architecture explained
The class loader, the runtime data areas, the execution engine and the JIT — what actually happens between javac and your code running.
JVM architecture — class loader, runtime data areas, execution engine, JIT What an OS is · kernel vs user mode · system calls · the syscall boundary
Stage 1 begins. Java is now both your DSA language and your backend language, so every hour here pays twice. And the JVM is the thing that makes every later Java answer either shallow or deep — "why is
Stringimmutable", "what doesvolatiledo", "why did we get an OOM" all bottom out here.
Part 1 — L-023 · JVM architecture
What the JVM is
A specification for an abstract computing machine, and the programs that implement it.
Two things to separate:
- The JVM specification — a document describing an instruction set, memory areas and behaviour
- A JVM implementation — HotSpot (the common one), OpenJ9, GraalVM, Azul Zing
Java the language and the JVM are independent. Kotlin, Scala, Groovy and Clojure all compile to JVM bytecode. The JVM neither knows nor cares that your source was Java. This decoupling is why the ecosystem is so large.
The three subsystems
┌──────────────────────────────────────────────────────────────────────┐
│ JVM │
│ │
│ ┌───────────────────────┐ │
│ │ 1. CLASS LOADER │ Loading → Linking → Initialisation │
│ │ SUBSYSTEM │ │
│ └───────────┬───────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 2. RUNTIME DATA AREAS │ │
│ │ │ │
│ │ ┌──────────────────────┐ ┌───────────────────────────┐ │ │
│ │ │ SHARED across │ │ PER-THREAD │ │ │
│ │ │ all threads │ │ │ │ │
│ │ │ │ │ • JVM Stack │ │ │
│ │ │ • Heap │ │ (frames, locals) │ │ │
│ │ │ • Method Area │ │ • PC Register │ │ │
│ │ │ (Metaspace) │ │ • Native Method Stack │ │ │
│ │ └──────────────────────┘ └───────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ 3. EXECUTION ENGINE │ Interpreter · JIT (C1, C2) · GC │
│ └───────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Memorise the shared-vs-per-thread split. It is the single most useful line in this diagram:
The heap is shared. The stack is per-thread.
That one sentence is the seed of:
- Why local variables are inherently thread-safe (Day 064)
- Why objects need synchronisation (Day 065)
- Why each thread costs ~1 MB (its stack) — Day 069's pool sizing
- Why a
StackOverflowErrorkills one thread and anOutOfMemoryErrorusually kills everything
1. The class loader subsystem
Three phases, in strict order:
Loading
Find the .class file, read the bytes, create a Class object on the heap.
The delegation hierarchy — this is asked:
Bootstrap ClassLoader (native code) — loads java.*, the core JDK
▲ delegates up
Platform ClassLoader — JDK modules beyond core
▲
Application ClassLoader — your classpath
▲
(custom loaders) — Tomcat, Spring Boot, OSGi
Parent delegation: a loader asks its parent first, and only loads the class itself if the parent can't.
Why: security and consistency. You cannot write your own java.lang.String and have it loaded —
the bootstrap loader always wins. Without delegation, a malicious classpath entry could replace core
classes.
Loading is lazy — a class is loaded on first active use, not at startup. This is why a missing dependency can surface twenty minutes into a run. (Day 076A goes deep on the debugging.)
Linking
Three sub-steps:
| Step | Does |
|---|---|
| Verification | Checks the bytecode is legal: no stack underflow, no illegal jumps, type-safe |
| Preparation | Allocates static fields and sets them to default values (0, null, false) — not your initialisers yet |
| Resolution | Turns symbolic references into direct references (may be lazy) |
Verification is why Java is memory-safe. A C program can scribble over arbitrary memory; JVM bytecode is proven well-formed before it runs. That's a genuine architectural answer to "why is Java safer than C?"
Initialisation
Runs static initialisers and static field assignments, in source order. This is where
static { ... } blocks execute.
class Demo {
static int x = 5; // preparation: x = 0; initialisation: x = 5
static { System.out.println("static block, x=" + x); }
}
Initialisation is thread-safe and runs exactly once, guaranteed by the JVM. That's why the "initialisation-on-demand holder" idiom is the correct lazy singleton — the JVM does the locking for you (Day 093).
2. Runtime data areas
| Area | Shared? | Holds | Error when full |
|---|---|---|---|
| Heap | Shared | All objects and arrays | OutOfMemoryError: Java heap space |
| Method Area (Metaspace) | Shared | Class metadata, static fields, the runtime constant pool | OutOfMemoryError: Metaspace |
| JVM Stack | Per-thread | Frames: local variables, operand stack, return address | StackOverflowError |
| PC Register | Per-thread | Address of the current instruction | — |
| Native Method Stack | Per-thread | Frames for native (JNI) calls | StackOverflowError |
Metaspace replaced PermGen in Java 8. PermGen was a fixed-size region inside the heap, and
OutOfMemoryError: PermGen space was a notorious problem — especially on app servers redeploying
web apps, where old classloaders leaked. Metaspace lives in native memory and grows by default.
That's a good, specific piece of history to know.
Day 025 goes deep on stack vs heap. Today, know the split exists.
3. The execution engine
bytecode
│
├──▶ INTERPRETER executes immediately, slow per instruction
│ │
│ │ profiling counters: how often is this method called?
│ ▼
├──▶ C1 (client) quick compile, modest optimisation
│ │
│ │ still hot?
│ ▼
└──▶ C2 (server) slow compile, aggressive optimisation
│
▼
native machine code
Tiered compilation — the default. Start interpreted for fast startup, promote to C1 quickly, then to C2 for the genuinely hot paths. You get both fast startup and fast steady state.
What C2 can do that javac cannot
| Optimisation | What |
|---|---|
| Inlining | Paste a small method's body into its caller. Enables everything else. |
| Escape analysis | If an object never leaves the method, allocate it on the stack — or eliminate it |
| Speculative devirtualisation | "This call site has always been ArrayList" — compile for that, guard in case it changes |
| Loop unrolling, dead code elimination, constant folding | Standard compiler work, with runtime facts |
Deoptimisation: if a speculative assumption breaks — a second implementation type finally shows up — C2 discards the compiled code and falls back to the interpreter. That's why benchmark numbers can suddenly get worse: you invalidated an assumption.
This is the concrete answer to "is Java slow?" — startup is slower than C, steady-state throughput is competitive because C2 optimises with information a static compiler never has.
Type this yourself
public class JvmArch {
static { System.out.println("3. static block runs (initialisation)"); }
static int counter = init();
static int init() { System.out.println("4. static field initialiser"); return 42; }
public static void main(String[] args) {
System.out.println("5. main");
// Which loader loaded what?
System.out.println("\nJvmArch loader: " + JvmArch.class.getClassLoader());
System.out.println("String loader: " + String.class.getClassLoader()); // null = bootstrap
System.out.println("Parent: " + JvmArch.class.getClassLoader().getParent());
// Memory areas
Runtime rt = Runtime.getRuntime();
System.out.printf("%nHeap max: %,d MB%n", rt.maxMemory() / 1024 / 1024);
System.out.printf("Heap total: %,d MB%n", rt.totalMemory() / 1024 / 1024);
System.out.printf("Heap free: %,d MB%n", rt.freeMemory() / 1024 / 1024);
// Per-thread stack — blow it up
try { recurse(0); } catch (StackOverflowError e) {
System.out.println("\nStackOverflowError — the STACK is per-thread and small");
}
}
static void recurse(int depth) {
if (depth % 5000 == 0) System.out.println("depth " + depth);
recurse(depth + 1);
}
}
Note String.class.getClassLoader() returns null. That isn't a bug — it means the bootstrap
loader, which is native code and has no Java object representing it. A classic interview detail.
Then observe the JIT and the loader directly:
java -verbose:class JvmArch | head -30 # every class as it loads — watch it be LAZY
java -XX:+PrintCompilation JvmArch # every JIT compilation event
java -Xint JvmArch # interpreter only — no JIT
java -Xss256k JvmArch # smaller stack → overflow much sooner
java -Xmx64m JvmArch # smaller heap
Run the -Xss256k version and compare the depth reached. You just changed the per-thread stack
size and watched it matter. That's the same knob behind "each thread costs ~1 MB" on Day 069.
Part 2 — B-01 · What an OS is
The two jobs
- Abstraction — turn hardware into usable interfaces. Files instead of disk sectors, processes instead of CPU time slices, sockets instead of network cards.
- Arbitration — decide who gets the CPU, the memory, the disk, and enforce isolation.
Every one of those abstractions you've already met: processes and ports (Day 003), sockets (Day 016, C-11), the loader (Day 001).
Kernel mode vs user mode
A hardware feature, not a software convention. The CPU has a privilege bit.
| User mode | Kernel mode | |
|---|---|---|
| Runs | Your code, the JVM | The OS kernel |
| Can execute privileged instructions | ❌ | ✅ |
| Can access any memory | ❌ — only its own mapping | ✅ |
| Can touch hardware directly | ❌ | ✅ |
This is what makes process isolation real (Day 003). Your program cannot read another process's memory — not by policy, but because the MMU refuses and the CPU traps.
System calls — the doorway
Your code needs privileged things: read a file, send a packet, create a thread. It cannot do them itself. It asks.
USER MODE KERNEL MODE
───────── ───────────
your Java code
│
socket.read()
│
JVM native method (JNI)
│
libc read()
│
── syscall instruction ──▶ ┌──────────────────────┐
│ TRAP │ 1. switch to kernel │
│ (mode switch) │ 2. validate args │
│ │ 3. do the work │
│ │ 4. switch back │
│ ◀────────────────────┴──────────────────────┘
return value
A syscall is not a function call. It's a controlled trap that switches privilege level. That costs — hundreds of nanoseconds to a few microseconds, plus cache and TLB disruption.
Why the cost matters to you
// 💀 One syscall per byte. Catastrophically slow.
FileInputStream in = new FileInputStream("big.txt");
int b;
while ((b = in.read()) != -1) { process(b); }
// ✅ One syscall per 8 KB. Often 100× faster.
BufferedInputStream in = new BufferedInputStream(new FileInputStream("big.txt"));
That is the entire reason BufferedInputStream exists — and it's the answer to "why do we wrap
streams in Java?" which most people answer with "it's faster" and nothing more. It's fewer mode
switches. Day 063 revisits this.
The same reasoning explains:
- Why batching database queries beats a loop of single queries
- Why
epollbeats one thread per connection (Day 067, B-14) — one syscall reports on 10,000 sockets - Why context switching is expensive (Day 033, B-04)
Where the JVM sits
Your Java code
↓
JVM (a normal user-mode process!)
↓ JNI / native methods
libc
↓ syscalls
Kernel
↓
Hardware
The JVM is just a process (Day 003). It has a PID, its own address space, and it makes syscalls
like anything else. java -jar app.jar starts a process; the JVM's heap is memory the OS gave that
process.
That framing makes the next few days concrete: heap exhaustion is a process running out of memory the OS allocated to it.
Type this yourself
# Watch a Java process make syscalls
strace -c java -version # Linux: count syscalls by type
strace -f -e trace=network java YourNetworkApp # just network syscalls
# Windows: use Process Monitor (procmon) and filter by the java.exe PID
// Measure the syscall cost yourself
import java.io.*;
public class SyscallCost {
public static void main(String[] args) throws Exception {
File f = File.createTempFile("test", ".bin");
try (FileOutputStream out = new FileOutputStream(f)) {
byte[] data = new byte[5_000_000];
out.write(data);
}
long start = System.nanoTime();
try (FileInputStream in = new FileInputStream(f)) {
while (in.read() != -1) {} // one syscall per byte
}
System.out.printf("Unbuffered: %,d ms%n", (System.nanoTime() - start) / 1_000_000);
start = System.nanoTime();
try (BufferedInputStream in = new BufferedInputStream(new FileInputStream(f))) {
while (in.read() != -1) {} // one syscall per 8 KB
}
System.out.printf("Buffered: %,d ms%n", (System.nanoTime() - start) / 1_000_000);
f.delete();
}
}
Write both numbers down. The ratio is the cost of crossing the syscall boundary five million times, measured on your machine.
Common mistakes
| Mistake | Correction |
|---|---|
| "The JVM is an interpreter" | It interprets and JIT-compiles. Tiered compilation uses both. |
| Thinking classes load at startup | Loading is lazy, on first active use. |
| Confusing heap and stack ownership | Heap shared across threads; stack per thread. This drives all of concurrency. |
| "Metaspace is part of the heap" | It's native memory since Java 8. PermGen was in the heap. |
| Ignoring buffering on I/O | Every unbuffered read is a syscall. Buffering is not a micro-optimisation. |
"String.class.getClassLoader() returns null means broken" |
null means the bootstrap loader — native, no Java object. |
Interview questions
Q: Describe the JVM architecture.
Three subsystems. The class loader loads, links and initialises classes, using parent delegation so core classes can't be replaced. The runtime data areas hold the heap and method area shared across threads, plus a per-thread stack, PC register and native stack. The execution engine interprets bytecode, JIT-compiles hot methods through C1 and C2, and runs garbage collection.
Q: What is parent delegation and why does it exist?
A class loader asks its parent to load a class before trying itself, so the bootstrap loader always wins for core classes. It prevents a classpath entry from substituting its own
java.lang.String, and guarantees a consistent single definition of core types.
Q: What does bytecode verification protect against?
It proves the bytecode is well-formed before execution — no operand stack underflow, no jumps to arbitrary addresses, type-correct operations. It's the foundation of Java's memory safety; a verified program cannot corrupt memory the way native code can.
Q: Why is the first request to a Java service slow?
Class loading is lazy so classes load on first use, and the JIT hasn't compiled hot paths yet, so early requests run interpreted. Steady-state performance arrives after warmup, which is why readiness probes and load-balancer warm-up matter for JVM services.
Q: What is a system call and why does it cost?
A controlled trap that switches the CPU from user mode to kernel mode so privileged work can be done on your behalf. It costs because of the mode switch plus cache and TLB disruption — which is why buffered I/O, batching and
epollall exist to reduce the number of crossings.
Q: Where does the JVM sit relative to the OS?
It's an ordinary user-mode process with its own address space. Its heap is memory the OS allocated to that process, and every file or network operation ultimately becomes a syscall.
Mini task
- Run
java -verbose:classon a small program. Find a class that loads late — prove laziness. - Run with
-Xss256kand-Xss4m. Record the recursion depth reached in each. - Run
SyscallCost. Record both timings and the ratio. - Draw the three-subsystem diagram from memory, including which areas are per-thread.
Exit questions
- Name the three JVM subsystems and what each does.
- What are the three class loading phases, and what happens in each?
- What is parent delegation and what does it prevent?
- Which runtime data areas are shared and which are per-thread? Why does that matter?
- What replaced PermGen, and where does it live now?
- What is tiered compilation? Name three things C2 can do that
javaccan't. - What is deoptimisation and when does it happen?
- What is kernel mode, and how is process isolation actually enforced?
- What is a syscall, why does it cost, and name three designs that exist to reduce syscalls.
Articulation drill
Two minutes: "What happens when you run java -jar app.jar?"
Process starts → JVM initialises → class loader loads the main class with delegation → verification →
static initialisation → main runs interpreted → hot methods JIT-compile. Name the memory areas as
you go.
Previous: Day 022 · Tomorrow: Day 024 — read bytecode yourself
with javap