If you have ever written a lambda that uses a variable declared outside it, you have written a closure. Most Java developers do this every day without ever calling it that — and without quite knowing the rules that govern it, until the compiler refuses to compile with the cryptic message “local variables referenced from a lambda expression must be final or effectively final.”
That error is the visible tip of a concept worth understanding properly. This post explains what a closure is, what “variable capture” means, why Java imposes the effectively-final rule, and the specific traps that capture sets for the unwary.
What a Closure Is
A closure is a function bundled together with the environment in which it was created. “The environment” means the local variables that were in scope where the function was defined. When a function refers to a variable that is neither its own parameter nor a local variable it declared, it captures that variable from the enclosing scope — and the function plus those captured variables is a closure.
The simplest possible example:
int factor = 3;Function<Integer, Integer> multiply = n -> n * factor; // captures 'factor'
multiply.apply(10); // 30The lambda n -> n * factor has one parameter, n. But it also uses factor, which it did not declare. factor comes from the surrounding method. The lambda has closed over factor — that is the origin of the word “closure.” The function carries factor with it.
This is what makes the lambda more than just a block of code. It is a block of code plus a snapshot of the relevant surrounding state. You can return it, store it, and call it long after the method that created it has returned — and it still knows that factor is 3.
static Function<Integer, Integer> multiplierOf(int factor) { return n -> n * factor; // the closure outlives this method}
Function<Integer, Integer> triple = multiplierOf(3);triple.apply(10); // 30 — 'factor' is still here, long after multiplierOf returnedThis is partial application, which we have covered elsewhere — and closures are the mechanism that makes it work. The captured factor is what lets the returned function “remember” the value it was configured with.
What “Capture” Actually Does
Here is the detail that trips people up: Java closures capture the value, not the variable.
In some languages, a closure captures the variable itself — a live reference to the storage location. If the variable changes later, the closure sees the new value. Java does not work this way. When a lambda captures a local variable, it copies the current value into the lambda at the moment of creation.
That copy is why the effectively-final rule exists. Consider what would happen if Java allowed this:
int count = 0;Runnable r = () -> System.out.println(count); // imagine this captured the variablecount = 5;r.run(); // would this print 0 or 5?Because Java copies the value at capture time, the answer would be ambiguous and surprising — and worse, a local variable lives on the stack, which disappears when the method returns, while the lambda might outlive it. Rather than expose this confusion, Java forbids it: a captured local variable must be final or effectively final, meaning its value never changes after assignment. If it never changes, there is no ambiguity between “the value at capture” and “the value now” — they are always the same.
So the rule is not an arbitrary restriction. It is the language closing off a question that would otherwise have no good answer.
“Effectively Final” in Plain Terms
A variable is effectively final if you could add the final keyword to it without causing a compile error. In other words: it is assigned exactly once, and never reassigned afterward.
// Effectively final — assigned once, never changed. Capture is allowed.String prefix = "LOG: ";Consumer<String> log = msg -> System.out.println(prefix + msg); // OK
// NOT effectively final — reassigned. Capture is rejected.String label = "start";label = "end"; // reassignmentRunnable r = () -> System.out.println(label); // compile errorYou do not have to write final yourself — since Java 8, the compiler infers it. But it can be a useful documentation habit on variables you intend to capture, because it makes the constraint explicit to the next reader.
Note that effectively final applies to the variable, not the object it points to. A final List reference cannot be reassigned, but the list it points to can still be mutated:
List<String> events = new ArrayList<>(); // effectively final referenceRunnable r = () -> events.add("clicked"); // OK — reference never changesr.run();events.add("hovered"); // also OK — mutating the object, not the variable// 'events' the variable is never reassigned; the list it points to changes freelyThis distinction — the reference is final, the object is not — is the loophole behind both a useful technique and a dangerous bug, which we come to next.
The Mutable-Holder Workaround (and Why to Avoid It)
Developers who want a lambda to “change” a captured value sometimes discover that they can sidestep the effectively-final rule by capturing a mutable object instead of a primitive:
int[] counter = {0}; // effectively final reference to a mutable arrayList.of("a", "b", "c").forEach(s -> counter[0]++); // mutate through the referenceSystem.out.println(counter[0]); // 3This compiles because counter (the reference) never changes — only counter[0] (the contents) does. The same trick appears with AtomicInteger, a single-element array, or a one-field holder object.
It works, but it is usually a sign that you are fighting the functional style rather than using it. The whole point of the constraint is to keep captured state immutable and predictable; the holder trick reintroduces exactly the shared mutable state that closures-over-final-values were protecting you from. In a stream, it is also outright unsafe under parallelism. Most of the time, the loop you are trying to write is really a reduce, a map, or a count:
// Instead of mutating a holder, express the computation as a foldlong count = Stream.of("a", "b", "c").count(); // 3int total = Stream.of(1, 2, 3).reduce(0, Integer::sum); // 6If you find yourself capturing a mutable holder to accumulate a result, pause and ask whether a stream operation expresses the same thing without the shared state. It usually does.
The Classic Loop-Capture Trap
The most famous capture pitfall comes from capturing a loop variable. Because capture takes the value at the moment the lambda is created, lambdas created inside a loop each capture the value the variable had on their iteration — which is what you want, and works correctly with the enhanced for loop:
List<Supplier<String>> suppliers = new ArrayList<>();for (String name : List.of("Ann", "Bob", "Cy")) { suppliers.add(() -> "Hello " + name); // each lambda captures its own 'name'}suppliers.forEach(s -> System.out.println(s.get()));// Hello Ann / Hello Bob / Hello Cy — correctEach iteration of an enhanced for introduces a fresh name variable, so each lambda closes over a different value. This is also why the enhanced-for variable is effectively final and capturable, while a classic counter is not:
for (int i = 0; i < 3; i++) { int current = i; // fresh effectively-final copy per iteration suppliers.add(() -> "Item " + current); // capture the copy, not 'i'}You cannot capture i directly — it is reassigned every iteration, so it is not effectively final. The idiomatic fix is the current copy above: a new effectively-final variable per iteration that snapshots the loop value. Developers coming from JavaScript’s pre-let era will recognize this trap; Java’s effectively-final rule actually prevents the worse version of it by refusing to compile the direct capture of i.
Capturing this and Instance Fields
Lambdas can also use instance fields and call instance methods. When they do, they capture this — the enclosing object — not a copy of the field. Fields are not subject to the effectively-final rule:
class Counter { private int count = 0; // a field, not a local variable
Runnable incrementer() { return () -> count++; // OK — fields can be mutated through 'this' }}This compiles even though count clearly changes, because the lambda captures this (an effectively-final reference) and reaches count through it. The field-vs-local distinction matters: the rule that frustrates you with locals does not apply to fields at all.
This has a consequence worth knowing: a lambda that captures this keeps the entire enclosing object alive for as long as the lambda is reachable. If you store such a lambda in a long-lived registry or listener list, you have also pinned the object that created it in memory. For most code this is irrelevant; for listeners and callbacks held for the lifetime of an application, it is a real source of memory leaks.
Lambdas vs Anonymous Classes
Closures predate lambdas in Java — anonymous inner classes capture variables under the same effectively-final rule (it was explicitly final before Java 8, effectively final since). The capture semantics are the same. The one meaningful difference is this:
class Widget { String name = "widget";
void demo() { // Lambda: 'this' is the Widget Runnable lambda = () -> System.out.println(this.name); // "widget"
// Anonymous class: 'this' is the anonymous Runnable, not the Widget Runnable anon = new Runnable() { public void run() { // System.out.println(this.name); // would not compile — Runnable has no 'name' System.out.println(Widget.this.name); // must qualify } }; }}In a lambda, this refers to the enclosing instance, which is almost always what you want. In an anonymous class, this refers to the anonymous instance itself, so reaching the outer object requires the Outer.this qualifier. This is one of the quiet reasons lambdas read more cleanly than the anonymous classes they replaced.
Summary
A closure is a function plus the captured environment it was created in. In Java:
- A lambda captures any variable it uses from the enclosing scope.
- Capture copies the value at creation time, not a live link to the variable.
- Because of that copy, captured local variables must be final or effectively final — assigned once and never reassigned. The rule removes an otherwise-ambiguous question.
- The reference being final does not make the object immutable; capturing a mutable holder to dodge the rule reintroduces the shared mutable state the rule exists to prevent.
- Fields are exempt — a lambda captures
thisand can mutate fields through it, which also keeps the enclosing object alive. - The classic loop trap is solved by snapshotting the loop value into a fresh per-iteration variable; the enhanced
fordoes this for you.
You were already using closures the first time you wrote a lambda that referenced an outside variable. Knowing the capture rules turns that from something the compiler occasionally rejects into a tool you can reach for deliberately — to configure behavior, to defer computation, and to carry exactly the state a function needs and nothing more.
Further reading
- Higher-Order Functions Explained with Real Examples — functions that take and return functions, the place closures earn their keep
- Currying and Partial Application in Practice — capture is the mechanism that makes a partially applied function remember its fixed arguments
- Pure Functions and Side Effects — why capturing immutable values keeps closures predictable
- Why Avoid Mutable State? — the deeper case against the mutable-holder workaround
