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?
answer
- var capture -> kotlin.jvm.internal.Ref (IntRef/ObjectRef)
- Shared .element field = shared mutation
- val capture -> no box, value/ref copied
- Inline lambdas -> often no Ref at all
- Ref is heap-allocated and not thread-safe
basics
~10 sFor 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 sJVM 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// 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
Knows the compiler does 'something' to let the var be shared but may not name Ref.
Names kotlin.jvm.internal.Ref / IntRef / ObjectRef and explains the shared .element field, contrasting val (no box).
Adds that inline lambdas avoid the allocation and discusses GC/perf and thread-safety implications of the Ref.
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