JDK, JRE and JVM

What each one is, what javac emits, and what the JVM does with a .class file before your first line runs.

12 min read Java Fundamentals

Three acronyms, three different things, and an interviewer will ask you to separate them in the first five minutes. The distinction matters beyond the interview: it decides what you install on a build server, what you ship in a container, and what "Java is slow to start" actually refers to. This lesson separates them, then opens the .class file javac writes and follows it through the JVM until your first line runs, because that path is where startup time and half of all "works on my machine" problems live.

The JVM runs bytecode

The Java Virtual Machine is a program. It reads .class files, verifies them, and executes the instructions inside — bytecode — on whatever CPU it happens to be running on. The JVM specification says what those instructions mean; HotSpot (the JVM in every OpenJDK build) is one implementation of it, and there are others: OpenJ9, GraalVM, Azul Zing.

The JVM knows nothing about the Java language. It has never seen a for loop or a generic type. It sees a stack machine, a constant pool and a set of opcodes like invokevirtual and getfield. That is why Kotlin, Scala, Groovy and Clojure all run on it: each has its own compiler that emits the same bytecode.

The JRE is the JVM plus the class library

A Java Runtime Environment is what you need to run a Java program: the JVM, plus the standard library (java.base, java.sql, java.net.http and the rest), plus the launcher and supporting files. No compiler. Oracle stopped shipping a standalone JRE to end users after Java 8, which is why "install the JRE" stopped being advice. Vendors still publish JRE imageseclipse-temurin:8-jre and its successors all exist — but the recommended answer is now jlink, which builds a trimmed runtime containing exactly the modules your application uses. What ended is the JRE as a separate product, not as a thing you can obtain.

The JDK is the JRE plus the tools

The Java Development Kit adds the tools that produce and inspect programs: javac, jar, javadoc, javap (the disassembler — worth knowing), jshell, jcmd, jstack, jmap, jfr. A JDK contains a JRE. A build machine needs the JDK; a production container needs only a runtime.

JDKjavac · jar · javap · jshell · jcmd · jfr JREjava.base · java.sql · launcher JVM (HotSpot)load · verify · interpret · JIT Main.javaMain.classmachine code javac, once JVM, every start bytecode is the contract between the two
What ships where: a build machine needs the outer box, a container needs the middle one, and only the inner one executes anything.

Under the hood: inside a .class file

javac does not produce machine code. It produces one .class file per class (including nested and anonymous classes: Outer$Inner.class, Outer$1.class), with a fixed layout the JVM specification defines byte by byte:

SectionWhat it holds
magic 0xCAFEBABEthe first four bytes of every class file since 1995
minor, major version65 is Java 21, 61 is Java 17, 69 is Java 25; a JVM refuses a class file newer than itself with UnsupportedClassVersionError
constant poolevery string literal, class name, field and method reference the class uses, numbered; the bytecode refers to entries by index
access flags, this, super, interfacespublic final class Hello extends Object
fields and methodseach method carries a Code attribute: max stack depth, max locals, the bytecode itself
attributesLineNumberTable (where stack-trace line numbers come from), LocalVariableTable, StackMapTable (what the verifier checks against), annotations, generic signatures

You can look at all of this with the disassembler that ships with every JDK:

bash
javac Hello.java
javap -v -p Hello        # -v: constant pool and attributes too
Hello.javajava
public class Hello {
    public static void main(String[] args) {
        int total = 0;
        for (int i = 0; i < 3; i++) total += i;
        System.out.println(total);
    }
}
javap -c Hello, the loopplaintext
 0: iconst_0          // push 0
 1: istore_1          // total = 0        (local slot 1; slot 0 is args)
 2: iconst_0
 3: istore_2          // i = 0            (slot 2)
 4: iload_2
 5: iconst_3
 6: if_icmpge 19      // if i >= 3 jump out
 9: iload_1
