The JDK compiles your code, the JRE supplies what a program needs to run, and the JVM executes the compiled bytecode. Each layer contains the next: JDK ⊃ JRE ⊃ JVM. Most confusion comes from treating them as three separate installs. They are three nested responsibilities.
Quick Takeaways
- JVM (Java Virtual Machine): the engine that loads, verifies, and runs
.classbytecode. It is platform-specific, while bytecode is not. - JRE (Java Runtime Environment): the JVM plus the core class libraries (
java.baseand friends). It runs programs but cannot compile them. - JDK (Java Development Kit): the JRE plus developer tools such as javac, jar, javap, jlink, and jshell.
- Since Java 11, Oracle and OpenJDK builds no longer ship a standalone JRE. You build a trimmed runtime with jlink.
| Component | Purpose | Contains | Needed to compile? | Needed to run? |
|---|---|---|---|---|
| JVM | Execute bytecode | Class loader, interpreter, JIT, GC | No | Yes |
| JRE | Provide a runtime environment | JVM + standard libraries | No | Yes |
| JDK | Build and debug Java apps | JRE + compiler and tools | Yes | Yes |
The Execution Pipeline, Start to Finish
A Java program passes through four stages before it prints anything.
- Write source code in a
.javafile. - Compile it with javac into platform-neutral bytecode (
.class). - Load and link the classes through the JVM’s class loaders.
- Execute the bytecode by interpretation and JIT compilation to native machine code.
The compiler runs once per build. The JVM runs on every launch. That split is why one .class file runs on Linux, Windows, and macOS: only the JVM is built per platform.
The JDK: Your Toolbox
The JDK is what you install on a development machine or CI server. Its bin directory holds the tools you use daily.
| Tool | Job |
|---|---|
| javac | Compile .java to .class |
| java | Launch the JVM |
| jar | Package classes into archives |
| javap | Disassemble bytecode |
| jshell | Interactive REPL |
| jlink | Build a custom runtime image |
| jdeps | Analyze module dependencies |
| jcmd / jstack | Inspect running JVMs |
Check what you have installed:
# Prints the runtime version
java --version
# Prints the compiler version. If this command is missing,
# you have a runtime only, not a full JDK.
javac --version
# Shows where the JDK lives
echo "$JAVA_HOME"
If java --version works but javac --version returns “command not found”, the machine has a runtime without the compiler.
Compiling and Running: A Minimal Example
Start with the smallest program that shows every stage.
// Hello.java
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, JVM");
}
}
Compile and run it as two separate steps:
javac Hello.java # JDK tool: produces Hello.class
java Hello # JVM launcher: loads and executes Hello.class
Output:
Hello, JVM
javac wrote Hello.class. The java command started a JVM, loaded that class, found main, and ran it. Since Java 11, you can also skip the explicit compile step for a single file with java Hello.java. The launcher compiles in memory and runs the result.
What Bytecode Looks Like
Bytecode is a compact instruction set for a stack-based machine. javap disassembles it.
javap -c Hello
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello, JVM
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
Constant pool indexes (#7, #13, #15) vary between compiler versions. The logic does not. The method pushes System.out onto the operand stack, pushes the string constant, calls println, and returns. No instruction names a CPU register or an operating system, which is what makes the code portable.
Inside the JVM
The JVM has three jobs: load classes, manage memory, and execute code.
Class Loading and Linking
Three built-in class loaders work in a delegation chain.
| Loader | Loads from | Example classes |
|---|---|---|
| Bootstrap | Core runtime modules | java.lang.String, java.util.List |
| Platform | Platform and extension modules | java.sql.* |
| Application (system) | Your classpath / module path | Your own classes |
A loader asks its parent first. This delegation prevents application code from replacing java.lang.String. After loading, the JVM links the class in three phases:
- Verification: checks that the bytecode is structurally valid and type-safe.
- Preparation: allocates static fields and sets default values.
- Resolution: turns symbolic references into direct references.
Initialization then runs static initializers, the first time the class is actively used.
Runtime Memory Areas
// MemoryAreas.java
public class MemoryAreas {
// Static field: lives in metadata (Metaspace), values on the heap
private static int counter = 0;
public static void main(String[] args) {
int local = 42; // local variable: thread stack frame
Object obj = new Object(); // object: heap; reference: stack
counter++;
System.out.println(local + " " + obj.hashCode() + " " + counter);
}
}
The primitive local and the reference obj live in a stack frame owned by the current thread. The Object itself lives on the heap. Class metadata lives in Metaspace, which is allocated from native memory.
| Area | Holds | Shared across threads? | Common failure |
|---|---|---|---|
| Heap | Objects, arrays | Yes | OutOfMemoryError: Java heap space |
| Thread stack | Frames, locals, references | No | StackOverflowError |
| Metaspace | Class metadata | Yes | OutOfMemoryError: Metaspace |
| Code cache | JIT-compiled native code | Yes | Compilation stops when full |
Execution Engine: Interpreter, JIT, and GC
The JVM starts by interpreting bytecode, which gives fast startup. It profiles which methods run often (“hot” methods). The JIT compiler then compiles those methods to native code. HotSpot uses tiered compilation: C1 compiles quickly with light optimization, and C2 compiles hot code with aggressive optimization such as inlining and escape analysis.
The garbage collector reclaims heap objects that are no longer reachable. G1 is the default collector. ZGC and Shenandoah target low pause times. Parallel targets throughput, and Serial suits tiny heaps.
Inspect JVM defaults on your machine:
java -XshowSettings:vm -version
This prints the initial and maximum heap sizes the JVM picked for your hardware, which is useful when tuning containers.
The JRE: What Happened to It?
The JRE was a separate download for years. That ended with the module system in Java 9. The runtime is now divided into modules, and you can assemble only the ones your application needs.
List the modules in your JDK:
java --list-modules
Find which modules your app requires, then build a minimal runtime:
# Analyze which JDK modules the application uses
jdeps --print-module-deps --ignore-missing-deps app.jar
# Build a custom runtime from those modules
jlink \
--add-modules java.base,java.logging \
--strip-debug \
--no-header-files \
--no-man-pages \
--compress=zip-6 \
--output custom-runtime
# Run the app with the trimmed runtime
./custom-runtime/bin/java -jar app.jar
The result is a self-contained runtime that is often far smaller than a full JDK. You ship it with the app, so the target machine needs no Java install.
JDK vs JRE vs JVM: Direct Comparison
| Feature | JVM | JRE | JDK |
|---|---|---|---|
Runs .class files |
Yes | Yes | Yes |
| Standard library | No | Yes | Yes |
| Compiler (javac) | No | No | Yes |
| Debugger and profilers | No | No | Yes |
| Platform-specific | Yes | Yes | Yes |
| Typical audience | Embedded in runtime | Production servers | Developers, CI |
| Standalone download today | No | No (use jlink) | Yes |
Best Practices: Bad Code vs. Good Code
Most real-world problems come from misusing these layers in build and deploy scripts, not from the Java code itself.
Anti-pattern: Full JDK, Untuned JVM, Hardcoded Paths
# BAD: ships the compiler and every tool to production,
# hardcodes a vendor path, and ignores container memory limits
FROM ubuntu:latest
RUN apt-get update && apt-get install -y openjdk-21-jdk
COPY . /app
WORKDIR /app
RUN javac -d out src/*.java
ENV JAVA_HOME=/usr/lib/jvm/java-21-openjdk-amd64
CMD ["java", "-cp", "out", "Main"]
Problems: the image carries the JDK (compiler included) at runtime, the build happens in the final image, latest is not reproducible, and the JAVA_HOME path breaks on a different CPU architecture.
Refactored: Multi-Stage Build with a Trimmed Runtime
# GOOD: compile in a JDK stage, run on a minimal runtime
FROM eclipse-temurin:21-jdk AS build
WORKDIR /src
COPY src/ ./src/
RUN mkdir out && javac -d out src/*.java \
&& jar --create --file app.jar --main-class Main -C out .
# Build a minimal runtime containing only required modules
RUN jlink --add-modules java.base \
--strip-debug --no-header-files --no-man-pages \
--compress=zip-6 --output /javaruntime
FROM debian:stable-slim
COPY --from=build /javaruntime /opt/java
COPY --from=build /src/app.jar /app/app.jar
# Size the heap relative to the container limit, not the host
ENTRYPOINT ["/opt/java/bin/java", \
"-XX:MaxRAMPercentage=75", \
"-jar", "/app/app.jar"]
The final image has no javac, no hardcoded JAVA_HOME, a pinned base version, and a heap sized from the container’s memory limit through -XX:MaxRAMPercentage. Pin exact image tags in production for fully reproducible builds.
Anti-pattern: Assuming the Runtime Equals the Compiler
# BAD: compiles with one JDK, runs on an older runtime
javac Service.java # JDK 21
scp Service.class server:/app
ssh server "java Service" # server has Java 17
This fails with UnsupportedClassVersionError, because the class file targets a newer bytecode version than the runtime understands.
Refactored: Target the Runtime Version Explicitly
# GOOD: compile for the oldest runtime you must support
javac --release 17 Service.java
The –release flag compiles against the Java 17 API and emits Java 17 bytecode, so the class runs on any 17+ runtime. It also blocks accidental use of newer library methods that -source and -target alone would allow.
Edge Cases Worth Knowing
- Version mismatch: a newer JVM runs older bytecode, but an older JVM rejects newer bytecode. The error is
UnsupportedClassVersionError. - JRE vs JDK in containers: a runtime-only image cannot run
javac,jcmd, orjstack. Keep a JDK-based debug image for diagnostics. - Multiple installs:
javaandjavacon yourPATHcan come from different JDKs. Comparejava --versionandjavac --version. - Non-Java languages: Kotlin, Scala, and Groovy compile to the same bytecode, so they run on the same JVM.
- Startup cost: the interpreter-first model favors long-running servers. Short-lived CLI tools may prefer class data sharing or GraalVM native image.
Strategic FAQ
What is the difference between JDK, JRE, and JVM?
The JVM executes bytecode. The JRE is the JVM plus the standard class libraries needed to run programs. The JDK is the JRE plus development tools such as javac and jar. You need a JDK to build Java code and only a runtime to run it.
Do I need to install the JRE separately?
No. Standalone JRE downloads ended with Java 11. A JDK includes a full runtime, and you can generate a smaller, application-specific runtime with jlink.
Is the JVM platform-independent?
No. The JVM is built for each operating system and CPU architecture. Bytecode is the portable part. The same .class file runs on any platform that has a compatible JVM.
Can the JVM run languages other than Java?
Yes. Any language whose compiler emits valid class files runs on the JVM. Kotlin, Scala, Groovy, and Clojure all do.




