01 — JVM Architecture

A running JVM is three moving parts talking to each other: a class loader subsystem that pulls bytecode into memory, an execution engine that runs (or compiles) that bytecode, and a memory manager that hands out heap and reclaims it. Everything you’ll debug in Phase 6 — a slow method, a leaking loader, a NoClassDefFoundError at 3 a.m. — lives inside one of these three. Getting the layout in your head first makes every later chapter cheaper.

The goal of this file is to give you a mental model precise enough to predict JVM behavior, not just describe it. If someone asks “what happens between java Main and my main method running?”, you should be able to walk through it without flinching.

1. The Class Loader Hierarchy

Java loads classes lazily, on first use, through a parent-delegation chain. When a class is referenced, the loader asks its parent first; only if the parent can’t find it does the child try. This is how the platform prevents your com.evil.String from replacing java.lang.String.

Loader

Java 8 name

Java 9+ name

Loads

Bootstrap

Bootstrap

Bootstrap

Core JDK classes (java.lang.*, java.util.*). Written in C++, no parent.

Extension → Platform

Extension

Platform

Non-core JDK modules (java.sql, java.xml, crypto providers).

Application (System)

Application

Application

Everything on your -cp / module path. Loads your main.

Custom

(yours)

(yours)

App servers, plugin systems, URLClassLoader, OSGi.

What changed in Java 9. Modules replaced the flat classpath as the preferred structure, the Extension loader was renamed Platform, and sun.misc.Unsafe and friends got stricter access rules. The Application loader is still there, still loading your classpath — the module system is layered on top.

Two failure modes worth naming now:

  • ClassNotFoundException — the loader looked, nobody had it. Usually a classpath/jar-missing problem.

  • NoClassDefFoundError — the class was loaded once (during linking) but is missing at execution time, or a static initializer threw. Look upward in the stack trace for the first failure. This one bites people in <clinit> chains.

2. Bytecode: What javac Actually Emits

Java source compiles to a stack-based instruction set stored in .class files. Read one with javap. This is not academic — reading bytecode is how you resolve “the JIT inlined this and the profiler is lying” arguments.

javac Add.java
javap -c -p Add        # -c disassembles, -p shows private members

For int add(int a, int b) { return a + b; } you’ll see something like:

  0: iload_1        // push a
  1: iload_2        // push b
  2: iadd           // pop 2, push sum
  3: ireturn        // pop, return

Every opcode operates on the operand stack of the current stack frame. The variables (a, b, this) live in local variable slots in the same frame. Method invocation pushes a new frame.

Opcodes worth recognizing:

Prefix / Opcode

Meaning

iload, istore

int local variable in/out

aload, astore

reference local variable in/out

invokestatic

static call — no receiver

invokespecial

constructors, super, private methods

invokevirtual

normal instance call (virtual dispatch)

invokeinterface

via interface reference

invokedynamic

lambdas, string concat (Java 9+), pattern matching

new

allocate object (does not run constructor — invokespecial does)

getfield / putfield

instance field read / write

Try this once, seriously: write a switch expression and a lambda, compile them, javap -c -p -v both, and see how invokedynamic and the bootstrap-methods table show up. It demystifies a lot of “how does Java lambda work” questions.

3. Execution Engine: Interpreter → C1 → C2

Bytecode doesn’t run on your CPU. The JVM starts by interpreting it (a big loop that reads opcodes and does the work), then progressively hands hot methods to JIT compilers that produce native code. This is tiered compilation, on by default since Java 8.

   [Interpreter]                       simple, slow, gathers profile
        │
   ~2k invocations
        ▼
   [C1 — client]                       fast compile, lightly optimized
        │
   ~10k invocations, hot path
        ▼
   [C2 — server]                       slow compile, heavily optimized

Two important consequences:

  1. Startup vs steady-state performance are different questions. A JMH benchmark without @Warmup measures the interpreter’s speed, which is nobody’s real-world workload.

  2. Deoptimization exists. When C2’s speculative assumptions break (a new subclass appears, an inlined branch turns out to be wrong), it bails back to the interpreter. You’ll see this in -XX:+PrintCompilation logs as made not entrant. Repeated deopts are a real performance bug — Phase 6 file 03 covers how to spot them.

