Java JVM
The JVM (Java Virtual Machine) is the program that runs Java bytecode. It is called a virtual machine because it behaves like a computer with its own instruction set, its own memory model, and its own way of running threads, implemented in software on top of the real operating system and hardware. Understanding the JVM explains Java's portability, its memory behavior, its performance characteristics, and many of the errors you will see.
Why a Virtual Machine
A C program is compiled directly to the machine instructions of one CPU and the conventions of one operating system. To run on another platform, it must be recompiled.
Java compiles to bytecode, the instruction set of the JVM. A JVM exists for each platform, and each one translates bytecode into that platform's native operations:
App.class (bytecode)
┌──────────────┼──────────────┐
▼ ▼ ▼
JVM for Windows JVM for Linux JVM for macOS
x64 / ARM x64 / ARM Apple Silicon
The same .class file runs on all of them. The JVM is the platform-specific part; your program is not.
The JVM also provides services that the language relies on: automatic memory management, bytecode verification for safety, thread scheduling, exception handling, and runtime optimization.
JVM Architecture
┌──────────────────────────────────────────────────────────┐
│ Class Loader Subsystem │
│ loading → linking (verify, prepare, resolve) → init│
└───────────────────────────┬──────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ Runtime Data Areas │
│ Heap (shared) Metaspace (shared) │
│ Per thread: JVM Stack · PC Register · Native Stack │
└───────────────────────────┬──────────────────────────────┘
▼
┌──────────────────────────────────────────────────────────┐
│ Execution Engine │
│ Interpreter · JIT compilers · Garbage Collector │
└──────────────────────────────────────────────────────────┘
1. Class loader subsystem
Classes are not all loaded at startup. The JVM loads each class the first time it is needed. Loading proceeds in three phases:
- Loading reads the
.classbytes from a directory, JAR file, or module and creates an internal representation of the class. - Linking has three steps. Verification checks that the bytecode is well formed and safe: it does not misuse types, overflow the operand stack, or access private members illegally. Preparation allocates static fields and sets them to default values. Resolution replaces symbolic references (names like
java/util/ArrayList) with direct references. - Initialization runs static initializers and assigns static field values written in the source.
Class loaders form a hierarchy: the bootstrap loader loads core classes such as java.lang.String; the platform loader loads other standard modules; the application (system) loader loads your classes from the classpath or module path. A loader asks its parent first, which ensures that a class named java.lang.String always comes from the JDK, never from application code.
When a class cannot be found, you see:
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/Helper
This means the class was present at compile time but is missing from the runtime classpath, typically because a JAR was not included when launching the program.
2. Runtime data areas
| Area | Shared? | Contents |
|---|---|---|
| Heap | All threads | Every object and array created with new |
| Metaspace | All threads | Class metadata: method code, field layouts, constant pools |
| JVM stack | One per thread | A frame per active method call: local variables, parameters, operand stack |
| PC register | One per thread | The address of the bytecode instruction being executed |
| Native method stack | One per thread | Frames for native (C/C++) methods |
The heap versus stack distinction appears constantly in Java:
public static void main(String[] args) {
int count = 3; // primitive local: in main's stack frame
String name = "Ada"; // reference on the stack, String object on the heap
int[] scores = new int[count]; // reference on the stack, array on the heap
Person p = new Person(name); // reference on the stack, Person on the heap
}
Local variables (including references) live in the method's stack frame and disappear when the method returns. The objects they point to live on the heap and remain as long as anything still references them.
Two errors correspond to these areas:
StackOverflowError: a thread's stack ran out of space, almost always from unbounded recursion.OutOfMemoryError: Java heap space: the heap is full of reachable objects and the garbage collector cannot free enough.
3. Execution engine
Interpreter. Bytecode starts out interpreted: the JVM reads each instruction and performs it. Startup is fast, but sustained execution is relatively slow.
JIT (Just-In-Time) compiler. The JVM counts how often each method and loop runs. Frequently executed ("hot") code is compiled to native machine code while the program runs. HotSpot, the JVM used by OpenJDK, has two JIT compilers:
- C1 compiles quickly with moderate optimization.
- C2 compiles more slowly with aggressive optimization.
With tiered compilation (the default), code moves from the interpreter to C1 and then, if it stays hot, to C2. The JIT uses information gathered at runtime, such as which branches are actually taken and which concrete types actually appear at a call site, to inline methods, eliminate unnecessary checks, and remove allocations. This is why a Java service often gets faster over its first seconds or minutes of operation, a period known as warm-up.
Garbage collector. Described next.
Garbage Collection
The garbage collector (GC) reclaims heap memory used by objects that are no longer reachable. An object is reachable if a chain of references leads to it from a GC root: a local variable in an active stack frame, a static field, or an active thread.
void process() {
List<String> temp = new ArrayList<>();
temp.add("data");
} // after return, nothing refers to the list: it is eligible for collection
You never free memory explicitly. The GC runs when the JVM decides it is necessary, usually when an allocation area fills up.
Generations
Most objects die young: temporary strings, iterators, intermediate results. Collectors exploit this by dividing the heap:
- Young generation: where new objects are allocated. Collected frequently and cheaply, since most of its objects are already dead.
- Old generation: objects that survived several young collections are promoted here. Collected less often.
Available collectors
| Collector | Flag | Characteristics |
|---|---|---|
| G1 | -XX:+UseG1GC | Default on most machines. Balances throughput and pause times. |
| ZGC | -XX:+UseZGC | Very short pauses (typically under a millisecond) even with very large heaps |
| Shenandoah | -XX:+UseShenandoahGC | Low-pause collector, available in many OpenJDK builds |
| Parallel | -XX:+UseParallelGC | Maximizes throughput; longer pauses acceptable for batch jobs |
| Serial | -XX:+UseSerialGC | Single-threaded; suits small heaps and single-CPU containers |
The default is a sensible starting point. Changing the collector is a tuning decision made after measuring a real workload.
Memory leaks still happen
The GC cannot free objects that are still reachable. A collection that only grows is the usual cause:
public class Cache {
private static final Map<String, byte[]> DATA = new HashMap<>();
static void store(String key, byte[] value) {
DATA.put(key, value); // never removed: grows until OutOfMemoryError
}
}
The static map is a GC root, so everything in it stays alive forever. Bounded caches, removal of entries no longer needed, and weak references address this.
Heap Size and Common Options
java -Xms512m -Xmx2g -jar app.jar
| Option | Meaning |
|---|---|
-Xms512m | Initial heap size |
-Xmx2g | Maximum heap size |
-Xss1m | Stack size per thread (affects maximum recursion depth) |
-XX:MaxRAMPercentage=75 | Max heap as a percentage of available memory; preferred in containers |
-XX:+HeapDumpOnOutOfMemoryError | Write a heap dump file when the heap is exhausted, for later analysis |
-Xlog:gc | Log garbage-collection events |
Without -Xmx, the JVM chooses a maximum heap based on the memory it detects, by default about a quarter of physical memory. Modern JVMs detect container memory limits, which is why percentage-based options are favored in Docker and Kubernetes. JVM options have their own lesson in the deployment section.
Observing the JVM From Code
public class JvmInfo {
public static void main(String[] args) {
Runtime rt = Runtime.getRuntime();
long mb = 1024 * 1024;
System.out.println("JVM: " + System.getProperty("java.vm.name"));
System.out.println("Processors: " + rt.availableProcessors());
System.out.println("Max heap: " + rt.maxMemory() / mb + " MB");
System.out.println("Total heap: " + rt.totalMemory() / mb + " MB");
System.out.println("Free heap: " + rt.freeMemory() / mb + " MB");
int[][] blocks = new int[200][];
for (int i = 0; i < blocks.length; i++) {
blocks[i] = new int[250_000]; // about 1 MB each
}
long used = (rt.totalMemory() - rt.freeMemory()) / mb;
System.out.println("Used after allocation: " + used + " MB");
}
}
Run it with different heap limits:
java -Xmx512m JvmInfo.java
java -Xmx128m JvmInfo.java
With 512 MB the program allocates about 200 MB and reports it. With 128 MB, the 200 MB of reachable arrays cannot fit and it fails with OutOfMemoryError: Java heap space. maxMemory is the ceiling set by -Xmx; totalMemory is how much the heap has currently grown to; freeMemory is the unused portion of that. The heap grows on demand toward the maximum, which is why totalMemory is usually smaller than maxMemory early in a program's life.
Other JVM Implementations
"The JVM" is a specification, and several implementations exist:
- HotSpot: the JVM in OpenJDK and in almost every mainstream distribution.
- Eclipse OpenJ9: an alternative JVM emphasizing low memory use and fast startup, used in IBM Semeru builds.
- GraalVM: includes an advanced JIT compiler written in Java and Native Image, which compiles a Java application ahead of time into a standalone native executable with very fast startup and no JIT warm-up, at the cost of some dynamic features and longer build times.
Any conforming JVM runs the same bytecode, which is what makes alternative implementations possible.
Languages Beyond Java
The JVM executes bytecode, not Java source. Any language whose compiler emits bytecode runs on it and can use Java libraries: Kotlin, Scala, Groovy, and Clojure are the best known. Kotlin, Google's preferred language for Android, interoperates with Java in both directions within the same project. Knowledge of the JVM (its memory model, garbage collection, class loading, and tooling) therefore transfers across all of these languages.