10: iload_2
11: iadd
12: istore_1          // total += i
13: iinc 2, 1         // i++
16: goto 4
19: getstatic  #7     // System.out              (constant pool entry 7)
22: iload_1
23: invokevirtual #13 // PrintStream.println(int)
26: return

No trace of the word for: a comparison, a jump, an increment and a goto. Generics are gone too (erased to their bounds, with a Signature attribute for reflection), and the println is a symbolic reference, #13, into the constant pool: the class name, method name and descriptor (I)V, resolved to a real method only when the JVM first executes that instruction. That late binding is what lets you swap a library jar without recompiling your code, and what makes a NoSuchMethodError at run time possible when the swap was not compatible.

What happens before your first line runs

When you type java Hello, the launcher starts a JVM, which walks Hello.class through four steps, then repeats them lazily for every other class the program touches:

  1. Loading. A class loader finds the bytes and creates a Class object. There are three loaders in a chain: the bootstrap loader for java.base, the platform loader for the rest of the JDK, and the application loader for your classpath. Each asks its parent first (parent delegation), which is why you cannot replace java.lang.String by putting one on the classpath, and why two copies of a library can coexist under two loaders in an application server.
  2. Linking, in three parts. Verification type-checks the bytecode against the StackMapTable: every instruction gets the operand types it expects, no jump leaves the method, no uninitialised object is used. This is why a corrupted or malicious class file cannot crash the JVM by reading memory it should not, and why the JVM trusts java.base enough to skip it there. Preparation allocates static fields at their defaults. Resolution turns constant-pool symbols into direct references, lazily, at first use.
  3. Initialisation. Static initialisers and static field assignments run, in textual order, once, under a lock, the first time the class is actively used: an instance created, a static method called, a non-constant static field touched. Merely referencing the class (Foo.class) or a static final compile-time constant does not trigger it.
  4. Calls main.
loadverifyprepareresolveinitialise
Class objectcreatedstatic fieldsdo not exist yetinitialisernot run

load. A class loader finds the bytes and makes a Class object. Parent-first: the application loader asks the platform loader, which asks the bootstrap loader.

1 / 5

Two production facts follow. A static initialiser that fails throws ExceptionInInitializerError once, and every later use of the class throws NoClassDefFoundError with no further explanation; the real cause is the first stack trace in the log, often minutes earlier. And a Spring Boot application loads on the order of ten thousand classes at startup, so much of "Spring is slow to start" is this: reading, verifying and initialising classes, plus the reflection Spring does on top.

Walkthrough: 2.4 seconds, accounted for

A small Spring Boot service prints Started in 2.4 seconds. Where did it go? java -Xlog:class+load:file=classes.log -jar app.jar writes one line per class:

classes.log, countedplaintext
$ grep -c 'source: jrt:/' classes.log      # JDK classes
 3,212
$ grep -c 'source: file:' classes.log      # your jar and its dependencies
 7,940
$ grep -c 'shared objects file' classes.log   # served from the CDS archive
 1,180

Eleven thousand classes. Each one is a jar entry located, inflated, parsed, verified and, for a few thousand of them, initialised. Verification alone is a measurable slice; that is what the class data sharing archive removes. The JDK ships a default CDS archive of its own core classes (the shared objects file lines above), pre-parsed and pre-verified, mapped into memory at startup. -XX:ArchiveClassesAtExit=app.jsa after a training run, then -XX:SharedArchiveFile=app.jsa, extends that to your application's classes and typically takes a third or more off startup. Project Leyden's AOT cache in Java 24 and 25 goes further and caches linking and, in 25, method profiles. The rest of the 2.4 seconds is Spring reading annotations through reflection and building beans, which is the Spring Boot course's problem, not the JVM's.

Versions, vendors and what "Java 21" means

