What are the lifetime and memory implications of capturing variables in long-lived closures, and how can captured references cause leaks? Give Kotlin-specific guidance.
answer
- Closure keeps captures alive while it is reachable
- Long-lived lambda + big capture = leak
- Implicit member use captures outer this
- Capture a minimal val, not the whole object
- Cancel coroutine scopes; unregister callbacks
basics
~10 sA closure keeps its captured things alive as long as the closure lives. If a long-lived lambda captures a big or context object, that object can't be garbage-collected, causing a leak.
solid answer
~40 sCaptured variables are reachable through the closure object, so they share its lifetime. A lambda stored in a long-lived place (a singleton, a static registry, an event bus, a never-cancelled coroutine) keeps every captured reference alive, blocking GC. Capturing the enclosing `this` is the subtle trap: a lambda that uses a member property or method captures the whole outer instance (e.g. an Activity/ViewModel/service), so the instance leaks. For a captured var, the Ref holder is also retained. Mitigations: capture only the minimal val you need (pull a field into a local first), avoid referencing outer members implicitly, scope coroutines so they're cancelled (structured concurrency, viewModelScope), and unregister callbacks. The principle: a closure is a GC root for its captures for as long as the closure is reachable.
code
kotlin · 10 linesclass ProfileViewModel(private val repo: Repo, private val big: HugeCache) {
// BAD: lambda uses member `big` -> captures whole ViewModel, leaks via long-lived bus
fun bad(bus: EventBus) = bus.subscribe { use(big) }
// GOOD: capture only the minimal value needed
fun good(bus: EventBus) {
val snapshot = big.summary() // small immutable val
bus.subscribe { renderTop(snapshot) }
}
}go deeper
Understands a stored lambda 'holds onto' things and that this can use memory.
Explains reachability through the closure and the basic minimal-capture mitigation.
Identifies the implicit-this trap, ties it to Android/coroutine scopes, and prescribes structured concurrency and unregistration.
Sets team conventions (scope ownership, capture review, no GlobalScope) and reasons about leak detection and lifecycle architecture.
## Closures and reachability A closure is an object whose synthetic fields hold its **captured variables** (the value/reference for a `val`, or a `Ref` holder for a `var`). The JVM garbage collector keeps an object alive while it is **reachable**. Therefore, *anything a live closure captures stays alive as long as the closure does.* This is harmless for short-lived lambdas (`list.map { ... }` runs and is discarded). It becomes a **memory leak** when the closure outlives the natural lifetime of what it captured. ## Where long-lived closures hide - a lambda stored in a singleton, static field, or global registry - an event-bus / listener that is registered but never unregistered - a callback handed to a long-lived service - a coroutine launched in a scope that is never cancelled (e.g. `GlobalScope.launch { ... }`) ## The implicit `this` trap The most common real leak is capturing the **enclosing instance**. If a lambda references a member property or method without qualification, it implicitly captures the outer `this`: ```kotlin class Screen(val data: HugeData) { fun register(bus: EventBus) { bus.subscribe { render(data) } // captures `this@Screen` (uses member render/data) } fun render(d: HugeData) { /* ... */ } } ``` The lambda holds `this@Screen`, so the whole `Screen` (and its `HugeData`) cannot be collected while `bus` holds the subscription — a classic Android Activity/ViewModel leak. ## Mitigations (Kotlin-specific) - **Capture the minimum.** Copy just the field you need into a local `val` first, so the closure captures the small value, not the whole object: ```kotlin fun register(bus: EventBus) { val d = data // capture only the field bus.subscribe { renderStatic(d) } // no implicit this captured } ``` - **Avoid implicit member references** in long-lived lambdas; prefer top-level/static functions or explicitly captured locals. - **Use structured concurrency.** Launch in a lifecycle-bound scope (`viewModelScope`, `lifecycleScope`, or a `CoroutineScope` you cancel) instead of `GlobalScope`, so the coroutine (and its captures) is cancelled and released. - **Unregister callbacks/listeners** when the owner is destroyed. - Prefer capturing immutable `val`s; a captured `var` additionally retains its `Ref` box. ## Mental model Treat a reachable closure as a **GC root for its captures**. Before storing a lambda somewhere long-lived, ask: *what does this lambda capture, and how long should those things live?*
- How does referencing a member function inside a lambda cause the outer instance to be captured?An unqualified member access is implicitly `this.member`, so the lambda must hold `this` — the entire enclosing instance — to call it, extending that instance's lifetime.
- Why does using viewModelScope instead of GlobalScope reduce leak risk?viewModelScope is cancelled when the ViewModel clears, which cancels the coroutine and releases its captured references; GlobalScope lives for the whole process.
A closure is like a moving truck still parked in your driveway: as long as it sits there, everything you loaded into it stays put and can't be thrown away.
saying these in an interview costs you the question
- Claiming closures never affect GC because the JVM collects everything automatically
- Not recognizing that implicit member access captures outer this
- Saying capturing a small field and capturing the whole object are equivalent for memory
- Recommending GlobalScope for lifecycle-bound work
- Believing only var captures (not val) can cause leaks