JDK, JRE and the JVM
Know what the JDK, the JRE and the JVM each do, which one your laptop and your server need, and how source code becomes running bytecode.
javac -d out src/Main.java # JDK: compile to bytecode
java -cp out Main # JVM: run the class file
java -version # which runtime is on your PATHThree names come up as soon as you install Java. They are layers, each one containing the next.
- JVM (Java Virtual Machine): the program that runs compiled Java. It loads class files, verifies them, interprets the bytecode, compiles hot methods to machine code with its just-in-time (JIT) compiler and manages memory with a garbage collector.
- JRE (Java Runtime Environment): the JVM plus the standard class library (
java.lang,java.util,java.net.httpand the rest). Enough to run a program, not to build one. - JDK (Java Development Kit): the runtime plus the tools:
javac(compiler),jar,jshell,javadoc,jlink,jcmdand others.
From source to running code
javac turns .java files into .class files containing bytecode: instructions for the JVM, not for your CPU. The same class file runs on Linux, macOS and Windows. Each class file records the version it was compiled for; Java 25 writes class file version 69, and an older JVM refuses to load it with an UnsupportedClassVersionError. That error in a deploy log almost always means the build used a newer JDK than the server.
Which one do you install?
On your machine, install a JDK. On servers and in containers you only need a runtime. Oracle stopped shipping a separate JRE with Java 11, but some distributions, such as Eclipse Temurin, still publish JRE builds and container images. You can also build a trimmed runtime containing only the modules your service needs with jlink, which a later lesson covers.
Distributions
OpenJDK is the open-source project; Eclipse Temurin, Amazon Corretto, Oracle JDK, Azul Zulu and the Microsoft Build of OpenJDK are builds of it. For the same version they behave the same for application code. Pick one with the support and licence terms your team wants, and use the same feature version in development, CI and production.
Mistakes people make
- Having several JDKs installed and running a different one than expected.
java -versionandjavac -versionshould print the same feature number. - Assuming the JVM sees the whole host. In a container, the JVM reads the container's CPU and memory limits and sizes the heap and garbage collector from them.
- Treating the first requests' speed as the steady state. The JIT compiles hot code while the service runs, so latency usually drops after warm-up.
Example
When to use it
- Diagnose an UnsupportedClassVersionError after a CI pipeline upgraded its JDK but the servers did not.
- Choose a JDK distribution and pin the same feature version in development, CI and the container image.
- Fail a service's startup early with a clear log line when it is launched on an unsupported Java version.
More examples
Compile a class and read its bytecode header
Every class file starts with the bytes CAFEBABE followed by its version, and 69 is Java 25. A JVM older than 25 rejects this file, which is what UnsupportedClassVersionError reports.
void main() throws Exception {
Path dir = Files.createTempDirectory("jdk-demo");
Path source = dir.resolve("Greeter.java");
Files.writeString(source, "class Greeter { String hello() { return \"hi\"; } }");
// The same compiler the javac command runs, called in-process.
var javac = java.util.spi.ToolProvider.findFirst("javac").orElseThrow();
int exitCode = javac.run(System.out, System.err, "-d", dir.toString(), source.toString());
IO.println("javac exit code: " + exitCode);
byte[] bytes = Files.readAllBytes(dir.resolve("Greeter.class"));
IO.println("magic: %02X%02X%02X%02X".formatted(bytes[0], bytes[1], bytes[2], bytes[3]));
IO.println("class file version: " + (((bytes[6] & 0xFF) << 8) | (bytes[7] & 0xFF)));
Files.delete(dir.resolve("Greeter.class"));
Files.delete(source);
Files.delete(dir);
}
// Output:
// javac exit code: 0
// magic: CAFEBABE
// class file version: 69Check a minimum Java version at startup
Runtime.Version parses and compares version strings correctly, unlike comparing them as text. A service can fail fast with a clear message instead of an obscure error later.
boolean supported(Runtime.Version running, Runtime.Version required) {
return running.compareTo(required) >= 0;
}
void main() {
Runtime.Version required = Runtime.Version.parse("25");
for (String candidate : List.of("21.0.8", "25.0.1+8", "27")) {
Runtime.Version v = Runtime.Version.parse(candidate);
IO.println(candidate + " feature=" + v.feature() + " update=" + v.update()
+ " supported=" + supported(v, required));
}
IO.println("this JVM supported=" + supported(Runtime.version(), required));
}
// Output:
// 21.0.8 feature=21 update=8 supported=false
// 25.0.1+8 feature=25 update=1 supported=true
// 27 feature=27 update=0 supported=true
// this JVM supported=trueThe JVM loads classes only when they are first used
The static initialiser runs once, at the first use of Config, not at startup. Lazy loading is part of why large services spend their first seconds loading classes, and why the first request can be slower than the rest.
class Config {
static {
IO.println("Config class loaded");
}
static String databaseUrl() {
return "jdbc:postgresql://db.internal/app";
}
}
void main() {
IO.println("main started");
IO.println(Config.databaseUrl());
IO.println(Config.databaseUrl());
}
// Output:
// main started
// Config class loaded
// jdbc:postgresql://db.internal/app
// jdbc:postgresql://db.internal/app
Discussion