Chapter 25Lesson 04~275 minutes

Gradle Daemon, Workers, Parallelism, File-System Watching, Profiling, and Build Performance: Diagnostics, Failure Modes, Security, and Performance

Diagnose oversubscription, incompatible Daemon JVMs, unsupported file watching, shared-file races, memory pressure, and performance changes that accidentally weaken correctness controls without deleting normal Gradle state.

DiagnosticsOversubscriptionDaemon JVMShared outputsGC

Learning objectives

  • Apply an evidence-preserving performance diagnostic sequence before changing build or cache state.
  • Diagnose oversubscription by separating Gradle workers, test forks, Worker API processes, and host limits.
  • Recognize incompatible Daemon JVM/argument state and compare it with CI deliberately.
  • Treat file-system watching as environment-dependent and preserve a correctness-safe fallback.
  • Detect shared-file races exposed by parallel execution and repair the task model instead of disabling evidence.
  • Reject “optimizations” that disable tests, declared inputs, dependency verification, or other correctness/security gates.

1. Diagnostic sequence: preserve evidence first

  1. Preserve concise commands, elapsed time/profile, task outcomes, and the first failure.
  2. Confirm Wrapper/Gradle version, client JVM, Daemon JVM requirements, Java toolchains, and CI resource limits.
  3. Inspect settings/build scripts/properties for eager work, worker/parallel/heap/VFS flags, and test forks.
  4. Inspect the selected task graph and identify the wall-clock critical path.
  5. Inspect dependency/repository/cache/filesystem state without deleting normal caches.
  6. Inspect compiler/test/plugin/custom-task/Worker API evidence.
  7. Apply the least destructive correction to one cause.
  8. Repeat the same workload and re-prove tests, task graph, and artifact identity.

2. Failure: too many workers make the build slower

Symptom: --parallel --max-workers=8 on a 2-vCPU/4-GiB runner is slower than workers=2, with high context switching/GC or swap.

Preserve: both profile reports, exact worker/test-fork settings, runner CPU/memory quota, and failure rate.

./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache   --parallel --max-workers=8 --profile --console=plain | tee workers8.log

./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache   --parallel --max-workers=2 --profile --console=plain | tee workers2.log

Repair: choose the lower measured concurrency budget, then separately tune test forks/process-isolated workers if they are the real memory/CPU consumers. Do not “fix” contention by skipping tests.

3. Failure: local Daemon JVM arguments differ from CI

Symptom: local repeated builds are fast, CI repeatedly starts a new/single-use build JVM or has different GC/heap behavior.

./gradlew --version
./gradlew --status
printf '%s
' "${JAVA_HOME:-<unset>}"
printf '%s
' "${GRADLE_OPTS:-<unset>}"
printf '%s
' "${JAVA_OPTS:-<unset>}"
grep -E '^(org[.]gradle[.]jvmargs|org[.]gradle[.]java[.]home|org[.]gradle[.]daemon)' gradle.properties 2>/dev/null || true
ls "$GRADLE_USER_HOME/daemon/9.7.1" 2>/dev/null || true

Gradle Daemon compatibility includes Gradle version, Java version/home, immutable JVM properties, and heap/JVM args. If CI’s org.gradle.jvmargs differs, a different Daemon is expected. Standardize intentionally; do not copy workstation heap values into a small CI container without capacity evidence.

4. Failure: file watching assumption does not fit the filesystem

Symptom: repeated builds on a bind/network/synchronized mount show unexpected VFS behavior or Gradle reports that watching is unavailable.

./gradlew help --watch-fs --info -Dorg.gradle.vfs.verbose=true --console=plain | tee vfs-on.log
./gradlew help --no-watch-fs --info --console=plain | tee vfs-off.log

Interpretation: unsupported watching is primarily a performance/context finding. If correctness depends on watching, the task model is already wrong. Repair or move to a supported filesystem if measurement justifies it; otherwise allow Gradle’s fallback or explicitly disable watching for that environment.

5. Intentionally broken example: parallel tasks mutate one shared file

The following pattern is unsafe because two project tasks write the same root file without a declared relationship or separate outputs. Serial execution can hide the defect; parallel execution exposes it.

