Posts

JDK vs JRE vs JVM: What’s the Difference?

Published

Catherine Edelveis

16

TLDR

  • A Spring Boot fat jar carries Spring, Jackson and a PostgreSQL driver dependencies, but no Java SE classes.
  • Oracle stopped shipping a separate JRE starting with Java 11, but you can still download one today.
  • When Spring says your app started in three seconds, the JVM may still be busy turning it into faster machine code.

"What's the difference between the JDK, the JRE and the JVM?" is a popular question asked at technical interviews for a Java dev position. The expected answer is three nested boxes: the JDK contains the JRE, the JRE contains the JVM. Developers install the first one, users need the second one, and the third one is what breathes life into Java code.

But this is a bare minimum. A more detailed answer follows the app. Between writing a Java class and the CPU running it, the code gets compiled, packaged, loaded, interpreted, optimized and turned into machine code, and the three acronyms show up at different stops along the way. Let's follow one app all the way down.

The JDK: it starts with source code

Suppose we've started a new Java project:

import java.util.HashMap;

public class App {
    public static void main(String[] args) {
        var map = new HashMap<String, String>();
    }
}

To write and build it, we need the Java Development Kit, either installed ourselves or provided and configured by the IDE. Which OpenJDK distribution we pick at this point doesn't matter. Liberica JDK, Azul Zulu, Eclipse Temurin, Oracle JDK and other distributions of the same Java version provide the same standard APIs, because String, HashMap, List, Files and the rest of Java SE are defined by the specification. The distributions differ in everything around those APIs: supported platforms, bundled components, update policies, support, performance enhancements and licensing.

The JDK contains the Java platform itself, so classes such as String or HashMap are available to our code. It also includes development and diagnostic tools like jar, javap, jdeps, jlink and jcmd. And most importantly, it has the Java compiler, javac.

Now, imagine our one-class app has grown into MedFlow, a Spring Boot service for managing healthcare appointment slots, triage cases and slot suggestions across facilities. Here's one of its services:

package dev.cyberjar.medflow.staff;

import dev.cyberjar.medflow.staff.dto.StaffResponse;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class StaffService {

    private final StaffRepository staffRepository;

    public StaffService(StaffRepository staffRepository) {
        this.staffRepository = staffRepository;
    }

    public List<StaffResponse> findStaffBySpecialtyId(Long specialtyId) {
        return staffRepository.findStaffBySpecialtyId(specialtyId);
    }

}

The CPU can't just take and execute this code. This is where javac takes the stage.

Compilation: Java to JVM bytecode

When we compile the app, each source file becomes a .class file:

Source files to class files

A class file no longer holds high-level Java. It contains Java bytecode, the instruction set understood by the Java Virtual Machine, plus metadata: the class name, methods, field definitions, type information, and a constant pool with symbolic references to other classes, such as HashMap. If you are curious to check it out, javap from the JDK lets us look inside:

javap -c target/classes/dev/cyberjar/medflow/staff/StaffService.class

Compiled from "StaffService.java"
public class dev.cyberjar.medflow.staff.StaffService {
  public dev.cyberjar.medflow.staff.StaffService(dev.cyberjar.medflow.staff.StaffRepository);
    Code:
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: aload_0
         5: aload_1
         6: putfield      #7                  // Field staffRepository:Ldev/cyberjar/medflow/staff/StaffRepository;
         9: return

  public java.util.List<dev.cyberjar.medflow.staff.dto.StaffResponse> findStaffBySpecialtyId(java.lang.Long);
    Code:
         0: aload_0
         1: getfield      #7                  // Field staffRepository:Ldev/cyberjar/medflow/staff/StaffRepository;
         4: aload_1
         5: invokevirtual #13                 // Method dev/cyberjar/medflow/staff/StaffRepository.findStaffBySpecialtyId:(Ljava/lang/Long;)Ljava/util/List;
         8: areturn
}

Look at invokevirtual #13. The bytecode has no idea what StaffRepository does. It merely holds a symbolic reference that will be resolved later by something else.

