skip to content

When a Kotlin lambda captures a mutable var, what does the compiler actually emit on the JVM to make the mutation shared, and how does this differ from capturing a val?

level: middleimportance: should knowfreq 45%

answer

  1. var capture -> kotlin.jvm.internal.Ref (IntRef/ObjectRef)
  2. Shared .element field = shared mutation
  3. val capture -> no box, value/ref copied
  4. Inline lambdas -> often no Ref at all
  5. Ref is heap-allocated and not thread-safe

basics

~10 s

For a captured var the compiler wraps it in a small holder object (a Ref) so the lambda and outer code point at the same box. A captured val needs no box.

solid answer

~50 s

JVM local variables and method parameters are final-by-capture: a lambda compiled to a separate class can't share a mutable JVM local. So when a lambda captures a var, the Kotlin compiler boxes that var into a mutable wrapper object — kotlin.jvm.internal.Ref (e.g. Ref.IntRef, Ref.ObjectRef). The enclosing function and the lambda both hold a reference to that one Ref instance and read/write its .element field; that's how the mutation is shared. A captured val is immutable, so no box is needed — the value (or a reference to it) is simply copied into the lambda's synthetic fields. Inline lambdas (e.g. forEach, let) are inlined into the call site, so often no wrapper/object is emitted at all. The Ref boxing is an implementation detail you usually don't see, but it explains the heap allocation and shared mutation.

code

kotlin · 11 lines
kotlin
// Conceptually, this Kotlin:
fun build(): () -> Int {
    var n = 0
    return { n += 1; n }
}

// is lowered to use a Ref.IntRef holder:
// val n = Ref.IntRef().apply { element = 0 }
// return { n.element += 1; n.element }
// Both the function and the returned lambda share the one IntRef,
// so its mutable `element` field carries the state between calls.

go deeper

for a junior

Knows the compiler does 'something' to let the var be shared but may not name Ref.

for a middle

Names kotlin.jvm.internal.Ref / IntRef / ObjectRef and explains the shared .element field, contrasting val (no box).

for a senior

Adds that inline lambdas avoid the allocation and discusses GC/perf and thread-safety implications of the Ref.

for a principal

Reasons about lowering strategy, allocation in hot paths, and when to redesign (atomics/StateFlow/immutability) instead of relying on captured var mutation.

## The JVM constraint On the JVM, a non-inline lambda is compiled to a **separate synthetic class** (or invokedynamic-backed object). That object cannot directly share a method's local `var` slot, because JVM locals live on the stack frame and disappear when the method returns. To let both the enclosing function and the lambda **see the same mutable variable**, Kotlin needs a heap object they can both reference. ## Ref wrappers The compiler emits a small holder from `kotlin.jvm.internal.Ref`: - `Ref.IntRef`, `Ref.LongRef`, `Ref.DoubleRef`, `Ref.BooleanRef`, … for captured primitive `var`s - `Ref.ObjectRef<T>` for captured reference-typed `var`s Each has a public mutable field named `element`. Conceptually the compiler rewrites your code like this: ```kotlin // you write: var count = 0 val inc = { count += 1 } inc(); inc() println(count) // 2 // compiler emits (roughly): val count = Ref.IntRef() // heap object count.element = 0 val inc = { count.element += 1 } inc(); inc() println(count.element) // 2 ``` Because the enclosing function and the lambda both capture the **same `Ref` instance**, writes through `.element` are mutually visible. That is the mechanism behind "Kotlin can mutate a captured var." ## val: no box A captured `val` is read-only, so no shared mutable storage is required. The compiler simply copies the value (for primitives) or the reference (for objects) into the lambda's synthetic field. No `Ref` is allocated. This is cheaper and is why capturing `val` is the preferred, allocation-light pattern. ## Inline lambdas avoid it Functions marked `inline` (most stdlib scope/collection functions: `let`, `apply`, `run`, `forEach`, `map`'s lambda parameter, etc.) inline the lambda **body** directly into the call site. There is no separate lambda object, and a captured `var` can stay an ordinary local — so typically **no `Ref` allocation** occurs at all. The boxing cost appears mainly for **non-inline** captures (lambdas stored in variables, returned, passed to non-inline functions, or used across coroutine suspension). ## Practical implications - Capturing a `var` in a non-inline lambda allocates one `Ref` object (minor GC pressure in hot loops). - `.element` mutation is not thread-safe — concurrent writes from multiple threads/coroutines race; use proper synchronization or atomics/`StateFlow` instead. - Prefer capturing `val` when you don't need mutation; it avoids the box and is clearer.

  • Which concrete Ref type is used for a captured var of type String?
    Ref.ObjectRef<String> — object-typed vars use ObjectRef; primitives use specialized boxes like IntRef or BooleanRef.
  • Why does capturing a var in an inline forEach usually not allocate a Ref?
    Because the lambda body is inlined into the call site, so the var can remain a normal local — no separate lambda object needs to share it.

A captured var is a shared mailbox (the Ref): both the function and the lambda hold a key, so mail dropped by one is seen by the other. A captured val is just a photocopy handed over once.

saying these in an interview costs you the question

  • Saying Kotlin uses Java's effectively-final trick and copies the var
  • Claiming every captured variable (including val) gets boxed in a Ref
  • Asserting Ref boxing makes captured var mutation thread-safe
  • Confusing Ref.IntRef with autoboxing of Int into java.lang.Integer
  • Believing inline lambdas always allocate the same wrapper as non-inline ones

context