Version Compatibility

How the JVM actually enforces the class file version it will and won't load — the exact mechanism behind UnsupportedClassVersionError

← Back to Index

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.
Three distinct kinds of compatibility
  • Binary compatibility — will this compiled .class file load on that JVM?
  • Source compatibility — will this .java file 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 versionClass file major version
852
953
1054
1155
1761
2165
2569
2670
Forward compatibility does not exist — and the JVM checks this before loading a single instruction

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>
A real caveat: not every tool understands Multi-Release JARs

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>
The full removal timeline — not every API disappears the same way

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 --release explicitly 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 UnsupportedClassVersionError as 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

🎓 Junior level

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.

🔥 Senior level

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.