Part 3 of 16

Lambda Expressions (JEP 126): Syntax, Closures, and Target Typing

The Problem Lambdas Solve

Before Java 8, passing behaviour as a value required an anonymous inner class:

// Java 7: sort a list of strings by length
Collections.sort(names, new Comparator<String>() {
    @Override
    public int compare(String a, String b) {
        return Integer.compare(a.length(), b.length());
    }
});

// Run in a new thread
new Thread(new Runnable() {
    @Override
    public void run() {
        System.out.println("Hello from thread");
    }
}).start();

This works, but the boilerplate-to-intent ratio is terrible. The programmer intends to pass two lines of logic. The Java 7 syntax forces five extra lines of scaffolding for each. In large codebases this noise drowns out the actual logic.

Lambda expressions fix this. The same code in Java 8:

names.sort((a, b) -> Integer.compare(a.length(), b.length()));

new Thread(() -> System.out.println("Hello from thread")).start();

Lambda Syntax

A lambda expression has three parts:

(parameters) -> body

No Parameters

Runnable r = () -> System.out.println("Hello");

One Parameter

Parentheses are optional for a single parameter with an inferred type:

Consumer<String> print = s -> System.out.println(s);
// equivalent
Consumer<String> print2 = (s) -> System.out.println(s);
// with explicit type
Consumer<String> print3 = (String s) -> System.out.println(s);

Multiple Parameters

Comparator<String> byLength = (a, b) -> Integer.compare(a.length(), b.length());

Block Body

When the body needs multiple statements, use curly braces and an explicit return:

Comparator<String> complex = (a, b) -> {
    int lenDiff = Integer.compare(a.length(), b.length());
    if (lenDiff != 0) return lenDiff;
    return a.compareTo(b);
};

Expression Body

When the body is a single expression, the result is implicitly returned — no return keyword:

Function<Integer, Integer> square = n -> n * n;

Target Typing

A lambda expression has no type of its own. Its type is inferred from context — specifically from the target type the compiler expects at that position.

// Target type is Runnable — lambda must match void run()
Runnable r = () -> System.out.println("Hi");

// Target type is Callable<String> — lambda must match String call()
Callable<String> c = () -> "Hello";

// Target type is Comparator<String> — lambda must match int compare(String, String)
Comparator<String> cmp = (a, b) -> a.compareTo(b);

The compiler checks that:

  1. The target type is a functional interface (exactly one abstract method)
  2. The lambda’s parameter types match the abstract method’s parameter types
  3. The lambda’s return type is compatible with the method’s return type

This means the same lambda body can satisfy different functional interfaces as long as the signatures are compatible:

// Both have signature: () -> void — same lambda body, different target types
Runnable runnable = () -> System.out.println("x");
Executor executor = cmd -> cmd.run();  // different, but same body works for both

Effectively Final Variables

Lambdas can capture (read) local variables from the enclosing scope, but those variables must be effectively final — meaning they are never reassigned after their first assignment.

String prefix = "Hello, ";

// OK: prefix is effectively final — never reassigned
Consumer<String> greet = name -> System.out.println(prefix + name);

prefix = "Hi, ";  // COMPILE ERROR: variable used in lambda must be effectively final

Why this restriction? Lambda expressions can outlive the stack frame that created them (e.g., passed to another thread). If local variables could be mutated, the lambda would be reading a variable from a stack frame that no longer exists. The effectively-final rule ensures the lambda captures a snapshot of the value, which is safe.

Instance and static fields do not have this restriction — they live in the heap, not the stack:

public class Counter {
    private int count = 0;  // instance field — heap allocated

    public Runnable makeIncrementer() {
        return () -> count++;  // OK: capturing instance field
    }
}

Closures vs. Lambdas

A closure is a function that captures variables from its enclosing scope. Java lambdas are closures — they capture the value of effectively-final local variables at the time the lambda is created.

List<Runnable> tasks = new ArrayList<>();
for (int i = 0; i < 5; i++) {
    final int taskId = i;  // must copy to effectively-final variable
    tasks.add(() -> System.out.println("Task " + taskId));
}
tasks.forEach(Runnable::run);
// Prints: Task 0, Task 1, Task 2, Task 3, Task 4

If you try to capture i directly (which is reassigned on each iteration), you get a compile error. Creating a copy (taskId) that’s only assigned once fixes it.


How Lambda Expressions Work at Runtime

Understanding the runtime model helps you reason about performance and identity.

invokedynamic

Java 7 introduced the invokedynamic bytecode instruction for dynamic language support. Java 8 reuses it for lambda expressions. When the JVM first encounters a lambda, it calls a bootstrap method that generates a class implementing the target functional interface. On subsequent calls, the same class (and often the same instance) is reused.

This is more efficient than anonymous inner classes because:

  • The class is generated lazily (only when the lambda site is first hit)
  • Stateless lambdas (no captured variables) are typically represented as a singleton
  • The JVM can apply additional optimisations that weren’t possible with anonymous classes

Object Identity

