skip to content

How do you make a custom JavaExec task (or the application run task) execute on a specific JDK without changing the project-wide toolchain?

level: middleimportance: should knowfreq 35%

answer

  1. JavaExec.javaLauncher (same as Test)
  2. application run task IS a JavaExec
  3. launcherFor { languageVersion }
  4. scoped override, project toolchain untouched
  5. launcher.metadata.installationPath if path needed

basics

~10 s

Set the JavaExec task's javaLauncher to a launcher from javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(X) }. The project's java.toolchain stays unchanged.

solid answer

~40 s

`JavaExec` exposes the same `javaLauncher: Property<JavaLauncher>` as `Test`. To run one tool/app task on a different JDK, assign it a launcher from the `javaToolchains` service instead of touching `java.toolchain`: ```kotlin tasks.register<JavaExec>("runTool") { mainClass = "com.example.Tool" classpath = sourceSets["main"].runtimeClasspath javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(21) } } ``` The Application plugin's `run` task is itself a `JavaExec`, so the same property applies — `tasks.named<JavaExec>("run") { javaLauncher = ... }`. Because the launcher is a lazy provider, the JDK is only located at execution. This is the canonical way to run a code generator, a benchmark, or a migration tool on a JDK distinct from the build's compile target, keeping the override scoped to that single task.

code

kotlin · 5 lines
kotlin
tasks.named<JavaExec>("run") {
    javaLauncher = javaToolchains.launcherFor {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

go deeper

for a junior

Set javaLauncher on the JavaExec task from launcherFor; that's enough.

for a middle

Explain that run is a JavaExec, why per-task beats changing java.toolchain, and the laziness.

for a senior

Add provisioning behaviour, reading launcher metadata, and when a scoped JavaExec JDK is preferable architecturally.

for a principal

Discuss standardising tool-task JDKs across many builds and provisioning/governance implications.

## JavaExec and the launcher property `JavaExec` is Gradle's task type for forking a JVM to run a main class. Like `Test`, it carries a `javaLauncher: Property<JavaLauncher>`. Setting it selects the JVM used for that fork, overriding the project toolchain for this task only. ```kotlin val launcher21 = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(21) } tasks.register<JavaExec>("runTool") { mainClass = "com.example.Tool" classpath = sourceSets["main"].runtimeClasspath javaLauncher = launcher21 } ``` ## The application `run` task The `application` plugin's `run` task **is** a `JavaExec`, so you override it the same way: ```kotlin tasks.named<JavaExec>("run") { javaLauncher = javaToolchains.launcherFor { languageVersion = JavaLanguageVersion.of(21) } } ``` ## Why not just change java.toolchain? Changing `java { toolchain { ... } }` moves *every* JVM task (compile, test, javadoc, run) onto that JDK. A per-task override is the right tool when only one task needs the different JDK — e.g.: - A `JavaExec` benchmark harness requiring a JDK with a specific GC or preview feature. - A migration/codegen tool that needs newer APIs than the production code. ## Laziness and provisioning `launcherFor` returns a `Provider<JavaLauncher>`. Gradle resolves the JDK at execution; if it isn't installed and auto-provisioning (with a resolver such as Foojay) is configured, Gradle downloads it then. Referencing it costs nothing if the task never runs. ## Getting the resolved java executable If a downstream tool needs the actual binary path, you can read it from the resolved launcher's metadata: `launcher.get().executablePath` (a `RegularFile`) or `launcher.get().metadata.installationPath`. Prefer wiring the `Provider` directly rather than eagerly calling `.get()`.

  • The application plugin's run task — how do you target it for a per-task launcher?
    It is a JavaExec, so tasks.named<JavaExec>("run") { javaLauncher = javaToolchains.launcherFor { ... } } works directly.
  • How do you get the actual java binary path from a launcher if an external tool needs it?
    From the resolved launcher: launcher.get().metadata.installationPath or executablePath. Prefer passing the Provider lazily rather than eagerly resolving it during configuration.

saying these in an interview costs you the question

  • Editing java.toolchain to move just one task — that changes the whole project.
  • Eagerly calling .get() on the launcher at configuration time, defeating laziness.
  • Assuming the application run task needs special API — it's just a JavaExec.

context