Chapter 25Lesson 01~245 minutes

Gradle Daemon, Workers, Parallelism, File-System Watching, Profiling, and Build Performance: Concepts, Architecture, and Mental Model

Build a performance model that separates startup and configuration cost from execution work, then place Daemon reuse, worker limits, project parallelism, Worker API isolation, file-system watching, memory, and profiling on the correct layer.

Gradle 9.7.1DaemonWorkersParallelismProfiling

Learning objectives

  • Separate Gradle client/Daemon JVM identity from Java compilation and test toolchains introduced in Chapter 23.
  • Explain what the Daemon reuses, why compatibility matters, and why a warm build must not be compared blindly with a cold build.
  • Distinguish worker limits, project parallelism, and Worker API work isolation instead of treating them as one concurrency switch.
  • Explain file-system watching as reused file-system knowledge, not a substitute for correct task inputs/outputs.
  • Use profiling evidence to separate startup, configuration, dependency resolution, task execution, tests, and packaging before changing settings.
  • Recognize memory/GC pressure and CPU/I/O oversubscription as possible reasons that more concurrency makes a build slower.

1. The practical problem: a build can be correct and still waste capacity

Chapter 24 established that Gradle may safely avoid or reuse work only when inputs, outputs, cache keys, and reproducibility are trustworthy. Chapter 25 asks a different question: after correctness is protected, where does wall-clock time go?

Two developers can run the same task graph and see very different elapsed time because one invocation starts a new JVM, one reuses a warmed Daemon, one host has eight effective CPU cores, another CI container has two, one file system can be watched efficiently, and another is a network mount. The task graph can also expose parallel branches—or collapse into one serial critical path.

Performance is a controlled experiment. Do not begin by raising heap, enabling every concurrency flag, or disabling tests/caches/input tracking. Preserve a baseline, change one causal variable, and re-run the same workload with the same source/dependency/toolchain state.

2. Mental model: startup → configuration → execution → evidence

Layer / control What it changes Useful evidence Common wrong inference
Daemon reuse JVM startup, JIT warm-up, in-memory Gradle/plugin state across invocations. ./gradlew --status, Daemon logs, repeated-run profile/startup time. “Daemon on” means the same Daemon must be reused; JVM/Gradle/immutable JVM settings still have to be compatible.
Worker limit Maximum worker leases Gradle can use across task/Worker API work. --max-workers=N, host CPU/RAM notes, wall-clock comparison. More workers always means faster. CPU, memory, I/O, test forks, and external tools can oversubscribe the machine.
Project parallelism Allows tasks from different projects to execute concurrently when the graph permits. --parallel, task timeline/profile, unchanged dry-run graph. It creates missing task dependencies or makes one long serial chain parallel.
Worker API Lets one task submit units of work with no/classloader/process isolation. Task/plugin source plus observed worker behavior; process isolation may create session-scoped worker Daemons. Every Gradle worker is a separate JVM.
File-system watching Reuses file-system knowledge between builds on supported environments. --watch-fs --info, org.gradle.vfs.verbose=true, controlled file change. It replaces correct task inputs/outputs or guarantees every mounted/remote file system is watchable.
Profiling Measures where time is spent; it is evidence, not an optimization itself. --profile HTML, logs, controlled scenario timings; optional Build Scan. Total CPU percentage identifies the build critical path.
Performance pipeline and control points
flowchart TD
  W["./gradlew client"] --> D{"Compatible Daemon?"}
  D -->|"reuse / start"| C["Initialization + configuration"]
  C --> G["Selected task graph"]
  G --> P{"Independent work?"}
  P -->|projects| PP["--parallel"]
  P -->|"within task"| WA["Worker API queue"]
  PP --> E["Task execution"]
  WA --> E
  V["VFS watching"] --> C
  V --> E
  M["JVM heap / GC + host CPU / RAM"] --> D
  M --> E
  E --> R["tests + artifacts + profile evidence"]
            

The Wrapper client is short lived. It connects to or starts a compatible Daemon, which hosts initialization, configuration, dependency resolution, and task execution. The selected task graph determines whether project-level parallelism can overlap branches. A custom task or plugin can independently use the Worker API to submit smaller work items. File-system watching and in-memory Daemon state can reduce repeated-build overhead, but neither changes the correctness contract of the task graph.

3. The Daemon: process reuse, not “a background Gradle install”

Gradle 9.7.1 enables the Daemon by default and currently recommends it for developer machines and CI servers. The Wrapper client and Daemon are separate JVM processes. The client is launched by JAVA_HOME/PATH; the Daemon may be selected by org.gradle.java.home, Tooling API requests, or Daemon JVM criteria. A Java toolchain used by javac or Test is yet another identity.

Identity Question to answer Evidence
Wrapper/Gradle Which Gradle version is executing? ./gradlew --version and reviewed Wrapper properties.
Client JVM Which JVM starts the Wrapper client? java -version, JAVA_HOME, environment capture.
Daemon JVM Which JVM and immutable args run the build engine? ./gradlew --status, Daemon log, org.gradle.jvmargs.
Compile/test toolchain Which JDK compiles/runs project code? javaToolchains, task compiler/launcher configuration from Chapter 23.

Gradle only reuses a Daemon when its Gradle version, Java identity, JVM args, and other compatibility requirements match. Changing heap size can therefore start another Daemon rather than “resize” the existing one.

4. Read-only inspection before tuning

