ArchitectMind
  1. Home
  2. Tutorials
  3. Book 1 · Core Java & Modern Java
  4. Volume III
  5. Variable Capture
Book 1 · Volume III — Functional Java, Streams & Modern Java · Topic 1.3

Variable Capture

Effectively final variables and capturing state

8 min readStage 1 · Foundations7 interview questions
Prerequisites
1.2 Lambda Syntax · B1V1 3.3 Primitive vs Reference
Covers
Effectively final variables · Capturing state
Related
4.5 Parallel Streams · B1V4 (thread safety) · Book 2 (memory leaks)
On this page
  1. 1. Concept / Definition
  2. 2. Why It Exists
  3. 3. Core Concepts
  4. 4. How It Works Internally
  5. 5. APIs / Features / Syntax
  6. 6. Practical Examples
  7. 7. Common Problems & Mistakes
  8. 8. Performance & Production Considerations
  9. 9. Architecture & Design Considerations
  10. 10. Interview Questions & Answers

1Concept / Definition

A lambda can use variables from its enclosing scope: local variables and parameters of the enclosing method, and fields of the enclosing instance. Using a local variable is called capturing it. Java allows capturing only local variables that are final or effectively final — never reassigned after initialisation — and the lambda receives a copy of the value, not access to the variable itself. Fields are accessed through the captured this and have no such restriction.

Captured values become fields of the lambda object
Figure 1 — Captured values become fields of the lambda object

2Why It Exists

Local variables live in a method's stack frame, which disappears when the method returns — but a lambda can outlive it (stored, returned, run on another thread). Copying the value solves lifetime; requiring the variable never to change makes the copy indistinguishable from the variable, so there is no confusing difference between "the lambda's copy" and "the real variable". It also prevents unsynchronised sharing of mutable locals between threads.

3Core Concepts

Effectively final variables

Since Java 8 a local variable does not need the final keyword to be captured: it is enough that it is assigned exactly once. Assigning it again anywhere in the method — before or after the lambda — makes capture a compile error ("local variables referenced from a lambda expression must be final or effectively final"). The same rule applies to anonymous and local classes since Java 8.

Effectively final in practice
Figure 2 — Effectively final in practice

Capturing state

Three kinds of state are reachable from a lambda. Captured locals are copied values; if a value is a reference to a mutable object, the object can still be mutated through it, because only the reference is final. Instance fields are read and written through the captured this; the lambda sees their current values, not a copy. Static fields are accessed directly. Mutating shared state from lambdas — through fields, mutable holders such as int[] or AtomicInteger, or external collections — is legal but defeats the purpose and is unsafe in parallel code.

Mutable-holder workarounds versus letting the stream compute
Figure 3 — Mutable-holder workarounds versus letting the stream compute

A lambda does not introduce a new scope: this refers to the enclosing instance, and parameters or locals in the lambda may not shadow local variables of the enclosing method. This is the opposite of anonymous classes.

Lexical scoping of lambdas versus anonymous classes
Figure 4 — Lexical scoping of lambdas versus anonymous classes

4How It Works Internally

Captured values are passed to the lambda factory at creation and stored in final fields of the hidden class instance; the synthetic method receives them as extra leading parameters. A lambda that uses an instance field or method captures this. Consequently a non-capturing lambda is a shared singleton, while a capturing lambda allocates a new object every time the lambda expression is evaluated — for example, inside a loop.

Implicit capture of this can create memory leaks
Figure 5 — Implicit capture of this can create memory leaks

5APIs / Features / Syntax

Capture is not an API but a set of language rules, and they apply the same way to lambdas, anonymous classes and local classes. Any local name in scope can be captured if it is final or effectively final: method and constructor parameters, ordinary locals, the enhanced-for variable, catch parameters, try-with-resources variables (implicitly final) and pattern variables introduced by instanceof or a switch case. Fields, this and, inside a lambda, super are reached through the enclosing instance rather than copied.

