What Source/Target Compatibility Controls — and Why It's Not One Setting
The previous page established that compile time and runtime are
genuinely separate phases. Source/target compatibility is where that
distinction becomes a compiler flag: source controls
which language syntax javac will accept from your
.java files, and target controls which
bytecode version it emits for the JVM to run later — potentially a much
older JVM than the one doing the compiling. Developing on JDK 21 while
deploying to a fleet of Java 11 servers is the ordinary case this exists
to handle, and getting it wrong doesn't always fail loudly.
// BEFORE — compiling on JDK 17 for a Java 8 target, using only -source/-target
$ javac -source 8 -target 8 OrderService.java
public class OrderService {
public boolean isValidCode(String code) {
return !code.isBlank(); // COMPILES — javac only checks syntax against Java 8,
// it never checks whether isBlank() actually EXISTS
// in Java 8's API. It doesn't; isBlank() is Java 11.
}
}
// This class compiles cleanly, ships, and throws NoSuchMethodError the moment
// it actually runs on a real Java 8 JVM — a failure -source/-target can't see
// coming, because JDK 17's own bootclasspath was silently used to resolve
// String during compilation.
// AFTER — using --release, which restricts the API surface too
$ javac --release 8 OrderService.java
// error: cannot find symbol
// symbol: method isBlank()
// Caught at compile time — the build fails HERE, not in production on a
// server that doesn't have this method at all.
- Source — which language version's syntax is accepted (can I write
var? arecord? a sealed class?) - Target — which class file version is produced (which minimum JVM can load the result at all)
--release(Java 9+) — sets source and target together, and restricts which JDK APIs are visible to exactly that version's actual API surface
The Compiler Options
Using javac directly
# Compile Java 11 syntax, targeting Java 11 bytecode
javac --source 11 --target 11 OrderService.java
javac -source 11 -target 11 OrderService.java # short form, same effect
# The recommended form since Java 9 — see below for why
javac --release 11 OrderService.java
Source restricts syntax; target restricts bytecode — independently
// With --source 8: this fails — var didn't exist yet
var orders = new ArrayList<Order>(); // Error!
// With --source 11: this compiles fine
var orders = new ArrayList<Order>(); // OK
// --target independently controls the produced class file version —
// a class compiled with --target 11 simply refuses to load on a Java 8 JVM,
// regardless of whether its source used any Java 9+ syntax at all
Using -source/-target without also pointing
-bootclasspath at the actual target JDK's API classes
triggers: warning: [options] bootstrap class path not set in
conjunction with -source 8. This warning exists precisely
because of the failure mode shown in Section 0 — the compiler is
telling you it's resolving standard library classes from whatever
JDK is running javac right now, not from the target
version, and it cannot verify API compatibility as a result.
The --release Option — Why It's the Only Safe Default
Introduced in Java 9, --release sets source and target
together, and — this is the part -source/-target
cannot do — restricts the visible JDK API surface to exactly what
existed in that version, using a bundled symbol table rather than
whatever JDK happens to be running the compiler.
# Equivalent in intent to this, but built in and verified — not something
# you'd want to hand-maintain a matching bootclasspath JAR for yourself
javac --release 11 OrderService.java
# vs. the old, fragile manual equivalent:
# javac -source 11 -target 11 -bootclasspath <path-to-jdk11-rt.jar> OrderService.java
Compiling with --enable-preview (to use a feature still
in preview, like the String Templates covered on
Java Version History before their
withdrawal) does more than set the class file's major version — it
sets the minor version to 0xFFFF,
a special marker meaning "only the exact same JDK feature-version
build that compiled this may run it." This is stricter than ordinary
target compatibility: a normal Java 17-targeted class runs on any
Java 17+ JVM, but a preview-compiled class won't run on a
different build of the very same major version unless that
build is also invoked with --enable-preview. It's a
deliberate, structural reminder that preview features are not
production-stable — the JVM itself refuses to treat them as such.
Build Tool Configuration
Maven — maven-compiler-plugin
<!-- Recommended: release (Java 9+) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>17</release>
</configuration>
</plugin>
<!-- Shorthand: properties section -->
<properties>
<maven.compiler.release>17</maven.compiler.release>
</properties>
Gradle — Kotlin DSL
// Toolchain — recommended; also selects a matching JDK if one isn't already installed
java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
}
}
// Direct release option on the compile task
tasks.withType<JavaCompile> {
options.release.set(17)
}
sourceCompatibility/targetCompatibilityThe older sourceCompatibility/targetCompatibility
properties are the Gradle equivalent of raw
-source/-target — same API-surface gap
described above. A toolchain block additionally guarantees the build
uses an actual JDK of that version to compile, rather than
whatever JDK happens to be running Gradle itself, which matters the
moment a CI runner's default JDK doesn't match your project's
target.
Cross-Compilation — Developing Ahead of Your Deployment Target
// Scenario: develop on JDK 21, deploy to a fleet still running Java 11
<properties>
<maven.compiler.release>11</maven.compiler.release>
</properties>
// --release 11 rejects every Java 12+ addition at compile time, not deploy time:
// records, sealed classes, pattern matching, text blocks, virtual threads —
// all become compile errors immediately, rather than runtime surprises
| Feature | Minimum --release |
|---|---|
Lambdas, Streams, Optional | 8 |
var for local variables | 10 |
| Records (standard) | 16 |
| Sealed classes, text blocks (standard) | 17 |
| Virtual threads, record patterns, sequenced collections | 21 |
| Flexible constructor bodies, module import declarations, scoped values | 25 |
Troubleshooting
// "source release X requires target release X or later"
// Cause: target is set lower than source
javac --source 17 --target 11 OrderService.java // Error — target can't be older than source
// "class file has wrong version X.0, should be Y.0"
// Cause: running bytecode on a JVM OLDER than the target it was compiled for
// — this is the JVM enforcing the class file version check covered in full
// on Version Compatibility
// "cannot find symbol" for an API that clearly exists in your JDK's docs
// Cause: --release is doing its job — that API doesn't exist in the release
// version you specified, even though it exists in the JDK actually running javac
Checking effective settings
# Maven
mvn help:effective-pom | grep -A5 "maven-compiler-plugin"
# Gradle
./gradlew properties | grep -i java
# The actual class file version that was produced
javap -verbose OrderService.class | grep "major version"
Best Practices and Common Pitfalls
✅ Do
- Use
--release(or its Maven/Gradle equivalents) instead of separate source/target for any Java 9+ toolchain - Use a Gradle toolchain, or pin an explicit JDK version in CI, rather than relying on whatever JDK happens to be installed on the build agent
- Actually test on the real minimum JVM version you claim to support — a successful
--releasecompile catches API mismatches, but not runtime behavior differences - Document the minimum supported Java version explicitly in the project's README or build file comments
- Treat any
--enable-preview-compiled artifact as inherently non-portable across JDK builds, not just across major versions
❌ Don't
- Don't rely on
-source/-targetalone and assume it protects you from using APIs unavailable in your deployment target — it only checks syntax, never the API surface - Don't ignore the "bootstrap class path not set" warning — it's telling you exactly the gap that causes runtime
NoSuchMethodErrors in production - Don't set target lower than source — the compiler rejects this combination outright, and for good reason: newer syntax can't be expressed in older bytecode
- Don't ship a preview-feature-compiled artifact expecting it to run on any JVM matching the major version — it's pinned to the exact build that compiled it
Interview Questions
Q: What's the difference between --source and --target?
--source controls which language syntax the compiler
accepts — whether var or records are allowed in your
.java files. --target controls which class
file (bytecode) version is produced, which determines the minimum JVM
version that can load and run the result.
Q: Why is --release preferred over separate --source/--target flags?
--source/--target only check language syntax
and set the output bytecode version — they don't verify that the APIs
you call actually existed in that target version. --release
additionally restricts the visible JDK API surface to exactly that
version, so calling a method that was added later becomes a compile
error instead of a runtime crash on the actual deployment target.
Q: Can you set target to an older version than source?
No — the compiler rejects it with "source release X requires target
release X or later." Bytecode for an older JVM can't represent language
constructs newer than that JVM's own class file format supports.
Q: A team compiles with JDK 17 using -source 8 -target 8, and the build succeeds. In production on an actual Java 8 server, it throws NoSuchMethodError. Explain exactly why the compiler didn't catch this.
-source and -target only control which
language syntax is accepted and which class file version is emitted —
neither one restricts which JDK API methods are visible during
compilation. Without an explicit -bootclasspath pointing at
a real Java 8 rt.jar, the compiler resolves standard library
classes like String from whatever JDK is actually running
javac — JDK 17 in this case — which includes methods like
isBlank() that didn't exist in Java 8 at all. The code
compiles cleanly because JDK 17's String genuinely has that
method; it fails at runtime because the actual Java 8 JVM's
String class doesn't. --release 8 would have
caught this at compile time by using a curated symbol table for Java 8's
actual API surface instead of the running JDK's.
Q: Why does a class compiled with --enable-preview fail to run even on a JVM of the exact same major version, unless that JVM is also invoked with --enable-preview?
Preview-compiled class files set their minor version field to the
special value 0xFFFF, distinct from the ordinary minor
version used by stable class files. A JVM checks for this marker
specifically and refuses to load such a class unless it was itself
started with --enable-preview — this is a deliberate,
structural safeguard, not a version-numbering coincidence. It exists
precisely because preview features are, by design, unstable and subject
to change or withdrawal between even minor point releases of the same
major version — the JVM enforces that these artifacts can never
accidentally be mistaken for, or run as, production-stable bytecode.
Q: A CI pipeline's build agent has JDK 21 installed by default, but the project specifies maven.compiler.release=17. Is this configuration sufficient to guarantee the build behaves identically to a machine with JDK 17 actually installed?
maven.compiler.release guarantees source syntax and API
surface restriction to Java 17 — the compiled bytecode and any
compile-time API check will be correct regardless of which JDK invokes
javac. What it does NOT guarantee is that the build runs
the compiler itself using a JDK 17 toolchain, or that any
annotation-processing or build-plugin code that inspects the running
JDK's own version behaves as if it were JDK 17 — those still see JDK 21.
For full parity, pair release with an explicit Maven
toolchain (or Gradle's languageVersion toolchain
configuration) that provisions and invokes an actual JDK 17 to run the
build, rather than relying on the release flag alone to paper over a
mismatched build-agent JDK.