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.

Learn JVM Internals in our free Java course — a beginner-friendly interactive lesson with worked examples, a practice exercise and a quick reference.

Part of the free Java course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

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.

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

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.

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.

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 .

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.

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.

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:

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.

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.

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.

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

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.

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.