Contents
- What Is a Lambda Expression
- Lambda Syntax Variations
- Functional Interfaces Primer
- Variable Capture & Effectively Final
- this Inside Lambdas
- Method References — the 4 Kinds
- Lambda vs Anonymous Inner Class
- Practical Patterns
- Exceptions in Lambdas
- Best Practices & Pitfalls
A lambda expression is a compact, anonymous function — a block of code you can pass around and invoke later. Before Java 8, the only way to pass behavior to a method was an anonymous inner class: a full class declaration, a method override, and plenty of ceremony around a single line of logic. Lambdas collapse that ceremony into a (params) -> body expression and let Java finally speak the language of functional programming. Crucially, a lambda is not a closure object by itself — it is always an implementation of some functional interface (an interface with exactly one abstract method). The compiler figures out which interface through a mechanism called target typing: the expected type at the assignment or argument position tells the compiler which interface the lambda implements, and therefore what its parameter and return types must be.
Lambdas exist for three reasons. First, they make collection processing readable — the Stream API is unusable without them. Second, they enable first-class behavior parameterization: sorting comparators, event callbacks, and retry policies become values you can store, compose, and pass around. Third, they close a long-standing gap between Java and languages like Scala, Kotlin, and JavaScript where functions are values. The side-by-side below shows the same "button click" handler written both ways — same behavior, dramatically different signal-to-noise ratio.
// Pre-Java 8 — anonymous inner class
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
System.out.println("Clicked: " + e.getActionCommand());
}
});
// Java 8+ — lambda expression
button.addActionListener(e -> System.out.println("Clicked: " + e.getActionCommand()));
// Target typing — the same lambda takes on different types
// depending on the context it appears in
Runnable r = () -> System.out.println("run"); // Runnable
Callable<String> c = () -> "hello"; // Callable<String>
Comparator<String> byLen = (a, b) -> a.length() - b.length(); // Comparator<String>
// A lambda is NOT a standalone type — the line below does not compile
// var x = () -> 42; // error: cannot infer type
| Aspect | Anonymous Inner Class | Lambda Expression |
| Lines of boilerplate | 5–6 per callback | 1 |
| New class file? | Yes (Outer$1.class) | No (invokedynamic) |
| Meaning of this | The anonymous instance | The enclosing instance |
| Can target any SAM type | Yes (explicit) | Yes (via target typing) |
| Readability for one-liners | Poor | Excellent |
A lambda's type is never written in the lambda itself — it is inferred from the surrounding context. This is why the same () -> 42 can be a Supplier<Integer>, an IntSupplier, or a Callable<Integer> depending on where it appears.
Lambda syntax has several shorthands that make common cases terse while keeping complex cases expressive. The general form is (parameters) -> body, but almost every part can be abbreviated or expanded. Zero parameters require empty parentheses; a single parameter can drop its parentheses entirely; multiple parameters must be parenthesized. Parameter types are optional — the compiler infers them from the target functional interface — but if you write one type you must write all of them. The body can be a single expression (whose value becomes the return value implicitly) or a braced block with an explicit return statement. Knowing when to use each form is a small but important stylistic skill.
import java.util.function.*;
// Zero parameters — parentheses required
Runnable greet = () -> System.out.println("hi");
Supplier<Double> random = () -> Math.random();
// Single parameter — parentheses optional, most idiomatic without
Function<String, Integer> len = s -> s.length();
Predicate<Integer> positive = n -> n > 0;
Consumer<String> print = msg -> System.out.println(msg);
// Multiple parameters — parentheses required
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
Comparator<String> byLen = (a, b) -> Integer.compare(a.length(), b.length());
// Typed parameters — explicit, verbose, sometimes needed for disambiguation
BiFunction<Integer, Integer, Integer> mul = (int a, int b) -> a * b;
// MIXED types are NOT allowed — all or nothing:
// (int a, b) -> a + b // compile error
// Single-expression body — implicit return
Function<Integer, Integer> square = x -> x * x;
// Block body — explicit return required
Function<Integer, String> classify = n -> {
if (n < 0) return "negative";
if (n == 0) return "zero";
return "positive";
};
// Void body — no return needed
Consumer<String> log = msg -> {
long ts = System.currentTimeMillis();
System.out.println("[" + ts + "] " + msg);
};
// The var keyword in lambda parameters (Java 11+) — useful for annotations
BiFunction<String, String, String> concat = (var a, var b) -> a + b;
Prefer the shortest readable form. s -> s.length() is better than (String s) -> { return s.length(); }. But reach for the block form the moment you need more than one statement — do not try to cram conditional logic into a ternary just to keep a single-expression body.
Every lambda is an instance of some functional interface — an interface with exactly one abstract method (sometimes called a SAM type, for "Single Abstract Method"). The @FunctionalInterface annotation is optional but recommended: it makes the compiler reject any change that adds a second abstract method, protecting the interface's contract. An interface can still have default and static methods without losing its functional-interface status — only abstract methods count. Methods inherited from Object (equals, hashCode, toString) also do not count.
Java ships dozens of ready-made functional interfaces in java.util.function so you rarely need to write your own. The four core shapes are Function<T, R> (transform a value), Predicate<T> (test a condition), Consumer<T> (side effect with no return), and Supplier<T> (produce a value from nothing). For a deeper tour of all the built-in types, primitive specializations, and composition methods like andThen, compose, and, and or, see the dedicated Functional Interfaces article.
import java.util.function.*;
// Custom functional interface — one abstract method
@FunctionalInterface
interface Transformer<T> {
T transform(T input);
// default methods don't count against the SAM rule
default Transformer<T> andThen(Transformer<T> next) {
return t -> next.transform(transform(t));
}
}
Transformer<String> upper = s -> s.toUpperCase();
Transformer<String> exclaim = s -> s + "!";
System.out.println(upper.andThen(exclaim).transform("hi")); // HI!
// Built-in interfaces — use these before writing your own
Function<String, Integer> strLen = String::length;
Predicate<Integer> isEven = n -> n % 2 == 0;
Consumer<String> printer = System.out::println;
Supplier<java.util.List<String>> newList = java.util.ArrayList::new;
BiFunction<Integer, Integer, Integer> sum = Integer::sum;
UnaryOperator<String> trim = String::trim;
// @FunctionalInterface enforces the single-abstract-method rule
// Uncommenting the second method below would fail to compile:
//
// @FunctionalInterface
// interface Bad { void a(); void b(); }
// // error: Bad is not a functional interface
// // multiple non-overriding abstract methods
A lambda can refer to local variables from its enclosing scope — this is called variable capture, and it is what makes lambdas true closures. However, Java imposes a strict rule: any local variable captured by a lambda must be effectively final, meaning it is either declared final or is never reassigned after initialization. If you try to modify a captured local variable — from inside the lambda or elsewhere — the compiler rejects it. Instance fields and static fields are not subject to this rule; the lambda captures the enclosing this reference (or the class itself) and reads or writes the field through that reference at invocation time.
Why this restriction? Two reasons. First, closure semantics: in Java, captured locals are read by value, not by reference. The lambda receives a snapshot of the variable when it is created, and that snapshot must stay consistent. Second, the Java Memory Model: lambdas can execute on different threads, and allowing mutable captured locals would require synchronization or create subtle races. Making captured locals effectively final side-steps both problems entirely. The classic gotcha is the "loop variable capture" trap — creating a list of lambdas inside a loop that all reference the same index. Declare a fresh local inside the loop body and the problem disappears.
import java.util.*;
import java.util.function.*;
// Legal — x is effectively final
int x = 10;
Supplier<Integer> s1 = () -> x + 1;
System.out.println(s1.get()); // 11
// Illegal — reassigning x after capture breaks "effectively final"
// int y = 10;
// Supplier<Integer> s2 = () -> y + 1;
// y = 20; // compile error: local variables referenced from a lambda
// // must be final or effectively final
// Instance fields have NO such restriction — captured through this
class Counter {
int count = 0;
Runnable increment = () -> count++; // legal — mutates a field
}
// The classic loop-variable gotcha — fixed with a fresh local
List<Supplier<Integer>> suppliers = new ArrayList<>();
for (int i = 0; i < 3; i++) {
int captured = i; // fresh local per iteration
suppliers.add(() -> captured); // each lambda sees its own copy
}
suppliers.forEach(sup -> System.out.print(sup.get() + " ")); // 0 1 2
// Workaround for "mutable" state — use a mutable holder (e.g., AtomicInteger)
java.util.concurrent.atomic.AtomicInteger total = new java.util.concurrent.atomic.AtomicInteger();
List.of(1, 2, 3, 4).forEach(n -> total.addAndGet(n));
System.out.println(total.get()); // 10
The "effectively final" rule applies to the reference, not the object. A captured List reference cannot be reassigned, but you can still call list.add(x) on it from inside the lambda. This is technically legal but often a bad idea in stream pipelines — mutating shared state from a lambda defeats the purpose of functional style and breaks parallel streams.
Inside a lambda, the keyword this refers to the enclosing class instance — not to the lambda itself. This is one of the most important differences between lambdas and anonymous inner classes. In an anonymous inner class, this refers to the anonymous instance, and accessing the outer instance requires the awkward Outer.this syntax. A lambda has no such problem: there is no separate lambda object to bind this to, so this inside the lambda body means exactly what it means in the surrounding method. You can call enclosing methods, read fields, and pass this to other methods without any ceremony.
This design makes lambdas significantly less error-prone for callbacks that need to mutate enclosing state. You never accidentally shadow this, never reach for Outer.this, and never wonder which class you are actually inside. The trade-off is that lambdas cannot refer to "themselves" recursively through this — if you need a self-referential function, assign it to a field or use Function composition instead.
public class Widget {
private String name = "WidgetA";
private int clicks = 0;
public void wireUp() {
// Lambda — `this` is the Widget instance
Runnable onClick = () -> {
clicks++; // accesses Widget.clicks
System.out.println(name + ": " + clicks); // accesses Widget.name
report(this); // passes the Widget
};
onClick.run();
// Anonymous inner class — `this` is the Runnable instance
Runnable legacy = new Runnable() {
@Override
public void run() {
clicks++;
// this.clicks -- would fail: Runnable has no field clicks
System.out.println(Widget.this.name + ": " + Widget.this.clicks);
// Had to use Widget.this to reach the outer instance
}
};
legacy.run();
}
private void report(Widget w) {
System.out.println("reporting " + w.name);
}
public static void main(String[] args) {
new Widget().wireUp();
}
}
Because this inside a lambda is the enclosing instance, a lambda created inside an instance method will keep the enclosing object alive for as long as the lambda is reachable. If you stash a lambda in a long-lived collection (a static cache, a framework listener list) it can silently prevent its enclosing object from being garbage collected. Prefer static context lambdas when you do not need the enclosing state.
When a lambda does nothing but call a single existing method, Java lets you replace it with a method reference — the :: syntax. Method references are pure sugar: the compiler turns them into a lambda that delegates to the named method, and the resulting bytecode is equivalent. But they are often clearer to read and eliminate the noise of naming lambda parameters that only exist to forward straight through. There are exactly four kinds, and the compiler picks the right one based on the target functional interface and the shape of the method you name.
The four kinds are: (1) static method reference ClassName::staticMethod, which calls a static method with the lambda's arguments; (2) bound instance method reference instance::method, which calls a method on a specific object captured at the point the reference is created; (3) unbound instance method reference ClassName::instanceMethod, where the first lambda parameter becomes the receiver and the rest become arguments; and (4) constructor reference ClassName::new, which calls a constructor with the lambda's arguments. The tricky one is usually #3 — recognizing that String::length means s -> s.length(), not a call on some hidden String instance.
import java.util.*;
import java.util.function.*;
// ------------------------------------------------------------------
// 1. STATIC method reference — ClassName::staticMethod
// ------------------------------------------------------------------
// Lambda form:
Function<Integer, String> toStrLambda = i -> Integer.toString(i);
// Method reference:
Function<Integer, String> toStrRef = Integer::toString;
// Another static reference — Math.max
BiFunction<Integer, Integer, Integer> maxLambda = (a, b) -> Math.max(a, b);
BiFunction<Integer, Integer, Integer> maxRef = Math::max;
// ------------------------------------------------------------------
// 2. BOUND instance method reference — instance::method
// ------------------------------------------------------------------
// The receiver is captured when the reference is created
String prefix = "Hello, ";
Function<String, String> concatLambda = name -> prefix.concat(name);
Function<String, String> concatRef = prefix::concat; // prefix is bound
// Another bound reference — System.out is captured
Consumer<String> printLambda = s -> System.out.println(s);
Consumer<String> printRef = System.out::println;
// ------------------------------------------------------------------
// 3. UNBOUND instance method reference — ClassName::instanceMethod
// ------------------------------------------------------------------
// The FIRST lambda parameter becomes the receiver
// Lambda form:
Function<String, Integer> lenLambda = s -> s.length();
// Method reference — "call length on the String I'm given":
Function<String, Integer> lenRef = String::length;
// With two-argument methods, the first param is the receiver and
// the rest become method arguments
BiFunction<String, String, Boolean> startsWithLambda = (s, prefix2) -> s.startsWith(prefix2);
BiFunction<String, String, Boolean> startsWithRef = String::startsWith;
// ------------------------------------------------------------------
// 4. CONSTRUCTOR reference — ClassName::new
// ------------------------------------------------------------------
// Lambda form:
Supplier<List<String>> newListLambda = () -> new ArrayList<>();
Supplier<List<String>> newListRef = ArrayList::new;
// Constructor with an argument
Function<Integer, List<String>> sizedListRef = ArrayList::new; // calls ArrayList(int)
// Array constructor reference
Function<Integer, String[]> arrayRef = String[]::new;
String[] buf = arrayRef.apply(5); // new String[5]
// ------------------------------------------------------------------
// Putting it together — sort with an unbound method reference
// ------------------------------------------------------------------
List<String> words = new ArrayList<>(List.of("banana", "apple", "cherry"));
words.sort(String::compareToIgnoreCase); // unbound: first arg is receiver, second is argument
System.out.println(words); // [apple, banana, cherry]
When choosing between a lambda and a method reference, pick whichever is clearer. people.stream().map(Person::getName) reads as "extract the name of each person" — much cleaner than .map(p -> p.getName()). But a lambda wins when you need any additional logic at all: p -> p.getName().toUpperCase() cannot become a method reference without extracting a helper method.
Lambdas and anonymous inner classes can both implement a functional interface, but they are very different under the hood. An anonymous inner class is a real class — the compiler emits a .class file for it (Outer$1.class, Outer$2.class, etc.), allocates an instance on each invocation, and uses ordinary virtual dispatch. A lambda uses the JVM's invokedynamic instruction: the first time the lambda is reached, the runtime builds an implementation, caches it, and reuses the same object on subsequent calls when the lambda does not capture any state. This means stateless lambdas are typically allocated only once in the lifetime of the program, while anonymous inner classes allocate a fresh instance every time control reaches the new expression.
| Aspect | Anonymous Inner Class | Lambda Expression |
| Verbosity | High — full class declaration | Low — expression only |
| Meaning of this | The anonymous instance | The enclosing instance |
| Bytecode | New .class file generated | invokedynamic + synthetic method |
| Instance allocation | Fresh instance on every call | Cached when non-capturing |
| Can implement multiple methods | Yes (non-functional interfaces too) | No — only SAM types |
| Can have instance fields | Yes | No |
| Constructor body | Instance initializer block allowed | Not applicable |
| Serialization | Standard (implements Serializable) | Only if target type is Serializable; brittle |
| Stack traces | Outer$1.method | Outer.lambda$N$M |
| Shadowing outer variables | Allowed | Not allowed — compile error |
There are still a handful of situations where an anonymous inner class is the right tool. Use one when you need to implement an interface with more than one abstract method, when you need instance fields to hold per-callback state, when you need a constructor or an instance initializer, or when you explicitly want a fresh instance every time. For everything else — and especially for stream pipelines and event handlers — lambdas are shorter, faster on the hot path, and easier to read.
import java.util.*;
// Lambda form — concise, invokedynamic-backed, stateless
Comparator<String> byLenLambda = (a, b) -> a.length() - b.length();
// Anonymous inner class form — verbose, new class file, new instance
Comparator<String> byLenAnon = new Comparator<String>() {
@Override
public int compare(String a, String b) {
return a.length() - b.length();
}
};
// When anonymous classes still shine — needs mutable per-instance state
Runnable statefulCounter = new Runnable() {
private int count = 0; // field — impossible in a lambda
@Override
public void run() {
System.out.println("tick " + (++count));
}
};
statefulCounter.run(); // tick 1
statefulCounter.run(); // tick 2
// Multi-method interface — must use an anonymous (or named) class
java.awt.event.WindowListener listener = new java.awt.event.WindowAdapter() {
@Override public void windowOpened(java.awt.event.WindowEvent e) { /* ... */ }
@Override public void windowClosed(java.awt.event.WindowEvent e) { /* ... */ }
};
Lambdas show up in dozens of everyday Java APIs. Most of the power of the Stream API comes from accepting lambdas for filtering, mapping, and reducing. Outside streams, Comparator.comparing produces sort keys, Map.computeIfAbsent builds lazy caches, Collection.removeIf replaces verbose iterator loops, and one-line threads take a Runnable lambda. These patterns show up in almost every Java codebase — knowing them by heart is the difference between writing "Java with lambdas" and writing idiomatic modern Java.
import java.util.*;
import java.util.concurrent.*;
import java.util.function.*;
class Employee {
String name; String dept; int salary;
Employee(String n, String d, int s) { name = n; dept = d; salary = s; }
String getName() { return name; }
String getDept() { return dept; }
int getSalary() { return salary; }
}
// --- Sorting with Comparator.comparing + thenComparing ---------------
List<Employee> employees = new ArrayList<>(List.of(
new Employee("Alice", "Eng", 90_000),
new Employee("Bob", "Eng", 90_000),
new Employee("Carol", "Ops", 80_000)));
employees.sort(
Comparator.comparing(Employee::getDept)
.thenComparing(Employee::getName)
.thenComparingInt(Employee::getSalary));
// Reverse order
employees.sort(Comparator.comparingInt(Employee::getSalary).reversed());
// --- Event handlers --------------------------------------------------
// button.addActionListener(e -> controller.handleClick(e));
// textField.addKeyListener(e -> { if (e.getKeyCode() == KeyEvent.VK_ENTER) submit(); });
// --- One-line thread -------------------------------------------------
Thread worker = new Thread(() -> {
System.out.println("working on " + Thread.currentThread().getName());
});
worker.start();
// --- Map.forEach ------------------------------------------------------
Map<String, Integer> scores = Map.of("alice", 90, "bob", 75);
scores.forEach((k, v) -> System.out.println(k + " = " + v));
// --- Collection.removeIf ---------------------------------------------
List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6));
numbers.removeIf(n -> n % 2 == 0);
System.out.println(numbers); // [1, 3, 5]
// --- Map.computeIfAbsent — lazy cache --------------------------------
Map<String, List<String>> index = new HashMap<>();
for (String word : List.of("apple", "ant", "banana", "berry", "cherry")) {
index.computeIfAbsent(word.substring(0, 1), k -> new ArrayList<>()).add(word);
}
// { a=[apple, ant], b=[banana, berry], c=[cherry] }
// --- ExecutorService.submit with a lambda ---------------------------
ExecutorService exec = Executors.newSingleThreadExecutor();
Future<Integer> future = exec.submit(() -> 2 + 2);
exec.shutdown();
// --- Map.merge for running totals -----------------------------------
Map<String, Integer> totals = new HashMap<>();
for (String dept : List.of("Eng", "Ops", "Eng", "Eng", "Ops")) {
totals.merge(dept, 1, Integer::sum);
}
// { Eng=3, Ops=2 }
Checked exceptions and lambdas have an uncomfortable relationship. A lambda can only throw the checked exceptions declared by the functional interface's abstract method, and most built-in interfaces (Function, Predicate, Consumer, Supplier) declare none. That means calling Files.readString(path) or URI.create directly inside a Stream.map call will not compile — the checked IOException has nowhere to go. You have three reasonable options: catch the exception inside the lambda and wrap it in a RuntimeException, write a small utility that does the wrap for you, or declare your own functional interface that throws the checked exception you care about. All three are legitimate — pick based on whether the exception should be recoverable or is genuinely a programmer error.
import java.io.*;
import java.nio.file.*;
import java.util.*;
import java.util.function.*;
import java.util.stream.*;
// --- Option 1: try/catch inside the lambda, wrap as unchecked --------
List<Path> paths = List.of(Paths.get("a.txt"), Paths.get("b.txt"));
List<String> contentsA = paths.stream()
.map(p -> {
try {
return Files.readString(p);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
})
.collect(Collectors.toList());
// --- Option 2: a utility wrapper that hides the boilerplate ---------
@FunctionalInterface
interface CheckedFunction<T, R> {
R apply(T t) throws Exception;
}
static <T, R> Function<T, R> unchecked(CheckedFunction<T, R> fn) {
return t -> {
try {
return fn.apply(t);
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException(e);
}
};
}
// Now the pipeline stays clean
List<String> contentsB = paths.stream()
.map(unchecked(Files::readString))
.collect(Collectors.toList());
// --- Option 3: a custom functional interface that declares throws ---
@FunctionalInterface
interface IOSupplier<T> {
T get() throws IOException;
}
IOSupplier<String> loader = () -> Files.readString(Paths.get("config.json"));
// The caller of loader.get() must handle IOException normally.
Avoid the "sneaky throw" trick — using type erasure to throw a checked exception as if it were unchecked. It confuses the compiler, defeats throws declarations, and lands your team in a maintenance nightmare. If you want unchecked propagation, wrap in a RuntimeException; if you want checked, declare your own interface that throws it.
Lambdas reward restraint. A lambda that fits on one line reads like English; a lambda with ten lines of conditional logic and three helper variables reads like regret. The single strongest guideline is keep lambdas short — two or three lines at most. Anything longer should become a named method that you can reference with a method reference. Named methods show up in stack traces with real names, can be tested in isolation, and force you to pick a descriptive verb for what the code does.
Beyond brevity, avoid side effects inside stream lambdas — mutating an external list or counter from inside map or filter breaks the functional contract and silently corrupts parallel streams. Prefer method references when the lambda is pure forwarding. Do not shadow variables — Java will reject it, and the error messages are confusing. And when debugging, remember that lambdas appear in stack traces as synthetic method names like Outer.lambda$method$0; set a breakpoint on the first line of the body and the debugger will stop just fine, but the call stack above it will skip through the invokedynamic machinery.
import java.util.*;
import java.util.concurrent.atomic.*;
import java.util.stream.*;
// --- Keep lambdas short — extract longer logic to a named method ----
// Too long — hard to read inside a stream
// list.stream().map(x -> {
// if (x == null) return "null";
// String s = x.trim().toLowerCase();
// if (s.isEmpty()) return "empty";
// return s.substring(0, Math.min(3, s.length()));
// }).toList();
// Better — extract to a method and reference it
static String normalize(String x) {
if (x == null) return "null";
String s = x.trim().toLowerCase();
if (s.isEmpty()) return "empty";
return s.substring(0, Math.min(3, s.length()));
}
// usage: list.stream().map(Main::normalize).toList();
// --- Don't have side effects in stream lambdas ----------------------
List<Integer> nums = List.of(1, 2, 3, 4);
// BAD — mutating external state from inside map (breaks on parallel)
AtomicInteger bad = new AtomicInteger();
nums.stream().map(n -> { bad.addAndGet(n); return n; }).forEach(x -> {});
// GOOD — reduce over the stream
int good = nums.stream().mapToInt(Integer::intValue).sum();
// --- Prefer method references where they're clearer ----------------
// list.stream().map(s -> s.toUpperCase()) ...
// BETTER:
// list.stream().map(String::toUpperCase) ...
// --- Debugging tip: Lambdas in stack traces -------------------------
// Caused by: java.lang.RuntimeException: boom
// at com.example.Main.lambda$process$0(Main.java:42)
// at java.base/java.util.stream.ReferencePipeline$3$1.accept(...)
// The `lambda$process$0` means "the 0th lambda inside method `process`".
| Style | Rule of Thumb |
| Length | Keep lambda bodies to 1–3 lines; extract longer logic to a named method. |
| Method references | Use them whenever the lambda does nothing but forward arguments to one method. |
| Side effects | Never mutate external state from inside map, filter, or sorted lambdas. |
| Variable names | Single letters are fine in tiny lambdas (s, n), but use real names in block-body lambdas. |
| Checked exceptions | Either catch and rewrap, or use a custom @FunctionalInterface that declares throws. |
| Parallelism | Lambdas used in parallel streams must be stateless, non-interfering, and idempotent. |
| Readability | If a reader has to pause to understand the lambda, it is too clever — rewrite it. |
Performance note: non-capturing lambdas (those that do not reference enclosing locals or this) are cached by invokedynamic and allocate once per JVM lifetime. Capturing lambdas allocate a small object on each invocation but are still cheaper than anonymous inner classes in almost every realistic scenario. Don't prematurely optimize lambdas — write them for readability and let the JIT do its job.