Java Lambda Variable Capture: Rules and Pitfalls
Learn how Java lambdas capture local variables, why the effectively final rule exists, and how to work around mutable state in lambda expressions.
Java lambda variable capture follows a strict rule: a lambda expression can reference a local variable from the enclosing scope only if that variable is effectively final. This means the variable is never reassigned after it is first assigned. The rule applies to local variables and method parameters, and it is a common source of compiler errors when developers first work with lambdas.
The Effectively Final Rule
A local variable is effectively final when it is never reassigned after its initial assignment. The variable does not need the final keyword; the compiler checks only whether reassignment occurs in the scope where the variable is used.
public void process(List<String> items) { int threshold = 3; // effectively final items.stream() .filter(item -> item.length() > threshold) .forEach(System.out::println); }
If threshold is reassigned later in the method, the lambda no longer compiles:
public void process(List<String> items) { int threshold = 3; threshold = 5; // reassignment breaks capture items.stream() .filter(item -> item.length() > threshold) .forEach(System.out::println); }
The compiler reports that the variable must be effectively final. The same rule applies to method parameters: a parameter can be captured only if it is not reassigned inside the method.
Why the Rule Exists
When a lambda captures a local variable, the JVM copies the variable's value into the lambda object at the point the lambda is created. If the variable is an object reference, the reference value is copied, not the object itself. The lambda does not hold a reference to the stack slot of the original variable. If the variable could be reassigned after the lambda is created, the lambda would keep the old value while the enclosing method would see the new value, creating two different views of the same variable.
Requiring the variable to be effectively final guarantees that the copied value always matches the value in the enclosing scope. There is no possibility of divergence, so the capture semantics are safe and predictable. Because the captured value is stable, the JVM can treat the capture as a simple value copy rather than shared mutable state.
Capturing Instance and Static Fields
The effectively final rule does not apply to instance fields or static fields. A lambda can read and modify a field of the enclosing object because the lambda holds a reference to this rather than a copy of the field's value.
public class Counter { private int count = 0; public Runnable increment() { return () -> count++; } }
The lambda captures the Counter instance, not the current value of count. Each time the returned Runnable runs, it reads and updates the field through the object reference, so the lambda observes the latest value. This is a common way to maintain mutable state inside a lambda, but it introduces shared state and potential thread-safety concerns if the lambda runs concurrently.
Common Compiler Errors and Fixes
The most frequent error is attempting to capture a variable that is reassigned in a loop or after a conditional branch.
public void process(List<String> items) { int threshold = 3; for (String item : items) { threshold = item.length(); // reassignment Runnable task = () -> System.out.println(threshold); // compile error } }
A typical fix is to introduce a new local variable for each iteration:
public void process(List<String> items) { for (String item : items) { int current = item.length(); Runnable task = () -> System.out.println(current); // current is effectively final for this iteration } }
Because each iteration of an enhanced for loop creates a new loop variable, the variable is effectively final for that iteration as long as the body does not reassign it. It can therefore be captured directly.
Working Around Mutable State
When a lambda needs to modify a captured value, the standard approach is to use a mutable container such as an array, an AtomicInteger, or a custom holder object.
int[] total = {0}; items.forEach(item -> total[0] += item.length());
The array reference is effectively final; the lambda modifies the array element rather than reassigning the local variable. This works but is not idiomatic. A cleaner approach is to use AtomicInteger when thread-safety matters, or to restructure the code so that reduction happens through the Stream API:
int total = items.stream() .mapToInt(String::length) .sum();
Prefer the functional approach whenever the operation is a reduction or aggregation. Mutable containers are acceptable when the lambda performs side effects that cannot be expressed as a pure reduction.
Runtime Cost and Maintainability
Capturing a local variable copies the value or reference into the lambda object during creation; the value copy itself is cheap. Capturing an instance field means the lambda also holds a reference to the enclosing object, which can keep that object alive longer than expected. The same lifetime consideration applies when the lambda captures a reference to a large object.
The effectively final rule also improves maintainability. A captured local value or reference cannot be reassigned, so the lambda's view of that variable is stable. This makes the lambda easier to reason about than code that shares a mutable local variable. When a lambda captures mutable fields, the behavior depends on the object's state at invocation time, which is harder to reason about.
Comparison with Anonymous Inner Classes
Anonymous inner classes have the same underlying capture semantics: the local variable's value is copied at creation time. In current Java, anonymous inner classes also accept effectively final variables; before Java 8, they required an explicit final keyword. Lambdas and anonymous inner classes both capture the value, so the variable must not be reassigned after it is used.
A more significant difference is that an anonymous inner class can define its own fields and methods, while a lambda cannot. If the logic needs to maintain per-invocation state, an anonymous class or a named class is more appropriate.
When the Effectively Final Rule Does Not Apply
The rule applies only to local variables and method parameters. It does not apply to instance fields, static fields, array elements, or object fields accessed through a captured reference. For example, an array variable itself must be effectively final, but the array elements it refers to can be changed after the lambda is created; the lambda reads the element through the captured array reference.
| Captured element | Effectively final required | What the lambda holds |
|---|---|---|
| Local variable | Yes | Copied value or reference |
| Method parameter | Yes | Copied value or reference |
| Instance field | No | Reference to enclosing object (this) |
| Static field | No | Direct field access |
These fields and array elements are accessed through a reference rather than copied, so the underlying state can change after the lambda is created. This distinction is important when designing APIs that accept lambdas: if the lambda reads a mutable field, the behavior may change between invocations, and the caller must account for that.
The effectively final rule is a compile-time constraint, not a runtime one. The compiler enforces it before any bytecode is generated, so there is no runtime check or exception associated with capture violations. The error appears at compilation time, which makes it easy to catch early.