Source/Target Compatibility

Controlling exactly which language syntax the compiler accepts and which bytecode version it produces — and why --release is the only option that actually protects you

← Back to Index

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.
Three related but distinct settings
  • Source — which language version's syntax is accepted (can I write var? a record? 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
A compiler warning you should never silence without understanding it

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
Preview features add an even stricter pin than --release

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)
}
Prefer a Gradle toolchain over bare sourceCompatibility/targetCompatibility

The 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
FeatureMinimum --release
Lambdas, Streams, Optional8
var for local variables10
Records (standard)16
Sealed classes, text blocks (standard)17
Virtual threads, record patterns, sequenced collections21
Flexible constructor bodies, module import declarations, scoped values25

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 --release compile 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/-target alone 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

🎓 Junior level

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.

🔥 Senior level

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.