skip to content

How is `by lazy` implemented under the hood, and what runtime/memory cost does it add compared to an eagerly-initialized `val`?

level: seniorimportance: nice to knowfreq 26%

answer

  1. Desugars to a Lazy<T> delegate field + getValue() getter
  2. Default = SynchronizedLazyImpl, @Volatile + double-checked lock
  3. Initializer nulled after first use (GC of captured lambda)
  4. Extra heap object + per-read volatile check vs eager val
  5. NONE drops volatile/lock; PUBLICATION uses CAS

basics

~20 s

Each by lazy property creates a small helper object that holds the block and the computed value. Reading the property goes through that object and checks if the value exists yet, which costs a little more memory and a tiny check on each read.

solid answer

~50 s

`val x by lazy { ... }` desugars to a synthetic field holding a `Lazy<T>` delegate (e.g. `SynchronizedLazyImpl`), and the property's getter calls `delegate.getValue(thisRef, property)`, which returns `value`. So per lazy property you allocate: the `Lazy` object plus, until first access, the captured initializer lambda (released afterward). The default `SynchronizedLazyImpl` keeps a `@Volatile` value field and a lock object and uses double-checked locking; each read is a volatile load plus a not-`UNINITIALIZED_VALUE` check. Versus an eager `val` (a plain final field, direct read), lazy adds: extra heap allocation(s), an indirection through the delegate, a volatile/atomic check per read, and possible locking on first contended access. The trade-off is worth it when initialization is expensive or conditional; for cheap always-needed values, eager `val` is simpler and faster. `NONE` mode removes the volatile/lock overhead; `PUBLICATION` uses an atomic field updater instead of a lock.

code

kotlin · 9 lines
kotlin
// Conceptual desugaring of `val config by lazy { loadConfig() }`
private val config$delegate: Lazy<Config> = lazy { loadConfig() }
val config: Config
    get() = config$delegate.getValue(this, this::config)

// SynchronizedLazyImpl fast path is roughly:
//   val v = _value            // volatile read
//   if (v !== UNINITIALIZED_VALUE) return v as Config
//   synchronized(lock) { /* double-check, run initializer, store */ }

go deeper

for a junior

Knows there's a helper object behind the property and a small extra cost.

for a middle

Describes the Lazy<T> delegate, getValue wiring, and that lazy costs more than eager init.

for a senior

Explains SynchronizedLazyImpl internals (sentinel, volatile, double-checked locking, lambda GC) and quantifies the cost vs eager val per mode.

for a principal

Weighs allocation/read-barrier overhead at scale, JIT effects, and chooses defaults for library code where many lazy properties multiply the cost.

## Desugaring Given: ```kotlin val config: Config by lazy { loadConfig() } ``` the compiler generates (conceptually): ```kotlin private val config$delegate: Lazy<Config> = lazy { loadConfig() } val config: Config get() = config$delegate.getValue(this, ::config) ``` `lazy {}` returns a `Lazy<Config>`; the `getValue` operator (an extension on `Lazy<T>`) returns its `value`. The `by` keyword wires the getter to that call. ## The default implementation: `SynchronizedLazyImpl` Key fields: - `@Volatile private var _value: Any? = UNINITIALIZED_VALUE` — sentinel meaning 'not computed'. - a `lock` object (defaults to the `Lazy` instance itself). - `initializer: (() -> T)?` — the lambda, **set to `null` after first use** so it (and its captured state) can be garbage-collected. Reading `value` uses **double-checked locking**: 1. Volatile-read `_value`; if it isn't the sentinel, return it (the fast path, no lock). 2. Otherwise `synchronized(lock)`, re-check, run `initializer`, store into the volatile `_value`, null out `initializer`. `isInitialized()` simply checks whether `_value` is still the sentinel. ## Other modes - **`SafePublicationLazyImpl`** (PUBLICATION): no lock; uses `AtomicReferenceFieldUpdater.compareAndSet` to publish the first computed value; multiple inits may run. - **`UnsafeLazyImpl`** (NONE): a plain (non-volatile) field and a direct check — cheapest, no thread safety. ## Cost vs. eager `val` An eager `val foo = loadConfig()` compiles to a **final field**, read directly with zero overhead and one object's worth of memory. `by lazy` adds: - **Memory**: one extra heap object per property (the `Lazy` impl), plus the captured lambda retained until first access. - **Read cost**: an indirection (call into the delegate) and a volatile/atomic check every read (default & PUBLICATION). With NONE it's a plain field check. - **First-access cost**: potential lock acquisition (SYNCHRONIZED) or CAS (PUBLICATION). - **JIT note**: the volatile read and indirection are usually cheap and often optimized, but not free. ## When to use which | Situation | Choice | |-----------|--------| | Cheap value, always needed | Eager `val` | | Expensive or rarely needed | `by lazy` (SYNCHRONIZED) | | Hot path, single-threaded | `by lazy(NONE)` | | Cheap idempotent, avoid lock | `by lazy(PUBLICATION)` | ## Takeaway `by lazy` is a thin standard-library delegate, not compiler magic: a `Lazy<T>` object + a getter that checks/computes/caches. Its overhead is small but real — choose it for the laziness benefit, not by default.

  • Why does the implementation null out the initializer after first access?
    So the lambda and everything it captured become eligible for garbage collection; keeping it would needlessly retain memory for the life of the object.
  • Is `by lazy` zero-cost like a regular val once initialized?
    No. Even after init, each read goes through the delegate and (in default/PUBLICATION modes) does a volatile/atomic check, plus the extra Lazy object stays allocated. It's cheap but not free.

saying these in an interview costs you the question

  • Claims by lazy is a compiler intrinsic with no allocation
  • Says reads after init are identical in cost to a final field
  • Doesn't know the value/sentinel is volatile in the default impl
  • Thinks the captured lambda is retained forever
  • Confuses double-checked locking with locking on every read

context