The Java platform has a specification, versioned (17, 21, 25). OpenJDK is the reference implementation, developed in the open. Vendors — Eclipse Temurin, Amazon Corretto, Azul Zulu, Oracle, Microsoft, Red Hat — build and support binaries of it. They are the same code with different support contracts and, occasionally, different patch timing. Choosing a vendor is a procurement decision, not a technical one.

LTS releases come every two years (17, 21, 25) and receive years of updates; the releases in between get six months. Production services sit on an LTS. The six-month releases are where you try what is coming. The class-file major version rises by one every release, which is why a jar built with javac 25 and no --release flag fails on a 21 runtime with UnsupportedClassVersionError: class file version 69.0, and why --release 21 on the compiler is the setting that keeps a library honest about what it needs.

Try it yourself

Read the header

Run javap -v on any class you have compiled and find three things: the major version, the constant-pool entry for a string literal, and the LineNumberTable for main. Which line of source does bytecode offset 0 map to?

Answer

The major version is on the second line (major version: 65 for Java 21). String literals appear as #n = String #m pointing at a Utf8 entry. The LineNumberTable lists pairs like line 3: 0, meaning bytecode offset 0 belongs to source line 3: the first statement of main, not the method's opening line. A stack trace reads that table backwards from the offset that threw, which is why a jar compiled with -g:none shows Unknown Source.

Predict the initialisation order

java
class A { static { System.out.println("A"); } static final int X = 1; static int y = 2; }
class Main {
    public static void main(String[] a) {
        System.out.println(A.X);
        System.out.println("--");
        System.out.println(A.y);
    }
}

What prints, in order?

Answer

1, --, A, 2. A.X is a compile-time constant, inlined into Main by javac, so reading it is not an active use and A is not initialised. A.y is a non-constant static field; touching it triggers initialisation, which runs the static block (A) before returning 2. Make X non-final, or initialise it with a method call, and A prints first.

Two loaders, one class

A web application server loads your jar's com.acme.Money in one class loader and a shared library's com.acme.Money in another. Your code receives an instance from the library and casts it to Money. What happens, and why?

Answer

ClassCastException: com.acme.Money cannot be cast to com.acme.Money. A class's identity in the JVM is its name plus its defining loader; two loaders that each define the class produce two distinct types with the same name. Parent delegation exists to prevent this for shared classes: put Money in the parent loader's path so both children see one definition. The same error appears with OSGi, plugin systems, and hot-reload tools.

Misconceptions

  • "The JVM is an interpreter." It starts as one and compiles what gets hot to machine code. Steady-state Java runs native code; the next lesson is about how.
  • "javac optimises my code." It barely does: constant folding and little else. Every real optimisation happens in the JIT at run time, on the profile, which is why javap output looks so literal.
  • "A JDK in production is fine, it is just bigger." It is bigger, slower to pull, and ships a compiler, a debugger agent and a dozen tools to a box that should have none. Runtime images exist for this reason.
  • "Referencing a class initialises it." Only active use does. Foo.class, a static final constant, or an array of Foo does not run its static initialiser, which is why lazy holder classes work and why an ExceptionInInitializerError can appear far from where you expected.
  • "Java 21 code runs on any Java 21+." Usually, but a .class compiled for 25 does not run on 21, and --release is the flag that pins what you emit.

Going deeper

  • The JVM Specification, chapter 4 (the class file format) and chapter 5 (loading, linking, initialising): shorter and more readable than its reputation.
  • JLS §12.4, when initialisation occurs, with the exact list of active uses.
  • Class data sharing on this baseline: -Xshare:dump, then -XX:SharedArchiveFile. The JDK's own archive is already on; extending it to your classes is one training run.
  • Where this goes next: JEP 350 (dynamic CDS archives), and Project Leyden's AOT cache — JEP 483 in Java 24, JEP 515 in 25, JEP 516 in 26.
  • java -Xlog:class+load and -Xlog:class+init on any application you own.
  • jlink and jdeps --print-module-deps for building the runtime image a service actually needs.
Progress is saved on this device and to your account when signed in.