Java Streams API: Functional Programming in Java
Introduced in Java 8, the Streams API brought functional programming to Java. Streams enable declarative data processing — you describe what you want to accomplish (filter, map, reduce) rather than how to accomplish it with loops and temporary variables. This leads to more concise, readable, and often more parallelizable code. Streams process data from a source (collections, arrays, I/O channels, generators) through a pipeline of intermediate operations and a terminal operation that produces the result.
Creating Streams
Streams can be created from any Collection, an array, a range of numbers, or generated dynamically. The source data is not modified — streams are not data structures; they are views over data that apply transformations lazily. Operations on a stream are divided into intermediate operations (return a new stream) and terminal operations (produce a result or side effect and close the stream). Intermediate operations are lazy — they do not execute until a terminal operation is invoked.
import java.util.*;
import java.util.stream.*;
// Create a stream from a list
List<String> names = List.of("Alice", "Bob", "Charlie");
Stream<String> stream = names.stream();
// Create a stream from an array
int[] numbers = {1, 2, 3, 4, 5};
IntStream intStream = Arrays.stream(numbers);
// Create a stream of a range
IntStream.range(1, 10) // 1, 2, 3, ..., 9
.forEach(System.out::print);
// Generate an infinite stream (use limit to bound it)
Stream.generate(() -> Math.random())
.limit(5)
.forEach(System.out::println);
Understanding the difference between intermediate and terminal operations is crucial. Intermediate operations like filter(), map(), and sorted() return a new stream and are evaluated lazily. Terminal operations like collect(), forEach(), count(), and reduce() trigger the entire pipeline computation. A stream can only have one terminal operation, and after it is invoked, the stream is consumed and cannot be reused.
Filtering and Mapping
filter() selects elements that match a predicate (a function that returns true or false). map() transforms each element into something else by applying a function. These two operations together form the backbone of most stream pipelines. The predicate in filter is typically a lambda expression or method reference that tests each element. The function in map takes an element of the input type and returns an element of the output type — the types can differ.
List<String> names = List.of("Alice", "Bob", "Charlie", "David", "Eve");
// Filter: keep names longer than 3 characters
List<String> longNames = names.stream()
.filter(s -> s.length() > 3)
.collect(Collectors.toList());
// Result: ["Alice", "Charlie", "David"]
// Map: convert each name to its length
List<Integer> lengths = names.stream()
.map(String::length)
.collect(Collectors.toList());
// Result: [5, 3, 7, 5, 3]
// Chain filter then map
List<Integer> longNameLengths = names.stream()
.filter(name -> name.length() > 3)
.map(String::length)
.collect(Collectors.toList());
// Result: [5, 7, 5]
Method references like String::length are shorthand for lambdas that simply call a method. String::length is equivalent to s -> s.length(). Method references make stream pipelines more readable when the lambda body is a single method call. Other common method references include System.out::println (instance method on an object), Integer::parseInt (static method), and this::processItem (instance method on the current object).
Reduction with reduce and collect
Reduction combines all elements of a stream into a single value. The reduce() method takes an identity value (the starting value, which is also the result for an empty stream) and a binary operator that combines two values. collect() is a more general reduction that accumulates elements into a mutable container like a List, Set, Map, or a custom collection. The Collectors utility class provides factories for common collectors.
// reduce: sum all lengths
int totalLength = names.stream()
.map(String::length)
.reduce(0, Integer::sum);
// 5 + 3 + 7 + 5 + 3 = 23
// reduce with explicit lambda
int totalLength2 = names.stream()
.map(String::length)
.reduce(0, (a, b) -> a + b);
// collect: join into a single string
String joined = names.stream()
.collect(Collectors.joining(", "));
// "Alice, Bob, Charlie, David, Eve"
// collect: group by length
Map<Integer, List<String>> grouped = names.stream()
.collect(Collectors.groupingBy(String::length));
// {3=["Bob", "Eve"], 5=["Alice", "David"], 7=["Charlie"]}
// collect: partition by predicate
Map<Boolean, List<String>> partitioned = names.stream()
.collect(Collectors.partitioningBy(s -> s.length() > 3));
// {false=["Bob", "Eve"], true=["Alice", "Charlie", "David"]}
The groupingBy collector is particularly powerful — it is the Streams equivalent of SQL’s GROUP BY. You can chain downstream collectors to compute aggregates within each group. For example, groupingBy(String::length, counting()) counts how many names have each length, and groupingBy(String::length, mapping(String::toUpperCase, toList())) groups names by length and converts them to uppercase within each group.
flatMap for Nested Structures
When each element of a stream needs to be expanded into multiple elements, use flatMap. It takes a function that returns a Stream for each input element, and then flattens all those streams into a single stream. This is useful for processing nested collections, handling optional values, or splitting strings.
// Split each sentence into words
List<String> sentences = List.of(
"Hello world",
"Java Streams are powerful"
);
List<String> words = sentences.stream()
.flatMap(sentence -> Arrays.stream(sentence.split(" ")))
.collect(Collectors.toList());
// ["Hello", "world", "Java", "Streams", "are", "powerful"]
// flatMap with Optional — get all present values
List<Optional<String>> optionals = List.of(
Optional.of("Alice"),
Optional.empty(),
Optional.of("Bob")
);
List<String> present = optionals.stream()
.flatMap(Optional::stream)
.collect(Collectors.toList());
// ["Alice", "Bob"]
Parallel Streams
Streams can be parallelized easily by calling parallelStream() instead of stream() on a collection, or by applying .parallel() to an existing sequential stream. The stream is then split into multiple substreams that are processed by different threads and combined at the end. Parallel streams use the common ForkJoinPool behind the scenes. They work best with large datasets, CPU-intensive operations, and stateless, independent element processing. For small datasets or operations with high overhead (like I/O), parallel streams can actually be slower due to thread coordination costs.
// Sequential
long total = names.stream()
.map(String::length)
.reduce(0, Integer::sum);
// Parallel — just change stream() to parallelStream()
long totalParallel = names.parallelStream()
.map(String::length)
.reduce(0, Integer::sum);
// For large data, measure with System.nanoTime()
long start = System.nanoTime();
long result = largeList.parallelStream()
.filter(item -> expensiveTest(item))
.count();
long elapsed = System.nanoTime() - start;
The Streams API shifted Java toward functional programming patterns. Combined with lambdas and method references, streams make collection processing code shorter, clearer, and less error-prone than traditional for-loop approaches.
