What you'll learn

  • What a closure is
  • Why captured locals must be effectively final
  • Function factories that remember configuration
  • Keeping state in a closure safely
  • The loop-variable trap and how Java prevents it

A closure is a function bundled with the variables it uses from the place where it was created. Every Java lambda that refers to a variable from its surrounding code is a closure — and the rules Java puts on those variables explain one of the most common compiler errors in modern Java.

Capturing a variable

String customer = "Acme";
Runnable greet = () -> System.out.println("Hello " + customer);
greet.run();   // Hello Acme

The lambda captures customer. Even if greet is passed somewhere else and run much later, it still knows the value.

“Effectively final”

String customer = "Acme";
Runnable greet = () -> System.out.println("Hello " + customer);
customer = "Globex";   // ❌ compile error
error: local variables referenced from a lambda expression must be final or effectively final

A variable is effectively final if it’s never reassigned after being initialised — you could add final without changing anything. Java requires this because a lambda captures the variable’s value, not the variable itself. Lambdas can run later, or on another thread, after the method has returned and its local variables are gone. If reassignment were allowed, the lambda and the method could see different values — a source of subtle bugs. Java makes the problem impossible instead.

Note: Only local variables have this rule. Lambdas can freely read and change fields of the enclosing object, because those live on the heap, not on the stack.

Closures that remember configuration

A function factory returns a function customised by its arguments:

static Function<Double, Double> discountOf(double percent) {
    return price -> price - price * percent / 100;   // captures 'percent'
}

Function<Double, Double> tenOff = discountOf(10);
Function<Double, Double> quarterOff = discountOf(25);
tenOff.apply(200.0);       // 180.0
quarterOff.apply(200.0);   // 150.0

Each returned function carries its own percent. This is a clean way to build rules from configuration — tax rates per country, retry policies per partner.

Closures with state

You can’t reassign a captured local, but you can capture a reference to a mutable object and change what’s inside it:

static Supplier<Integer> counter() {
    AtomicInteger count = new AtomicInteger();   // the reference never changes
    return count::incrementAndGet;               // the value inside it does
}

Supplier<Integer> next = counter();
next.get();   // 1
next.get();   // 2

Each call to counter() creates a new, private AtomicInteger, so separate counters don’t interfere. AtomicInteger is also safe if the lambda runs on several threads.

Counting inside a lambda — and a better way

int errors = 0;
lines.forEach(l -> { if (l.equals("error")) errors++; });   // ❌ won't compile

The workaround is an AtomicInteger (or a one-element array). But usually the real fix is to stop mutating and compute instead:

long errors = lines.stream().filter("error"::equals).count();

If you find yourself fighting the effectively-final rule, it’s often a hint that a stream expresses the idea better.

The loop-variable trap

In some languages, closures created in a loop all see the final value of the loop variable. Java prevents that bug: the classic for (int i…) variable isn’t effectively final, so you must copy it:

for (int i = 0; i < 3; i++) {
    int copy = i;                        // a fresh, effectively final variable each round
    suppliers.add(() -> copy * 10);
}
// 0 10 20 — each closure has its own copy

(The for-each loop variable is effectively final, so for (String s : list) tasks.add(() -> use(s)); works directly.)

this inside a lambda

Unlike an anonymous class, a lambda doesn’t create a new scope for this: it refers to the enclosing object. That makes lambdas inside instance methods natural to write — and means they keep that object alive as long as the lambda exists.

Compared with Groovy

Groovy closures can reassign captured variables directly (total += amount inside each works). If you work in both languages — as in Boomi scripting — it’s worth knowing the difference; see Groovy closures.

The full example

// Closures.java — lambdas capturing variables: effectively final, state and pitfalls.
import java.util.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.*;

public class Closures {

    // A factory that returns a function "remembering" its argument
    static Function<Double, Double> discountOf(double percent) {
        return price -> price - price * percent / 100;    // captures 'percent'
    }

    // A counter whose state lives inside the closure
    static Supplier<Integer> counter() {
        AtomicInteger count = new AtomicInteger();          // the reference is effectively final
        return count::incrementAndGet;
    }

    public static void main(String[] args) {
        // 1. Capturing a local variable
        String customer = "Acme";                           // effectively final: never reassigned
        Runnable greet = () -> System.out.println("Hello " + customer);
        greet.run();

        // customer = "Globex";   // <- uncommenting this line breaks the lambda above:
        //                        //    "local variables referenced from a lambda expression
        //                        //     must be final or effectively final"

        // 2. Functions that remember their configuration
        Function<Double, Double> tenOff = discountOf(10);
        Function<Double, Double> quarterOff = discountOf(25);
        System.out.println(tenOff.apply(200.0) + " " + quarterOff.apply(200.0));   // 180.0 150.0

        // 3. Closures with their own state
        Supplier<Integer> next = counter();
        Supplier<Integer> other = counter();
        System.out.println(next.get() + " " + next.get() + " " + other.get());     // 1 2 1

        // 4. Counting inside a lambda: you can't do count++ on a local int...
        List<String> lines = List.of("ok", "error", "ok", "error", "error");
        // int errors = 0; lines.forEach(l -> { if (l.equals("error")) errors++; });  // won't compile
        AtomicInteger errors = new AtomicInteger();
        lines.forEach(l -> { if (l.equals("error")) errors.incrementAndGet(); });
        System.out.println("Errors: " + errors.get());                               // 3
        // ...but a stream says it more clearly:
        System.out.println("Errors: " + lines.stream().filter("error"::equals).count());

        // 5. The loop-variable trap (and why Java forbids it)
        List<Supplier<Integer>> suppliers = new ArrayList<>();
        for (int i = 0; i < 3; i++) {
            int copy = i;                                    // a fresh, effectively final copy each time
            suppliers.add(() -> copy * 10);
        }
        suppliers.forEach(s -> System.out.print(s.get() + " "));                     // 0 10 20
        System.out.println();

        // 6. Capturing 'this' in an instance context
        new Closures().run();
    }

    private final String name = "Closures demo";

    void run() {
        Runnable r = () -> System.out.println("Inside " + this.name);   // lambdas share 'this'
        r.run();
    }
}

Try it yourself

Write a method Supplier<String> idGenerator(String prefix) that returns a supplier producing PO-1, PO-2, PO-3… for idGenerator("PO-"). Two generators must count independently.

Show a solution
static Supplier<String> idGenerator(String prefix) {
    AtomicInteger seq = new AtomicInteger();
    return () -> prefix + seq.incrementAndGet();
}

Supplier<String> po = idGenerator("PO-");
Supplier<String> inv = idGenerator("INV-");
System.out.println(po.get() + " " + po.get() + " " + inv.get());   // PO-1 PO-2 INV-1

Both prefix and seq are captured; seq‘s reference never changes, only its value.

Key takeaways

  • A closure is a function plus the variables it captured.
  • Captured local variables must be effectively final; fields are not restricted.
  • Function factories capture configuration; mutable holders (like AtomicInteger) carry state.
  • If the rule gets in your way, a stream or a returned value is usually the cleaner design.

Example files