GraalVM and AOT (native-image) are the alternatives worth naming: Graal replaces C2 with a Java-implemented JIT, and native-image compiles ahead-of-time to a static binary. Both trade peak throughput for startup time and lower memory. File 03 goes deeper.

4. Memory Regions: Stack, Heap, Metaspace

Every thread has its own stack. Every allocation you write with new lives on the shared heap. Class metadata (the Class<?> objects, method bytecode, JIT-compiled code stubs) lives in Metaspace, which is native memory — not part of -Xmx.

Region

Per-thread?

Sized by

Failure mode

Stack

Yes

-Xss (default ~1 MB platform, ~1 KB virtual)

StackOverflowError

Heap

No

-Xms / -Xmx

OutOfMemoryError: Java heap space

Metaspace

No

-XX:MaxMetaspaceSize (default unbounded)

OutOfMemoryError: Metaspace

Code Cache

No

-XX:ReservedCodeCacheSize (~240 MB default)

JIT stops compiling; app runs slower

Direct Buffers

No

-XX:MaxDirectMemorySize

OutOfMemoryError: Direct buffer memory

PermGen is gone. It was removed in Java 8 and replaced by Metaspace. Any tutorial still telling you to set -XX:PermSize is from before your career started. Metaspace grows into native memory by default, so a class-loader leak (very common in app servers with hot redeploy) shows up as ever-growing RSS, not as heap pressure. File 06 will make you comfortable diagnosing this.

Stack vs heap in one line: locals and the operand stack live on the stack; objects and their fields live on the heap; references live wherever the variable does. escape analysis (file 03) can turn a heap allocation into stack storage — one of the JIT’s biggest wins.

5. A Complete Startup Walkthrough

Concretely, java com.example.App:

  1. The launcher (java) mmap’s libjvm.so, initializes the Bootstrap loader in C++.

  2. The Platform loader initializes; the module graph resolves.

  3. The Application loader finds com.example.App on the classpath / module path.

  4. App.class is loaded (bytes read), linked (verify → prepare → resolve), and initialized (<clinit> runs).

  5. The main thread is created; App.main(String[]) begins executing in the interpreter.

  6. Any classes App touches get loaded on demand, transitively.

  7. After ~2k invocations, hot methods get compiled by C1; hotter ones by C2.

Every failure you’ll see at startup maps to one of those steps: bad classpath (step 3), verifier rejection or missing dependency (step 4), static initializer throwing (step 4), main class missing a main method (step 5). Reading a stack trace with that map in mind cuts diagnosis time in half.

⚠️ What Most People Get Wrong

Two things people confidently get backward:

1. “Metaspace is on the heap.” No. It’s native memory. -Xmx doesn’t bound it. This is why hot-redeploy servers running with -Xmx4g can still be killed by the Linux OOM killer at 12 GB RSS — a class-loader leak is filling native, not Java heap. Bound it explicitly with -XX:MaxMetaspaceSize=512m in production.

2. “The JIT compiles my code at startup.” No. The interpreter runs it first, gathers profile data, and only compiles methods that exceed the tiered-compilation thresholds. This is why cold-start latency on a new pod is always worse than steady-state latency — and why -XX:+PrintCompilation output during your first few seconds looks empty, then explodes.

Recap

  • Three loaders (Bootstrap → Platform → Application), parent-delegation, custom loaders for plugins/app servers.

  • Bytecode is stack-machine; javap -c -p -v reads it; invokedynamic powers lambdas and string concat.

  • Interpreter → C1 → C2 tiered compilation; deopts exist and cost you.

  • Stack (per thread), Heap (shared, -Xmx), Metaspace (native, -XX:MaxMetaspaceSize), Code Cache, Direct Buffers.

  • PermGen is dead; if a doc mentions it, treat the doc as historical.


Return to README.md · Next: 02_memory_and_gc.md