JAVAWhat can and cannot be captured
void handle(Request req, List<Order> orders) {
    String tenant = req.tenant();               // effectively final
    orders.forEach(o -> audit(tenant, o));      // OK

    if (req.body() instanceof Payment p) {      // pattern variable
        executor.submit(() -> settle(p));       // OK: not reassigned
    }

    try (var conn = pool.acquire()) {           // implicitly final
        orders.forEach(o -> conn.queue(o));     // OK
    } catch (SQLException e) {
        executor.submit(() -> report(e));       // OK: not reassigned
    }

    int retries = 0;
    retries++;                                  // reassigned
    // Runnable r = () -> log(retries);         // compile error
}

Method references capture eagerly

A bound method reference such as order::ship evaluates its receiver when the reference is created, not when it is invoked. If order is null, the NullPointerException is thrown at creation; if the variable later points elsewhere, the reference still uses the original object. The equivalent lambda () -> order.ship() reads the variable lazily, which is why it is subject to the effectively final rule while the receiver expression of a method reference is not.

Since Java 22 an unnamed variable _ can stand in for a lambda parameter the body does not use, as in map.forEach((_, v) -> total.add(v)), which makes clear that nothing is captured or read from it.

6Practical Examples

JAVACapture rules in everyday code
// Effectively final: fine
void discount(List<Order> orders, BigDecimal rate) {
    BigDecimal factor = BigDecimal.ONE.subtract(rate);          // assigned once
    orders.replaceAll(o -> o.withTotal(o.total().multiply(factor)));
}

// Accumulating: don't mutate captured holders; reduce instead
BigDecimal sum = orders.stream().map(Order::total).reduce(BigDecimal.ZERO, BigDecimal::add);

// Loop variables: the enhanced-for variable is effectively final per iteration
for (Order o : orders) executor.submit(() -> ship(o));       // OK
for (int i = 0; i < n; i++) {
    int index = i;  // copy needed: i is reassigned
    executor.submit(() -> process(index));
}

// Avoid capturing 'this' in long-lived registrations
class Dashboard {
    private final Map<String, Widget> widgets = new HashMap<>();   // large
    void start(EventBus bus) {
        bus.subscribe(Tick.class, t -> refresh());             // captures this
    }
    void stop(EventBus bus) { bus.unsubscribeAll(this); }  // must be called
}

7Common Problems & Mistakes

  • Using int[], AtomicInteger or an external list to accumulate inside forEach — hides shared mutation; use reductions and collectors.
  • Capturing a mutable object and assuming the lambda sees a snapshot — it sees later mutations.
  • Implicitly capturing this in lambdas registered with long-lived objects — keeps the whole enclosing object alive.
  • Capturing a classic for loop index — compile error; copy to an effectively final local.
  • Capturing large objects (entire request contexts) in lambdas scheduled for later execution — memory and data-leak risk.

8Performance & Production Considerations

Capturing lambdas allocate on each evaluation; in very hot loops, hoisting the lambda out of the loop or making it non-capturing (passing data as parameters) avoids the allocation, although escape analysis often removes it anyway. Heap dumps of leaking services frequently show $$Lambda hidden-class instances holding references to controllers or sessions: the listener or callback registry that holds them is the real culprit.

9Architecture & Design Considerations

Capture rules push code toward stateless behaviour, which is exactly what concurrent and distributed execution needs. When lambdas are handed to executors, schedulers, reactive pipelines or event buses, treat what they capture as part of the contract: capture immutable values, never request-scoped mutable objects, and make lifetimes explicit by unsubscribing or using weak listeners where the registry outlives the subscriber.

10Interview Questions & Answers

Q1KnowledgeConcept

What is an effectively final variable?

An effectively final variable is a local variable or parameter that is assigned exactly once and never reassigned, even though it is not declared with the final keyword. The compiler decides this by analysing every assignment in the method, so a reassignment anywhere, before or after the lambda, removes the status. Since Java 8, lambdas, anonymous classes and local classes may capture only final or effectively final locals; before Java 8, anonymous classes required the explicit final keyword. Note that it constrains the variable, not the object it refers to: an effectively final List can still have elements added to it, which is exactly the loophole that causes many concurrency bugs.

