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.
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.
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.
🎯 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 .
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 .
- Stack — one 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.
🎯 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.
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:
Stage
Tier
Trade-off
Interpreter
Tier 0
Starts instantly, slowest execution
C1 (Client)
Tier 1–3
Fast to compile, moderate optimisation
C2 (Server)
Tier 4
Slow 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.
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.
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
Area
Scope
Stores
Error when full
Heap
Shared (all threads)
Objects created with new
OutOfMemoryError: Java heap space
Stack
One per thread
Call frames, locals, references
StackOverflowError
Metaspace
Shared (native mem)
Class metadata, methods
OutOfMemoryError: Metaspace
PC register
Current bytecode address
(never fills)
Frequently Asked Questions
🎉 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.
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
- Previous: Previous Lesson
- Next: Next Lesson