Because of the above, do not rely on lambda identity:

Runnable a = () -> System.out.println("x");
Runnable b = () -> System.out.println("x");

// This may be true OR false — undefined behaviour
System.out.println(a == b);

// Never use lambdas as map keys or in sets that rely on identity

Performance

For hot code paths, lambda expressions have essentially zero overhead compared to direct method calls after JIT compilation. The invokedynamic bootstrap cost is paid once per call site.


Lambda Composition

Functional interfaces in java.util.function provide default composition methods.

Predicate Composition

Predicate<String> nonEmpty = s -> !s.isEmpty();
Predicate<String> longEnough = s -> s.length() > 5;

// Compose: AND
Predicate<String> valid = nonEmpty.and(longEnough);

// Compose: OR
Predicate<String> acceptable = nonEmpty.or(longEnough);

// Negate
Predicate<String> empty = nonEmpty.negate();

Function Composition

Function<Integer, Integer> doubleIt = x -> x * 2;
Function<Integer, Integer> addThree = x -> x + 3;

// andThen: doubleIt first, then addThree
Function<Integer, Integer> doubleThenAdd = doubleIt.andThen(addThree);
// doubleThenAdd.apply(5) == 13

// compose: addThree first, then doubleIt
Function<Integer, Integer> addThenDouble = doubleIt.compose(addThree);
// addThenDouble.apply(5) == 16

Comparator Composition

Comparator received major enhancements in Java 8:

List<Person> people = ...;

// Sort by last name, then first name, then age descending
Comparator<Person> order = Comparator
    .comparing(Person::getLastName)
    .thenComparing(Person::getFirstName)
    .thenComparingInt(Person::getAge).reversed();

people.sort(order);

Comparator.comparing takes a key extractor function, which is typically a method reference.


Common Patterns

Replace Runnable

// Before
executor.submit(new Runnable() { public void run() { doWork(); } });

// After
executor.submit(() -> doWork());

Replace Callable

// Before
Future<Result> f = executor.submit(new Callable<Result>() {
    public Result call() throws Exception { return compute(); }
});

// After
Future<Result> f = executor.submit(() -> compute());

Replace Comparator

// Before
Collections.sort(items, new Comparator<Item>() {
    public int compare(Item a, Item b) { return a.getName().compareTo(b.getName()); }
});

// After
items.sort(Comparator.comparing(Item::getName));

Replace Strategy Pattern

// Interface
interface TaxCalculator {
    double calculate(double amount);
}

// Java 7: create an anonymous class per strategy
TaxCalculator uk = new TaxCalculator() {
    public double calculate(double amount) { return amount * 0.20; }
};

// Java 8: lambda directly
TaxCalculator uk = amount -> amount * 0.20;
TaxCalculator us = amount -> amount * 0.08;

Pitfalls

Capturing Mutable State

// WRONG: list is mutated inside the lambda — side-effectful, hard to reason about
List<String> results = new ArrayList<>();
names.stream().filter(s -> s.length() > 3).forEach(s -> results.add(s));

// RIGHT: use collect
List<String> results = names.stream()
    .filter(s -> s.length() > 3)
    .collect(Collectors.toList());

Checked Exceptions

Functional interfaces in java.util.function don’t declare checked exceptions. Calling a method that throws a checked exception inside a lambda causes a compile error:

// COMPILE ERROR: Files.readAllBytes throws IOException
List<byte[]> bytes = paths.stream()
    .map(p -> Files.readAllBytes(p))  // IOException not handled
    .collect(Collectors.toList());

// Fix option 1: wrap in unchecked
List<byte[]> bytes = paths.stream()
    .map(p -> {
        try { return Files.readAllBytes(p); }
        catch (IOException e) { throw new UncheckedIOException(e); }
    })
    .collect(Collectors.toList());

// Fix option 2: extract to a helper
private static byte[] readBytes(Path p) {
    try { return Files.readAllBytes(p); }
    catch (IOException e) { throw new UncheckedIOException(e); }
}
// Then: .map(this::readBytes)

Overloaded Methods with Lambdas

When a method is overloaded and multiple overloads accept different functional interfaces, the compiler may not be able to infer the target type. Be explicit:

// Ambiguous if execute(Runnable) and execute(Callable<Void>) both exist
execute(() -> doWork());  // COMPILE ERROR: ambiguous

// Fix: cast or use explicit type
execute((Runnable) () -> doWork());

Summary

ConceptKey point
Syntax(params) -> body or (params) -> { statements; return val; }
Target typingLambda type is inferred from the functional interface at the use site
Effectively finalCaptured local variables cannot be reassigned
ClosuresLambdas capture the value at lambda-creation time
Runtimeinvokedynamic + lazily-generated class; stateless lambdas are singletons
CompositionPredicate.and/or/negate, Function.andThen/compose, Comparator.comparing/thenComparing

Next Step

Functional Interfaces: Predicate, Function, Supplier, Consumer →

Part of the DevOps Monk Java tutorial series: Java 8Java 11Java 17Java 21