Q2UnderstandingInternals

Why must captured local variables be effectively final?

Because the lambda gets a copy of the value, not access to the variable. Locals live in the method's stack frame, which disappears when the method returns, yet the lambda may be stored, returned or run later on another thread. Copying solves the lifetime problem, and forbidding change makes the copy indistinguishable from the original, so there is never a question of which version the lambda sees. It also avoids data races on locals, which are otherwise thread-confined and need no synchronisation. Languages that allow mutable capture, such as JavaScript or C#, have to move the variable onto the heap, and that hidden sharing is a known source of bugs.

Q3UnderstandingTricky

Can a lambda modify an instance field?

Yes. A lambda that uses an instance field captures this, not the field, so it reads and writes the field's current value through the enclosing object and the effectively final rule does not apply. Static fields are reached directly through the class. Being legal does not make it safe: if the lambda runs on another thread, an executor or a parallel stream, the field is shared mutable state and needs synchronisation, a volatile or atomic type, or a redesign. A cleaner approach is usually to return a result from the lambda, or from a stream reduction, and assign it in one place on the calling thread.

Q4ApplicationTricky

What does 'this' mean inside a lambda compared with an anonymous class?

In a lambda, this means the enclosing instance, because a lambda is lexically scoped and does not introduce a new scope; the same applies to super, toString and any unqualified method call. In an anonymous class, this is the anonymous class instance itself, and reaching the outer object needs Outer.this. The difference has practical consequences. An anonymous class can hold its own state and refer to itself, for example to unregister itself as a listener, while a lambda cannot. A lambda that calls an instance method silently captures the outer this, whereas an anonymous class in an instance method historically always held the outer instance and still does whenever it uses it.

Q5ApplicationCode

Why is using an AtomicInteger to count inside forEach considered a smell?

Because it uses side effects to work around the capture rule instead of expressing the computation. The AtomicInteger or int array holder is effectively final, but the state inside it is shared and mutated, so the code reads like a loop pretending to be a stream. It hides intent, couples the lambda to external state and becomes a contention point, or a race if someone swaps in a plain holder, once the stream is made parallel. The idiomatic alternatives say what is computed: count(), mapToInt(...).sum(), reduce, or Collectors.groupingBy with counting(). These are side-effect free, parallel-safe and easier for a reviewer to verify.

Q6Senior EngineerScenario

How can a lambda cause a memory leak?

A lambda keeps alive everything it captures. If it uses an instance field or calls an instance method, it captures this, often without the author noticing. When that lambda is registered with something long-lived, such as an event bus, a static cache, a scheduler or a framework callback list, the registry references the lambda, the lambda references the enclosing object, and that object references all its fields, perhaps a large map or a request context. None of it can be collected until the lambda is removed. In a heap dump this appears as $$Lambda instances on the path to GC roots. The fix is to unsubscribe on shutdown, capture only small values, or use weak listeners.

Q7Technical ArchitectDesign

What should a team standardise about lambdas passed to asynchronous infrastructure?

Treat capture as part of the contract for any lambda handed to executors, schedulers, reactive pipelines, CompletableFuture chains or message listeners. Capture only immutable values or snapshots, typically records, never mutable request, session or entity objects that another thread may change. Avoid implicit this capture in long-lived registrations, and pair every subscribe with an unsubscribe tied to the component's lifecycle. Propagate context such as tenant, trace or security information explicitly, through parameters or Java 25 scoped values, instead of relying on capture or ThreadLocal leaking across pooled threads. Back the standard with code review checks and static analysis rules so it holds across teams.

Newsletter

Get new tutorials every week

New ArchitectMind tutorials and interview questions, once a week. No spam, unsubscribe anytime.

Almost done! Check your inbox and click the confirmation link to complete your subscription.