DEV Community

Cover image for What jvmToolchain(N) actually does (and why it replaces the compileOptions block)
Anubhav
Anubhav

Posted on Edited on

What jvmToolchain(N) actually does (and why it replaces the compileOptions block)

Older Android modules typically pinned their Java version like this:

android {
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_17
        targetCompatibility = JavaVersion.VERSION_17
    }
    kotlinOptions {
        jvmTarget = "17"
    }
}
Enter fullscreen mode Exit fullscreen mode

Newer modules replace all of that with a single line:

kotlin {
    jvmToolchain(17)
}
Enter fullscreen mode Exit fullscreen mode

At first glance this looks like a cosmetic cleanup. It is not. The one-line version is doing strictly more work than the block it replaced, and it closes a class of runtime bug that the older setup silently allowed.

The three moving parts

To see why this matters, three things need to be separated:

  1. Which JDK runs the compiler. The javac binary that actually turns your source into bytecode. Traditionally this is whichever JDK your JAVA_HOME points at, or whichever launched Gradle.
  2. Which Java language features your source code is allowed to use. This is what sourceCompatibility controls.
  3. Which Java version can execute the bytecode you produce. This is what targetCompatibility controls.

These three can all be different from each other, and that is exactly what causes the confusion.

What sourceCompatibility actually does

Java the language keeps evolving with every new version. Each release adds new keywords and new grammar rules. Java 10 added var. Java 14 added record. Java 17 added sealed classes. Setting sourceCompatibility = 11 tells the compiler: "when you read my source files, pretend you only understand Java up to version 11". If you try to use record or sealed in your code, the compiler will reject those files and refuse to build.

This gives you a safety net. Even if the JDK doing the compilation is JDK 21 (which knows about all the newer keywords), sourceCompatibility = 11 stops you from accidentally using them.

But that safety net only covers source syntax: the shape of your code, the keywords, the grammar, the spelling. It does not police which methods or classes you call. Those live in the standard library, and the standard library is chosen by whichever JDK is doing the compilation. The split looks like this:

  • Writing var x = 10 is syntax (the var keyword). Controlled by sourceCompatibility.
  • Writing "hello".indent(4) is a method call. Not controlled by sourceCompatibility.

Both were added in newer Java versions (var in 10, indent() in 12), but only the first one gets caught by sourceCompatibility = 11. The second one slips through.

What targetCompatibility actually does

Setting targetCompatibility = 11 sounds like it guarantees your code will run on JVM 11. It does not. All it really does is write a version number inside your compiled .class files that says "this bytecode is in Java 11 format". That number tells any JVM loading the file "you need to be at least Java 11 to run me". What it does not do is check whether the code inside those files uses features or APIs that only exist in newer Java versions. It is a label, not a verifier.

What neither of them controls

To compile your code, Gradle needs javac, the Java compiler. But javac is not one fixed program. Every JDK ships with its own copy of it. Gradle uses whichever JDK is on your machine (usually the one your JAVA_HOME points to). If that happens to be JDK 21, then JDK 21's javac does the compilation, even if you have set sourceCompatibility = 11 and targetCompatibility = 11. Neither of those settings has any say in which JDK is picked.

That matters, because each JDK also brings its own standard library: the built-in String, List, Map, and hundreds of other classes. When JDK 21's javac compiles your code, JDK 21's standard library is what is visible to you. So you can type String.indent(), a method that was only added in Java 12. Your IDE autocomplete will show it, and the compiler will accept it, because from its point of view the method exists.

The trap this leaves open

Here is the concrete failure mode. Suppose you are building a library with the older block:

compileOptions {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}
Enter fullscreen mode Exit fullscreen mode

And your JAVA_HOME points to JDK 21. You write:

public String cleanup(String input) {
    return input.indent(4);
}
Enter fullscreen mode Exit fullscreen mode

Everything looks fine. Autocomplete offers indent(). The compiler accepts it. Gradle builds a JAR whose bytecode is stamped "Java 11 format". You publish the library.

A consumer picks up your library and runs it on JVM 11. On the first call to cleanup(), the JVM tries to resolve String.indent() and throws:

java.lang.NoSuchMethodError: 'java.lang.String java.lang.String.indent(int)'
Enter fullscreen mode Exit fullscreen mode

The bytecode was valid for JVM 11. The problem is what the bytecode contained: a call to a method that JVM 11's String class simply does not have. Nothing in the build caught it, because at compile time the classpath had JDK 21's String, which does have indent().

What jvmToolchain(N) does differently

When you write jvmToolchain(17), Gradle:

  1. Finds (or downloads and caches) an actual JDK 17 on your machine.
  2. Uses JDK 17's javac to compile your code.
  3. Puts JDK 17's standard library on the classpath at compile time.
  4. Emits JVM 17 bytecode by default.

The loophole from the earlier scenario closes at step 3. If your target were 11 instead of 17, then JDK 11's String would be what the compiler sees, and String.indent() would not exist to be called. Autocomplete would not offer it. The compiler would reject it. The error moves from production runtime back to your local build, which is where it belongs.

The single call is doing the job of three separate settings, plus one guarantee the old settings could not give you.

Mapping the responsibilities

Setting Controls Does not control
sourceCompatibility Which source language features are allowed Which JDK compiles, which library APIs are visible
targetCompatibility Bytecode format version for Java .class files Which library APIs are visible
kotlinOptions.jvmTarget Bytecode format version for Kotlin .class files Which library APIs are visible
jvmToolchain(N) Compiler JDK, source features, bytecode version for both Java and Kotlin, standard library on the classpath (nothing left over)

Notice that kotlinOptions.jvmTarget had to be set separately in the old block, because compileOptions only covers Java files. jvmToolchain covers both languages at once, which is another reason it collapses the whole compileOptions { ... } and kotlinOptions { ... } pair into a single line.

Summary

The migration from compileOptions plus kotlinOptions to jvmToolchain is not just fewer lines. It is a different guarantee.

  • sourceCompatibility limits what source syntax the compiler accepts. It does not decide which JDK compiles or which standard library APIs are visible.
  • targetCompatibility stamps a version number into the bytecode. It does not verify that the code inside actually runs on that version.
  • Neither setting stops you from calling a method that only exists in a newer JDK's standard library. That is a runtime crash waiting to happen.
  • jvmToolchain(N) uses an actual JDK N to compile, which puts JDK N's standard library on the classpath. Post-N APIs literally are not visible to be called. The gap the older settings left open is closed at compile time.

If you are maintaining a library or a shared module, this is the difference between "hope it runs on the target JVM" and "cannot compile against anything the target JVM will not have".

Top comments (0)