Introduction
Lambda expressions and the Stream API, introduced in Java 8 (2014), represent the most significant paradigm shift in Java's history. They brought functional programming capabilities to a traditionally object-oriented language, fundamentally changing how Java developers write code.
Before Java 8, processing collections required verbose imperative code with explicit loops and mutable state. Lambda expressions enable treating functions as first-class citizens—passing behavior as data. The Stream API provides a declarative, pipeline-based approach to data processing that's more readable, composable, and parallelizable.
The Problem Lambdas Solve
To understand why lambdas matter, consider how Java handled behavior parameterization before Java 8:
┌─────────────────────────────────────────────────────────────────┐
│ BEFORE JAVA 8: Anonymous Classes │
├─────────────────────────────────────────────────────────────────┤
│ │
│ // To sort a list by length, you needed THIS: │
│ │
│ Collections.sort(names, new Comparator<String>() { │
│ @Override │
│ public int compare(String s1, String s2) { │
│ return Integer.compare(s1.length(), s2.length()); │
│ } │
│ }); │
│ │
│ Problem: 6 lines of boilerplate for 1 line of logic! │
│ │
├─────────────────────────────────────────────────────────────────┤
│ AFTER JAVA 8: Lambda Expression │
├─────────────────────────────────────────────────────────────────┤
│ │
│ // Now you can write THIS: │
│ │
│ names.sort((s1, s2) -> Integer.compare(s1.length(), s2.length()));
│ │
│ // Or even better with method reference: │
│ │
│ names.sort(Comparator.comparingInt(String::length)); │
│ │
│ Benefit: Express intent clearly, let compiler handle ceremony │
│ │
└─────────────────────────────────────────────────────────────────┘
Lambda Expressions
A lambda expression is an anonymous function—a block of code that can be passed around and executed later. Unlike methods, lambdas don't belong to a class; they represent pure behavior.
Lambda Syntax
┌─────────────────────────────────────────────────────────────────┐
│ LAMBDA SYNTAX │
├─────────────────────────────────────────────────────────────────┤
│ │
│ (parameters) -> expression │
│ │
│ (parameters) -> { statements; } │
│ │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Components: │
│ ┌──────────┐ ┌─────┐ ┌────────────────┐ │
│ │Parameters│ → │Arrow│ → │Body (expression│ │
│ │ (x, y) │ │ -> │ │ or block) │ │
│ └──────────┘ └─────┘ └────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
// No parameters
() -> System.out.println("Hello")
// One parameter (parentheses optional)
x -> x * x
(x) -> x * x // equivalent
// Multiple parameters (parentheses required)
(x, y) -> x + y
// Explicit types (usually inferred)
(Integer x, Integer y) -> x + y
// With code block (braces and return required)
(x, y) -> {
int sum = x + y;
return sum;
}
// Multi-line body
(x, y) -> {
if (x > y) {
return x;
} else {
return y;
}
}
Before vs After: Real Examples
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// ─── BEFORE: Anonymous class (verbose) ───
Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String s1, String s2) {
return s1.compareTo(s2);
}
});
// ─── AFTER: Lambda expression (concise) ───
Collections.sort(names, (s1, s2) -> s1.compareTo(s2));
// ─── EVEN BETTER: Method reference ───
Collections.sort(names, String::compareTo);
// ─── BEST: List.sort() with Comparator ───
names.sort(String::compareTo);
Variable Capture and Effectively Final
Lambdas can capture (use) variables from their enclosing scope, but with restrictions:
int multiplier = 3; // Effectively final (never modified)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4);
// ✅ This works - multiplier is effectively final
List<Integer> result = numbers.stream()
.map(n -> n * multiplier)
.collect(Collectors.toList());
// ❌ This would NOT compile:
// multiplier = 4; // Makes multiplier NOT effectively final
// Lambdas cannot capture mutable local variables
// Why? Lambdas may execute later (different thread), and Java
// avoids the complexity of capturing mutable state
Functional Interfaces
A functional interface is an interface with exactly one abstract method (SAM - Single Abstract Method). Lambdas are the implementations of these interfaces. The @FunctionalInterface annotation is optional but recommended—it causes a compile error if the interface has more than one abstract method.
@FunctionalInterface
interface Calculator {
int calculate(int a, int b);
// Default methods are allowed (not abstract)
default int addThenDouble(int a, int b) {
return calculate(a, b) * 2;
}
// Static methods are allowed
static Calculator adder() {
return (a, b) -> a + b;
}
}
// Using lambdas to implement the interface
Calculator add = (a, b) -> a + b;
Calculator multiply = (a, b) -> a * b;
Calculator max = (a, b) -> a > b ? a : b;
int sum = add.calculate(5, 3); // 8
int product = multiply.calculate(5, 3); // 15
int doubled = add.addThenDouble(5, 3); // 16
Built-in Functional Interfaces (java.util.function)
Java provides a comprehensive set of functional interfaces so you rarely need to define your own:
┌─────────────────────────────────────────────────────────────────┐
│ CORE FUNCTIONAL INTERFACES │
├─────────────────┬───────────────┬───────────────┬──────────────┤
│ Interface │ Input │ Output │ Method │
├─────────────────┼───────────────┼───────────────┼──────────────┤
│ Predicate<T> │ T │ boolean │ test() │
│ Function<T,R> │ T │ R │ apply() │
│ Consumer<T> │ T │ void │ accept() │
│ Supplier<T> │ (none) │ T │ get() │
├─────────────────┼───────────────┼───────────────┼──────────────┤
│ BiPredicate │ T, U │ boolean │ test() │
│ BiFunction │ T, U │ R │ apply() │
│ BiConsumer │ T, U │ void │ accept() │
├─────────────────┼───────────────┼───────────────┼──────────────┤
│ UnaryOperator │ T │ T │ apply() │
│ BinaryOperator│ T, T │ T │ apply() │
└─────────────────┴───────────────┴───────────────┴──────────────┘
import java.util.function.*;
// ─── Predicate: T → boolean (for filtering/testing) ───
Predicate<Integer> isEven = num -> num % 2 == 0;
Predicate<String> isNotEmpty = str -> !str.isEmpty();
isEven.test(4); // true
isEven.and(n -> n > 0).test(4); // true (chaining)
isEven.negate().test(4); // false
// ─── Function: T → R (for transformation) ───
Function<String, Integer> length = str -> str.length();
Function<Integer, String> intToString = Object::toString;
length.apply("Hello"); // 5
length.andThen(intToString).apply("Hi"); // "2" (chaining)
// ─── Consumer: T → void (for side effects) ───
Consumer<String> print = msg -> System.out.println(msg);
Consumer<String> log = msg -> logger.info(msg);
print.accept("Hello"); // Prints "Hello"
print.andThen(log).accept("Hello"); // Print then log
// ─── Supplier: () → T (for lazy generation) ───
Supplier<Double> random = () -> Math.random();
Supplier<LocalDateTime> now = LocalDateTime::now;
double value = random.get(); // Random number
// ─── BiFunction: (T, U) → R (two inputs) ───
BiFunction<String, String, String> concat = (a, b) -> a + b;
concat.apply("Hello, ", "World"); // "Hello, World"
// ─── UnaryOperator: T → T (same type in/out) ───
UnaryOperator<Integer> square = x -> x * x;
UnaryOperator<String> toUpper = String::toUpperCase;
// ─── BinaryOperator: (T, T) → T (for reduce operations) ───
BinaryOperator<Integer> sum = (a, b) -> a + b;
BinaryOperator<Integer> max = Integer::max;
Method References
Method references are shorthand for lambdas that simply call an existing method. They're more concise and clearly express the intent to delegate to an existing method.
┌─────────────────────────────────────────────────────────────────┐
│ METHOD REFERENCE TYPES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Type │ Syntax │ Lambda Equivalent│
│ ─────────────────────────┼──────────────────┼─────────────────│
│ Static method │ Class::method │ x -> Class.method(x)
│ Instance method (object) │ object::method │ x -> object.method(x)
│ Instance method (type) │ Class::method │ (obj,x) -> obj.method(x)
│ Constructor │ Class::new │ x -> new Class(x)│
│ │
└─────────────────────────────────────────────────────────────────┘
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// ─── Static Method Reference ───
// Lambda: x -> Integer.parseInt(x)
// Method ref: Integer::parseInt
Function<String, Integer> parser = Integer::parseInt;
// ─── Instance Method Reference (bound to object) ───
// Lambda: x -> System.out.println(x)
// Method ref: System.out::println
names.forEach(System.out::println);
String prefix = "Hello, ";
// Lambda: x -> prefix.concat(x)
Function<String, String> greeter = prefix::concat;
// ─── Instance Method Reference (unbound - on type) ───
// Lambda: (s1, s2) -> s1.compareTo(s2)
// Method ref: String::compareTo
names.sort(String::compareToIgnoreCase);
// Lambda: s -> s.toUpperCase()
// Method ref: String::toUpperCase
List<String> upper = names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// ─── Constructor Reference ───
// Lambda: s -> new StringBuilder(s)
// Method ref: StringBuilder::new
Function<String, StringBuilder> sbCreator = StringBuilder::new;
// Creating objects in streams
List<Person> people = names.stream()
.map(Person::new) // Calls new Person(name)
.collect(Collectors.toList());
// Array constructor reference
String[] array = names.stream().toArray(String[]::new);
Stream API
A Stream is a sequence of elements supporting sequential and parallel aggregate operations. Streams don't store data—they convey data from a source (collection, array, generator, I/O channel) through a pipeline of operations.
┌─────────────────────────────────────────────────────────────────┐
│ STREAM PIPELINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌──────────┐ ┌─────────────────┐ ┌───────────────┐ │
│ │ SOURCE │ → │ INTERMEDIATE │ → │ TERMINAL │ │
│ │ │ │ OPERATIONS │ │ OPERATION │ │
│ └──────────┘ └─────────────────┘ └───────────────┘ │
│ │
│ Collection filter(), map(), collect(), forEach(), │
│ Array sorted(), distinct(), reduce(), count(), │
│ Generator limit(), skip(), findFirst(), anyMatch() │
│ I/O Channel flatMap(), peek() toArray(), min(), max() │
│ │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ LAZY EVALUATION: Intermediate operations don't execute │ │
│ │ until a terminal operation is invoked! │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
Creating Streams
// From Collection
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
Stream<Integer> stream = numbers.stream();
// From Array
String[] arr = {"A", "B", "C"};
Stream<String> arrStream = Arrays.stream(arr);
// Using Stream.of()
Stream<String> ofStream = Stream.of("X", "Y", "Z");
// Empty stream
Stream<Object> empty = Stream.empty();
// Infinite streams (use with limit()!)
Stream<Integer> infinite = Stream.iterate(0, n -> n + 2); // 0, 2, 4, 6...
Stream<Double> randoms = Stream.generate(Math::random); // Random numbers
// Finite iterate (Java 9+)
Stream<Integer> finite = Stream.iterate(0, n -> n < 10, n -> n + 1);
// From Builder
Stream<String> built = Stream.<String>builder()
.add("one")
.add("two")
.add("three")
.build();
// Primitive streams (avoid boxing overhead)
IntStream intStream = IntStream.range(1, 6); // 1, 2, 3, 4, 5
IntStream inclusive = IntStream.rangeClosed(1, 5); // 1, 2, 3, 4, 5
LongStream longStream = LongStream.of(1L, 2L, 3L);
DoubleStream doubleStream = DoubleStream.of(1.0, 2.0);
Intermediate Operations (Lazy)
List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "Alice");
// ─── filter(): Keep elements matching predicate ───
names.stream()
.filter(name -> name.startsWith("A")) // ["Alice", "Alice"]
// ─── map(): Transform each element ───
names.stream()
.map(String::toUpperCase) // ["ALICE", "BOB", "CHARLIE", "ALICE"]
// ─── flatMap(): Flatten nested structures ───
List<List<Integer>> nested = Arrays.asList(
Arrays.asList(1, 2),
Arrays.asList(3, 4)
);
nested.stream()
.flatMap(List::stream) // [1, 2, 3, 4]
// ─── distinct(): Remove duplicates ───
names.stream()
.distinct() // ["Alice", "Bob", "Charlie"]
// ─── sorted(): Sort elements ───
names.stream()
.sorted() // Natural order
.sorted(Comparator.reverseOrder()) // Reverse
.sorted(Comparator.comparingInt(String::length)) // By length
// ─── limit(): Take first N elements ───
names.stream()
.limit(2) // ["Alice", "Bob"]
// ─── skip(): Skip first N elements ───
names.stream()
.skip(2) // ["Charlie", "Alice"]
// ─── peek(): Debug without modifying (for side effects) ───
names.stream()
.peek(name -> System.out.println("Processing: " + name))
.map(String::toUpperCase)
.collect(Collectors.toList());
// ─── takeWhile() / dropWhile() (Java 9+) ───
Stream.of(1, 2, 3, 4, 5)
.takeWhile(n -> n < 4) // [1, 2, 3]
Stream.of(1, 2, 3, 4, 5)
.dropWhile(n -> n < 4) // [4, 5]
Terminal Operations (Trigger Execution)
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
// ─── collect(): Gather results into collection ───
List<Integer> list = numbers.stream()
.filter(n -> n > 2)
.collect(Collectors.toList());
Set<Integer> set = numbers.stream()
.collect(Collectors.toSet());
// ─── forEach(): Execute action for each element ───
numbers.stream().forEach(System.out::println);
// ─── reduce(): Combine all elements into one ───
int sum = numbers.stream()
.reduce(0, (a, b) -> a + b); // 15
Optional<Integer> max = numbers.stream()
.reduce(Integer::max); // Optional[5]
// ─── count(): Count elements ───
long count = numbers.stream().count(); // 5
// ─── min() / max(): Find extremes ───
Optional<Integer> min = numbers.stream().min(Integer::compare);
Optional<Integer> maxVal = numbers.stream().max(Integer::compare);
// ─── findFirst() / findAny(): Get element ───
Optional<Integer> first = numbers.stream()
.filter(n -> n > 3)
.findFirst(); // Optional[4]
// ─── anyMatch() / allMatch() / noneMatch(): Test predicates ───
boolean hasEven = numbers.stream().anyMatch(n -> n % 2 == 0); // true
boolean allPos = numbers.stream().allMatch(n -> n > 0); // true
boolean noneNeg = numbers.stream().noneMatch(n -> n < 0); // true
// ─── toArray(): Convert to array ───
Integer[] arr = numbers.stream().toArray(Integer[]::new);
Collectors Deep Dive
The Collectors utility class provides powerful reduction operations for stream pipelines:
List<Person> people = Arrays.asList(
new Person("Alice", 25, "Engineering"),
new Person("Bob", 30, "Engineering"),
new Person("Charlie", 35, "Marketing"),
new Person("Diana", 28, "Marketing")
);
// ─── Basic Collection ───
List<String> names = people.stream()
.map(Person::getName)
.collect(Collectors.toList());
Set<String> departments = people.stream()
.map(Person::getDepartment)
.collect(Collectors.toSet());
// ─── Joining Strings ───
String joined = people.stream()
.map(Person::getName)
.collect(Collectors.joining(", ")); // "Alice, Bob, Charlie, Diana"
String formatted = people.stream()
.map(Person::getName)
.collect(Collectors.joining(", ", "[People: ", "]"));
// "[People: Alice, Bob, Charlie, Diana]"
// ─── Grouping By ───
Map<String, List<Person>> byDept = people.stream()
.collect(Collectors.groupingBy(Person::getDepartment));
// {Engineering=[Alice, Bob], Marketing=[Charlie, Diana]}
// Grouping with downstream collector
Map<String, Long> countByDept = people.stream()
.collect(Collectors.groupingBy(
Person::getDepartment,
Collectors.counting()
));
// {Engineering=2, Marketing=2}
Map<String, Double> avgAgeByDept = people.stream()
.collect(Collectors.groupingBy(
Person::getDepartment,
Collectors.averagingInt(Person::getAge)
));
// {Engineering=27.5, Marketing=31.5}
// ─── Partitioning (boolean grouping) ───
Map<Boolean, List<Person>> partitioned = people.stream()
.collect(Collectors.partitioningBy(p -> p.getAge() >= 30));
// {false=[Alice, Diana], true=[Bob, Charlie]}
// ─── To Map ───
Map<String, Integer> nameToAge = people.stream()
.collect(Collectors.toMap(
Person::getName,
Person::getAge
));
// {Alice=25, Bob=30, Charlie=35, Diana=28}
// Handle duplicate keys
Map<String, Integer> deptToMaxAge = people.stream()
.collect(Collectors.toMap(
Person::getDepartment,
Person::getAge,
Integer::max // Merge function for duplicates
));
// ─── Statistics ───
IntSummaryStatistics stats = people.stream()
.collect(Collectors.summarizingInt(Person::getAge));
// count=4, sum=118, min=25, average=29.5, max=35
Parallel Streams
Streams can easily be parallelized to leverage multi-core processors. However, parallelization has overhead and isn't always beneficial.
┌─────────────────────────────────────────────────────────────────┐
│ PARALLEL STREAM EXECUTION │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Sequential: [1, 2, 3, 4, 5, 6, 7, 8] ─────────────→ Result │
│ ↓ single thread │
│ │
│ Parallel: [1, 2, 3, 4, 5, 6, 7, 8] │
│ │ │
│ ├── Thread 1: [1, 2] ──┐ │
│ ├── Thread 2: [3, 4] ──┼──→ Combine → Result
│ ├── Thread 3: [5, 6] ──┤ │
│ └── Thread 4: [7, 8] ──┘ │
│ │
│ Uses ForkJoinPool.commonPool() by default │
│ │
└─────────────────────────────────────────────────────────────────┘
List<Integer> numbers = IntStream.rangeClosed(1, 1_000_000)
.boxed()
.collect(Collectors.toList());
// ─── Creating Parallel Streams ───
long sum1 = numbers.parallelStream()
.mapToLong(Integer::longValue)
.sum();
long sum2 = numbers.stream()
.parallel() // Convert existing stream to parallel
.mapToLong(Integer::longValue)
.sum();
// ─── When Parallel Helps ───
// ✅ Large datasets (thousands+ elements)
// ✅ CPU-intensive operations per element
// ✅ Stateless, independent operations
// ✅ Easy-to-split data sources (ArrayList, arrays)
// ─── When Parallel Hurts ───
// ❌ Small datasets (overhead > benefit)
// ❌ I/O-bound operations
// ❌ LinkedList, Streams with limit()
// ❌ Operations with ordering requirements
// ❌ Shared mutable state
- Don't use with blocking I/O - monopolizes common pool threads
- Avoid shared mutable state - causes race conditions
- Order may not be preserved - use forEachOrdered() if needed
- Measure before using - parallel isn't always faster
Practical Examples
Example 1: Processing User Data
class User {
private String name;
private int age;
private List<String> roles;
private boolean active;
// getters...
}
List<User> users = getUsers();
// Find all active admin users over 18, sorted by name
List<User> activeAdmins = users.stream()
.filter(User::isActive)
.filter(u -> u.getAge() >= 18)
.filter(u -> u.getRoles().contains("ADMIN"))
.sorted(Comparator.comparing(User::getName))
.collect(Collectors.toList());
// Get all unique roles across all users
Set<String> allRoles = users.stream()
.flatMap(u -> u.getRoles().stream())
.collect(Collectors.toSet());
// Count users by role
Map<String, Long> usersByRole = users.stream()
.flatMap(u -> u.getRoles().stream())
.collect(Collectors.groupingBy(
Function.identity(),
Collectors.counting()
));
Example 2: File Processing
// Read file, process lines, count word frequencies
Map<String, Long> wordFrequency;
try (Stream<String> lines = Files.lines(Path.of("document.txt"))) {
wordFrequency = lines
.flatMap(line -> Arrays.stream(line.split("\\s+")))
.map(String::toLowerCase)
.filter(word -> !word.isEmpty())
.collect(Collectors.groupingBy(
Function.identity(),
Collectors.counting()
));
}
// Find top 10 most frequent words
List<Map.Entry<String, Long>> top10 = wordFrequency.entrySet().stream()
.sorted(Map.Entry.<String, Long>comparingByValue().reversed())
.limit(10)
.collect(Collectors.toList());
Example 3: E-commerce Order Processing
List<Order> orders = getOrders();
// Calculate total revenue by product category
Map<String, BigDecimal> revenueByCategory = orders.stream()
.flatMap(order -> order.getItems().stream())
.collect(Collectors.groupingBy(
OrderItem::getCategory,
Collectors.reducing(
BigDecimal.ZERO,
item -> item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity())),
BigDecimal::add
)
));
// Find customers who spent more than $1000
List<Customer> highValueCustomers = orders.stream()
.collect(Collectors.groupingBy(
Order::getCustomer,
Collectors.summingDouble(Order::getTotal)
))
.entrySet().stream()
.filter(e -> e.getValue() > 1000)
.map(Map.Entry::getKey)
.collect(Collectors.toList());
// Get orders from last 30 days, grouped by status
LocalDate thirtyDaysAgo = LocalDate.now().minusDays(30);
Map<OrderStatus, List<Order>> recentByStatus = orders.stream()
.filter(o -> o.getOrderDate().isAfter(thirtyDaysAgo))
.collect(Collectors.groupingBy(Order::getStatus));
Common Pitfalls
⚠️ Pitfall 1: Reusing Streams
Streams can only be consumed once. After a terminal operation, the stream is closed.
Stream<String> stream = Stream.of("a", "b", "c");
stream.forEach(System.out::println); // Works
stream.forEach(System.out::println); // IllegalStateException!
// Solution: Create a new stream each time, or collect first
List<String> list = Stream.of("a", "b", "c").collect(Collectors.toList());
list.forEach(System.out::println); // Works
list.forEach(System.out::println); // Works again
⚠️ Pitfall 2: Side Effects in Streams
Avoid modifying external state in stream operations—it's error-prone and breaks parallelism.
// ❌ BAD: Mutating external collection
List<String> results = new ArrayList<>();
names.stream()
.filter(n -> n.length() > 3)
.forEach(results::add); // Not thread-safe!
// ✅ GOOD: Use collect()
List<String> results = names.stream()
.filter(n -> n.length() > 3)
.collect(Collectors.toList());
⚠️ Pitfall 3: Infinite Streams Without Limit
Infinite streams without a short-circuit operation will run forever.
// ❌ BAD: Never terminates!
Stream.iterate(0, n -> n + 1)
.filter(n -> n > 100) // Always more to check
.collect(Collectors.toList());
// ✅ GOOD: Use limit()
Stream.iterate(0, n -> n + 1)
.limit(200)
.filter(n -> n > 100)
.collect(Collectors.toList());
// ✅ GOOD: Use takeWhile() (Java 9+)
Stream.iterate(0, n -> n + 1)
.takeWhile(n -> n <= 200)
.filter(n -> n > 100)
.collect(Collectors.toList());
⚠️ Pitfall 4: Forgetting Streams Are Lazy
Without a terminal operation, nothing happens!
// ❌ BAD: Nothing prints! peek() is lazy
names.stream()
.filter(n -> n.length() > 3)
.peek(System.out::println); // No terminal operation!
// ✅ GOOD: Add terminal operation
names.stream()
.filter(n -> n.length() > 3)
.peek(System.out::println)
.collect(Collectors.toList()); // Now it executes
⚠️ Pitfall 5: NullPointerException in Streams
Streams don't handle nulls gracefully in most operations.
List<String> names = Arrays.asList("Alice", null, "Bob");
// ❌ BAD: NullPointerException
names.stream()
.map(String::toUpperCase)
.collect(Collectors.toList());
// ✅ GOOD: Filter nulls first
names.stream()
.filter(Objects::nonNull)
.map(String::toUpperCase)
.collect(Collectors.toList());
// ✅ GOOD: Use Optional in map
names.stream()
.map(n -> Optional.ofNullable(n).map(String::toUpperCase).orElse(""))
.collect(Collectors.toList());
Interview Questions
Q1: What's the difference between intermediate and terminal operations?
Intermediate operations (filter, map, sorted) return a new Stream and are lazy—they don't execute until a terminal operation is called. They can be chained.
Terminal operations (collect, forEach, reduce) trigger the actual processing of the pipeline and produce a result or side-effect. After a terminal operation, the stream cannot be reused.
Q2: What is a functional interface? Name some built-in ones.
A functional interface has exactly one abstract method. Examples: Predicate<T> (test), Function<T,R> (apply), Consumer<T> (accept), Supplier<T> (get), Comparator<T> (compare), Runnable (run), Callable<V> (call).
Q3: What does "effectively final" mean?
A variable is effectively final if it's never modified after initialization. Lambdas can only capture local variables that are effectively final. This avoids the complexity of capturing mutable state across threads.
Q4: Explain flatMap vs map.
map() transforms each element to exactly one output element. flatMap() transforms each element to zero or more elements and flattens the result into a single stream.
// map: Stream<List<String>> → Stream<List<String>>
// flatMap: Stream<List<String>> → Stream<String>
Q5: When should you use parallel streams?
Use parallel streams when: large datasets (thousands+), CPU-intensive operations, stateless operations, easily splittable sources (ArrayList, arrays). Avoid for: small datasets, I/O-bound work, operations requiring order, shared mutable state.
Q6: What are the four types of method references?
- Static:
ClassName::staticMethod - Bound instance:
object::instanceMethod - Unbound instance:
ClassName::instanceMethod - Constructor:
ClassName::new
Q7: How does lazy evaluation benefit streams?
Lazy evaluation means operations only execute when needed. Benefits: (1) short-circuit operations like findFirst() can stop early, (2) intermediate operations are fused into a single pass, (3) infinite streams are possible, (4) no unnecessary computation if result isn't used.
Best Practices
- Keep lambdas short - If a lambda exceeds 3 lines, extract it to a method and use a method reference
- Prefer method references -
String::toUpperCaseis clearer thans -> s.toUpperCase() - Use primitive streams -
IntStream,LongStream,DoubleStreamavoid boxing overhead - Avoid side effects - Don't modify external state in stream operations
- Don't overuse streams - Simple loops can be more readable for basic iterations
- Use collect() over forEach() - For building collections, collect() is cleaner and thread-safe
- Measure before parallelizing - Parallel streams have overhead; benchmark first
- Close streams from I/O - Use try-with-resources for streams from Files.lines() etc.
Why It Matters
- Concise Code - Express complex operations in fewer, more readable lines
- Declarative Style - Describe what you want, not how to do it
- Composability - Chain operations fluently, build pipelines from reusable parts
- Parallel Processing - Trivially parallelize with
.parallelStream() - Lazy Evaluation - Efficient short-circuit operations, infinite streams
- Industry Standard - Modern Java codebases use lambdas and streams extensively
See Also
- java.util.stream Package (Oracle Docs)
- java.util.function Package (Oracle Docs)
- Book: "Java 8 in Action" by Urma, Fusco, Mycroft
- Book: "Modern Java in Action" (2nd edition)