Gradle Daemon, Workers, Parallelism, File-System Watching, Profiling, and Build Performance: Guided Hands-On Workflow and Core Operations
Profile a disposable multi-project JVM build with a verified Wrapper, compare warm and cold process conditions, vary worker/parallel settings one at a time, inspect file watching, and identify the real critical path from local evidence.
Learning objectives
- Create a disposable multi-project JVM workload using only a reviewed Gradle 9.7.1 Wrapper and isolated Gradle User Home.
- Record Daemon/JDK/worker/file-system context before measuring.
-
Capture repeated serial and parallel profiles while controlling
task reuse with
--rerun-tasks. - Use local profile HTML and logs to separate configuration cost from execution critical-path cost.
- Observe file-system watching and Daemon reuse without editing normal user-wide Gradle configuration.
- Choose a next experiment from evidence rather than copying a “fastest settings” recipe.
1. Scenario and safety boundary
The fixture has three independent Java subprojects. Each runs one tiny JUnit test, creates a JAR, and executes a 450 ms synthetic task. The root build intentionally performs an expensive audit-only fingerprint during configuration. The synthetic delays are not claims about a real application; they make configuration and execution branches visible enough to practice measurement.
Use a reviewed Wrapper. Chapter 15 established
the Gradle 9.7.1 Wrapper and distribution checksum. This chapter
does not regenerate the Wrapper. Set
WRAPPER_SOURCE to a trusted local project that
already contains the reviewed gradlew,
gradlew.bat, and gradle/wrapper/ files.
2. Preflight: preserve environment evidence
export LAB="$PWD/gradle-performance-lab"
export WRAPPER_SOURCE="$PWD/trusted-gradle-wrapper"
rm -rf "$LAB"
mkdir -p "$LAB"
test -f "$WRAPPER_SOURCE/gradlew" || { echo "Set WRAPPER_SOURCE to a reviewed Gradle 9.7.1 wrapper project"; exit 1; }
test -f "$WRAPPER_SOURCE/gradle/wrapper/gradle-wrapper.properties" || exit 1
cp "$WRAPPER_SOURCE/gradlew" "$LAB/"
cp "$WRAPPER_SOURCE/gradlew.bat" "$LAB/"
mkdir -p "$LAB/gradle"
cp -R "$WRAPPER_SOURCE/gradle/wrapper" "$LAB/gradle/"
chmod +x "$LAB/gradlew"
cd "$LAB"
export GRADLE_USER_HOME="$LAB/.gradle-user-home"
./gradlew --version | tee env-gradle.txt
java -version 2>&1 | tee env-java.txt
./gradlew --status | tee env-daemons-before.txt || true
printf 'processors='; getconf _NPROCESSORS_ONLN 2>/dev/null || true
printf 'GRADLE_USER_HOME=%s
' "$GRADLE_USER_HOME" | tee env-home.txt
Windows PowerShell can set the same isolation with
$env:GRADLE_USER_HOME = "$PWD\.gradle-user-home" and
use .\gradlew.bat. The Bash path above is the canonical
lab transcript.
3. Create the project structure
mkdir -p config
mkdir -p alpha/src/main/java/dev/academy/performance/alpha alpha/src/test/java/dev/academy/performance/alpha
mkdir -p beta/src/main/java/dev/academy/performance/beta beta/src/test/java/dev/academy/performance/beta
mkdir -p gamma/src/main/java/dev/academy/performance/gamma gamma/src/test/java/dev/academy/performance/gamma
# 1 MiB deterministic local policy fixture; used only to magnify eager configuration work.
python - <<'PY'
from pathlib import Path
Path('config/policy.bin').write_bytes((b'policy-v1\n' * 120000)[:1048576])
PY
rootProject.name = "performance-lab"
include("alpha", "beta", "gamma")
import java.security.MessageDigest
plugins {
base
}
// Intentionally bad teaching fixture: this audit-only fingerprint is computed
// eagerly during configuration for every Gradle invocation, even `help`.
fun expensiveFingerprint(bytes: ByteArray): String {
var value = ByteArray(0)
repeat(120) {
value = MessageDigest.getInstance("SHA-256").digest(bytes)
}
return value.joinToString("") { "%02x".format(it) }
}
val policyBytes = layout.projectDirectory.file("config/policy.bin").asFile.readBytes()
val policyFingerprint = expensiveFingerprint(policyBytes)
logger.lifecycle("configuration policy fingerprint: ${policyFingerprint.take(16)}")
tasks.register("perfPipeline") {
group = "verification"
description = "Runs tests, jars, and one synthetic independent work task per subproject."
dependsOn(
":alpha:check", ":beta:check", ":gamma:check",
":alpha:jar", ":beta:jar", ":gamma:jar",
":alpha:simulateWork", ":beta:simulateWork", ":gamma:simulateWork"
)
}
import org.gradle.api.DefaultTask
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.TaskAction
plugins {
`java-library`
}
group = "dev.academy.performance"
version = "1.0.0"
repositories {
mavenCentral()
}
java {
toolchain {
languageVersion = JavaLanguageVersion.of(21)
}
}
tasks.withType<JavaCompile>().configureEach {
options.release.set(17)
}
dependencies {
testImplementation(platform("org.junit:junit-bom:6.1.3"))
testImplementation("org.junit.jupiter:junit-jupiter")
}
tasks.test {
useJUnitPlatform()
}
abstract class SimulateWork : DefaultTask() {
@get:Input
abstract val delayMs: Property<Long>
@get:Input
abstract val label: Property<String>
@get:OutputFile
abstract val outputFile: RegularFileProperty
@TaskAction
fun executeWork() {
Thread.sleep(delayMs.get())
val out = outputFile.get().asFile
out.parentFile.mkdirs()
out.writeText("${project.name}:done\n")
}
}
tasks.register<SimulateWork>("simulateWork") {
// A deterministic training fixture that makes independent project work
// visible in a profile. Real projects should profile real tasks first.
delayMs.set(450)
label.set(project.name)
outputFile.set(layout.buildDirectory.file("performance/simulated.txt"))
}
Save the root snippets as settings.gradle.kts and
build.gradle.kts. Save the subproject build snippet as
each of alpha/build.gradle.kts,
beta/build.gradle.kts, and
gamma/build.gradle.kts.
4. Add tiny production/tests so correctness remains in the benchmark
package dev.academy.performance.alpha;
public final class AlphaService {
private AlphaService() {}
public static String value() { return "alpha"; }
}
package dev.academy.performance.alpha;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class AlphaServiceTest {
@Test void valueIsStable() { assertEquals("alpha", AlphaService.value()); }
}
package dev.academy.performance.beta;
public final class BetaService {
private BetaService() {}
public static String value() { return "beta"; }
}
package dev.academy.performance.beta;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class BetaServiceTest {
@Test void valueIsStable() { assertEquals("beta", BetaService.value()); }
}
package dev.academy.performance.gamma;
public final class GammaService {
private GammaService() {}
public static String value() { return "gamma"; }
}
package dev.academy.performance.gamma;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class GammaServiceTest {
@Test void valueIsStable() { assertEquals("gamma", GammaService.value()); }
}
Place each pair under its matching module path created above. The external footprint is intentionally small: one JUnit BOM/test dependency set from Maven Central, already used in Chapter 22.
5. Read-only model inspection before timing
./gradlew projects
./gradlew perfPipeline --dry-run --console=plain | tee graph-serial.txt
./gradlew --status | tee daemon-status.txt
./gradlew tasks --all | grep -E 'perfPipeline|simulateWork|policyFingerprint' || true
The dry run proves which tasks are selected. It does not measure concurrency, and it should not be used to claim that tasks are safe to overlap. Safety comes from correct dependencies and non-overlapping state.
6. Warm dependencies and the Daemon separately from measurement
./gradlew clean perfPipeline --console=plain
./gradlew --status | tee daemon-after-warmup.txt
# Preserve the first report/log as warm-up evidence, but do not mix it into the measured series.
The first invocation may download the Gradle distribution and JUnit dependencies and start/JIT-warm a Daemon. Measuring it as though it represented a steady developer edit loop would mix unrelated cold-start effects into the execution experiment.
7. Baseline: forced work, one worker, no project parallelism
for i in 1 2 3; do
./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache --no-parallel --max-workers=1 --profile --console=plain | tee "baseline-${i}.log"
done
ls -1t build/reports/profile/*.html | head -n 3 | tee baseline-profiles.txt
--rerun-tasks forces selected task work so Chapter 24’s
up-to-date/build-cache mechanisms do not dominate this performance
experiment. Build and Configuration Caches are also disabled for the
measured series. The Daemon remains enabled so repeated-build
startup behavior is representative of Gradle’s recommended mode.
8. Daemon experiment: change only process reuse
./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache --no-parallel --max-workers=1 --profile --console=plain | tee daemon-on.log
./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache --no-parallel --max-workers=1 --no-daemon --profile --console=plain | tee daemon-off.log
./gradlew --status | tee daemon-status-after.txt || true
Interpret this as a startup/process-reuse comparison, not a
universal CI prescription. Current Gradle guidance recommends the
Daemon in CI too. Also remember the nuanced implementation detail:
--no-daemon can still result in a single-use build JVM
when the client JVM cannot satisfy the build JVM requirements.
9. Worker/parallel experiment: one variable family at a time
# Keep source, Daemon mode, cache flags, and workload identical.
./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache --no-parallel --max-workers=2 --profile --console=plain | tee workers2-serial.log
./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache --parallel --max-workers=2 --profile --console=plain | tee workers2-parallel.log
./gradlew perfPipeline --rerun-tasks --no-build-cache --no-configuration-cache --parallel --max-workers=3 --profile --console=plain | tee workers3-parallel.log
First, comparing max-workers 1 versus 2 without
--parallel asks whether other Gradle work uses the
additional worker capacity. Then enabling
--parallel tests whether independent subprojects
shorten the wall-clock critical path. Finally, 2 versus 3 workers
tests scaling. Do not assume the highest number wins on a two-core
CI limit.
10. Observe file-system watching without changing correctness
./gradlew help --watch-fs --info --console=plain | tee watch-on-1.log
./gradlew help --watch-fs --info --console=plain | tee watch-on-2.log
printf '
// harmless local comment
' >> alpha/build.gradle.kts
./gradlew help --watch-fs -Dorg.gradle.vfs.verbose=true --info --console=plain | tee watch-after-change.log
./gradlew help --no-watch-fs --info --console=plain | tee watch-off.log
Look for VFS/file-watching diagnostics and changed-file effects, but do not write a CI gate that depends on one log phrase. On unsupported file systems Gradle can fall back without making the build invalid. Restore the harmless comment before final artifact comparison.
11. Read the profile as a critical-path hypothesis
Open the newest files under build/reports/profile/.
Record startup/settings/configuration time and the slowest tasks.
The three simulateWork tasks are deliberately
independent across projects, so their overlap should matter after
project parallelism is enabled. The eager policy fingerprint appears
as configuration overhead instead.
| Observation | Likely hypothesis | Next controlled test |
|---|---|---|
| Large startup on every run | Compatible Daemon not reused, Wrapper distribution/startup/init script issue. |
Compare --status, Daemon log, JVM args and same
workload warm runs.
|
Large configuration on even help |
Eager build-script/plugin work. |
Profile help, remove/defer one eager
computation, compare.
|
| Three independent project tasks run serially | Parallel projects disabled or worker capacity limited. |
Enable --parallel at fixed workers, then vary
workers.
|
| More workers increases elapsed time | CPU/RAM/I/O oversubscription. | Reduce workers while preserving graph/workload and compare host metrics. |
| Second no-change invocation still scans heavily | VFS unsupported/disabled or build logic invalidates state. |
Inspect --watch-fs --info evidence and
filesystem context.
|
12. Challenge: choose the correct control
Your profile shows 1.2 seconds of configuration and one 7-second
:gamma:test task. CPU is mostly idle during that test
because it waits on a local synthetic service fixture. Which first
change is more defensible: increase --max-workers from
2 to 8, enable --parallel, or profile/fix the 7-second
test fixture?
Reason before revealing: identify the critical path and whether additional runnable work exists. If the 7-second test lies on the critical path and is not CPU-parallelizable, worker count is unlikely to solve it. Fix or isolate the measured test bottleneck first, then remeasure.
13. Cleanup
cd "$LAB"
# Preserve profile/log evidence first if this were a real performance investigation.
cd ..
rm -rf gradle-performance-lab
Only the disposable lab and its isolated User Home are removed. Do
not delete normal ~/.gradle or shared CI caches as a
performance ritual.
Knowledge check
Why use --rerun-tasks in the measured execution
series?
To force the selected task work so up-to-date/build-cache reuse does not dominate the comparison. Cache correctness/performance was the focus of Chapter 24.
Why warm once before collecting steady-state samples?
The first run can include Wrapper/dependency downloads, Daemon startup, class loading, and JIT warm-up that are not representative of repeated developer builds.
What does comparing --parallel at the same
--max-workers isolate?
It isolates whether project-level parallel execution can exploit independent project branches without simultaneously changing the worker ceiling.
Why can 3 workers be slower than 2?
The machine may be CPU-, memory-, I/O-, or external-process-constrained. More concurrent work can cause contention, GC pressure, swapping, or test/service interference.
Why is --scan not mandatory here?
A Build Scan publishes metadata to an external service. The
required path uses local --profile reports and
logs; external telemetry needs an explicit privacy/governance
decision.
A no-change help build is slow. Which phase should
you examine first?
Startup and configuration, because task execution is minimal; eager settings/build/plugin/init-script work is a stronger hypothesis than compilation.
Official references and version notes
- Gradle 9.7.1 release notes — current pinned patch release, including 9.7.1 file-system-watching improvements.
- Gradle Daemon — client versus Daemon JVM, compatibility, status/logs, CI recommendation, memory defaults, and performance behavior.
-
Build environment configuration
—
org.gradle.jvmargs,org.gradle.parallel,org.gradle.workers.max, and VFS properties/defaults. -
Gradle CLI
—
--max-workers,--parallel,--profile,--scan,--watch-fs, and Daemon options. - Developing Parallel Tasks / Worker API — work queues and no/classloader/process isolation; worker Daemons are scoped to one build session.
-
Inspecting and profiling builds
— Build Scan, free local
--profilereports, and low-level profiling options. - Best practices for performance — measure configuration/execution work and avoid expensive configuration computations.
- Parallel project execution — project-parallel behavior, graph constraints, and the distinction from configuration-on-demand.
- Compatibility matrix — current Gradle runtime and supported-platform expectations, including file-system assumptions.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.