JVM Internals

Reviewed & published by Brayan K

Look under the hood of the Java Virtual Machine. By the end you'll read the bytecode your code compiles to, trace how classes are loaded, name the memory areas where objects and variables live, and explain how the JIT turns slow interpreted code into fast native code while your program runs.

Part of the free Java course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

Before You Start

This is an advanced, "how it really works" lesson. You should already be comfortable writing and running a Java program, and understand classes and objects and the difference between primitives and reference types. Nothing here changes how you write code — it explains the runtime system that makes "compile once, run anywhere" possible, so the performance and memory behaviour you see day to day finally has a name.

Real-World Analogy: An Airport, Not a Single Machine

💡 Analogy: The JVM is less like one engine and more like a busy airport. Check-in (the class loader) brings each passenger — a class — into the building in the right order and checks their papers. The terminal areas (heap, stack, metaspace) are where everyone waits: long-stay parking for objects (the heap), a personal tray that follows each traveller through security and is emptied at the gate (each thread's stack), and a permanent records office holding the blueprints of every aircraft type (metaspace). The interpreter is a clerk reading instructions aloud one at a time; the JIT compiler notices a route flown a thousand times and prints a laminated fast-track card for it (native code). And cleaners (the garbage collector) constantly clear out gates nobody is using anymore.

Keep this picture in mind. Every section below is just one part of the airport: where a class enters, where its data sits, who executes its instructions, and who clears up afterwards.

1️⃣ Bytecode & the Class File

When you run javac Main.java, the compiler does not produce machine code for your CPU. It produces bytecode — a compact, platform-neutral instruction set — and stores it in a .class file. The JVM is the program that reads and runs that bytecode, which is exactly why the same .class file runs on Windows, macOS, or Linux: the JVM does the translating, not the compiler.

Bytecode runs on a stack machine. Instead of registers, instructions push and pop values on an operand stack. To compute a + b, the JVM pushes a (iload_0), pushes b (iload_1), then iadd pops both and pushes the sum. You can see all of this with the javap -c disassembler — the worked example below shows the real output for a two-line method.

public class Main {
    // A trivial method so the bytecode is short and readable.
    static int add(int a, int b) {
        return a + b;          // one addition, then return
    }

    public static void main(String[] args) {
        int result = add(2, 3);            // 5
        System.out.println(result);        // prints 5
    }
}
public class Main {
  public Main();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  static int add(int, int);
    Code:
       0: iload_0
       1: iload_1
       2: iadd
       3: ireturn

  public static void main(java.lang.String[]);
    Code:
       0: iconst_2
       1: iconst_3
       2: invokestatic  #7                  // Method add:(II)I
       5: istore_1
       6: getstatic     #13                 // Field java/lang/System.out:Ljava/io/PrintStream;
       9: iload_1
      10: invokevirtual #19                 // Method java/io/PrintStream.println:(I)V
      13: return
}

2️⃣ The Class-Loader Hierarchy

A class isn't in memory until something asks for it. Class loaders find a .class file, verify it, and turn it into a usable class — lazily, the first time it's referenced. They form a parent-first chain of three:

The crucial rule is delegation: before a loader loads a class itself, it asks its parent first, all the way up to Bootstrap. This is why you can't write your own java.lang.String to hijack the real one — the Bootstrap loader always answers first. The example below walks the chain for a real program.

public class Main {
    public static void main(String[] args) {
        // Walk the ClassLoader hierarchy for one of YOUR classes.
        ClassLoader appLoader = Main.class.getClassLoader();
        System.out.println("App loader:        " + appLoader.getClass().getName());
        System.out.println("  parent (Platform): " + appLoader.getParent().getClass().getName());

        // Core JDK classes (String, Integer, ...) are loaded by the
        // Bootstrap loader, which is native code, so it reports as null.
        ClassLoader coreLoader = String.class.getClassLoader();
        System.out.println("String loaded by:  " + coreLoader + "  (Bootstrap)");

        // Loading follows the chain UP first (delegation), so no app class
        // can ever shadow java.lang.String — the Bootstrap loader wins.
        System.out.println("Integer loaded by: " + Integer.class.getClassLoader());
    }
}

🎯 Your Turn #1: Find the Loaders

Fill in the two blanks so the program prints which loader handled your Main class versus the core String class. Remember: core JDK types come from the Bootstrap loader, which reports as null.

public class Main {
    public static void main(String[] args) {
        // 🎯 YOUR TURN — fill in the blanks marked with ___

        // 1) Get the ClassLoader that loaded YOUR Main class
        ClassLoader mine = Main.class.___;          // 👉 getClassLoader()
        System.out.println("Main loaded by: " + mine);

        // 2) Get the loader for the core JDK type String
        ClassLoader core = String.class.___;        // 👉 getClassLoader()
        System.out.println("String loaded by: " + core);

        // ✅ Expected output:
        // Main loaded by: jdk.internal.loader.ClassLoaders$AppClassLoader@...
        // String loaded by: null
    }
}

3️⃣ Runtime Memory Areas

Once classes are loaded and running, the JVM divides memory into distinct areas. Confusing two of them is the single most common source of "why did it crash?" bugs, so it pays to know exactly what each one holds:

The mental model that prevents most bugs: objects live on the heap; the references and primitive locals that point at them live on the stack. When a method returns, its stack frame vanishes — but any object it created stays on the heap until the garbage collector proves nothing points to it anymore.

public class Main {
    // Each recursive call adds a frame to the STACK. Infinite recursion
    // fills the stack -> StackOverflowError (NOT an OutOfMemoryError).
    static int deep(int n) {
        return deep(n + 1);                // never returns
    }

    public static void main(String[] args) {
        // Objects you 'new' live on the HEAP. Local variables and the
        // method-call chain live on the STACK.
        int[] onHeap = new int[3];         // the array object -> heap
        int onStack = 42;                  // this int -> current stack frame
        System.out.println(onHeap.length + " " + onStack);

        try {
            deep(0);                       // fills the stack...
        } catch (StackOverflowError e) {   // ...an Error, but one you CAN catch
            System.out.println("StackOverflowError: the stack ran out");
        }
    }
}

🎯 Your Turn #2: Heap or Stack?

Fill in the blank with the keyword that allocates an object on the heap. Then notice: the reference b sits in the stack frame, but the Box it points to lives on the heap.

public class Main {
    static class Box { int value = 10; }

    public static void main(String[] args) {
        // 🎯 YOUR TURN — fill in the blanks marked with ___

        // 1) Create a Box object. Use 'new' so the OBJECT lands on the HEAP.
        Box b = ___ Box();                  // 👉 the keyword that allocates on the heap

        // 2) A plain int local — this lives in the current STACK frame.
        int count = 5;

        // The reference 'b' sits on the stack; the Box it points to is on the heap.
        System.out.println("b.value = " + b.value + ", count = " + count);

        // ✅ Expected output: b.value = 10, count = 5
    }
}

4️⃣ Interpretation vs JIT Compilation

How does the JVM actually run bytecode? At first, an interpreter reads each instruction and performs it — simple, no warmup, but slow because every instruction is decoded every time. Meanwhile the JVM counts how often each method runs. When a method gets "hot," the JIT (Just-In-Time) compiler compiles its bytecode into native machine code, and future calls run that fast code directly.

HotSpot uses tiered compilation with two compilers. C1 compiles quickly with light optimisation to get you fast-ish code soon; C2 compiles slowly but optimises aggressively (inlining, loop unrolling, dead-code removal) for the very hottest methods. Code climbs the tiers as it proves itself:

StageTierTrade-off
InterpreterTier 0Starts instantly, slowest execution
C1 (Client)Tier 1–3Fast to compile, moderate optimisation
C2 (Server)Tier 4Slow to compile, aggressive optimisation

This is why a Java server is slow for its first few seconds (still interpreting and compiling) and then speeds up — and why micro-benchmarks are misleading unless you let the JIT "warm up" first. The example below shows the real -XX:+PrintCompilation log as C1 then C2 compile a hot method.

public class Main {
    // A "hot" method: called millions of times, so the JIT compiles it
    // from bytecode into native machine code while the program runs.
    static long square(long n) {
        return n * n;
    }

    public static void main(String[] args) {
        long sum = 0;
        for (long i = 0; i < 2_000_000L; i++) {   // enough calls to get the JIT's attention
            sum += square(i);              // hot loop -> JIT kicks in
        }
        System.out.println("sum = " + sum);
    }
}

5️⃣ Garbage Collection — A High-Level View

You never free() memory in Java. The garbage collector (GC) automatically reclaims objects on the heap once nothing references them anymore — it traces from "roots" (stack frames, static fields) and anything unreachable is garbage.

The key idea that makes GC fast is the generational hypothesis: most objects die young. So the heap is split into generations:

By collecting the young generation frequently and the old generation seldom, the GC does most of its work where most of the garbage is, keeping pauses short. Modern collectors — G1 (the default), and low-pause options like ZGC and Shenandoah — all build on this generational idea while shrinking pause times further.

🧩 Mini-Challenge: Disassemble Your Own Code

Scaffolding removed. Using only the comment outline below, write the main body, then disassemble the class with javap -c and hunt for the multiply instruction. Everything you need is in the worked examples above.

public class Main {
    static int factorial(int n) {
        // (already written for you)
        return n <= 1 ? 1 : n * factorial(n - 1);
    }

    public static void main(String[] args) {
        // 🎯 MINI-CHALLENGE: prove where things live, and disassemble it
        // 1. Call factorial(5) and print the result (it is 120)
        // 2. Print which ClassLoader loaded Main  (Main.class.getClassLoader())
        // 3. In your terminal, run:  javac Main.java  then  javap -c Main
        //    Find the 'imul' bytecode instruction inside factorial — that is
        //    the integer multiply the JVM runs for n * factorial(n - 1).
        //
        // ✅ Expected output: 120  (plus the AppClassLoader line)

        // your code here
    }
}

Common Errors & Pitfalls

Pro Tips

💡 javap -c YourClass shows exactly what the compiler generated — invaluable for understanding autoboxing, string concatenation, and lambda desugaring.

💡 -XX:+PrintCompilation and -Xlog:gc let you watch the JIT and the garbage collector work without any code changes.

💡 Let it warm up. For realistic performance numbers, exercise the code thousands of times first (or use JMH), so the JIT has compiled the hot paths before you start timing.

💡 GraalVM native-image ahead-of-time compiles to a standalone binary (~10ms startup) — great for CLIs and serverless, at the cost of JIT peak performance and some reflection limitations.

📋 Quick Reference — Runtime Memory Areas

AreaScopeStoresError when full
HeapShared (all threads)Objects created with newOutOfMemoryError: Java heap space
StackOne per threadCall frames, locals, referencesStackOverflowError
MetaspaceShared (native mem)Class metadata, methodsOutOfMemoryError: Metaspace
PC registerOne per threadCurrent bytecode address(never fills)

🎉 Lesson Complete!

You can now see past your source code into the machine running it: bytecode in the .class file (readable with javap -c), the Bootstrap → Platform → Application loader chain that brings classes in, the heap/stack/metaspace/PC areas where data lives, the interpreter-then-C1-then-C2 path that makes Java fast, and the generational GC that cleans up after you. Most importantly, you can tell a heap problem from a stack problem from a metaspace problem — and reach for the right fix.

Practice quiz

What does javac produce from your .java source?

  • Native machine code
  • An executable binary
  • Bytecode in a .class file
  • Assembly for your CPU

Answer: Bytecode in a .class file. javac produces platform-neutral bytecode in a .class file; the JVM translates it to native code at runtime.

For 'return a + b;', which bytecode instruction performs the int addition?

  • iadd
  • aload
  • invokevirtual
  • ireturn

Answer: iadd. iload_0 and iload_1 push the operands, then iadd pops both and pushes the sum. The 'i' prefix means int.

What does String.class.getClassLoader() return, and why?

  • The Application loader
  • The Platform loader
  • A NullPointerException
  • null, because core JDK classes are loaded by the native Bootstrap loader

Answer: null, because core JDK classes are loaded by the native Bootstrap loader. Core java.base classes are loaded by the native Bootstrap loader, which reports as null.

What is the parent-first delegation rule for class loaders?

  • A loader loads classes before asking its parent
  • A loader asks its parent first, all the way up to Bootstrap
  • Loaders never share classes
  • Only the Application loader loads anything

Answer: A loader asks its parent first, all the way up to Bootstrap. Delegation means each loader asks its parent first, which is why you can't shadow java.lang.String.

Which runtime area stores class metadata since Java 8?

  • Metaspace (in native memory)
  • The heap
  • The stack
  • The PC register

Answer: Metaspace (in native memory). Class metadata lives in Metaspace, which replaced PermGen in Java 8 and lives in native memory.

What does the JIT compiler do?

  • Compiles all code before the program starts
  • Interprets bytecode line by line forever
  • Compiles 'hot' methods to native code while the program runs
  • Removes the garbage collector

Answer: Compiles 'hot' methods to native code while the program runs. The JIT watches for hot methods and compiles their bytecode to optimised native code as the program runs.

In HotSpot tiered compilation, which compiler optimises most aggressively?

  • The interpreter
  • C2 (Server)
  • C1 (Client)
  • javac

Answer: C2 (Server). C1 compiles quickly with light optimisation; C2 (tier 4) compiles slowly but optimises the hottest methods aggressively.

Infinite recursion fills which area and throws what?

  • The heap; OutOfMemoryError
  • Metaspace; OutOfMemoryError: Metaspace
  • The PC register; an error
  • The stack; StackOverflowError

Answer: The stack; StackOverflowError. Each call adds a stack frame; unbounded recursion overflows the stack and throws StackOverflowError, not an OOM.

Is JIT-compiled code always faster than interpreted code?

  • Yes, always
  • No — code run only a few times may never be compiled, and compilation itself costs CPU
  • Only on Windows
  • Only for static methods

Answer: No — code run only a few times may never be compiled, and compilation itself costs CPU. Rarely-run code stays interpreted, and short-lived programs pay startup overhead — which is why you warm up before benchmarking.

Why should you avoid relying on finalize() for cleanup?

  • It is too fast
  • It is private
  • It runs only when the GC decides to collect — maybe late or never — and is deprecated for removal
  • It only works on the heap

Answer: It runs only when the GC decides to collect — maybe late or never — and is deprecated for removal. Finalizers offer no timing guarantee and are deprecated; use try-with-resources/AutoCloseable for deterministic cleanup.

Continue this course

Frequently asked questions

What is the difference between the heap and the stack in the JVM?

The heap is one shared region where every object you create with 'new' lives; it is managed by the garbage collector and shared across all threads. The stack is per-thread: each method call pushes a frame holding that call's local variables and the reference values, and the frame is popped when the method returns. Objects go on the heap, the references and primitive locals that point at them go on the stack.

What is bytecode and why does Java use it?

Bytecode is the compact, platform-neutral instruction set (iload, iadd, invokevirtual, ...) that javac produces in .class files. The JVM executes bytecode instead of native CPU instructions, which is what makes Java 'write once, run anywhere': the same .class file runs on any machine that has a JVM. You can inspect it with 'javap -c YourClass'.

What does the JIT compiler do, and is JIT-compiled code always faster?

The Just-In-Time compiler watches the program run, finds 'hot' methods that are called many times, and compiles their bytecode into optimised native machine code (C1 quickly, then C2 aggressively). It is not always faster: code that runs only once or a few times may never get compiled, and compilation itself costs CPU and memory, which is why short-lived programs see startup overhead before any speedup.

What is Metaspace and how is it different from the heap?

Metaspace is the runtime memory area that stores class metadata — the structure of every loaded class, its methods, and constant pool. It is separate from the object heap and, since Java 8, lives in native memory rather than the old 'PermGen'. It grows as you load classes, so apps that generate classes dynamically (proxies, heavy reflection, redeploys) can exhaust it and throw 'OutOfMemoryError: Metaspace' even when the object heap is fine.

Should I rely on finalize() to clean up resources?

No. Finalizers run only when the garbage collector decides to reclaim an object, which may be much later or never, so they cannot guarantee timely cleanup; finalize() is deprecated for removal. Use try-with-resources and the AutoCloseable interface for files, sockets, and connections, and Cleaner or PhantomReference for the rare native-resource case.

Related lessons