skip to content

Why does enabling the configuration cache break code that holds shared mutable state (e.g. a static counter or a shared object referenced by tasks), and how does a BuildService solve it?

level: middleimportance: must knowfreq 55%

answer

  1. task graph serialized + reused
  2. statics/shared objects not reconnected
  3. BuildService = Gradle-owned shared state
  4. reference tracked, not value-captured
  5. single lazy instance, AutoCloseable teardown

basics

~20 s

The configuration cache serializes the task graph and reuses it, so plain shared objects or statics aren't re-created or shared correctly across tasks and parallel workers. A BuildService is the supported holder for shared state that the config cache understands and reuses safely.

solid answer

~40 s

With the configuration cache on, Gradle runs the configuration phase once, **serializes the resulting task graph**, and on later builds skips configuration entirely — loading tasks straight from the cache. Any state a task reaches through a captured object reference, a static field, or `project` at execution time is either not serializable, not reconnected, or shared in a way that breaks parallel execution. A `BuildService` fixes this: it's a build-scoped object Gradle owns. Tasks declare it via a `Property<MyService>` and Gradle injects the same instance, lazily instantiating it once and tearing it down at build end. Because Gradle tracks the reference, it survives serialization, is thread-safe across parallel tasks, and is config-cache compatible.

code

kotlin · 9 lines
kotlin
abstract class CounterService : BuildService<BuildServiceParameters.None> {
    private val n = java.util.concurrent.atomic.AtomicInteger()
    fun next() = n.incrementAndGet()
}

val counter = gradle.sharedServices.registerIfAbsent("counter", CounterService::class) {}

tasks.register("a") { val s = counter; doLast { println(s.get().next()) } }
tasks.register("b") { val s = counter; doLast { println(s.get().next()) } }

go deeper

for a junior

Know that the configuration cache reuses a serialized task graph and that plain shared/static state breaks; a BuildService is the fix.

for a middle

Explain reference-tracking vs value-capture, lazy single instance, and how registerIfAbsent + a Property wire it into tasks.

for a senior

Discuss parallel-safety responsibilities, idempotent registration across plugins, and AutoCloseable lifecycle for resources.

for a principal

Frame it as the canonical migration target for config-cache-incompatible shared state and set conventions so plugins don't reintroduce statics.

## The problem the configuration cache creates The **configuration cache** makes Gradle run the configuration phase (evaluating build scripts, registering and wiring tasks) **once**, then **serialize the task graph** to disk. On subsequent invocations with the same inputs, Gradle skips configuration and deserializes the graph directly into the execution phase. This is great for speed but imposes a rule: **everything a task needs at execution time must be captured as serializable task state**, not reached live. Classic patterns that break: - A `companion object` / static counter shared across tasks — statics aren't part of the serialized graph, so the value isn't preserved or shared correctly. - A plain shared object created during configuration and captured by several task actions — it may not be serializable, and even if it is, each task could deserialize its **own copy**, defeating the "shared" intent. - Touching `project`, `Gradle`, or `Task` objects at execution time — these are explicitly disallowed by the config cache. ## How a BuildService fixes it A **`BuildService`** is a build-scoped, Gradle-managed object designed exactly for shared state under the configuration cache. Key properties: - **Single instance, lazily created** — Gradle instantiates it the first time a task actually uses it and **closes** it (if it implements `AutoCloseable`) at the end of the build. - **Reference-tracked, not value-captured** — tasks hold a `Property<MyService>`; Gradle serializes the *reference to the service registration*, not a snapshot of its mutable state, so all tasks see the **same** instance even after config-cache reuse. - **Thread-safe sharing** — because there is one instance, it's the right place to coordinate state across **parallel** tasks (you still make the internal state thread-safe yourself, e.g. with `AtomicInteger`). ## Registering and consuming You register with `gradle.sharedServices.registerIfAbsent(name, Type) { parameters { ... } }`, which returns a `Provider<MyService>`. Tasks then expose a `Property<MyService>` and you wire the provider in. `registerIfAbsent` is idempotent, so two plugins registering the same named service get the same instance. ```kotlin abstract class CounterService : BuildService<BuildServiceParameters.None> { private val count = java.util.concurrent.atomic.AtomicInteger() fun next(): Int = count.incrementAndGet() } val counter = gradle.sharedServices.registerIfAbsent("counter", CounterService::class) {} tasks.register("report") { val svc = counter // captured as a Provider, config-cache safe doLast { println(svc.get().next()) } } ``` The service replaces the static/shared object entirely: state lives inside the service, tasks reach it only through the injected provider, and the configuration cache is happy.

  • If the service stores mutable state, who is responsible for thread safety?
    You are. Gradle guarantees a single instance shared across parallel tasks, but the internal state must be made thread-safe yourself (e.g. AtomicInteger, ConcurrentHashMap, or synchronization).
  • When is the service instance created and destroyed?
    It is lazily instantiated the first time a task actually consumes it, and if it implements AutoCloseable its close() runs at the end of the build.

A plain shared object is like passing photocopies of a notebook to each task — they diverge. A BuildService is the one library copy on a chain that everyone reads and writes through.

saying these in an interview costs you the question

  • Saying a BuildService is just a singleton — missing that the config cache tracks the reference and reuses the registration across builds.
  • Claiming the config cache makes statics work again — it doesn't; the static still isn't part of the serialized graph.

context