How the JVM Works

Intermediate
9 min read· Backend & Databases

The Java Virtual Machine (JVM) is what makes Java "write once, run anywhere." Your source code compiles to bytecode — a platform-neutral instruction set — and the JVM interprets and compiles that bytecode at runtime for whatever OS and CPU the code is actually running on. The JVM also manages memory automatically through garbage collection, optimises hot code paths using a Just-In-Time (JIT) compiler, and isolates each application in its own managed runtime.

Think of the JVM as a universal translator

Imagine writing a speech in Esperanto — a language designed to be understood by everyone. A local translator (the JVM) then converts it in real time into English, French, or Japanese depending on where you are. You write one speech (Java bytecode); the JVM translates it to native machine code for Windows, Linux, or macOS. The translation is so good that the JVM even watches which sentences you say most often and prepares instant translations (JIT compilation) for those.

Step by Step

1 / 6

Key Concepts

Heap

The shared memory area where all objects are allocated with new. Divided into Young Generation (Eden + two Survivor spaces) and Old Generation. The GC operates here. Tune with -Xmx (max heap) and -Xms (initial heap). Too small: frequent GCs. Too large: long GC pauses when it does collect.

Stack

Each thread has its own private stack. Every method call pushes a new stack frame containing local variables and the operand stack. Primitive values (int, long, boolean) and object references live on the stack. The objects they point to live on the heap. Stack frames are popped automatically when a method returns — no GC needed. StackOverflowError means you ran out of stack space (usually deep or infinite recursion).

Metaspace

Memory for class metadata (class structures, method bytecode, constant pool). Replaced PermGen in Java 8. Unlike PermGen, Metaspace uses native memory and grows automatically. OutOfMemoryError: Metaspace means too many classes are loaded — common with framework proxies, class generation (Hibernate, CGLIB), or classloader leaks.

JIT (Just-In-Time) Compiler

Compiles hot bytecode to native machine instructions at runtime. HotSpot JVM uses tiered compilation: Level 0 (interpreted), Level 1-3 (C1, progressively more optimised), Level 4 (C2, fully optimised). The threshold is ~10,000 invocations for a method and ~10,000 back-edge (loop) iterations in HotSpot. Deoptimisation can happen if runtime assumptions (e.g., this class has no subclasses) are violated later.

Garbage Collection

The process of automatically finding and freeing unreachable objects. "Unreachable" means no live thread can access the object through any chain of references from GC roots (stack variables, static fields, JNI references). Modern collectors pause application threads only briefly ("stop-the-world") for root scanning; most collection work runs concurrently. ZGC and Shenandoah target sub-millisecond pauses even on 100GB heaps.

ClassLoader Hierarchy

A delegation chain: Application ClassLoader → Platform ClassLoader → Bootstrap ClassLoader. When loading a class, each loader first delegates to its parent. Only if the parent cannot find it does the child try. This prevents application code from shadowing core Java classes. You can create custom ClassLoaders for plugins, hot reloading, or class isolation (OSGi, servlet containers).

Key Facts

  • The JVM specification defines the bytecode format and runtime behaviour. There are multiple JVM implementations: HotSpot (Oracle/OpenJDK), OpenJ9 (IBM), GraalVM. Any compliant JVM can run any valid .class file.
  • GraalVM Native Image compiles Java ahead-of-time to a native executable with no JVM at runtime — millisecond startup, no JIT warm-up. The trade-off: no dynamic class loading, reduced peak throughput compared to a warmed-up HotSpot JVM.
  • The JVM's JIT compiler performs speculative optimisations it can roll back (deoptimise) if runtime assumptions are violated. This is why Java performance can be non-obvious — a microbenchmark that seems slow may not reflect production behaviour once the JIT is warm.
  • -XX:+PrintGCDetails and -Xlog:gc* are the flags to diagnose GC behaviour. Tools: VisualVM, JFR (Java Flight Recorder), async-profiler, and jmap for heap analysis.
  • Thread stacks default to 512KB–1MB each. A JVM running 1000 threads uses 500MB–1GB just for thread stacks before allocating a single object on the heap.
  • Project Loom (Java 21+) introduces virtual threads — JVM-managed lightweight threads that are much cheaper than OS threads. 1 million virtual threads can run concurrently where 1 million OS threads would exhaust system memory.

Real-World Applications

Diagnosing memory leaks

When heap usage grows over time and GC cannot reclaim it, take a heap dump with jmap -dump:format=b,file=heap.hprof <pid>. Open in Eclipse MAT or VisualVM to find objects with unexpectedly high retention. Common causes: static collections, ThreadLocal values not cleared, unclosed streams holding off-heap buffers, or listener registrations that prevent GC of objects.

Tuning GC for low latency

For APIs with strict P99 latency requirements, use ZGC (-XX:+UseZGC) or Shenandoah instead of G1GC. Set -Xmx and -Xms to the same value to avoid heap expansion pauses. Monitor GC pause times with -Xlog:gc*. For batch jobs where throughput matters more than latency, the Parallel GC (-XX:+UseParallelGC) is often faster.

JVM warm-up in production

New JVM instances interpret code before the JIT compiles it — request latency is high for the first few minutes ("cold start"). Spring Boot applications with many layers take especially long to warm up. Mitigations: AOT compilation (Spring Native), GraalVM Native Image for serverless, or CRaC (Coordinated Restore at Checkpoint) which snapshots and restores a warmed-up JVM state.

Frequently Asked Questions

Why does Java sometimes feel slow on startup?

Two reasons: (1) Class loading — the JVM must load, verify, and initialise every class on first use. Large frameworks like Spring load hundreds of classes at startup. (2) JIT warm-up — for the first seconds or minutes, hot code runs interpreted or lightly optimised. A Spring Boot app's first few requests are much slower than steady-state. Solutions: GraalVM Native Image (no JVM, instant startup) or Spring AOT (ahead-of-time compilation hints).

What is the difference between -Xmx and -Xms?

-Xms sets the initial heap size that the JVM claims from the OS at startup. -Xmx sets the maximum the heap can grow to. If -Xms is small, the JVM grows the heap incrementally as needed, which takes time and causes GC pressure. For production services, set -Xms equal to -Xmx to pre-allocate all heap memory upfront and avoid heap expansion pauses.

What causes OutOfMemoryError?

Several distinct causes: (1) Java heap space — objects are allocated faster than GC can reclaim them, or there is a genuine memory leak. (2) Metaspace — too many classes loaded (proxy generation, classloader leaks). (3) GC overhead limit exceeded — the JVM spends more than 98% of time in GC recovering less than 2% of heap (effectively out of useful memory). (4) Unable to create new native thread — OS thread limit reached. Each has a different fix.

How does the JVM handle multi-threading?

The JVM maps Java threads directly to OS threads (platform threads). Thread safety is the programmer's responsibility — the JVM provides the Java Memory Model (JMM) which defines when writes by one thread are visible to another. The JMM allows CPUs and compilers to reorder instructions for performance, but guarantees visibility across synchronized blocks, volatile variables, and java.util.concurrent primitives (which use memory barriers internally).

Related Topics