Java Toolchains, Compiler Configuration, Annotation Processing, Kotlin/JVM, and Cross-Version Builds: Concepts, Architecture, and Mental Model
Separate the JVM that runs Gradle from the JDK that compiles, the bytecode/API level you promise consumers, the annotation-processor path, and the runtimes that execute tests.
Learning objectives
- Separate the Gradle runtime JVM, Java compiler toolchain, Java compatibility target, annotation-processor path, Kotlin/JVM target, and test-runtime matrix.
-
Explain why Java toolchains and
--releasesolve different problems and are often intentionally combined. - Understand that annotation processors are executable compile-time dependencies and should not leak onto the runtime classpath.
- Describe Kotlin/JVM target validation and why Java/Kotlin bytecode targets must agree for related source sets.
- Inspect tool/runtime identity before changing build configuration.
1. The practical problem: “Java 17 compatible” can mean six different things
A CI agent can run Gradle on JDK 21, invoke javac from
JDK 21, intentionally emit Java 17 bytecode, run an annotation
processor inside that compiler, compile optional Kotlin with its own
JVM target, and then test the result on JDK 17 and JDK 21. Saying
“the build uses Java 17” hides all of those identities.
The distinction matters because success at one layer is not proof at
another. A JDK 21 daemon can run perfectly while the requested Java
17 compiler toolchain is missing. A Java 21 compiler can emit Java
17-compatible classes when --release 17 is used, yet a
careless source/target-only setup can still compile against APIs
absent from Java 17. A test on JDK 21 can pass even though the same
artifact fails to start on JDK 17.
2. Mental model: six identities, one artifact
flowchart TD
R["Gradle runtime JVM"] --> B["Gradle build model"]
B --> T["Java toolchain: javac / java / javadoc"]
B --> P["annotationProcessor path"]
T --> C["JavaCompile --release 17"]
P --> C
C --> A["class files / JAR"]
B --> K["optional Kotlin JVM compiler target"]
K --> A
A --> J17["test runtime JDK 17"]
A --> J21["test runtime JDK 21"]
The arrow from the Gradle runtime to the build model means the JVM
first has to be capable of running Gradle 9.7.1. The toolchain arrow
is a separate selection: Java tasks can use a different JDK. The
processor arrow represents compile-time executable code loaded by
javac. The Java and optional Kotlin compilers both emit
class files, so their bytecode targets must be compatible. Finally,
the same artifact should be exercised on every runtime you claim to
support.
3. Files, models, outputs, and trust boundaries
| State | Role | Evidence to record |
|---|---|---|
| Gradle runtime JVM | Runs Gradle client/daemon and build logic. It is not automatically the same identity as every compiler/test launcher. |
./gradlew --version: Gradle 9.7.1,
Launcher/Daemon JVM. Current Gradle supports JVM 17–26.
|
| Java toolchain |
Selects javac, java, and
javadoc used by compatible tasks.
|
./gradlew -q javaToolchains plus
compileJava --info.
|
options.release
|
Restricts source language level, target bytecode, and
available JDK API surface for javac.
|
Build script plus javap -verbose; Java 17
class-file major is 61.
|
| Annotation processor path |
Executable compile-time code invoked by javac;
separate from ordinary compile/runtime dependencies.
|
annotationProcessor, generated-source
directory, and runtime dependency report.
|
| Kotlin/JVM target | Controls Kotlin-emitted JVM bytecode and must be compatible with the related Java compilation target. |
Kotlin compilerOptions.jvmTarget; KGP target
validation is error by default on Gradle 8+.
|
| Runtime matrix | The JDKs that actually execute tests/consumers. A newer runtime passing does not prove an older supported runtime. |
Named Test tasks with explicit
JavaLauncher, or documented unavailable-cell
evidence.
|
The filesystem reflects those boundaries. The Wrapper and build
scripts are reviewed source. Gradle User Home contains caches and,
if provisioning is configured, downloaded JDKs.
build/classes and build/generated are
generated outputs. JDK installations are tool inputs. None of these
should be silently substituted for another.
4. Toolchain versus --release
A Java toolchain answers which JDK tools execute? For
example, languageVersion = 21 selects a Java 21
compiler for compatible Java tasks.
options.release = 17 answers a different question:
what Java language/API/bytecode contract may this compilation
use?
Gradle’s current guidance recommends --release for
strict cross-compilation because it prevents accidental use of APIs
that were introduced after the target release.
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
}
This combination deliberately uses the Java 21 compiler
implementation while promising Java 17 language/API/bytecode
compatibility. Toolchains alone do not enforce an older API surface;
--release does not choose which JDK runs
javac.
5. Annotation processors are compile-time executable dependencies
Java annotation processors run inside compilation and can inspect
source-model elements and generate source/resources. The Java plugin
gives them a dedicated annotationProcessor dependency
bucket and uses the resulting files as the processor path. This
separation is both a correctness and supply-chain boundary: a
processor is code you execute during the build, but it normally
should not become an application runtime dependency.
dependencies {
compileOnly(project(":processor"))
annotationProcessor(project(":processor"))
}
The first edge makes the annotation type visible to source
compilation without putting it on the runtime classpath. The second
edge makes the processor implementation executable by
javac. A later lesson will prove that the generated
source exists while the processor project is absent from
runtimeClasspath.
6. Kotlin/JVM is another compiler with another target
The Kotlin Gradle plugin supports JVM toolchains and checks
JVM-target compatibility between related Kotlin and Java compile
tasks. In current Kotlin documentation,
compilerOptions is the supported typed DSL; legacy
kotlinOptions is deprecated. For Gradle 8+ projects the
target-compatibility validation mode defaults to error,
so a Java 17 target paired with Kotlin 21 bytecode is a build
problem rather than something to ignore.
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
kotlin("jvm") version "2.4.10"
}
java {
toolchain { languageVersion = JavaLanguageVersion.of(21) }
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.fromTarget("17"))
}
}
This is an optional lane in this chapter because it needs the external Kotlin JVM plugin. The mandatory Java lab does not require it. The key engineering rule is still observable: related Java/Kotlin outputs intended for one component need one coherent JVM compatibility contract.
7. Read-only inspection before mutation
Start with identity and inventory. Use the project Wrapper, keep user state isolated, and do not configure a download resolver merely to make a missing JDK disappear.
export GRADLE_USER_HOME="$PWD/.gradle-user-home"
./gradlew --version
./gradlew -q javaToolchains
./gradlew projects
./gradlew :library:dependencies --configuration annotationProcessor
./gradlew :library:dependencies --configuration runtimeClasspath
--version proves the Gradle runtime JVM.
javaToolchains lists discovered/provisioned
installations Gradle knows about. The two dependency reports prove
that annotation-processing and runtime classpaths are different
graphs. None of these commands should change source or normal
user-wide caches when the isolated home is used.
Trust boundary: Do not auto-download an unreviewed JDK or add a resolver plugin solely because CI lacks the requested toolchain. Toolchain provisioning is executable/settings-level supply-chain configuration. Prefer preinstalled pinned JDKs for the mandatory lab; if provisioning is adopted later, review the resolver, repository, vendor/version policy, and resulting JDK identity.
8. DevOps connection: compatibility is evidence, not an image tag
A production build should record at least the Wrapper/Gradle version, Gradle runtime JVM, compiler toolchain identity, bytecode/API target, processor dependencies, generated-source evidence, and every supported test runtime. CI images are implementation details; the repository’s build contract should survive moving between agents when those declared tools are available.
9. Summary and bridge
You now have a six-part compatibility model instead of one ambiguous
“Java version.” Lesson 2 turns it into a local, inspectable build: a
Java 21 compiler toolchain, Java 17 --release, a
repository-owned processor, bytecode inspection, and explicit
runtime matrix tasks.
Knowledge check
If Gradle runs on JDK 21, does that prove
compileJava uses JDK 21?
No. The Gradle runtime JVM and a task toolchain are separate
identities; inspect javaToolchains and compile-task
evidence.
What extra guarantee does
options.release = 17 provide beyond choosing a Java
21 toolchain?
It restricts source language level, emitted bytecode target, and accessible JDK APIs to the Java 17 release contract.
Why is an annotation processor not just another
implementation dependency?
It is executable compile-time code loaded on a dedicated processor path; putting it on implementation/runtime unnecessarily expands the runtime graph and trust surface.
What should happen if related Kotlin and Java compilation targets diverge?
Current Kotlin Gradle plugin target validation should report the incompatibility; on Gradle 8+ the default mode is error.
Why can a JDK 21 test pass be insufficient for a Java 17 support claim?
A newer JVM can execute older bytecode and may hide runtime/API assumptions; the supported older runtime needs its own test evidence or an explicitly documented unavailable-cell simulation.
Official references and version notes
- Gradle Compatibility Matrix — Gradle 9.7.1 currently requires JVM 17–26 to run; supported toolchain compile/test versions are a separate concern.
-
Toolchains for JVM projects
— Java toolchain selection,
--release, vendor selection, discovery, provisioning, andjavaToolchainsdiagnostics. - JavaToolchainSpec API — valid toolchain specifications and language-version/vendor semantics.
-
Gradle Java Plugin
—
annotationProcessor, annotation processor path isolation, generated sources, incremental annotation processing, and Java compilation behavior. -
Building Java & JVM Projects
— current guidance for toolchains,
release, and legacy source/target compatibility. - Kotlin Gradle project configuration — Kotlin/JVM plugin 2.4.10 examples, JVM toolchain behavior, and Java/Kotlin JVM-target compatibility checks.
-
Kotlin compiler options
— typed
compilerOptions,JvmTarget, and the deprecation of legacykotlinOptions. - Kotlin releases — Kotlin 2.4.10 is the current stable line used only in the optional Kotlin/JVM lane.
Version-sensitive behavior was rechecked against current Gradle and
Kotlin primary documentation on 2026-08-24. Mandatory labs use
Gradle 9.7.1 through the previously verified Wrapper, JDK 21 as the
Gradle runtime and Java compiler toolchain, Java 17 as the strict
--release target, JUnit 6.1.3 for the Java test
fixture, and an isolated GRADLE_USER_HOME. Kotlin/JVM
2.4.10 is optional because applying it may require plugin
resolution. Toolchain auto-provisioning is not assumed: an
unavailable JDK cell is recorded/simulated unless the learner has
deliberately configured a reviewed resolver.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.