Chapter 23Lesson 01~225 minutes

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.

Gradle 9.7.1JDK 21Java toolchains--releaseCompatibility

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 --release solve 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

JVM and toolchain identity flow
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?

What extra guarantee does options.release = 17 provide beyond choosing a Java 21 toolchain?

Why is an annotation processor not just another implementation dependency?

What should happen if related Kotlin and Java compilation targets diverge?

Why can a JDK 21 test pass be insufficient for a Java 17 support claim?

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, and javaToolchains diagnostics.
  • 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 legacy kotlinOptions.
  • 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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.