JAVA / PLATFORM, BUILDS AND TESTING
Compiling across releases and the version trap
Target an older JDK for real: use javac --release, read the version stamp in a .class file, and tell the two mismatch failures apart.
What you will learn
- Use javac --release N instead of -source/-target N so the API surface shrinks too
- Read a class file's major version and map it to a release with major minus 44
- Tell UnsupportedClassVersionError (stamp too new) from NoSuchMethodError (API too new)
- Verify a build's target with javap -v before shipping to an older runtime
Understanding Compiling across releases and the version trap
Every class file opens with the four bytes CAFEBABE, then a minor and a major version number. The major number is the release plus 44, so 52 is Java 8, 55 is Java 11, 61 is Java 17 and 65 is Java 21, and the first thing a JVM does when loading a class is compare that number with the highest it understands. If the number is higher it throws UnsupportedClassVersionError before executing a single instruction, with wording like "class file version 65.0" against "only recognizes class file versions up to 61.0". The check is deliberately one-way: a new JVM reads old class files, an old JVM never reads new ones.
The older way of aiming at an earlier release, -source 11 -target 11, controls only two things: which syntax javac accepts and which number it stamps. The class library on the compile path is still the one inside the JDK that is running javac, so String.formatted (Java 15) or Math.clamp (Java 21) resolve happily and end up as method references inside bytecode stamped 55.0. That artifact loads fine on Java 11 and then throws NoSuchMethodError the first time the offending line runs, because the JVM resolves each call site lazily rather than at startup, which is why this failure so often surfaces in a rare code path in production.
--release N, added in JDK 9, closes the hole by setting source level, target stamp and API surface together: javac compiles against a recorded snapshot of release N's signatures shipped in the JDK's lib/ct.sym, so anything newer becomes a "cannot find symbol" error at the exact call site. That converts the trap from a runtime surprise into a compile failure, which is the entire value of the flag. Coverage is not total, though: reflection, class names built from strings, and dependency jars compiled for a newer release are all invisible to javac, so a green --release N build still deserves one test run on a real JDK N runtime.
import java.io.DataInputStream;
import java.io.InputStream;
public class ClassFileStamp {
public static void main(String[] args) throws Exception {
try (InputStream bytes = ClassFileStamp.class.getResourceAsStream("ClassFileStamp.class");
DataInputStream in = new DataInputStream(bytes)) {
int magic = in.readInt();
int minor = in.readUnsignedShort();
int major = in.readUnsignedShort();
System.out.printf("magic %X, class file version %d.%d%n", magic, major, minor);
System.out.println("stamped for Java " + (major - 44));
}
System.out.println("this JVM loads up to " + System.getProperty("java.class.version"));
}
}The class file version stamp and the API you compiled against are two separate decisions, and only --release sets both to the same older release.
Worked examples
The API that slips past -target
Two calls that javac accepts under -source 11 -target 11 on a JDK 21 compiler but rejects under --release 11.
public class Baseline {
public static void main(String[] args) {
String template = "score=%d";
String label = template.formatted(72);
int safe = Math.clamp(72, 0, 60);
System.out.println(label + " " + safe);
}
}Example explained
Line 1javac -source 11 -target 11 Baseline.java produces class files here with no errors, because javac resolves both calls against the JDK 21 java.lang.String and java.lang.Math it was launched with.
Line 2javac --release 11 Baseline.java swaps in the Java 11 signatures recorded in lib/ct.sym, so neither member exists any more.
Line 3String.formatted was added in Java 15 (line 4) and Math.clamp in Java 21 (line 5); nothing in the source text itself looks modern.
Line 4The caret sits on the dot, meaning the receiver type resolved fine and only the member is absent from that release's API.
Bytecode the old JVM refuses to load
A class that uses no modern API at all still fails on Java 17, purely because a JDK 21 compiler stamped it 65.0.
public class Report {
public static void main(String[] args) {
System.out.println("report generated");
}
}Example explained
Line 1javac Report.java on JDK 21 defaults to release 21, so the file carries major version 65.
Line 2A Java 17 runtime stops at 61.0 and rejects the class while loading it, before main is entered, which is why "report generated" never appears.
Line 3Recompiling the identical source with --release 17 stamps 61.0 and it runs unchanged, proving the source was never the problem.
Line 4The rule is one-way: that same JDK 21 runtime would load a 52.0 class file built for Java 8 without complaint.
Important notes
The numbers shown come from building and running on JDK 21; the same program prints 61.0 twice under JDK 17. Compiling with --enable-preview also sets the minor version to 65535, so only that exact JDK release will load the class.
javac on JDK 21 accepts --release 8 through 21 and rejects anything older, and it guards only the JDK's own API: a dependency jar compiled for a newer release still fails at run time.
Common mistakes
Setting -target 11 and treating the artifact as Java 11 safe: javac still compiles against the JDK it runs on, so "x=%d".formatted(1) links cleanly and then throws NoSuchMethodError on the Java 11 server.
Reading UnsupportedClassVersionError as a compiler problem and upgrading the build JDK: the running JVM is the old one, so a higher stamp only pushes the class further out of its reach.
Adding --release 8 to a project that already uses var, records or switch patterns: --release lowers the source level too, so the build now fails on syntax that compiled a minute earlier.
Try it yourself
Change, predict, then run
Write a loadable(int major) helper that compares a major version against the first number in System.getProperty("java.class.version") and print its verdict for 52, 61, 65 and 69; confirm that only versions above your JVM's ceiling come out false.
Open the Java workspaceCheck your understanding
A jar built on JDK 21 with -source 11 -target 11 runs on a Java 11 server for a week, then one rarely used endpoint fails with NoSuchMethodError. What is the most accurate reading?
- The classes load fine at 55.0, but a call to a method that exists only in a newer JDK was baked in at compile time and is linked only when that code path first executes.
- The classes were stamped 65.0, so Java 11 rejected them the first time that endpoint touched them.
- The Java 11 server is missing a module, and adding --add-modules java.base would resolve the reference.
- -target 11 was overridden by the newer default source level, so the whole jar actually contains Java 21 bytecode.
Show answer
-target only chose the class file stamp; the compile classpath was still JDK 21's class library, so a Java 15 or later method resolved at compile time and the JVM only attempts to link that call site the first time it runs, which explains the week-long delay. Option 2 is tempting, but a too-new stamp is caught while the class is being loaded and raises UnsupportedClassVersionError, not a per-method NoSuchMethodError.