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:
- The target type is a functional interface (exactly one abstract method)
- The lambda’s parameter types match the abstract method’s parameter types
- 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
| Concept | Key point |
|---|---|
| Syntax | (params) -> body or (params) -> { statements; return val; } |
| Target typing | Lambda type is inferred from the functional interface at the use site |
| Effectively final | Captured local variables cannot be reassigned |
| Closures | Lambdas capture the value at lambda-creation time |
| Runtime | invokedynamic + lazily-generated class; stateless lambdas are singletons |
| Composition | Predicate.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 8 → Java 11 → Java 17 → Java 21