./gradlew --version
./gradlew --status
./gradlew projects
./gradlew tasks --all
./gradlew perfPipeline --dry-run  # when the lab task exists
java -version
printf 'JAVA_HOME=%s
' "${JAVA_HOME:-<unset>}"
printf 'GRADLE_USER_HOME=%s
' "${GRADLE_USER_HOME:-<default>}"

--status shows Daemons for the same Gradle version, not every Gradle process on the machine. With a JDK, jps can provide an additional process view. Preserve these identities with the timing evidence.

5. Worker limits, project parallelism, and Worker API are different controls

--max-workers=N controls Gradle’s maximum worker leases; the current default is the number of detected processors. --parallel enables concurrent execution across independent projects and is off by default. A plugin/custom task can separately use Worker API queues to parallelize work inside one task.

import javax.inject.Inject
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
import org.gradle.workers.WorkAction
import org.gradle.workers.WorkParameters
import org.gradle.workers.WorkerExecutor

interface DigestParameters : WorkParameters {
    val inputFile: RegularFileProperty
    val outputFile: RegularFileProperty
}

abstract class DigestWork : WorkAction<DigestParameters> {
    override fun execute() {
        // Small example only. Real work should be deterministic and fully modeled.
        val bytes = parameters.inputFile.get().asFile.readBytes()
        parameters.outputFile.get().asFile.writeBytes(bytes)
    }
}

abstract class DigestTask @Inject constructor(
    private val workers: WorkerExecutor
) : DefaultTask() {
    @get:InputFile abstract val inputFile: RegularFileProperty
    @get:OutputFile abstract val outputFile: RegularFileProperty

    @TaskAction
    fun submit() {
        val queue = workers.noIsolation()
        queue.submit(DigestWork::class.java) {
            inputFile.set(this@DigestTask.inputFile)
            outputFile.set(this@DigestTask.outputFile)
        }
    }
}

noIsolation() has the least isolation. classLoaderIsolation() separates classpaths. processIsolation() can execute work in separate session-scoped worker Daemons. That last mode adds process isolation and startup/memory cost, so it should be justified by library/JVM isolation needs rather than selected as a generic speed option.

6. File-system watching: reusable file knowledge

On supported operating systems/file systems, Gradle 9.7.1 enables file-system watching by default. The Virtual File System (VFS) can retain file metadata between builds so Gradle does less filesystem work. It does not make an undeclared input safe, and a container bind mount, network filesystem, unusual volume driver, or unsupported platform may behave differently.

./gradlew help --watch-fs --info
./gradlew help --no-watch-fs --info
./gradlew help --watch-fs -Dorg.gradle.vfs.verbose=true --info

Read logs as evidence, not as a brittle string assertion. Gradle 9.7.1 also improved file-system-watching compatibility with custom project-cache directory locations; that does not imply the underlying custom filesystem itself supports every watch behavior.

7. Profiling: measure the stage that is actually slow

Stage Typical evidence Typical causes
Startup Profile startup section; Daemon status/log. Wrapper distribution startup, no compatible Daemon, init scripts, JVM startup.
Configuration Profile project/configuration section. Eager I/O/computation, cross-project configuration, plugin configuration, disabled Configuration Cache.
Resolution Task/log timing around dependency resolution. Cold dependency metadata/artifacts, repository latency/order, dynamic versions.
Execution Per-task profile times and task graph. Compilation/tests/generation/package work, serial dependency chain, external processes.
Resource saturation OS/container CPU/RAM/I/O plus GC/Daemon evidence. Too many workers/forks, heap pressure, I/O bottleneck, external services.
./gradlew perfPipeline --profile --console=plain
# Default report location for the root build:
ls -1t build/reports/profile/*.html | head -n 1

--profile is the mandatory free/local path in this chapter. --scan can provide richer detail but publishes build metadata to an external service, so it is optional and requires an explicit privacy/data decision.

8. Memory and GC: capacity is finite

org.gradle.jvmargs controls the Daemon JVM, not the short-lived client. Current Gradle defaults include a 512 MiB maximum heap and a 384 MiB maximum metaspace unless the build environment requests different values. Larger builds may need more heap, but “increase Xmx” is not a diagnosis.

A host with four cores and 4 GiB available memory can become slower when Gradle workers, test JVMs, process-isolated Worker API jobs, Kotlin daemons, and external compilers all compete. When concurrency rises, observe wall time, CPU utilization, resident memory, swapping, test stability, and Daemon/GC evidence together.

9. DevOps connection: optimize the measured critical path

CI capacity is an economic resource. The useful metric is often feedback latency or total agent-minutes for a change—not “CPU was 90%.” If one ten-minute task lies on every path to completion, making unrelated three-second tasks parallel will not materially reduce the build. If three independent four-minute modules dominate the graph, project parallelism may.

A production optimization therefore carries four proofs: the same input/build identity, a measured before/after, unchanged correctness gates, and an explanation of why the mechanism should generalize to the target developer/CI topology.

Knowledge check

Why can two ./gradlew invocations use different Daemons?

Does --max-workers=8 automatically enable project-level parallel execution?

What does process-isolated Worker API work add?

What is the minimum free profiling path in this chapter?

Why is high CPU not enough to prove an optimization?

What does file-system watching never replace?

Official references and version notes

Version-sensitive behavior was rechecked against current Gradle primary documentation on 2026-08-24. The mandatory path uses Gradle 9.7.1 through the previously verified Wrapper, JDK 21 as the Gradle runtime/toolchain, Java 17 as the project target, JUnit 6.1.3 only for the small local test fixture, and an isolated GRADLE_USER_HOME. Build Scan publication and commercial/hosted telemetry are optional; the required evidence uses the free local --profile report and ordinary logs.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.