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:
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
-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 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.
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.
-Xjdk-release
flag maps to Java's --release
flag and should be configured too. Read more ↩