What is Version Compatibility — and What Actually Enforces It?
The previous page showed how --release controls what
javac is willing to compile. This page is about the other
side of that same coin: what the JVM does the moment it
actually tries to load a compiled class file, and exactly why it accepts
some and rejects others. Every class file carries an explicit major
version number, and the rule the JVM enforces is strict and
one-directional — a JVM runs class files from its own version or
earlier, never later. There's no negotiation, no partial
compatibility, no "it might work" — the check happens at class-loading
time, before a single instruction of your code executes.
// BEFORE — deploying without checking the target JVM at all
$ java -jar order-service.jar
Error: A JNI error has occurred, please check your installation and try again
Exception in thread "main" java.lang.UnsupportedClassVersionError:
com/shop/OrderService 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.0
// Compiled with --release 21 (class file major version 65), deployed to a
// server still running Java 17 (which only accepts up to major version 61).
// This fails at the exact moment the JVM tries to load the class — not on
// the first line of actual application logic.
// AFTER — verifying the class file version BEFORE shipping
$ javap -verbose OrderService.class | grep "major version"
major version: 65
// 65 means Java 21+ only. Compare directly against `java -version` on the
// actual target server before deploying — this one check would have caught
// the mismatch in a five-second local command, not a production incident.
- Binary compatibility — will this compiled
.classfile load on that JVM? - Source compatibility — will this
.javafile compile against that JDK? - API compatibility — do the classes and methods this code calls actually exist in that version? (covered in depth on Source/Target Compatibility)
Class File Version Numbers — the Mechanism Itself
Every .class file's first eight bytes are structural: the
4-byte magic number 0xCAFEBABE (identifying it as Java
bytecode at all), followed by a 2-byte minor version and a 2-byte major
version. The major version is what the JVM checks against its own
maximum supported version before doing anything else — the pattern is a
flat, predictable increment: Java 8 is major version 52, and every
release since adds exactly 1.
| Java version | Class file major version |
|---|---|
| 8 | 52 |
| 9 | 53 |
| 10 | 54 |
| 11 | 55 |
| 17 | 61 |
| 21 | 65 |
| 25 | 69 |
| 26 | 70 |
A JVM accepts its own major version or lower, and nothing higher —
full stop. Java 17 (major version 61) cannot run a class compiled
for Java 21 (major version 65), regardless of whether the actual
code inside that class uses any Java 21-specific language feature at
all. The check is purely numeric and happens during class loading,
before verification, before linking, before main() ever
runs — which is exactly why the failure in Section 0 happens
instantly, with an empty stack trace pointing at nothing in your own
business logic.
Checking Compatibility Before You Deploy
From the command line
javap -verbose OrderService.class | grep "major version"
# major version: 61
java -version
# openjdk version "17.0.9" ... — compare this directly against the class file's major version
Programmatically — reading the raw bytes yourself
public class ClassVersionChecker {
public static void main(String[] args) throws Exception {
try (DataInputStream dis = new DataInputStream(
new FileInputStream("OrderService.class"))) {
int magic = dis.readInt(); // must be 0xCAFEBABE
int minor = dis.readUnsignedShort();
int major = dis.readUnsignedShort();
System.out.println("Major version: " + major);
}
}
}
Checking the running JVM's own version
String version = System.getProperty("java.version");
// Java 9+ — structured version access, no string parsing needed
Runtime.Version runtimeVersion = Runtime.version();
System.out.println("Feature: " + runtimeVersion.feature()); // e.g. 17
System.out.println("Update: " + runtimeVersion.update()); // e.g. 9
Multi-Release JARs — One Artifact, Several Bytecode Versions
Java 9 introduced Multi-Release JARs (JEP 238): a single JAR that ships a baseline class alongside version-specific overrides, letting a library use a newer, better implementation automatically on newer JVMs while still working correctly on older ones.
catalog-utils.jar
├── META-INF/
│ ├── MANIFEST.MF # declares Multi-Release: true
│ └── versions/
│ ├── 11/com/shop/Helper.class # used on Java 11+
│ └── 17/com/shop/Helper.class # used on Java 17+ — takes priority over the 11 version there
└── com/shop/Helper.class # base version — used on anything below 11
<!-- pom.xml -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifestEntries>
<Multi-Release>true</Multi-Release>
</manifestEntries>
</archive>
</configuration>
</plugin>
java -jar and a compliant classloader handle version
selection automatically per JEP 238 — but older build tools, some
static analyzers, and naive "unzip and scan every .class file"
tooling built before MRJARs existed can see only the base version,
or worse, get confused by multiple classes sharing the same fully
qualified name across versions/ directories. Verify any
MRJAR dependency against the actual tools in your pipeline, not just
against java -jar working correctly by itself.
Common Compatibility Issues
Removed APIs
// javax.xml.bind (JAXB) was removed from the JDK itself in Java 11
import javax.xml.bind.JAXBContext; // won't compile on 11+ without an explicit dependency
// Migration: add the Jakarta EE equivalent explicitly
// <dependency>
// <groupId>jakarta.xml.bind</groupId>
// <artifactId>jakarta.xml.bind-api</artifactId>
// </dependency>
Some APIs go from deprecated to gone within one release; others take
decades, with an intermediate "degrade to throw an exception" stage
in between. Thread.stop() was deprecated in Java 1.2
(1998) and only fully removed in Java 26 — the complete, verified
timeline for that and other real APIs (Security Manager, the Applet
API) is covered in depth on
Backward Compatibility.
Reflective access under the module system (Java 9+)
// Worked without complaint on Java 8:
Field field = SomeClass.class.getDeclaredField("internalCache");
field.setAccessible(true); // may now fail — strong encapsulation restricts this by default
// Explicit opt-in required:
// --add-opens java.base/java.lang=ALL-UNNAMED
Compatibility Testing Strategies
Maven Toolchains — build against a specific, real JDK
<!-- pom.xml -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-toolchains-plugin</artifactId>
<executions>
<execution><goals><goal>toolchain</goal></goals></execution>
</executions>
<configuration>
<toolchains><jdk><version>17</version></jdk></toolchains>
</configuration>
</plugin>
CI matrix testing — every LTS you actually claim to support
# GitHub Actions
jobs:
test:
strategy:
matrix:
java: [17, 21, 25]
steps:
- uses: actions/setup-java@v4
with:
java-version: ${{ matrix.java }}
distribution: 'temurin'
- run: mvn test
Best Practices and Common Pitfalls
✅ Do
- Verify a JAR's class file major version against the actual target JVM's version before every deployment, not just before the first one
- Specify
--releaseexplicitly in build configuration (see Source/Target Compatibility) rather than relying on the build agent's default JDK - Test against every JVM version you claim to support, in CI, not just locally
- Verify a Multi-Release JAR dependency against every tool in your actual build/scan pipeline, not just against running it directly
- Monitor deprecation warnings continuously and check the full removal timeline (not just "is it removed yet") before upgrading
❌ Don't
- Don't assume a successful local build guarantees the artifact will run on the production JVM — the class file version check happens independently, at deploy time, on a potentially different machine
- Don't treat
UnsupportedClassVersionErroras a mysterious failure — it names the exact class file version and the exact maximum the running JVM supports, right in the message - Don't expect any forward compatibility whatsoever — an older JVM will never run a newer class file, regardless of what language features that class file's source actually used
- Don't rely on reflective access to internals across a Java 9+ upgrade without testing it explicitly — strong encapsulation changes real, working Java 8 code
Interview Questions
Q: What does a class file's major version number represent, and who checks it?
It identifies which Java version's bytecode format the class was
compiled to. The JVM checks it against its own maximum supported version
the moment it attempts to load the class — before verification, before
linking, before any of the class's own code executes.
Q: Can a Java 11 JVM run a class file compiled for Java 17?
No. A JVM only runs class files at its own major version or lower —
there is no forward compatibility at all. Java 11 (major version 55)
rejects anything at major version 56 or higher outright, with
UnsupportedClassVersionError.
Q: How do you check a compiled class's major version from the command line?
javap -verbose MyClass.class | grep "major version" — this
prints the exact number, which you can then compare directly against
the target JVM's own java -version output.
Q: A deployment fails instantly with UnsupportedClassVersionError, with no application stack trace at all. Explain precisely why the failure looks like this rather than a normal exception from inside the code.
The major version check happens during class loading, which is a JVM
bootstrapping step that occurs before the class is verified, linked, or
initialized — meaning before any application code, including static
initializers, has run at all. There is no call stack inside your
application to report, because your application never started
executing; the failure is the JVM itself refusing to load the bytecode
in the first place. This is why the error message names the specific
class file version and the running JVM's own maximum, rather than
pointing at any line of business logic — there's no business logic
context to point at yet.
Q: A library ships a Multi-Release JAR with a Java 17-specific implementation, but a static analysis tool in your pipeline reports only the base (Java 8) implementation's code paths. Is this a bug in the tool, and what should you actually verify?
Not necessarily a bug — plenty of tooling built before, or without
explicit support for, JEP 238's Multi-Release JAR format simply reads a
JAR as a flat archive and has no concept of version-specific overrides
under META-INF/versions/. This isn't a defect in the JAR;
it's a gap in that specific tool's JAR-reading logic. What actually
needs verifying is whether the tool's blind spot matters for its
purpose — a security scanner missing the Java 17-specific code path
could miss a real vulnerability that only exists in that version's
implementation, which is a meaningfully different risk than simply "the
tool shows outdated info." The fix is confirming MRJAR support in each
pipeline tool specifically, not assuming JEP 238 compliance is universal
across the whole toolchain just because the JVM itself handles it
correctly.
Q: Why does the class file version check happen at the byte level (magic number, then minor, then major version) rather than the JVM simply trusting a file's .class extension?
The .class extension is a filesystem convention with zero
enforcement — anything could be named that way. The JVM specification
requires validating the actual structural header (the 4-byte
0xCAFEBABE magic number, confirming this is genuinely a
Java class file at all, followed by the minor and major version fields)
before treating the byte stream as loadable bytecode at all. This is a
basic integrity and safety check independent of version compatibility —
a corrupted file, a renamed non-class file, or a deliberately malformed
file all get rejected at this same structural check, before the
separate major-version compatibility check discussed throughout this
page even runs.