Safely Target Java Versions Using Gradle's Toolchains

Gradle is a powerful build tool that is widely used in the JVM ecosystem. Gradle itself is also written in Java and consequentially requires an installed Java runtime. In the past, the JDK tools from the runtime used to run a Gradle build were usually also used to build and run the project. This approach had several drawbacks:

  • The JDK tools could only be overwritten per task by providing paths to local tools. This is cumbersome and especially problematic when running builds on different machines.
  • With Java adopting a more rapid release cycle, Gradle frequently was lagging behind in supporting running on the latest Java versions.

In Gradle 6.7, the concept of Java toolchains was introduced. Toolchains provide a way to conveniently decouple the runtime used to run a build from the JDK used to compile and run the project. Toolchains can be managed and provisioned by Gradle, which makes it easy to use multiple toolchains in a single build or across different machines. While toolchains solve many problems, there are still some pitfalls to avoid when using them. In this guide, we'll explore how to configure Java toolchains properly to compile Java libraries.1

The problem with -source and -target

Let's assume we have a project that should run on a Java 8 runtime. We've since upgraded our installed JDK to Java 17 though. Thus, without toolchains, Java 17 is used to run our Gradle build.

Before toolchains, our build script might have looked like this:

java {
    sourceCompatibility = JavaVersion.VERSION_1_8
    targetCompatibility = JavaVersion.VERSION_1_8
}

The sourceCompatibility and targetCompatibility properties are simple abstractions for the -source and -target flags of the Java compiler. The source compatibility determines which version of the Java language is used to compile our code. The target compatibility allows us to generate bytecode that is compatible with a specific version of Java. Theoretically, this can already be sufficient to support Java 8 while compiling with Java 17. However, -source and -target have a significant drawback: They don't prevent us from using APIs that are not available in Java 8.

Consider the following method:

public void printNotEmpty(CharSequence source) {
    if (!source.isEmpty()) {
        System.out.println(source);
    }
}

This compiles perfectly fine with -source 1.8 -target 1.8. However, the isEmpty method was only introduced in Java 15. If we run this code on Java 8, we get a NoSuchMethodError at runtime.

Using Java toolchains

Using Java toolchains is a preferred way to target a language version.

The Gradle documentation recommends using Java toolchains to target specific Java versions. Let's give this a try:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(8)
    }
}

There is a key difference to our previous setup though: Instead of "cross-compiling" from Java 17 to Java 8, we now compile with Java 8. This means that we are missing out on all performance improvements and bug fixes for javac that have not been backported. If we also use other Java tools like javadoc to generate our documentation, we might even miss out on significant improvements made to these tools.2

We could live with this trade-off, but we can do better:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(23)
    }
}

tasks.withType<JavaCompile>().configureEach {
    sourceCompatibility = "1.8"
    targetCompatibility = "1.8"
}

Now, we use a Java 233 toolchain to build our project, but we still target Java 8. However, this is not an ideal configuration since we are using -source and -target again. Fortunately in Java 9, the --release flag was introduced as de facto replacement for -source and -target. Contrary, this flag instructs the compiler to work with symbol tables for a specified Java version in addition to configuring the language level and bytecode version.

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(23)
    }
}

tasks.withType<JavaCompile>().configureEach {
    options.release = 8
}

Great! Now we can compile with the latest Java toolchain that is automatically provisioned and managed by Gradle while still safely targeting Java 8.

Conclusion

Finally, we should also pass the --release flag to JavaDoc generation and configure tests to run on our minimum supported Java version. Our final configuration looks like this:

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(23)
    }
}

tasks {
    withType<JavaCompile>().configureEach {
        options.release = 8
    }
    
    withType<Javadoc>().configureEach {
        with(options as StandardJavadocDocletOptions) {
            addStringOption("-release", "8")
        }
    }

    withType<Test>().configureEach {
        javaLauncher.set(project.javaToolchains.launcherFor {
            languageVersion = JavaLanguageVersion.of(8)
        })
    }
}

With this configuration, we compile with the latest Java toolchain while safely targeting Java 8. Further, the runtime used to run the build itself does not factor into the build process anymore. This significantly reduces the risk of running into issues when building on different machines. Gradle's auto-provisioning of toolchains ensures that no manual JDK installation are required (expect for the runtime used by Gradle).

However, this approach adds complexity and maintenance overhead to the build logic. It's important to regularly update the Java toolchain version to benefit from the latest improvements and bug fixes. Additionally, it's crucial to properly configure the --release flag consistently across all tasks for good results. If a build is growing in complexity, this can be achieved using convention plugins.

Footnotes

  1. While this guide focuses on Java libraries, all the concepts also apply to Kotlin libraries, as the Kotlin Gradle plugin supports Java toolchains. The Kotlin compiler's -Xjdk-release flag maps to Java's --release flag and should be configured too. Read more
  2. Jake Wharton wrote a blog post about the caveats of using Gradle toolchains. While I disagree with his conclusion, his points about why using old Java toolchains is a bad idea are valid.
  3. Java 23 is the latest version of Java at the time of writing. If you're copying this code in the future, adjust this to use the latest version of Java available.