Engineering Notes
Home/Languages/Java/Streams and Lambdas
Languages · Java

Streams and Lambdas

A lambda expression is shorthand syntax for implementing a functional interface — any interface with exactly one abstract method. Before Java 8, the same thing required an anonymous inner class.

💻 Languages● Advanced⏱ 7 min read🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Lambdas: Anonymous Implementations of a Single-Method Interface
  • The Core Functional Interfaces
  • Method References: a Lambda That Just Calls One Existing Method
  • The Stream Pipeline: Source, Intermediate, Terminal
  • filter, map, reduce

1Lambdas: Anonymous Implementations of a Single-Method Interface

A lambda expression is shorthand syntax for implementing a functional interface — any interface with exactly one abstract method. Before Java 8, the same thing required an anonymous inner class.

java
// pre-lambda: anonymous inner class implementing Comparator<String>
java.util.Comparator<String> byLength = new java.util.Comparator<String>() {
    public int compare(String a, String b) {
        return a.length() - b.length();
    }
};

// lambda: same functional interface, far less ceremony
java.util.Comparator<String> byLengthLambda = (a, b) -> a.length() - b.length();

The compiler infers which functional interface a lambda implements from the assignment/parameter context (target typing) — a lambda has no type of its own in isolation.

2The Core Functional Interfaces

java.util.function supplies the functional interfaces almost every stream operation is built on:

java
java.util.function.Function<String, Integer> length = s -> s.length();   // T -> R
java.util.function.Predicate<String> isEmpty = s -> s.isEmpty();          // T -> boolean
java.util.function.Consumer<String> printer = s -> System.out.println(s); // T -> void
java.util.function.Supplier<String> greeting = () -> "hello";             // () -> T
java.util.function.BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b; // (T,U) -> R

3Method References: a Lambda That Just Calls One Existing Method

When a lambda's entire body is "call this one existing method," a method reference (::) says the same thing more directly:

java
java.util.List<String> names = java.util.List.of("charlie", "alice", "bob");

names.forEach(s -> System.out.println(s));   // lambda
names.forEach(System.out::println);           // equivalent method reference

names.stream().map(s -> s.toUpperCase());      // lambda calling an instance method on the parameter
names.stream().map(String::toUpperCase);        // equivalent — "unbound" instance method reference

Four forms exist: Type::staticMethod, instance::instanceMethod, Type::instanceMethod (unbound — the first stream element becomes the receiver), and Type::new (constructor reference).

4The Stream Pipeline: Source, Intermediate, Terminal

A stream pipeline always has exactly this shape: one source, zero or more intermediate operations, one terminal operation.

java
java.util.List<String> names = java.util.List.of("charlie", "alice", "bob", "dave");

java.util.List<String> result = names.stream()      // source
    .filter(s -> s.length() > 3)                      // intermediate
    .map(String::toUpperCase)                          // intermediate
    .sorted()                                            // intermediate
    .collect(java.util.stream.Collectors.toList());       // terminal

// result: ["ALICE", "CHARLIE", "DAVE"]

A Stream is not a data structure — it doesn't store elements. It's a description of a computation over a source, and nothing runs until a terminal operation is invoked.

5filter, map, reduce

java
java.util.List<Integer> nums = java.util.List.of(1, 2, 3, 4, 5, 6);

java.util.List<Integer> evenSquares = nums.stream()
    .filter(n -> n % 2 == 0)        // keep elements matching a Predicate
    .map(n -> n * n)                 // transform each element via a Function
    .collect(java.util.stream.Collectors.toList());
// [4, 16, 36]

int sum = nums.stream()
    .reduce(0, (acc, n) -> acc + n); // fold to a single value: (identity, accumulator)
// 21

reduce's first argument is the identity/seed value; the accumulator combines the running result with each element. An overload with no identity returns Optional<T>, since an empty stream has no result to produce.

6Collectors: Turning a Stream Back Into Something Concrete

java
java.util.List<String> names = java.util.List.of("alice", "bob", "charlie", "amy");

java.util.List<String> asList = names.stream().collect(java.util.stream.Collectors.toList());

String joined = names.stream().collect(java.util.stream.Collectors.joining(", "));
// "alice, bob, charlie, amy"

java.util.Map<Character, java.util.List<String>> byFirstLetter = names.stream()
    .collect(java.util.stream.Collectors.groupingBy(s -> s.charAt(0)));
// {a=[alice, amy], b=[bob], c=[charlie]}

long count = names.stream().filter(s -> s.length() > 3).count();

groupingBy is the workhorse for anything resembling a SQL GROUP BY — the classifier function (s -> s.charAt(0)) produces the map's keys, and every element mapping to the same key is collected into a List under that key by default.

7Laziness of Intermediate Operations

Intermediate operations (filter, map, sorted, ...) are lazy — building the pipeline does no work. Only calling a terminal operation (collect, count, forEach, reduce, ...) actually pulls elements through the pipeline.

java
java.util.List<String> names = java.util.List.of("alice", "bob", "charlie");

java.util.stream.Stream<String> pipeline = names.stream()
    .filter(s -> {
        System.out.println("filtering " + s); // nothing printed yet — no terminal op called
        return s.length() > 3;
    });

System.out.println("pipeline built, nothing has run yet");
long count = pipeline.count(); // NOW "filtering alice", "filtering bob", "filtering charlie" print

Laziness also enables short-circuiting: .filter(...).findFirst() can stop pulling elements the moment a match is found, without processing the rest of the source, because filter and findFirst are fused into a single per-element pass rather than run as separate full sweeps.

8Lambda Variable Capture: Effectively Final

A lambda can reference a local variable from its enclosing scope only if that variable is effectively final — never reassigned after initialization, even if not literally marked final.

