Managing JDK Versions per Project
A developer who works on two services gets Unsupported class file major version 65 in one and error: release version 21 not supported in the other, depending on which JDK JAVA_HOME happened to point at when they opened the terminal. The IDE compiles with its own bundled JDK, Gradle's daemon uses a third, and CI a fourth. Java projects are especially sensitive to the JDK version because bytecode targets, language features and build plugins all depend on it. This page pins the JDK per repository and makes the shell, the build tool, the IDE and CI all use it, as part of toolchain version management.
The mapping to remember: class file major version 61 is Java 17, 65 is Java 21. The error means the code was compiled for a newer Java than the one trying to run it.
Diagnostic
Show every JDK in play — shell, build tool, daemon, IDE — for the current project:
#!/usr/bin/env bash
set -euo pipefail
echo "JAVA_HOME=${JAVA_HOME:-unset}"
java -version 2>&1 | head -1
./gradlew --version 2>/dev/null | grep -E '^(Gradle|Launcher JVM|Daemon JVM)' || true
./gradlew -q javaToolchains 2>/dev/null | grep -E 'Language Version|Detected by' | head -6 || true
grep -hE 'languageVersion|sourceCompatibility|<maven.compiler.release>' build.gradle.kts pom.xml 2>/dev/null || true
cat .tool-versions .sdkmanrc 2>/dev/null || echo "no per-project JDK pin"
Expected bad output:
JAVA_HOME=/Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home
openjdk version "17.0.12" 2024-07-16
Gradle 8.10
Launcher JVM: 17.0.12 (Eclipse Adoptium 17.0.12+7)
Daemon JVM: 17.0.12
languageVersion = JavaLanguageVersion.of(21)
no per-project JDK pin
The build asks for Java 21, but the shell, launcher and daemon all run 17, and nothing in the repository says which JDK to install.
Root cause
Java has no built-in per-project version selection. The java on PATH and JAVA_HOME are global shell state, typically set once by whoever installed the first JDK. Build tools add their own layers: Gradle runs a launcher JVM, a long-lived daemon JVM (which keeps the JDK it started with), and — if toolchains are configured — a separate compiler JDK; Maven uses JAVA_HOME unless the toolchains plugin says otherwise. IDEs have a project SDK setting that is independent of all of these. Without a pin file, each developer's setup reflects their history, and switching between projects with different requirements means manually changing JAVA_HOME — which people forget, producing the class-version errors above.
The daemon layer causes the most confusion. A developer notices the wrong JDK, fixes JAVA_HOME, reruns the build — and gets the same error, because the Gradle daemon started earlier is still alive and still running the old JVM. From the developer's point of view the fix "did not work", and the next steps are usually reinstalling JDKs or clearing caches, neither of which helps. Knowing that the daemon is a separate, long-lived process with its own JDK, and that ./gradlew --stop resets it, turns a frustrating hour into a one-line fix.
Vendors add a smaller source of drift. Temurin, Zulu, Corretto and Oracle builds of the same Java version are compatible for application code, but differ in bundled certificates, default garbage collector settings and available tools. Pinning the vendor along with the version keeps performance tests and TLS behaviour consistent across machines.
Resolution
- Pin the JDK in the repository with a toolchain manager that switches automatically on
cd. With mise:
#!/usr/bin/env bash
set -euo pipefail
mise use [email protected]+7.0.LTS
cat mise.toml
mise exec -- java -version 2>&1 | head -1
mise sets JAVA_HOME and PATH whenever the shell enters the directory. With SDKMAN, commit an .sdkmanrc (java=21.0.4-tem) and enable sdkman_auto_env=true for the same behaviour.
- Configure the build's toolchain explicitly so compilation uses Java 21 regardless of the launcher, and Gradle can auto-provision it:
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
vendor = JvmVendorSpec.ADOPTIUM
}
}
For Maven, set <maven.compiler.release>21</maven.compiler.release> and enforce the JDK with the enforcer plugin's requireJavaVersion rule set to [21,22).
- Stop stale daemons after changing JDKs, since a running Gradle daemon keeps its old JVM:
#!/usr/bin/env bash
set -euo pipefail
./gradlew --stop
./gradlew --version | grep -E 'Launcher JVM|Daemon JVM'
Point the IDE at the same JDK. In IntelliJ IDEA, set the project SDK to the mise-managed JDK path (
mise where java) and set Gradle JVM to "Project SDK"; VS Code's Java extension readsJAVA_HOMEfrom the environment direnv or mise provides.Make CI read the same pin:
- uses: jdx/mise-action@v2
with:
install: true
- run: ./gradlew build --no-daemon
Expected output
$ cd ~/src/orders && java -version 2>&1 | head -1
openjdk version "21.0.4" 2024-07-16 LTS
$ ./gradlew --version | grep -E 'Launcher JVM|Daemon JVM'
Launcher JVM: 21.0.4 (Eclipse Adoptium 21.0.4+7-LTS)
Daemon JVM: 21.0.4
$ cd ~/src/legacy-billing && java -version 2>&1 | head -1
openjdk version "17.0.12" 2024-07-16
Each repository gets its own JDK automatically on cd, the build tool's launcher, daemon and compiler agree, and switching projects needs no manual JAVA_HOME changes.
Opening both projects in the IDE shows the same result: each project's SDK points at its own mise-managed JDK, and running tests from the IDE and from the terminal produce identical results. The class-version errors disappear because nothing selects a JDK by accident any more.
Prevention
Fail the build on the wrong JDK with the Maven enforcer rule or a Gradle check, so a misconfigured shell produces a clear message rather than a class-version error.
Add the JDK to
make doctorby comparingjava -versionwith the pin file.Upgrade deliberately. Change the pin file, the toolchain
languageVersionand CI in one pull request, and let CI prove the upgrade.
Platform caveats
macOS:
/usr/libexec/java_homeand JDKs in/Library/Java/JavaVirtualMachinesare global; mise- or SDKMAN-managed JDKs live in the home directory and take precedence when the shell is activated.
Apple Silicon (ARM64): install arm64 builds (Temurin, Zulu and others publish them); x86_64 JDKs run under Rosetta and are much slower for builds.
Windows: SDKMAN needs WSL2 or Git Bash; mise supports Java on native Windows. Gradle toolchain auto-provisioning works on all platforms.
Rollback
Remove the pin file and toolchain block; builds fall back to the shell's JDK:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- mise.toml build.gradle.kts
./gradlew --stop
Frequently Asked Questions
What does "Unsupported class file major version 65" mean?
Code compiled for Java 21 (class file version 65) is being loaded by an older JVM. Run it with Java 21 or later, or compile for the older target. Version 61 is Java 17, 55 is Java 11.
Should we use mise or SDKMAN?
Either works. mise handles Java alongside Node, Python and other tools in one file; SDKMAN is Java-focused and also manages Gradle, Maven and Kotlin versions. Use whichever the team already uses for other tools.
Why does Gradle still use the old JDK after I switched?
The Gradle daemon keeps running with the JDK it started with. Stop it with ./gradlew --stop, and configure a toolchain so compilation does not depend on the daemon's JDK.
Do Gradle toolchains replace a JDK manager?
They cover compilation and tests inside Gradle, including downloading the right JDK. You still need a JDK for the launcher and for tools outside Gradle, so a per-project pin remains useful.