mar. Nov 29th, 2022

Maintaining Gradle build setups to have a reliable and reproducible build process is not always easy. In this blog post, we’ll tell you about one of the Gradle features that Kotlin has recently added support for – JVM toolchain. This feature provides a simple way to have a reproducible build independent from the user JDK, plus it reduces the complexity of working with multiple JDKs.

What is the Gradle JVM toolchain feature and how to use it? 

Issues with building Gradle JVM projects

Imagine you maintain a Gradle build setup for some JVM library or application. One of the common user mistakes is using the wrong JDK version to compile the project, which leads to strange errors. 

This becomes even more complicated when your project has to support different JDK versions to compile itself or to run the tests against. You need to add extensive documentation on how to set up the build environment, some custom build scripts to pick up the correct JDK version for the specific compile or test task, and a complex CI setup. Even then, errors may slip through and the Gradle remote build cache could be populated with a wrong entry, leading to a hard-to-debug error in later release builds. 

Unfortunately, the Kotlin Gradle plugin did not consider the JDK version as a task input until the Kotlin 1.5.30 release.

Kotlin repository suffered as well

As you may have already guessed, this is how it was in the Kotlin repository itself. Kotlin 1.6.0 supports JDK versions from JDK 1.6 through 17 (the latest released version at the moment). Additionally, it uses different JDKs inside the compiler, and the build tool tests to verify that the compiler’s output works correctly with the different JDKs.

To start working with a Kotlin project, a contributor had to install a variety of JDKs locally. This was already a painful step for several reasons. For example, on newer macOS versions it’s hard to find working JDK 1.6 and JDK 1.7 releases. To overcome this, the Kotlin repository added a flag to substitute these JDKs with JDK 1.8. 

After that, the contributor had to set up all of the environmental variables that needed to correctly correspond to the JDK location. Sometimes contributors made a mistake and provided the wrong JDK version, again leading to strange compilation and test errors. 

Finally, the contributor had to ensure that the build was running on JDK 1.8 (usually by changing the JAVA_HOME environmental variable). 

Solution: the toolchain feature

Fortunately, the Gradle team introduced a new feature called JVM toolchain in the 6.7 release. Initially, only Java compilation was supported, but with the recent Gradle 7.2 release, Groovy and Scala compilations also work with JVM toolchains. The Kotlin Gradle plugin added support for this feature starting from the 1.5.30 release! 

Building with a toolchain – how does it help?

What does the JVM toolchain feature do? 

Based on a specification provided by the user, Gradle detects a locally installed JDK or JRE or downloads a requested one. The default download destination is the GRADLE_USER_HOME/jdks/ directory. The specification includes such things as:

Java major language versionOptional vendor (for example, Azul)Optional implementation (for example, J9)

After the toolchain becomes available, Gradle provides toolchain tools such as javac, javadoc, and java executables to the related tasks. 

Additionally, if the user hasn’t set a specification explicitly, Gradle configures source and target compatibility values to be equal to the toolchain ones. The Kotlin Gradle plugin does the same for Kotlin tasks. 

Let’s start with an easy case: your library has a requirement to support JDK versions 11 or newer. Just add the following toolchain spec in build.gradle.kts: 

java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(11))
}
}

This will tell Gradle to locate JDK 11 and use it for compilation, javadoc, and test tasks. If you don’t have JDK 11 installed locally, Gradle will download it automatically. 

Let’s imagine that at some point you’ve decided to run tests against the latest JDK to validate that the project works without problems. With toolchains, it is, again, easy to do – you just add a new task: 

tasks.register<Test>(« testsOnLatestJDK ») {
val javaToolchains = project.extensions.getByType<JavaToolchainService>()
javaLauncher.set(javaToolchains.launcherFor {
// 17 is latest at the current moment
languageVersion.set(JavaLanguageVersion.of(17))
})
}

You may also want to support some features introduced in newer JDKs. You’ve learned about Gradle metadata and want users with the latest JDK to be able to use all released features. This is a little more complicated, but still easy to do: 

val java17SourceSet = sourceSets.create(« java17 »)

java {
toolchain {
languageVersion.set(JavaLanguageVersion.of(11))
}

registerFeature(« java17 ») {
usingSourceSet(java17SourceSet)
capability(project.group.toString(), project.name, project.version.toString())
}
}

tasks.named<JavaCompile>(java17SourceSet.compileJavaTaskName) {
val javaToolchains = project.extensions.getByType<JavaToolchainService>()
javaCompiler.set(
javaToolchains.compilerFor {
languageVersion.set(17)
}
)
source = sourceSets.main.get().allJava + java17SourceSet.allJava
}

You could go even further and add run tasks that will run your project on JDK 11 and JDK 15. 

After these changes, new developers can start contributing to the project immediately. 

What’s even better, if you have configured the remote build cache before this change – now independent from the JDK version Gradle is running on  – the build will hit this remote cache for tasks that use the toolchain. Note that you should also define the toolchain in the buildSrc module to fully utilize the remote build cache. 

Toolchain support in the Kotlin plugin 

Similar to Java compilation, the Kotlin compiler also depends on the JDK it’s running on for the Kotlin/JVM target with a default configuration. In such cases, the Kotlin compiler uses the JDK runtime classes it’s executing on. But, at the same time, the Kotlin compiler has the option -jdk-home with the following description: Include a custom JDK from the specified location into the classpath instead of the default JAVA_HOME. Actually, the Kotlin compiler itself always produces the same output independent from the runtime JDK version when -jdk-home (or related -no-jdk) is specified. 

In Kotlin, toolchain support affects only the Kotlin/JVM -jdk-home option’s value and additionally sets the -jvm-target value if it was not set explicitly by the user. Additionally, the toolchain’s major JDK version is considered as a task input now. 

We’ve added a special DSL in the Kotlin extension to configure a toolchain similarly to the Gradle DSL for Java. It emphasizes a toolchain affecting only Kotlin/JVM and simplifies the build script a bit when you are using only Kotlin. Under the hood, this DSL configures the same default toolchain object both for Java and Kotlin compilations in the same Gradle module. Or, as you’ve already guessed, you can still use the Gradle DSL for the Java toolchain. Both approaches will do the same: 

kotlin {
jvmToolchain {
(this as JavaToolchainSpec).languageVersion.set(JavaLanguageVersion.of(17))
}
}

The Kotlin Gradle plugin ensures that Kotlin and Java compilation tasks have the same JVM target to avoid hard-to-debug errors. Even if a toolchain is explicitly set for a specific task, Kotlin compilation will still check the related Java compilation JVM target and produce warnings if there is a mismatch. This can be controlled via the kotlin.jvm.target.validation.mode property. 

Conclusion 

I once asked Gradle developers about their vision regarding the toolchain feature, and they said that it’s to allow users to run the build on any JDK, but still produce the same output. 

Expanding on this answer, any task whose output depends on a JDK/Java version should use the toolchain feature to select a predefined JDK. This will allow a user to have reproducible builds independent from the current user environment.

Actually, this has been achieved in the Kotlin repo! Now developers can use any JDK to compile Kotlin and, in case a JDK is missing, it will be auto-provisioned by Gradle. You can try it yourself – just clone the Kotlin repo and run some Gradle tasks. Unfortunately, the Gradle toolchain feature does not auto-provision JDKs before version 1.8, but the Kotlin repo still needs JDK 1.6 and JDK 1.7 for some modules. You can add kotlin.build.isObsoleteJdkOverrideEnabled=true to local.properties to force the build to use only JDK versions 1.8 and newer.

See also

The Dark Secrets of Fast Compilation for Kotlin

By admin

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *