sourceCompatibility, targetCompatibility, and JVM toolchains in Gradle explained

Although I have to admit it is complex, it is quite easy to understand once you know what they do.

Stefan M.
7 min readApr 21, 2023

Motivation

As an Android developer, I always try to be on the cutting edge of my toolchain. So eventually I tried to update the Android Gradle Plugin (AGP) to version 8.0. Unfortunately, I failed, and the plugin told me that I had to use Java in version 17. Since I had already defined sourceCompatibilty and targetCompatibilty as well as JVM toolchains, I thought I could just update these values and it would work… unfortunately, it doesn’t, so I did some research and finally found out what these settings actually do and what I need to change to use AGP 8.0.

Note: All of the following concepts can be applied to pure Java and/or Kotlin projects as well. This is not Android specific, but since the AGP 8.0 required changes, I thought it made sense to explain them with an Android project in mind.

When you run Gradle (tasks) in your project, it will by default — that is, if you don’t configure sourceCompatibility, targetCompatibility, or a JVM toolchain — compile, test, etc. with the JDK version you have installed on your machine.

You can check the currently used Java version by running:

java --version

The output will look something like this:

openjdk 11.0.15 2022-04-19
OpenJDK Runtime Environment Temurin-11.0.15+10 (build 11.0.15+10)
OpenJDK 64-Bit Server VM Temurin-11.0.15+10 (build 11.0.15+10, mixed mode)

In case I run any Gradle task, Gradle itself as well as all tasks will use this exact JDK. In this example, it would use the JDK in version 11 produced by the Adoption project (Temurin).

In other words, your source code can use features up to Java 11, and consumers of your project also require at least Java 11 to run it.

You can change these defaults by overriding them with sourceCompatibility and/or targetCompatibility without installing another JDK.

sourceCompatibility

The following snippet shows you how to change this value in both Android and Java/Kotlin projects:

// Android
android.compileOptions.sourceCompatibility = JavaVersion.VERSION_17

// Java/Kotlin
java.sourceCompatibility = JavaVersion.VERSION_17

If you have defined sourceCompatibility, your source files can use features up to the specified Java version. However, this also means that you must have at least the version of JDK version you specified (in our case JDK 17) installed (and using) to build the project. If you use an older JDK version but you use Java 17 features in your sources and try to compile it, it will fail with something like this:

 Unresolved reference: method_name_that_is_not_available_pre_jdk17

If you don’t use any of the Java 17 features in your source code, it will still compile without any errors. But you may get errors when you run tests. This is due to the targetCompatibility

tl;dr:
The sourceCompatibility defines the maximum Java version your source code supports.

targetCompatibility

This value can be changed in Android projects as well as in Java/Kotlin projects with the following:

// Android
android.compileOptions.targetCompatibility = JavaVersion.VERSION_17

// Java/Kotlin
java.targetCompatibility = JavaVersion.VERSION_17

// Kotlin only
tasks.withType<KotlinCompile> {
compilerOptions.jvmTarget.set(JvmTarget.JVM_17)
}

By default, the targetCompatibility is set to the same value as the sourceCompaitiblity (if it is set).

The targetCompatibility defines what version of Java the output (the bytecode) will have. Therefore, this value defines the minimum Java version that the consumer of your project will need to run it. If they try to run this code with an older version of Java, the output will look something like this:

java.lang.UnsupportedClassVersionError: your/package/Class has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 55.0

While version 61 is Java 17 and version 55 is Java 11 in this example. Here, I tried to run a program with Java 11 that was compiled (or at least had the targetCompatibility set) with Java 17.

Another example, if you run the test task with a JDK in version 8, but targetCompatibility is set to version 11, it will fail. Why does it fail? Because the test task is using version 8 to run the tests that run against a program that has been compiled to version 11.

tl;dr:
The targetCompaitiblity defines the minimum Java version that your output (the bytecode) supports, and therefore the required version consumers must be used to run it.

Mix and match

A funny thing about this is, that we could involve three different Java versions.

For example, you could use JDK 17 (to trigger Gradle and run any “non-compiler-related” tasks), set sourceCompatibility to Java 8 (for whatever reason), and set targetCompatibility to Java 11.

In this case, you will build with a current Java version (17), but it will ensure that you don’t use features in your source files that are only available higher than Java 8, but the minimum Java version required to run your project is Java 11.

I have no idea why anyone would do this — so I would not recommend it — but, well, it is possible 🤷‍.

Even though we now know how to configure the project with different Java versions, we still use a single JDK to build them all (the one we have installed locally).

But if you take your project seriously, there are at least two different machines involved. Your local machine and the one on your continuous integration server. This means that there are probably two different JDKs involved. We need to consider differences not only in version (even if it is just a patch version), but also in vendor, which can result in different outcomes or errors. This is where the JVM toolchains come into play…

JVM toolchains

If you have configured a JVM toolchain, a feature that is supported by both the Java Gradle plugin and the Kotlin Gradle plugin, Gradle will make sure that you use the exact same JDK everywhere to build (compile, test, etc.) your project.

This feature can be enabled as follows:

// Java
java.toolchain {
languageVersion.set(JavaLanguageVersion.of(17))
// Optional: vendor.set(JvmVendorSpec.[VENDOR])
}

// Kotlin
kotlinExtension.jvmToolchain {
languageVersion.set(JavaLanguageVersion.of(17))
// Optional: vendor.set(JvmVendorSpec.[VENDOR])
}

Starting with Gradle 8 you probably also need the foojay-toolchains Gradle plugin. You also need to apply it to your settings.gradle[.kts] file:

plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version("0.4.0")
}

With this setup, it “no longer matters” which JDK version you use to run Gradle (tasks). You can even use your locally installed JDK in version 8. Gradle will detect that it should use a JDK in version 17 to build (compile, test, etc.), so it will download it for you and store it in ~/.gradle/jdks for later reuse.

Also, when you configure a JVM toolchain, it will automatically set the sourceCompatibility and targetCompatibility for you. It will set the values for both to the version of the toolchain. However, you can override this behavior by explicitly changing these values.

Note: Configuring a JVM toolchain is the preferred way over setting up the sourceCompatibility and targetCompatibility.

The attentive reader may notice the double quotes I put around no longer matters, in the section above. This is intentional, because the catch to all of this, is that it does matter which JDK you have installed locally (and on the continuous integration server machine) to run Gradle (tasks).

As mentioned at the beginning of this blog, AGP 8.0 requires Java 17. And that is what it means. This doesn’t mean that the source code or target (bytecode) needs to be written in version 17, it means that the AGP itself requires Java 17, since it is compiled with a JDK in version 17, or at least targetCompatibility is set to version 17.

To put it more generally:
The minimum JDK version used must be in this version (or higher, thanks to backwards compatibility), Gradle itself or an applied Gradle plugin requires to be executed.

Since AGP 8.0 itself requires JDK 17, setting the sourceCompatibility, targetCompatibility or the JVM toolchain doesn’t affect this at all. You still need to have a JDK in version ≥ 17 installed on your machine and on your continuous integration server (as well as on your teammates’ machines, if you work in a team, which is probably the case 😀) for it to work.

I hope I was able to give you a little more insight into all these different configurations and made it clearer when, how, and why you should (or should not) have these different types of settings.

If you‘re interested, you can also expand your knowledge by asking yourself the following questions in terms of your consumers (which could be an Android app, an Android library, or a Gradle plugin) and in terms of build time:

  • Does it have any effect (positive or negative) if I use JDK 17, but set the JVM toolchain to version 8?
  • Does it have any effect if I use JDK 8, set JVM toolchain to version 17, but targetCompatibility to version 8?
  • What Java version does Android (the operating system) support? When building an app, can you set it to targetCompatibility 20? What is the highest Java version supported by Android?

But I leave these tasks open for you 😉 .
I guess you will figure it out and you might want to comment here with the answers you found.

--

--