A lambda expression is a compact way to write an implementation of a single-method interface inline, without the ceremony of a named class or anonymous class:
Runnable r = () -> System.out.println("running");
Comparator<String> byLength = (a, b) -> a.length() - b.length();
Lambdas only type-check against a functional interface — an interface with exactly one abstract method (it may have any number of default/static methods, which don't count). The lambda's parameter list and body supply the implementation of that one abstract method; the compiler infers everything else from the target type context.
@FunctionalInterface
interface Calculator {
int operate(int a, int b);
}
Calculator add = (a, b) -> a + b;
add.operate(3, 4); // 7
The @FunctionalInterface annotation isn't required, but documents intent and causes a compile error if a second abstract method is later added by mistake, protecting the lambda-compatibility contract.
Compared to an equivalent anonymous class, a lambda is more concise, doesn't create an extra .class file for each usage (implemented instead via invokedynamic and method handles under the hood), and — unlike an anonymous class — does not introduce its own this (a lambda's this refers to the enclosing instance, not the lambda itself).
Lambdas underpin the entire Java 8 functional style: the java.util.function package, the Stream API, and countless JDK APIs (Comparator.comparing, list.forEach, map.computeIfAbsent) all accept functional-interface parameters designed to be filled with lambdas.
java.util.function defines a small set of general-purpose functional interfaces covering the most common "shapes" of behavior, so you rarely need to declare your own:
| Interface | Method | Signature shape | Typical use |
|---|---|---|---|
Function<T, R> | R apply(T t) | takes one, returns a (possibly different) type | transforming a value |
Predicate<T> | boolean test(T t) | takes one, returns boolean | filtering / conditions |
Supplier<T> | T get() | takes nothing, returns a value | lazy value creation / factories |
Consumer<T> | void accept(T t) | takes one, returns nothing | performing an action on a value |
BiFunction<T,U,R> | R apply(T t, U u) | two inputs, one output | combining two values |
UnaryOperator<T> | T apply(T t) | Function<T,T> specialization | transform within the same type |
BinaryOperator<T> | T apply(T a, T b) | BiFunction<T,T,T> specialization | reduction/combining, same type |
Function<String, Integer> length = String::length;
Predicate<String> isLong = s -> s.length() > 5;
Supplier<List<String>> newList = ArrayList::new;
Consumer<String> printer = System.out::println;
list.stream()
.filter(isLong) // Predicate
.map(length) // Function
.forEach(printer::accept); // Consumer
Optional.ofNullable(cachedValue)
.orElseGet(newList); // Supplier — lazily creates only if needed
Most also have default combinator methods for composing behavior without writing a new lambda from scratch — e.g., Predicate.and()/or()/negate(), Function.andThen()/compose(). There are also primitive-specialized variants (IntPredicate, ToIntFunction<T>, IntSupplier, ...) to avoid autoboxing overhead in numeric-heavy code.
Related Resources
A Stream<T> represents a (possibly lazy, possibly infinite) sequence of elements that supports a chain of functional-style operations, typically built from a collection (list.stream()), an array, or a generator. Crucially, a stream doesn't store data itself and doesn't modify its source — it's a pipeline description, evaluated on demand.
Intermediate operations (filter, map, sorted, distinct, limit, ...) return a new Stream, and are lazy — nothing actually executes until a terminal operation is invoked. This laziness lets the pipeline be optimized as a whole (e.g., fusing filter+map into a single pass per element, or short-circuiting with limit).
Terminal operations (collect, forEach, reduce, count, anyMatch, toList) trigger the actual traversal, produce a final result (or side effect), and consume the stream — you cannot reuse or chain further operations onto a stream after a terminal operation runs (attempting to reuses throws IllegalStateException).
List<String> names = List.of("Alice", "Bob", "Charlie", "Dave");
List<String> result = names.stream() // create
.filter(n -> n.length() > 3) // intermediate — lazy, not yet run
.map(String::toUpperCase) // intermediate — lazy, not yet run
.sorted() // intermediate — lazy, not yet run
.collect(Collectors.toList()); // terminal — pipeline actually executes now
Because intermediate operations are lazy, names.stream().filter(...) alone does nothing observable — no exception is even thrown for a filter predicate that would fail on some element, until a terminal operation actually pulls elements through the pipeline. This lazy, pull-based evaluation (each element flows through the entire pipeline one at a time, rather than each stage processing the whole collection before the next) is what makes operations like limit() able to short-circuit an otherwise-infinite stream.
Related Resources
Both are intermediate stream operations that transform each element, but differ in cardinality — how many output elements one input element produces:
map(Function<T,R>): a strict one-to-one transformation — each input element produces exactly one output element (of possibly a different type).
Stream<String> names = Stream.of("Alice", "Bob");
Stream<Integer> lengths = names.map(String::length); // one Integer per String
If the mapping function itself naturally returns a collection/stream per element, map() produces a stream of streams, which is rarely what you want:
Stream<List<Integer>> nested = Stream.of("Alice", "Bob").map(name -> parseDigits(name));
// Stream<List<Integer>> — awkward to work with directly
flatMap(Function<T, Stream<R>>): applies a function that returns a stream per element, then flattens all those inner streams into one single, combined output stream — a one-to-many transformation followed by merging.
List<List<Integer>> nestedLists = List.of(List.of(1,2), List.of(3,4), List.of(5));
List<Integer> flat = nestedLists.stream()
.flatMap(List::stream) // Stream<List<Integer>> -> flattened Stream<Integer>
.collect(Collectors.toList());
// [1, 2, 3, 4, 5]
A classic real-world use: given a List<Order> where each Order has a List<LineItem>, orders.stream().flatMap(o -> o.getLineItems().stream()) gives you a single flat Stream<LineItem> across all orders, ready for further filtering/aggregation — something map() alone can't produce directly.
Optional<T> is a container object that either holds a non-null value or is empty, designed to make "this might have nothing to return" explicit in the type signature — the caller can't accidentally forget a null check the way they can with a plain reference that might be null.
Optional<User> findUser(String id) {
User u = repository.lookup(id);
return Optional.ofNullable(u); // empty if u was null
}
Good usage patterns:
findUser(id)
.map(User::getEmail) // transform if present
.filter(email -> email.contains("@"))
.orElse("no-email@example.com"); // default if empty/filtered out
findUser(id).ifPresentOrElse(
user -> sendWelcome(user),
() -> logMissingUser(id)
);
User user = findUser(id).orElseThrow(() -> new UserNotFoundException(id));
Common misuses to avoid:
- Calling
.get()directly without checkingisPresent()first — this just reintroduces the same "forgot to check" riskOptionalwas meant to prevent; preferorElse/orElseGet/orElseThrow/map/ifPresentinstead. - Using
Optionalas a field type — it's notSerializableand adds an unnecessary wrapper allocation; for fields, anull(or a documented sentinel) plus normal null-handling is the JDK team's own recommendation. - Using
Optionalas a method parameter type — forces every caller to wrap a value inOptional.of(...)just to call the method; overloading or anull-accepting parameter is usually cleaner. - Wrapping every return value reflexively —
Optionalis meant specifically for "may legitimately have no result" cases (a lookup that might not find anything), not as a blanket replacement for all nullable returns.
Design intent, in one line: Optional is a return-type-only tool for communicating "no result" explicitly at API boundaries — not a general-purpose null-replacement mechanism throughout a codebase.