skip to content

How can you implement thread-safe lazy initialization using a Supplier or java.util.concurrent helpers, and what are the trade-offs versus the holder idiom?

level: seniorimportance: should knowfreq 48%

answer

  1. Supplier<T> = no-arg factory; memoize caches first get()
  2. Guava Suppliers.memoize / memoizeWithExpiration
  3. computeIfAbsent = atomic per-key lazy compute-and-store
  4. Memoizing supplier is DCL with the work injected
  5. Holder = static singleton only; Supplier = parameterized/instance/resettable
  6. Trade simplicity for flexibility + a wrapper object

basics

~20 s

You wrap the expensive computation in a Supplier and cache its result the first time it's called, so later calls return the cached value. Helpers like ConcurrentHashMap.computeIfAbsent or a memoizing Supplier make this thread-safe and let you parameterize or reset it, which the holder idiom can't.

solid answer

~50 s

A Supplier<T> is a no-arg function that produces a value; lazy init means you only call it once and cache the result. A memoizing supplier wraps the real supplier so the first get() computes and stores, and later calls return the stored value — typically made thread-safe with double-checked locking or AtomicReference inside the wrapper (Guava's Suppliers.memoize is the canonical example). For per-key lazy values, ConcurrentHashMap.computeIfAbsent atomically computes-and-stores a value the first time a key is seen, sharing it across threads. The advantages over the initialization-on-demand holder idiom are flexibility: you can parameterize the computation (closures/keys), hold the lazy value in an instance field rather than a static, reset or invalidate it, and handle/retry failures more gracefully. The cost is a wrapper object and slightly more allocation/indirection, and you must trust the helper's thread-safety (or write the DCL/volatile correctly yourself). The holder idiom remains simplest for a single static singleton; Supplier/computeIfAbsent wins when you need parameters, per-key caching, or lifecycle control.

go deeper

for a junior

Knows a Supplier defers work and that you can cache its result to avoid recomputing.

for a middle

Can use Suppliers.memoize / computeIfAbsent and explain they give thread-safe lazy values, with computeIfAbsent being per-key.

for a senior

Recognizes a memoizing supplier as injected DCL, picks the right tool by parameterization/per-key/lifecycle needs, and knows the volatile/atomicity requirements.

for a principal

Designs lazy-caching strategy across a system (expiry, invalidation, per-tenant isolation, failure/retry) and weighs library dependencies vs hand-rolled primitives.

## Vocabulary first - A **`Supplier<T>`** (from `java.util.function`) is a functional interface with one method, `T get()` — a no-argument factory that produces a `T`. You can write one as a lambda: `Supplier<Heavy> s = () -> new Heavy();`. - **Memoization** means caching the result of an expensive function so subsequent calls with the same inputs return the cached value instead of recomputing. - **Lazy init via a Supplier** = a Supplier whose `get()` computes the value the **first** time and then returns the same cached value forever after. ## A memoizing supplier (hand-rolled) ```java public final class Lazy<T> implements Supplier<T> { private final Supplier<T> source; private volatile T value; // volatile for safe publication public Lazy(Supplier<T> source) { this.source = source; } public T get() { T v = value; if (v == null) { // fast path synchronized (this) { v = value; if (v == null) { v = source.get(); // expensive, once value = v; // publish (volatile) } } } return v; } } ``` This is just **double-checked locking** (DCL) generalized: the expensive logic is injected as a `Supplier` instead of hard-coded. Because it lives in an **instance field**, not a static, you can have many independent lazy values — something the static holder idiom can't express. Libraries provide this ready-made: **Guava's `Suppliers.memoize(Supplier)`** returns a thread-safe memoizing supplier (with `memoizeWithExpiration` for time-based invalidation). ## Per-key lazy init: `computeIfAbsent` When you need a **different lazy value per key** (e.g. one connection per host), use a `ConcurrentHashMap`: ```java private final ConcurrentHashMap<String, Conn> conns = new ConcurrentHashMap<>(); Conn connFor(String host) { return conns.computeIfAbsent(host, h -> openConnection(h)); } ``` `computeIfAbsent` atomically checks for the key and, if absent, runs the mapping function **once** and stores the result, so concurrent callers for the same key share one value and the function isn't run twice (for that key). It's lazy (computed on first request per key) and thread-safe. Caveat: the mapping function must not modify the same map (can deadlock/throw), and it should be relatively quick since it can block other operations on that bin. ## Other JDK building blocks - **`AtomicReference`** with `compareAndSet` lets you build lock-free lazy init that tolerates a benign race (two threads might both build, but only one result is kept) — acceptable when construction is side-effect-free and cheap-ish. - **`CompletableFuture`** can model a lazily-started async computation that many threads await. ## Trade-offs vs. the holder idiom | Aspect | Holder idiom | Supplier / computeIfAbsent | |---|---|---| | Laziness + thread-safety | Yes, free from JVM | Yes (helper or your DCL) | | Static singleton | Ideal | Works but heavier | | Parameterized / per-key | **No** | **Yes** (closures, keys) | | Instance-field lazy value | No | Yes | | Reset / expiry / retry on failure | Awkward | Easy (rebuild supplier, memoizeWithExpiration) | | Overhead | Minimal (field read) | Wrapper object + indirection | | Correctness risk | Lowest (JVM owns it) | You trust the helper or write DCL right | ## Bottom line Use the **holder idiom** for a plain static singleton — least code, least risk. Reach for a **memoizing Supplier** when the lazy value is an instance field, needs parameters, or needs lifecycle control (expiry/reset). Use **`computeIfAbsent`** for per-key lazy caches. All of these still rest on the same primitives — `volatile`/DCL or the concurrent collection's internal synchronization — to guarantee safe, single creation.

  • Is a Supplier lambda by itself lazy and cached?
    No. A Supplier defers the work until get() is called (lazy in that sense), but a plain supplier recomputes on every get(). To cache the result you must memoize it — wrap it so the first get() stores the value and later calls return the cached one (e.g. Guava's Suppliers.memoize or a hand-rolled DCL wrapper).
  • When would you choose computeIfAbsent over a memoizing Supplier?
    When you need a distinct lazily-built value per key rather than one shared singleton — e.g. one cached resource per tenant or host. computeIfAbsent atomically builds and stores each key's value on first request and shares it across threads, which a single-value memoizing Supplier can't express.

saying these in an interview costs you the question

  • Thinking a plain Supplier lambda is itself lazy/cached — it recomputes every get() unless memoized
  • Assuming computeIfAbsent runs the mapping function once globally rather than once per key
  • Mutating the same ConcurrentHashMap inside its own computeIfAbsent function
  • Dropping volatile in a hand-rolled memoizing supplier and assuming it's safe
  • Claiming the holder idiom can be parameterized at runtime

context