On This Page
1. The Error
The modern wording, printed by every JVM from JDK 8u onward. This is a JDK 21 build running on a JDK 17 runtime:
Error: LinkageError occurred while loading main class com.example.App
java.lang.UnsupportedClassVersionError: com/example/App has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 61.0Same build on a JDK 11 runtime — only the trailing number changes:
java.lang.UnsupportedClassVersionError: com/example/App has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 55.0Two details people miss: the class name is the internal name with slashes (com/example/App, not com.example.App), and the Error: LinkageError occurred while loading main class prefix is printed by the java launcher only when the main class itself fails to load. When a dependency trips the check, you get a real stack trace mid-run:
main started
Exception in thread "main" java.lang.UnsupportedClassVersionError: Greeter has been compiled by a more recent version of the Java Runtime (class file version 65.0), this version of the Java Runtime only recognizes class file versions up to 55.0
at java.base/java.lang.ClassLoader.defineClass1(Native Method)
at java.base/java.lang.ClassLoader.defineClass(ClassLoader.java:1022)
at java.base/java.security.SecureClassLoader.defineClass(SecureClassLoader.java:174)
at java.base/jdk.internal.loader.BuiltinClassLoader.defineClass(BuiltinClassLoader.java:800)
at java.base/jdk.internal.loader.BuiltinClassLoader.findClassOnClassPathOrNull(BuiltinClassLoader.java:698)
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClassOrNull(BuiltinClassLoader.java:621)
at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:579)
at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:178)
at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:527)
at Main.main(Main.java:4)defineClass1 being the top frame is the tell: the failure happens in the define step of class loading, not in resolution. That is what separates this from ClassNotFoundException (the loader could not find the bytes at all) and NoClassDefFoundError (the bytes were found once but definition failed or a static initializer blew up).
Preview-feature variant — same exception type, different message:
Error: LinkageError occurred while loading main class Prev2
java.lang.UnsupportedClassVersionError: Preview features are not enabled for Prev2 (class file version 65.65535). Try running with '--enable-preview'Legacy wording — JDK 7 and earlier printed a different string, still worth knowing if you search old tickets:
java.lang.UnsupportedClassVersionError: com/example/App : Unsupported major.minor version 52.0Compile-time counterpart — javac refuses a too-new class on the classpath before the JVM ever sees it. This is JDK 11's javac reading a 65.0 class:
app/Main.java:4: error: cannot access Greeter
System.out.println(Greeter.greet());
^
bad class file: libout/Greeter.class
class file has wrong version 65.0, should be 55.0
Please remove or make sure it appears in the correct subdirectory of the classpath.
1 errorAll of the above were produced on openjdk 21.0.10, 17.0.18 and 11.0.32.
2. How to Reproduce It (step-by-step)
Two JDKs, three commands:
cat > App.java <<'EOF'
public class App {
public static void main(String[] args) {
System.out.println("Started on Java " + System.getProperty("java.version"));
}
}
EOF
/usr/lib/jvm/java-21-openjdk-amd64/bin/javac -d out App.java
/usr/lib/jvm/java-17-openjdk-amd64/bin/java -cp out App # boomConfirm the version stamped in the bytecode without running anything:
javap -verbose -cp out App | grep -E 'major|minor'
# minor version: 0
# major version: 65
od -A x -t x1z -v out/App.class | head -1
# 000000 ca fe ba be 00 00 00 41 ... 0xCAFEBABE, minor 0x0000, major 0x0041 = 65Reproduce the dependency flavour by compiling the library at 21 and the app at 11:
javac -d libout Greeter.java # JDK 21 javac -> 65.0
javac --release 11 -d appout -cp libout Main.java
java -cp appout:libout Main # JDK 11 runtime -> stack trace aboveReproduce the preview flavour on a single JDK 21:
javac --enable-preview --release 21 -d prev2 Prev2.java # class file 65.65535
java -cp prev2 Prev2 # fails
java --enable-preview -cp prev2 Prev2 # worksReproduce the build-tool flavour — a JDK 17 javac asked to target 21:
javac --release 21 App.java # error: release version 21 not supported
javac -source 21 -target 21 App.java # error: invalid source release: 21
javac -source 17 -target 21 App.java # error: invalid target release: 21Under Maven those surface as Fatal error compiling: error: release version 21 not supported. Note the three distinct strings — blogs conflate them, but they tell you exactly which option is wrong.
Environment-specific triggers worth knowing: a CI image whose JAVA_HOME differs from the java on PATH; a Docker runtime image (eclipse-temurin:17-jre) that is older than the builder stage (:21-jdk); an IDE compiling with a project SDK newer than the Gradle/Maven toolchain; a single third-party JAR upgraded to a 21-compiled release inside an otherwise Java 17 application.
3. Version Behaviour Matrix
The mapping is fixed by the JVM spec and exposed as constants on java.lang.classfile.ClassFile (Java 24+):
| Java SE | Class file major | Runtime accepts up to | --release targets available in that JDK |
|---|---|---|---|
| 8 | 52 | 52.0 | 6–8 |
| 11 | 55 | 55.0 | 6–11 |
| 17 | 61 | 61.0 | 7–17 |
| 21 | 65 | 65.0 | 8–21 |
| 25 | 69 | 69.0 | 8–25 |
Behavioural changes across releases:
- JDK 7 and earlier: message reads
Unsupported major.minor version 52.0. JDK 8u and later: thehas been compiled by a more recent version of the Java Runtimewording, still current in 25. - JDK 9: the
Error: LinkageError occurred while loading main classlauncher prefix, andjava.base/...module prefixes in stack frames. - JDK 12+: preview class files carry
minor_version = 65535and are accepted only by the JVM whose major version matches exactly — a 21-preview class will not run on 25 even with--enable-preview. - JDK 12:
--releasegained-Xlint:optionsobsolescence warnings; on JDK 21,--release 8still compiles but emitswarning: [options] source value 8 is obsolete and will be removed in a future release. - JDK 20:
--release 7and below dropped, per JEP 182's "one plus three back" retirement policy. Expect--release 8to go the same way.
The error itself is version-neutral in nature: no JDK release has ever made a JVM accept a higher class file version than it ships with, and none ever will — that would require executing bytecode whose semantics did not exist when the VM was built.
4. Why It Happens — Surface Level
javac stamps every .class file with the major version of the platform it targeted. A JVM refuses to define any class whose major version exceeds its own. Bytecode is forward-incompatible by design: Java 21 can run Java 8 classes, never the reverse.
So the mismatch is always the same shape — something compiled newer than the thing running it. Your build JDK, a dependency's build JDK, your runtime image, or your IDE's project SDK. The number in the message tells you which end moved: class file version 65.0 is the offender (Java 21), up to 61.0 is your runtime (Java 17).
5. Why It Happens — Under the Hood
Every class file starts with a fixed 8-byte prologue defined in JVMS §4.1:
u4 magic 0xCAFEBABE
u2 minor_version
u2 major_versionHotSpot's ClassFileParser reads those before it reads anything else — before the constant pool, before the verifier, before any name resolution. If major_version > JVM_CLASSFILE_MAJOR_VERSION (the constant baked into that build), parsing aborts immediately and throws UnsupportedClassVersionError, a subclass of ClassFormatError → LinkageError → Error. That is why the frame is ClassLoader.defineClass1(Native Method): the failure is inside the native define call, in the loading phase of the load → link → initialize lifecycle, long before verification or linkage constraints get a chance to run.
This ordering has a practical consequence. The JVM has not yet parsed the constant pool, so it has no Class object and no source file name — hence the bare internal name com/example/App in the message rather than a nicely formatted binary name.
Why can't the VM just skip the unknown parts? Because a major version bump is a format and semantics contract, not a feature flag. Version 65 permits constant pool entries, attribute types, verification type inference rules and instruction semantics that a version-61 VM's verifier and interpreter simply do not implement. invokedynamic bootstrap shapes, CONSTANT_Dynamic, nest-based access control, PermittedSubclasses — each arrived with a version bump and a verifier change. Accepting the file would mean running unverifiable bytecode.
The preview mechanism reuses the same field. minor_version = 65535 (ClassFile.PREVIEW_MINOR_VERSION) marks a class as depending on preview semantics, and the VM enforces major_version == latestMajorVersion() and --enable-preview. This is deliberate: preview features carry no compatibility promise, so the JVM refuses to run preview bytecode from any release but its own. It is also why a library must never publish preview-compiled classes to a repository.
Related linkage failures on the same upgrade path — NoSuchMethodError and AbstractMethodError from shaded or conflicting dependency versions, LinkageError: loader constraint violation from duplicate classes across loaders, and InaccessibleObjectException from JPMS strong encapsulation — all arrive at the same phase of class loading but for different reasons; UnsupportedClassVersionError is the only one that is purely about the file header.
6. The Fix
Fix A — run on a JVM at least as new as the build. Usually the right answer.
- FROM eclipse-temurin:17-jre
+ FROM eclipse-temurin:21-jre
COPY target/app.jar /app.jar
ENTRYPOINT ["java","-jar","/app.jar"]Verify at runtime, not by intuition — java -version and JAVA_HOME disagree constantly on CI:
java -version; echo "$JAVA_HOME"; readlink -f "$(which java)"Fix B — compile for the older target with --release. When the runtime cannot move (a vendor appliance, a shared app server):
<properties>
- <maven.compiler.source>21</maven.compiler.source>
- <maven.compiler.target>21</maven.compiler.target>
+ <maven.compiler.release>17</maven.compiler.release>
</properties>--release is not sugar for -source/-target. It also swaps in that release's API signature data, so List.of(...) compiled with --release 8 is a compile error instead of a runtime NoSuchMethodError. Plain -source 8 -target 8 on JDK 21 warns exactly about this:
warning: [options] bootstrap class path not set in conjunction with -source 8Fix C — pin the toolchain so the build JDK is not whatever the agent happens to have.
// build.gradle
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
}<!-- pom.xml -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-toolchains-plugin</artifactId>
<version>3.3.0</version>
<executions><execution><goals><goal>toolchain</goal></goals></execution></executions>
<configuration>
<toolchains><jdk><version>21</version></jdk></toolchains>
</configuration>
</plugin>Fix D — find the one offending JAR when the failing class is not yours. Scan without running anything:
for e in $(unzip -Z1 app.jar | grep '\.class$'); do
v=$(unzip -p app.jar "$e" | od -An -j6 -N2 -tu2 --endian=big | tr -d ' ')
echo "$v $e"
done | sort -rn | headThen downgrade that dependency, or move the whole application up.
Fix E — preview mismatch. Either add --enable-preview to the runtime command (and accept it must match the compile JDK exactly), or drop --enable-preview and rewrite with final APIs. Never ship preview-compiled artifacts to a shared repository.
7. Best Practices & The Better Design
- Use
--release, never-source/-target. It is the only option that validates against the target release's API surface. - Declare the toolchain in the build, not the environment. Gradle toolchains and
maven-toolchains-pluginmake the build JDK reproducible; the developer'sJAVA_HOMEstops mattering. - Fail the build on a wrong JDK with Maven Enforcer, so a mismatch is a build error, not a 3 a.m. page:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-enforcer-plugin</artifactId>
<version>3.6.3</version>
<executions><execution>
<id>enforce-jdk</id><goals><goal>enforce</goal></goals>
<configuration><rules>
<requireJavaVersion><version>[21,22)</version></requireJavaVersion>
</rules></configuration>
</execution></executions>
</plugin>- Keep builder and runtime images in lockstep in the Dockerfile:
FROM eclipse-temurin:21-jdk AS buildmust pair withFROM eclipse-temurin:21-jre. Parameterise the tag once. - Ship libraries at the lowest release you support, and use a multi-release JAR if you need newer bytecode for newer runtimes —
META-INF/versions/21/classes are invisible to a Java 17 VM by design, so you get the optimisation without the crash. - Log the runtime at startup so the artefact tells you what it ran on:
public final class RuntimeBanner {
public static void log() {
Runtime.Version v = Runtime.version();
System.out.printf("java %s (feature=%d) vendor=%s%n",
v, v.feature(), System.getProperty("java.vendor"));
}
}8. How to Prevent It Long-Term
- CI matrix on the next LTS. Add a parallel job building and testing on the next LTS. Upgrades stop being events.
- A class-version gate in the pipeline. Run the
unzip | odscan above over the packaged artefact and fail if any class exceeds the deployment target. Ten lines, catches every transitive dependency drift. jdeps --multi-release <n> -s app.jarto see module and version structure;jdeps --jdk-internalsto catchsun.*usage before it becomes anInaccessibleObjectExceptionon 17+;jdeprscan --release 21to flag APIs removed in the target.-Xlint:all -Werror, plus-Xlint:optionsleft on sosource value 8 is obsoleteis visible rather than buried.- Watch for
Error: LinkageError occurred while loading main classas a distinct log signature — it means the container image is wrong, and it is worth its own alert because the process never reaches your health endpoint. - Team convention: the JDK version lives in exactly one place per repo (the toolchain block), and the Dockerfile references the same number. Anything that hardcodes it twice will eventually disagree.
9. Key Takeaways
- The two numbers in the message are the whole diagnosis:
class file version Xis the artefact,up to Yis the runtime. Map with 52=8, 55=11, 61=17, 65=21, 69=25. - The error is thrown in
ClassLoader.defineClass1from the 8-byte class file header, before the constant pool is parsed — earlier thanNoClassDefFoundError,NoSuchMethodErroror any verifier failure. minor version 65535means preview bytecode: it runs only on the exact same major version, only with--enable-preview, and must never be published.--release Nbeats-source N -target Nbecause it validates the API surface too — the latter compiles happily and fails at runtime withNoSuchMethodError.- Pin the JDK once, in the build toolchain, and make the Dockerfile and CI read the same number. Most occurrences of this error are two places disagreeing about one integer.
Gopi Gorantala Newsletter
Join the newsletter to receive the latest updates in your inbox.