// BROKEN training example in two different subprojects:
tasks.register("appendShared") {
    doLast {
        rootProject.layout.buildDirectory.file("shared/results.txt")
            .get().asFile.appendText("${project.path}\n")
    }
}
./gradlew :alpha:appendShared :beta:appendShared --parallel --max-workers=2 --console=plain
cat build/shared/results.txt 2>/dev/null || true

Do not repair by forcing alphabetical order. Give each producer its own declared @OutputFile/@OutputDirectory, then add a separate aggregation task that consumes those outputs. That restores data ownership and lets Gradle schedule safely.

// Safer shape: each project owns one file under its own build directory.
tasks.register("writeOwnResult") {
    val out = layout.buildDirectory.file("results/result.txt")
    outputs.file(out)
    doLast {
        out.get().asFile.apply {
            parentFile.mkdirs()
            writeText("${project.path}\n")
        }
    }
}
// Aggregate later from declared producer outputs; do not concurrently append one shared file.

6. Failure: the “performance fix” weakens correctness

Fast-looking change Why it is invalid Correct response
Add -x test to CI default Removes verification evidence instead of optimizing it. Profile tests; fix slow fixtures, right-size forks, or split deliberate suites while keeping required gates.
Mark changing input @Internal Can create stale UP-TO-DATE/cache hits. Declare the input accurately; optimize serialization/path sensitivity, not correctness.
Disable dependency verification/repository policy Reduces supply-chain controls, often unrelated to the critical path. Measure resolution separately; improve trusted repository/cache placement.
Delete ~/.gradle before every build Destroys useful dependency/cache/Daemon evidence and creates artificial cold builds. Use isolated fresh GRADLE_USER_HOME only for controlled comparison.
Increase every heap/fork/worker value Can increase GC/swap/contention. Tune one bounded resource at a time against host limits and profile evidence.

7. Separate configuration bottlenecks from execution bottlenecks

If ./gradlew help --profile is slow, investigate startup/settings/configuration/init scripts before compile/test settings. If help is fast but :gamma:test dominates, changing configuration cache or eager build logic is unlikely to move the critical path enough.

Chapter 24’s Configuration Cache may remove compatible repeated configuration work, but it must not be used to hide unsupported configuration-time side effects. First make build logic lazy/deterministic; then measure cache reuse.

8. Memory/GC diagnosis without guesswork

Inspect org.gradle.jvmargs, test JVM settings, worker isolation, and the CI memory limit together. Symptoms such as Daemon disappearance/restart, long GC pauses, OOM, swapping, or machine-wide slowdown require preserving Daemon logs and host/container memory data.

Gradle’s Daemon has built-in performance monitoring for heap exhaustion/leak behavior. Disabling that monitor is not a normal optimization and is deliberately outside this lab.

9. Fresh-state comparison without deleting normal caches

export DIAG_HOME="$PWD/.diag-gradle-home"
rm -rf "$DIAG_HOME"
GRADLE_USER_HOME="$DIAG_HOME" ./gradlew --version
GRADLE_USER_HOME="$DIAG_HOME" ./gradlew perfPipeline --profile --console=plain | tee fresh-home.log
# Preserve evidence, then remove only the disposable diagnostic home.
rm -rf "$DIAG_HOME"

A fresh User Home changes Wrapper/dependency caches, Daemon registry/logs, and other user-level state simultaneously. Use it only when that controlled “fresh environment” question matters; do not mix it into a worker-count experiment.

10. Least-destructive repair order

  1. Restore correct task dependencies/inputs/outputs/tests/security gates.
  2. Normalize Wrapper/JDK/Daemon JVM identity between compared environments.
  3. Remove or defer unnecessary eager configuration work.
  4. Right-size workers/test forks/process isolation to the host.
  5. Enable project parallelism only if the graph exposes safe branches.
  6. Keep file watching where supported and beneficial; document exceptions.
  7. Reintroduce Chapter 24 caches only after task correctness remains proven.
  8. Repeat profile + test + artifact checks.

Knowledge check

A build gets slower after workers increase from 2 to 8. What is the first response?

Why might changing org.gradle.jvmargs create another Daemon?

Parallel execution exposes corrupted results.txt. Is mustRunAfter the best repair?

What does disabling test prove about performance?

Why use an isolated diagnostic GRADLE_USER_HOME?

help is slow but tests are fast. Which phase is the stronger suspect?

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.