Capturing Variables & Scope
A lambda expression is not an isolated snippet of code — it is a closure. It can reach outside its own parameter list and read variables that were declared in the surrounding method or class. Understanding what a lambda can capture, why certain restrictions exist, and how this behaves inside a lambda are the three pillars of this lesson.
Effectively Final: the Golden Rule
Java allows a lambda to capture a local variable only if that variable is effectively final — meaning its value is never reassigned after it is first set. The variable does not have to be declared final, but it must behave as if it were.
public class Capture {
public static void main(String[] args) {
int threshold = 10; // effectively final — never reassigned
// OK: threshold is captured safely
Runnable r = () -> System.out.println("Threshold is " + threshold);
r.run(); // Threshold is 10
}
}
Now watch what happens when you try to reassign the variable after defining the lambda:
int threshold = 10;
threshold = 20; // reassignment — no longer effectively final
// COMPILE ERROR: Variable used in lambda expression should be effectively final
Runnable r = () -> System.out.println(threshold);
Why this restriction exists: local variables live on the stack of the current thread. A lambda object can outlive that stack frame — it could be stored, passed to another thread, or invoked later. If the variable could change, the lambda might read a stale or concurrent value. By requiring effective finality, the compiler guarantees the lambda always sees a consistent, immutable snapshot.
Instance Fields and Static Fields Are Different
The effectively-final rule applies only to local variables. Instance fields and static fields are accessed through the heap, not the stack, so lambdas can freely read and write them without restriction.
public class Counter {
private int count = 0; // instance field — heap-based
public Runnable makeIncrementer() {
// reading and writing 'count' is fine — it is a field, not a local
return () -> count++;
}
public int getCount() { return count; }
public static void main(String[] args) {
Counter c = new Counter();
Runnable inc = c.makeIncrementer();
inc.run();
inc.run();
System.out.println(c.getCount()); // 2
}
}
Mutating shared state from a lambda is dangerous in concurrent code. The fact that Java allows it does not mean it is safe. When multiple threads invoke a lambda that writes to a shared field, you need synchronisation. For single-threaded code it is fine, but keep this trade-off in mind.
Capturing the Enclosing Scope: What the Lambda Can See
A lambda inherits the full lexical scope of the method in which it is written. That includes every local variable that is in scope at the point the lambda is defined — not just variables explicitly mentioned inside it. The lambda "closes over" the variables it actually uses.
import java.util.List;
import java.util.function.Predicate;
public class ScopeDemo {
public static Predicate<String> startsWithFilter(String prefix) {
// 'prefix' is a parameter — effectively final (never reassigned)
return s -> s.startsWith(prefix); // captures 'prefix' from the outer scope
}
public static void main(String[] args) {
Predicate<String> startsWithJ = startsWithFilter("J");
List.of("Java", "Kotlin", "JavaScript", "Python")
.stream()
.filter(startsWithJ)
.forEach(System.out::println);
// Java
// JavaScript
}
}
Here startsWithFilter has long returned by the time startsWithJ is evaluated by the stream. The lambda carries a copy of prefix with it — that copy is the captured value.
How this Behaves Inside a Lambda
This is where lambdas differ fundamentally from anonymous classes. Inside an anonymous class, this refers to the anonymous class instance. Inside a lambda, this refers to the enclosing class instance — the same this you would use anywhere else in that method.
public class Greeter {
private final String name;
public Greeter(String name) {
this.name = name;
}
public Runnable asRunnable() {
// 'this' inside the lambda is the Greeter instance
return () -> System.out.println("Hello from " + this.name);
}
public static void main(String[] args) {
Greeter g = new Greeter("Alice");
Runnable r = g.asRunnable();
r.run(); // Hello from Alice
}
}
Compare this with the anonymous class equivalent:
public Runnable asRunnableAnon() {
return new Runnable() {
@Override
public void run() {
// 'this' here is the anonymous Runnable instance, NOT the Greeter
// to reach the outer class you would write Greeter.this.name
System.out.println("Hello from " + Greeter.this.name);
}
};
}
Prefer lambdas when you need to reference the enclosing object. Because this in a lambda always means the outer class, you never need the OuterClass.this qualifier. This reduces boilerplate and removes a common source of confusion for developers new to anonymous classes.
Practical Patterns to Avoid Capture Problems
Sometimes you want to use a variable inside a lambda but the compiler rejects it because it was reassigned. The idiomatic fix is to copy it into a new, effectively-final variable before the lambda:
public static void printIfAboveThreshold(List<Integer> values, int threshold) {
// threshold is a parameter; suppose business logic later changes it
threshold = adjustThreshold(threshold); // reassigned — now NOT effectively final
int limit = threshold; // copy into a new, never-reassigned variable
values.stream()
.filter(n -> n > limit) // captures 'limit', not 'threshold'
.forEach(System.out::println);
}
private static int adjustThreshold(int t) { return t + 5; }
This pattern is idiomatic, clean, and makes the intent explicit: the lambda should use the adjusted value, not the original parameter.
Summary
- Lambdas can capture effectively-final local variables — variables that are assigned once and never changed.
- Instance and static fields can be freely read and written (though concurrent writes need care).
- The captured value is a snapshot; the lambda carries its own copy of a local variable.
this inside a lambda refers to the enclosing class instance, not to a synthetic lambda object.
- When a variable is not effectively final, copy it to a fresh variable before the lambda.
These rules make lambdas safe to pass around and invoke later without unexpected aliasing or data races on local state.