JDK Distributions, Version Strategy
Why this matters in production
In production, Java problems often show up as build drift: a developer compiles with one JDK, CI tests with another, and the server runs a third. The symptoms vary (TLS handshake issues, different default encodings, subtle performance changes, or even bytecode version errors), but the root cause is the same: the toolchain is not pinned.
Choose a JDK distribution (what changes, what does not)
All mainstream distributions are OpenJDK-based and should behave the same for most applications. Differences appear in packaging, update cadence, support windows, and sometimes bundled components. Your main objective is operational stability:
- Pick one vendor/distribution for your org (for example, Temurin, Corretto, Microsoft Build of OpenJDK, Oracle JDK) and stick to it for services.
- Pick one major LTS line (commonly 17 or 21) and standardize it.
- Prefer predictable updates: plan how you roll patch updates across environments (dev → CI → staging → prod).
Define a version strategy
A practical, low-drama strategy:
- Major: pick an LTS (17 or 21) and do not mix majors across services unless there is a strong reason.
- Patch: allow patch upgrades, but only via a controlled pipeline. Do not let laptops auto-upgrade without CI and staging validating first.
- Build tool versions: pin Maven/Gradle versions (or use Maven Wrapper / Gradle Wrapper) so CI and dev run the same build engine.
Pin the toolchain in the build
Do not rely on whatever JDK happens to be installed. Make the build enforce the Java version.
Gradle toolchain example
// build.gradle (Groovy)
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
// Optional: make compilation target explicit
tasks.withType(JavaCompile).configureEach {
options.release = 21
}
Maven toolchain example
Use a toolchain so Maven can select the correct JDK even when multiple are installed.
<!-- pom.xml -->
<properties>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.12.1</version>
<configuration>
<release>21</release>
</configuration>
</plugin>
</plugins>
</build>
Operational verification (what to log and check)
At service startup, log Java version info once. This is incredibly useful during incidents when a node is accidentally running a different runtime.
System.getProperty("java.version");
System.getProperty("java.vendor");
System.getProperty("java.vm.name");
System.getProperty("file.encoding");
System.getProperty("user.timezone");
On hosts and in containers, verify the runtime:
java -version javac -version echo $JAVA_HOME which java
Containers: avoid hidden mismatches
- Build image and runtime image should be compatible (same major). If you compile with 21, do not run with 17.
- Prefer wrappers (Gradle Wrapper / Maven Wrapper) so the build is deterministic inside the container.
- Record the base image tag (including patch) so you can reproduce and roll forward/back safely.
Common production failure modes
- UnsupportedClassVersionError: code compiled for a newer Java major than the runtime.
- TLS surprises: different runtime or security providers across nodes; handshake failures after partial rollout.
- Encoding bugs: file.encoding differs across environments; input parsing breaks with non-ASCII data.
- CI drift: tests pass locally but fail in CI because CI uses a different JDK or build tool version.
Practical checklist
- Standardize on one major LTS line for the org.
- Pin toolchain in Gradle/Maven and use wrappers.
- Log java.version/vendor at startup (once).
- Roll patch updates through a controlled pipeline.