The JVM, JRE, JDK — What's Actually Happening
From .java to running bytes. Class loaders, runtime memory areas, JIT, and the garbage collector — the whole machine demystified.
What happens when you run a Java program
You type java MyProgram and your program runs. Between those two events, more happens than most Java developers realise.
A lot of people who've written Java for years still describe the JVM as "that thing my code runs on." Which is correct but unhelpful. Understanding what the JVM actually does — class loading, memory management, garbage collection, just-in-time compilation — pays off when you need to debug a memory leak, tune a slow application, or explain to an interviewer what OutOfMemoryError: Metaspace means.
This chapter walks through the JVM from input (your .class files) to output (a running program). It's deliberately conceptual — we'll look at every subsystem, but won't dive into the lowest-level details unless they matter for real debugging.
The compilation pipeline
Your Java source code goes through two stages before it actually executes.
**Stage 1: source → bytecode.** When you run javac MyProgram.java, the Java compiler converts your human-readable source code into MyProgram.class. The .class file contains **bytecode** — a stack-based instruction set defined by the JVM specification. Bytecode is platform-independent. The same .class file runs identically on Windows, macOS, and Linux.
A bytecode instruction looks like iload_1 (load an integer from local variable slot 1 onto the stack) or invokevirtual (call a virtual method on an object). You'll rarely look at raw bytecode, but tools like javap -c MyProgram will show it to you if you want to.
**Stage 2: bytecode → native code.** When you run java MyProgram, the JVM loads the .class file and starts executing. Initially, it **interprets** the bytecode — reading each instruction and performing it one at a time, slowly. As your program runs and the JVM notices methods being called frequently (a "hot" method), it kicks in the **JIT compiler** (Just-In-Time) to compile that bytecode into native machine code on the fly. Subsequent calls to the same method run the native code directly. This is why Java programs often start slow and speed up as they "warm up."
This two-stage design is what gives Java its portability (bytecode is portable) AND its speed (JIT-compiled code is fast). Languages that compile directly to native code (C, Go, Rust) are faster on startup but lose portability. Languages that interpret all the way (Python, Ruby) are slower at peak but easier to develop in.
.java file
│
│ javac (compiler)
▼
.class file (bytecode)
│
│ java (JVM)
▼
Interpreter (slow) ─┐
├─► your program running
JIT compiler ──────┘
│
▼
Native machine code (fast, cached for next call)
Class loading: how .class files reach the JVM
The JVM doesn't load all your classes at startup. It loads them lazily, on first use. This is handled by the **class loader subsystem**.
When the JVM needs a class (say, your code does new ArrayList() for the first time), the class loader:
- **Locates** the
.classfile — looking in the classpath, JAR files, modules. - **Loads** the file's bytes into memory.
- **Links** it — verifies bytecode is well-formed and safe, prepares static fields with default values, resolves symbolic references.
- **Initialises** it — runs static initialisers and assigns static field values.
Class loaders form a hierarchy. The **Bootstrap class loader** loads core Java classes (java.lang.Object, java.util.HashMap, etc.) from the JDK itself. The **Platform class loader** (formerly Extension) loads platform modules. The **Application class loader** loads your own code and third-party libraries.
When a class is requested, the loader asks its parent first — this is called **parent delegation**. It prevents your code from accidentally overriding core JDK classes. If you wrote a class called java.lang.String, the JVM wouldn't use it; it would use the official one loaded by the bootstrap loader.
┌─────────────────────────┐
│ Bootstrap class loader │ ← java.lang.*, java.util.*
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Platform class loader │ ← platform modules
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Application class loader│ ← YOUR classes + dependencies
└─────────────────────────┘
Why does this matter in practice? Two scenarios:
**Servlet containers and app servers** (Tomcat, JBoss) use custom class loaders so each deployed app has its own loader. This is why you can deploy two webapps using different versions of the same library to the same Tomcat instance without conflicts.
**ClassNotFoundException vs NoClassDefFoundError.** The first means a class wasn't found at runtime when you tried to load it (e.g., Class.forName("...")). The second means a class WAS available at compile time but is missing at runtime. Both are class-loader problems.
Runtime memory areas
Once a class is loaded, the JVM divides its memory into several distinct areas, each with a specific purpose. Knowing what goes where is the foundation for diagnosing memory issues.
**Heap** — where all objects live. Shared across every thread. This is what people usually mean when they say "Java memory." The heap is further divided into generations (Young + Old) for garbage collection efficiency, which we'll cover shortly.
**Stack** — one per thread. Stores method frames: each frame holds local variables, partial results, and the return address. Stack frames are pushed when a method is called, popped when it returns. The stack is fast (LIFO allocation), small (typically 512KB-1MB per thread), and not shared.
**Method Area / Metaspace** — stores class metadata: the bytecode of methods, the structure of classes, constant pools, static fields. In Java 7 and earlier this was called the "PermGen" (Permanent Generation) and lived inside the heap with a fixed maximum size — causing the famous OutOfMemoryError: PermGen space for apps that loaded many classes. Java 8 moved it out of the heap and renamed it **Metaspace**, which auto-grows by default but can still OOM if you have a class-loading leak.
**Program Counter (PC) registers** — one per thread. Tracks the address of the currently executing instruction.
**Native Method Stack** — used when Java code calls native (non-Java) methods via JNI. Most apps don't use much of it.
Out of these five areas, the heap and the stack are by far the most important to understand. Almost every memory issue you'll diagnose involves one of those two.
A quick rule of thumb on what goes where: **primitives** (int, long, double, char, boolean, byte, short, float) declared as local variables live on the stack. **Objects** always live on the heap, even if they're tiny or referenced by a single local variable. The variable on the stack just holds a reference (an address) into the heap.
Garbage collection — the core idea
In C and C++, you allocate memory with malloc (or new) and free it with free (or delete). Forget to free, you have a memory leak. Free too early or twice, you have a use-after-free or double-free bug. These are the source of an enormous fraction of all security vulnerabilities and crashes in software.
Java's design choice: don't make programmers handle this. The JVM tracks what's still reachable in the heap and automatically reclaims everything else. You allocate (new), and the garbage collector handles the rest.
The fundamental algorithm is called **reachability**. Start from a set of **GC roots** — local variables on every thread's stack, static fields, JNI references. Walk the object graph from those roots, following every reference. Anything you can reach is "live." Everything you can't reach is "garbage" and can be reclaimed.
This algorithm is correct but naive. Done literally, scanning the entire heap on every collection would be brutally slow on a multi-gigabyte heap. So modern garbage collectors use the **generational hypothesis**: most objects die very young. A typical Java app creates millions of short-lived objects (temporary strings, intermediate collection results, method-scope helpers) and a much smaller number of long-lived objects (caches, configuration, the application's core state).
The heap is split into:
- **Young generation** — divided into Eden + two Survivor spaces (S0, S1). New objects allocate into Eden. When Eden fills up, a fast "minor GC" runs: live objects copy to a Survivor space, dead objects evaporate. Objects that survive multiple Survivor cycles get **promoted** to the Old generation.
- **Old generation** (also called Tenured) — for long-lived objects. Cleaned up less frequently by a slower, more thorough "major GC."
This design exploits the observation that most objects are short-lived. The young generation gets collected often (cheap, fast). The old generation gets collected rarely (expensive but worth it because most allocations don't make it that far).
GC algorithms in modern Java
The basic generational design is implemented by several different algorithms. The right choice depends on your workload.
**Serial GC** — single-threaded, stops the world during collection. Designed for small heaps and single-CPU machines. You'll never pick this in production.
**Parallel GC** (was the default through Java 8) — uses multiple threads to do GC work in parallel. Still stops the world, but the pause is shorter. Optimised for throughput — the total amount of useful work the JVM gets done. Pick this if you don't care about pause time, only throughput.
**G1 (Garbage First)** — the default since Java 9. Divides the heap into many small regions and prioritises collecting regions with the most garbage first (hence the name). Designed for predictable, low-ish pause times (~100-200ms) on multi-gigabyte heaps. Good general-purpose default.
**ZGC** — designed for ultra-low pause times (under 10ms, often under 1ms). Uses clever tricks like coloured pointers and concurrent relocation to avoid stop-the-world pauses almost entirely. Good for latency-sensitive applications with huge heaps. Stable since Java 15.
**Shenandoah** — similar goals to ZGC (sub-10ms pauses), developed by Red Hat. Available in OpenJDK and some commercial JDKs.
Selecting a GC happens at JVM startup with flags like -XX:+UseG1GC, -XX:+UseZGC. The default is fine for most apps. You only switch GCs after measuring a real problem.
The key insight: **GC pauses are a tradeoff**, not a flaw. Higher throughput often means longer pauses. Shorter pauses mean somewhat lower throughput. There's no algorithm that's "just better" — it depends on whether your app values total work done or consistent response times more.
JIT compilation — why Java warms up
When the JVM first starts executing your code, it interprets bytecode one instruction at a time. This is slow — perhaps 10-50x slower than native code. But it starts fast: no compilation step delays startup.
As your code runs, the JVM watches which methods get called frequently. Once a method crosses a "hotness" threshold (typically 10,000 invocations or loop iterations), the **JIT compiler** kicks in and compiles that method's bytecode to native machine code in the background. The next time the method is called, the JVM uses the compiled code instead of interpreting.
Modern Java actually has TWO JIT compilers:
**C1** (also called the client compiler) — fast compilation, less optimisation. Used for moderately hot code where you want compiled code quickly.
**C2** (the server compiler) — slower compilation, aggressive optimisation. Used for the hottest methods where the runtime cost of compilation pays back many times over.
By default, the JVM uses **tiered compilation**: code starts interpreted, gets compiled by C1 once it's warm, and gets recompiled by C2 once it's *very* hot. You get the best of both worlds — quick startup and peak performance for steady-state code.
This is why Java applications, especially long-running ones like web servers, often have a "warm-up" period of 30 seconds to several minutes during which they're noticeably slower. After warm-up, performance is competitive with C++.
A common trap: **microbenchmarks that don't warm up the JVM** measure interpreted code, not compiled code. The results are useless. Use a framework like JMH for any serious Java benchmarking.
Common OutOfMemoryError types
When the JVM runs out of memory, you get an OutOfMemoryError. The error message tells you *where* you ran out, which is the first clue to diagnosing the problem.
**java.lang.OutOfMemoryError: Java heap space** — your application created more objects than the heap can hold. Either you have a real memory leak (objects accumulating that should be GC'd but aren't), or your heap is too small for your workload. Fix: heap dump analysis with VisualVM or Eclipse MAT to find what's accumulating, then either fix the leak or raise -Xmx.
**java.lang.OutOfMemoryError: Metaspace** — too many classes loaded. Usually caused by class-loader leaks in app servers (re-deploying webapps without proper unloading) or aggressive use of dynamic class generation (CGLIB, ASM, certain reflection patterns). Fix: identify what's loading classes endlessly. As a stopgap, raise -XX:MaxMetaspaceSize.
**java.lang.OutOfMemoryError: GC overhead limit exceeded** — the JVM is spending more than 98% of its time doing GC but reclaiming less than 2% of the heap. Effectively the program is paralysed. Usually a precursor to heap-space OOM. Same investigation applies.
**java.lang.StackOverflowError** — a thread's stack is full. Almost always caused by unbounded recursion. The fix is usually "fix the recursion," not "increase the stack size."
These are some of the most useful error messages in Java. They tell you exactly which memory area failed.
Tools to peek inside the JVM
When you actually need to investigate JVM behaviour, the JDK ships with command-line tools. Most developers don't know these exist. They're invaluable in production.
**jps** — lists running JVMs and their PIDs. Like ps but Java-aware.
**jstack <pid>** — dumps the current stack trace of every thread in the JVM. Critical for diagnosing deadlocks ("why is my app frozen?") and finding what threads are doing.
**jmap <pid>** — dumps heap state. jmap -histo <pid> shows a histogram of objects by class with counts and bytes. Useful for spotting what's bloated.
**jstat <pid>** — live JVM statistics: GC activity, heap usage, JIT compilation counts. jstat -gc <pid> 1000 prints GC stats every second.
**jcmd <pid>** — Swiss Army knife. Replaces several older tools. Can trigger heap dumps, thread dumps, GC, JFR recordings.
**VisualVM** — graphical tool. Connect to a running JVM and see live charts of heap, threads, CPU, GC. Free and excellent.
**Java Flight Recorder (JFR)** — production-grade profiler built into the JVM. Always-on overhead is tiny. Records detailed events you can analyse later in JDK Mission Control.
For day-to-day development you don't need any of this. For diagnosing production issues, knowing these tools exist is the difference between "I have no idea why my app is slow" and "here's what's happening, let's fix it."
What you don't need to memorise
The JVM is a deep topic. There are people who spend their careers tuning garbage collectors. Most Java developers don't need to. Here's the realistic minimum:
- Understand the conceptual difference between heap and stack, and what goes where. (Critical.)
- Know that GC exists, runs automatically, and that pauses can happen — but don't worry about which algorithm runs by default unless you have a measurable problem.
- Recognise the names of OOM errors and what each one means.
- Know that
jstack,jmap, and VisualVM exist so you can investigate when something goes wrong.
You can ignore JIT internals, class loader hierarchies, and GC tuning flags entirely until you hit a real problem. The default JVM configuration on modern Java handles 95% of workloads fine.
When you do hit a problem, the first move is almost always: enable a heap dump on OOM (-XX:+HeapDumpOnOutOfMemoryError), reproduce the issue, and analyse the dump with Eclipse MAT or VisualVM. That single workflow solves the majority of real-world JVM memory problems.
⁂ Back to all modules