java
int threshold = 3; // never reassigned anywhere below — effectively final

java.util.List<Integer> nums = java.util.List.of(1, 2, 3, 4, 5);
long countAbove = nums.stream().filter(n -> n > threshold).count(); // legal: threshold is effectively final

int total = 0;
nums.forEach(n -> {
    // total += n; // COMPILE ERROR: total is reassigned elsewhere, not effectively final
});
Under the Hoodinvokedynamic, Not Anonymous Classes▾

Lambdas are not compiled to anonymous inner classes, despite behaving similarly at the source level — this was a deliberate design choice for Java 8. Each lambda body is compiled into a private synthetic method on the enclosing class, and the call site that "creates" the lambda is compiled to an invokedynamic bytecode instruction rather than a new AnonymousClass() allocation.

At first use of a given lambda call site, invokedynamic invokes a bootstrap method (LambdaMetafactory, part of the JDK) which generates the actual implementing class at runtime, using low-level MethodHandles pointing at the synthetic method holding the lambda body, then caches that generated class for all future calls to the same call site. This means: no .class file exists on disk per lambda (unlike an anonymous inner class, which does produce one), lambda class generation is deferred until the JIT/runtime actually needs it, and two syntactically identical lambdas at two different source locations become two independent generated classes, because each invokedynamic call site gets its own bootstrap invocation and its own cached generated class — they are never deduplicated into one.

This is also why capturing a variable in a lambda differs mechanically from an anonymous class capturing one: an anonymous class captures by copying the value into a final field of the generated class at construction time; a lambda's captured variables are instead passed as extra arguments to the generated implementation method by the LambdaMetafactory-generated glue code — functionally equivalent from a language standpoint (still requires effective finality, since the value is captured once, not re-read live), but a different mechanism than the desugaring anonymous classes use.

A stream pipeline pulls one element at a time through filter and map only when the terminal operation asks, short-circuiting at findFirst; each lambda is an invokedynamic call site
A stream pipeline pulls one element at a time through filter and map only when the terminal operation asks, short-circuiting at findFirst; each lambda is an invokedynamic call site

⚠️ Common Pitfalls

  1. Calling a stream's terminal operation twice — a Stream can only be consumed once; calling .collect(...) and then .count() on the same stream instance throws IllegalStateException: stream has already been operated upon or closed. Build a fresh stream (e.g. call .stream() again on the source) for each traversal.
  2. Assuming intermediate operations run immediately — side effects inside .map()/.filter() lambdas won't happen until a terminal operation runs the pipeline; debugging by adding a System.out.println inside .map() with no terminal op afterward prints nothing.
  3. Trying to mutate a captured local variable from inside a lambda — violates the effectively-final rule and fails to compile; use an AtomicInteger/array-of-one-element workaround or, better, restructure with reduce/collect instead of external mutable state.
  4. Using reduce for something collect does better — reduce is meant for combining immutable values into a new immutable result; building up a mutable collection (like a List) with reduce works but is awkward and inefficient compared to collect, which is designed exactly for that mutable-accumulation case.
  5. Overusing streams for simple loops — a for loop over 3 elements with a single if is often clearer than a stream pipeline; streams pay off with multi-step transformations or when parallelism (.parallelStream()) is genuinely needed, not as a blanket style rule.

📋 Quick Reference

java
list.stream()                        // source (or Stream.of(...), Arrays.stream(arr))
    .filter(x -> ...)                  // intermediate: keep matching (Predicate)
    .map(x -> ...)                      // intermediate: transform (Function)
    .sorted() / .sorted(comparator)      // intermediate
    .collect(Collectors.toList())         // terminal: materialize back into a List
    .collect(Collectors.joining(", "))    // terminal: join Strings
    .collect(Collectors.groupingBy(fn))   // terminal: Map<K, List<T>>
    .reduce(identity, accumulator)          // terminal: fold to one value
    .count() / .findFirst() / .anyMatch()    // terminal

Type::staticMethod   instance::instanceMethod
Type::instanceMethod (unbound)   Type::new

Lazy: intermediate ops build a plan; nothing runs until a terminal op
Effectively final: captured locals must never be reassigned after init
A Stream is single-use — build a new one per traversal

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What are lambdas and functional interfaces?core▾

Lambdas are concise implementations of functional interfaces (one abstract method): Function, Predicate, Supplier, Consumer, Runnable, Comparator. Method references (User::getName) are shorthand.

Follow-ups interviewers ask
  • Captured variables? Must be effectively final.
Q2How do streams work?core▾

A pipeline of a source, intermediate operations (filter, map, flatMap, sorted) and a terminal operation (collect, reduce, forEach). Intermediate operations are lazy; nothing runs until the terminal operation.

Follow-ups interviewers ask
  • Can a stream be reused? No, it's consumed once.
Q3What are common collectors?core▾

toList, toSet, toMap (watch duplicate keys), groupingBy (with downstream counting/mapping), partitioningBy, joining, summingInt.

Follow-ups interviewers ask
  • Group users by country counting? groupingBy(User::country, counting()).
Q4When are parallel streams a bad idea?senior▾

For small data, I/O-bound work, shared mutable state, or in servers where they use the common ForkJoinPool and compete with other requests. Measure before using.

Follow-ups interviewers ask
  • Ordering cost? Ordered operations reduce parallel speedup.
Q5What's Optional and how should it be used?core▾

A container for maybe-absent return values, with map, orElse, orElseThrow. Use it as a return type, not for fields or parameters, and never call get() without checking.

Follow-ups interviewers ask
  • orElse vs orElseGet? orElse evaluates eagerly; orElseGet lazily.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.