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.

Where a Java Build Gets Its JDK Layers of JDK selection from the shell's JAVA_HOME down to the compiler toolchain Gradle or Maven uses. Where a Java Build Gets Its JDK shell JAVA_HOME first java on PATH build launcher runs gradlew or mvn build daemon may cache an old JDK compiler toolchain languageVersion IDE project SDK its own setting
Each layer can pick a different JDK unless one pin file drives them all.

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

  1. 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.

  1. 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).

  1. 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'
  1. 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 reads JAVA_HOME from the environment direnv or mise provides.

  2. Make CI read the same pin:

      - uses: jdx/mise-action@v2
        with:
          install: true
      - run: ./gradlew build --no-daemon
Reading a Java Version Error Decision diagram mapping two common Java version errors to their cause. Reading a Java Version Error Which error appears? class file version 65 running on JDK below 21 release 21 not supported compiling with old JDK
Both errors mean the runtime and the build target disagree about the Java version.

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

  1. 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.

  2. Add the JDK to make doctor by comparing java -version with the pin file.

  3. Upgrade deliberately. Change the pin file, the toolchain languageVersion and CI in one pull request, and let CI prove the upgrade.

Global JAVA_HOME vs Per-Project Pin Comparison of one global JDK setting against a pinned JDK per repository. Global JAVA_HOME vs Per-Project Pin global JAVA_HOME mise.toml or .sdkmanrc one JDK for everything JDK per repository manual switching switches on cd daemon keeps old JDK --stop after changes IDE set separately IDE uses project JDK
Per-project pins make switching between Java versions automatic.

Platform caveats

macOS: /usr/libexec/java_home and JDKs in /Library/Java/JavaVirtualMachines are 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.