Real applications have hundreds of classes and dependencies, and nobody wants to ship a directory stuffed with loose .class files. That's what packaging is for.

Packaging: what goes into the JAR?

Packaging is a separate step from compilation. For instance, if you use Maven, mvn compile produces compiled classes. mvn package runs compilation and the other earlier lifecycle phases, and then creates the distributable artifact — a JAR file.

Let's package MedFlow and peek inside:

jar tf target/med-flow-0.0.1-SNAPSHOT-spring-boot.jar
...
BOOT-INF/classes/dev/cyberjar/medflow/triage/dto/LabResultDto.class
BOOT-INF/classes/dev/cyberjar/medflow/triage/dto/TriageCaseDetailsResponse.class
BOOT-INF/classes/dev/cyberjar/medflow/triage/TriageController.class
...
org/springframework/boot/loader/jar/JarEntriesStream$InputStreamSupplier.class
org/springframework/boot/loader/jar/JarEntriesStream.class
org/springframework/boot/loader/jar/ManifestInfo.class
...
BOOT-INF/lib/log4j-api-2.25.5.jar
BOOT-INF/lib/jakarta.annotation-api-3.0.0.jar
BOOT-INF/lib/snakeyaml-2.6.jar
BOOT-INF/lib/jackson-core-3.1.5.jar
BOOT-INF/lib/postgresql-42.7.13.jar
...

Our own classes are in BOOT-INF/classes, compiled during the build. The Spring Boot loader is there too, since it knows how to launch a jar full of other jars. The BOOT-INF/lib directory includes Spring, Jackson, the PostgreSQL driver and the rest: these have already been compiled and packaged by their publishers.

What we won't find is java/util/HashMap.class, or String, or Files, or any other class from the Java platform.

A JAR is not an executable: enter the Java runtime

We can't run the JAR on its own. Its .class files contain JVM bytecode, which an x86 or ARM processor doesn't understand. And the platform classes our code relies on, like String, List, HttpClient etc., aren't in it.

This is where we need a Java runtime, the environment required to execute a compiled Java application. It gives us the java launcher, the platform classes and modules, native libraries and configuration, and an implementation of the JVM. HashMap, for example, lives in the java.base module. MedFlow doesn't ship its own copy because the runtime provides it.

Historically, this runtime was called the JRE, or Java Runtime Environment. Before Java 9, a JDK literally contained a jre directory: the runtime, with the development tools on top because running an app doesn't require those tools. Java 9 introduced modular runtime images and dropped the jre subdirectory, and since JDK 11, Oracle no longer ships a separate JRE.

Interestingly, the concept survived. OpenJDK vendors such as BellSoft, Azul, and Eclipse Temurin still offer standalone JRE builds. So today "JRE" means a Java runtime distribution, whether or not anything on disk is called jre.

Do we need the entire runtime?

Since Java 9, the platform is organized into modules. java.base holds the fundamentals like String, collections and I/O, and then there are java.sql, java.logging, java.net.http, java.desktop and many more. A backend service has little use for some of them.

If we don't need the full JDK, maybe we don't need a full JRE either? Can we keep only the modules we use?

Yes, with jlink. It takes the modules our application needs, adds their transitive dependencies, and assembles a custom Java runtime built for that one app. The result still has the java launcher, the required modules and a JVM capable of running our bytecode. Everything the app never touches stays out.

To find out which modules those are, the JDK has jdeps. For MedFlow, the sequence looks roughly like this.

Unpack the fat jar so jdeps can see classes and libs:

mkdir app && cd app && jar xf ../target/med-flow-0.0.1-SNAPSHOT-spring-boot.jar

Ask jdeps which platform modules the app and its dependencies use:

jdeps --ignore-missing-deps --print-module-deps -q \
    --recursive --multi-release 25 \
    --class-path 'BOOT-INF/lib/*' \
    BOOT-INF/classes > ../modules.txt

Assemble a runtime with only those modules:

