Skip to main content

    Lesson 30 • Advanced

    JVM Internals

    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.

    What You'll Learn in This Lesson

    • Read the bytecode in a .class file with javap -c
    • Trace the Bootstrap → Platform → Application class-loader chain
    • Name the runtime memory areas: heap, stack, metaspace, PC register
    • Explain interpretation vs JIT compilation (C1, C2, tiered)
    • Understand why generational garbage collection is fast
    • Tell heap errors apart from stack and metaspace errors

    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.

    Worked Example: Source → Bytecode (javap -c)
    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
        }
    }
    Output
    $ javac Main.java        # source  -> Main.class (bytecode)
    $ javap -c Main          # disassemble the .class file
    
      static int add(int, int);
        Code:
           0: iload_0          // push parameter a onto the operand stack
           1: iload_1          // push parameter b onto the operand stack
           2: iadd             // pop both, push (a + b)
           3: ireturn          // return the int on top of the stack
    
      public static void main(java.lang.String[]);
        Code:
           0: iconst_2         // push the constant 2
           1: iconst_3         // push the constant 3
           2: invokestatic  #7 // Method add:(II)I  -> calls add(2, 3)
           5: istore_1         // store the result (5) in local variable 1
           6: getstatic     #13 // Field System.out
           9: iload_1          // push result
          10: invokevirtual #19 // Method println:(I)V
          13: return
    The "Output" panel is the disassembly from javap -c, not program output. Compile any class with javac Main.java, then run javap -c Main in your own terminal to see the bytecode for yourself.

    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:

    • Bootstrap loader — native code built into the JVM. Loads the core JDK (java.base: String, Integer, ...). Because it's native, asking a core class for its loader returns null.
    • Platform loader — loads additional standard modules that ship with the JDK (formerly the "extension" loader).
    • Application (system) loader — loads your classes and any libraries from the classpath. This is the one that loads Main.

    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.

    Worked Example: Walking the Loader Chain
    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);
            System.out.println("  parent (Platform): " + appLoader.getParent());
    
            // 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());
        }
    }
    Output
    App loader:        jdk.internal.loader.ClassLoaders$AppClassLoader@251a69d7
      parent (Platform): jdk.internal.loader.ClassLoaders$PlatformClassLoader@5fd0d5ae
    String loaded by:  null  (Bootstrap)
    Integer loaded by: null
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    🎯 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.

    Fill in the blanks (___)
    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
        }
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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:

    • Heap — one big region shared by all threads, holding every object you create with new. The garbage collector manages it. When it fills up: OutOfMemoryError: Java heap space.
    • Stackone per thread. Each method call pushes a frame holding that call's local variables and the references it uses; the frame pops when the method returns. Too-deep recursion overflows it: StackOverflowError.
    • Metaspace — holds class metadata (the structure of every loaded class, its methods, the constant pool). It lives in native memory (since Java 8, replacing "PermGen"). Loading too many classes: OutOfMemoryError: Metaspace.
    • PC register — tiny, one per thread: it just remembers the address of the bytecode instruction that thread is currently executing.

    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.

    Worked Example: Heap vs Stack (and a StackOverflowError)
    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);
    
            deep(0);                           // crash: stack runs out
        }
    }
    Output
    $ java -Xss256k -Xmx64m Main     # -Xss = stack size, -Xmx = max heap
    3 42
    Exception in thread "main" java.lang.StackOverflowError
    	at Main.deep(Main.java:5)
    	at Main.deep(Main.java:5)
    	at Main.deep(Main.java:5)
    	...
    The "Output" is from a JVM run with custom memory flags (-Xss sets stack size, -Xmx sets max heap). Try it locally — shrinking -Xss makes the overflow happen sooner.

    🎯 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.

    Fill in the blank (___)
    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
        }
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    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.

    Worked Example: Watch the JIT Compile (-XX:+PrintCompilation)
    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 < 50_000_000L; i++) {
                sum += square(i);              // hot loop -> JIT kicks in
            }
            System.out.println("sum = " + sum);
        }
    }
    Output
    $ java -XX:+PrintCompilation Main
    
    # columns: timestamp  compile-id  tier  method
        98    1       3       Main::square (4 bytes)    # C1 compiles (tier 3)
       142    7 %     4       Main::main @ 14 (38 bytes) # C2 compiles the hot loop (tier 4)
       145    8       4       Main::square (4 bytes)     # C2 recompiles, fully optimised
    sum = 41666666958333325000
    The "Output" is the -XX:+PrintCompilation log, not normal stdout. Run java -XX:+PrintCompilation Main against a local JDK to watch C1 (tier 3) and then C2 (tier 4) compile your hot method live.

    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:

    • Young generation — where new objects are born. It's collected often and very cheaply (a "minor GC"), because the vast majority of these objects are already dead by the time the collector looks.
    • Old (tenured) generation — objects that survive several young collections get promoted here. It's collected rarely and more expensively (a "major"/"full GC").

    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.

    Comment outline only — you write the code
    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
        }
    }
    This is real code — run it for free atonecompiler.com/javaor in your own editor.

    Common Errors & Pitfalls

    • Confusing heap and stack: a StackOverflowError means a thread's call stack is too deep (usually runaway recursion) — raising -Xmx won't help; you need a base case or -Xss. An OutOfMemoryError: Java heap space means you're holding too many live objects — raising -Xss won't help. They are different memory areas with different fixes.
    • Metaspace OutOfMemoryError: OutOfMemoryError: Metaspace is about classes, not objects. It's typically caused by loading or generating too many classes (dynamic proxies, heavy reflection, repeated hot-redeploys leaking old class loaders). The heap can be nearly empty while this happens; the fix is to stop leaking class loaders, not to add heap.
    • Assuming the JIT always makes code faster: code that runs only a handful of times may never be compiled, so it stays interpreted. And compilation itself costs CPU — short-lived programs pay startup overhead with no payoff. Always warm up before benchmarking; use JMH rather than wrapping a loop in System.nanoTime().
    • Relying on finalizers for cleanup: finalize() runs only if/when the GC decides to collect the object, which may be far in the future or never — it's deprecated for removal. Use try-with-resources + AutoCloseable to close files, sockets, and connections deterministically.
    • ClassNotFoundException vs NoClassDefFoundError: the first means a class wasn't found on the classpath when you asked for it by name (e.g. Class.forName); the second means the class was found at compile time but is missing or failed to initialise at runtime. They point at different problems — classpath vs linking/initialisation.

    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)

    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.

    🎉 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.

    Next up: Reflection API — inspecting and modifying classes at runtime, built directly on the class-loading machinery you just learned.

    Sign up for free to track which lessons you've completed and get learning reminders.

    Previous

    Cookie & Privacy Settings

    We use cookies to improve your experience, analyze traffic, and show personalized ads. You can manage your preferences below.

    By clicking "Accept All", you consent to our use of cookies for analytics and personalized advertising. You can customize your preferences or reject non-essential cookies.

    Privacy PolicyTerms of Service