jlink --add-modules "$(cat ../modules.txt)" \
    --strip-debug --no-man-pages --no-header-files \
    --compress zip-6 \
    --output ../medflow-runtime

There is a catch, especially with frameworks. jdeps performs static analysis, so anything discovered dynamically at runtime may be invisible to it, and frameworks sometimes load classes at runtime. If a module is missing, the image builds fine and the app fails on the first code path that needs it. So always test a custom runtime before you ship it!

Why this matters for containers

The difference between the JDK and the JRE starts to matter when Java apps are put into containers. Building the image requires the full JDK to compile and package the app, but once the JAR exists, it doesn’t usually need the dev tools, especially the compiler. With a Docker multi-stage build, we can compile in a JDK stage and copy the JAR into a final image that holds only a Java runtime, which reduces the image size and the attack surface. We can also go further and use a jlink-ed runtime. I discuss both these and other options in my video Java Docker Images in 2026: From 331 MB to 28 MB.

What is the JVM, then?

Whatever runtime option we choose, one component remains essential: the Java Virtual Machine, the machine our .class files were compiled for.

More precisely, the JVM specification defines an abstract machine with its own instruction set, class-file format, memory areas and execution rules. HotSpot, used by OpenJDK and many OpenJDK distributions, is one implementation of that specification. And HotSpot itself is a regular native program compiled for your platform, so when it starts, the CPU is executing HotSpot's own machine code.

Let's start MedFlow:

java -jar application.jar

The JVM begins loading the classes the application needs. Some are our own, some come from dependencies such as Spring, and platform classes like HashMap come from the runtime itself. The JVM loads these classes, verifies them, links them into its runtime state and initializes them when required. Then it can execute the bytecode.

The processor still doesn't understand bytecode, though. Turning it into native instructions is also the JVM's job. HotSpot has a bytecode interpreter, so it can start executing right away. While the application runs, HotSpot collects information about how the code is used, and the JIT compiler compiles frequently executed methods into native machine code for the current processor. With tiered compilation, code moves through different levels of compilation and optimization as more profiling information becomes available.

So the JVM doesn't have to compile the entire application into native code before it can start. It begins with the interpreter and optimizes the important parts as the application runs.

From source code to machine code

This explains Java warmup

The ability to optimize code while the application runs gives Java an interesting performance characteristic. Namely, there is a difference between Java startup and warmup.

Startup is the time required before our application is ready to do useful work. For Spring Boot, that includes starting the JVM, loading and initializing classes, creating the application context and the beans, configuring the embedded server, connecting to infrastructure, and so on.

Warmup can continue long after startup is over. MedFlow may already be accepting requests while HotSpot is still collecting profiles and compiling hot methods. So when Spring logs that the application started in three seconds, the JVM hasn't necessarily reached its steady-state performance yet.

This is one reason the JVM has traditionally been so good for long-running server applications: it uses information from the real workload to optimize the code while serving that workload. For applications that need to start very quickly or live only for a short time, on the other hand, startup and warmup can become real concerns.

Back to the interview question

So, what's the difference between the JDK, the JRE and the JVM?

The JDK is what we develop our Java applications with. It includes the Java platform plus tools such as javac, which compiles our source code into JVM bytecode. The resulting JAR isn't a native executable and doesn't contain the platform classes, so running it takes a Java runtime, traditionally called the JRE, with the platform modules, supporting libraries and the JVM. The JVM is the execution engine at the center of that runtime. It loads and executes our bytecode, manages memory and runtime state, and in HotSpot's case, compiles frequently executed code into native machine instructions as the app runs.

To see it on your own project, run javap -c on one of your compiled classes and jar tf on your fat jar, and notice which classes aren't there. If your app runs in containers, the Docker video linked above puts the JDK, JRE and jlink choices to work in real images, built on Liberica JDK and JRE base images.

Also, subscribe to our newsletter for monthly picks on all things Java, from best development practoces to Java in containers and the latests news around the platform.

 

Subcribe to our newsletter

figure

Read the industry news, receive solutions to your problems, and find the ways to save money.

Further reading