Java Streams

ADVANCED ~8 min read Tutorial

The Streams API, added in Java 8, is a declarative way to process sequences of values. A stream pipeline consists of a source, zero or more intermediate operations (filter, map, sorted, distinct...), and a terminal operation (collect, reduce, count, forEach). Streams are lazy — nothing happens until a terminal operation is invoked.

This tutorial covers creating streams, the most common intermediate and terminal operations, the difference between lazy and eager, parallel streams, and the collectors that turn a stream back into a collection.

1. Creating Streams

java
import java.util.stream.Stream;
import java.util.stream.IntStream;
import java.util.List;

class=class="tok-str">"tok-cmt">// from a collection
Stream<String> s1 = List.of("a","b","c").stream();

class=class="tok-str">"tok-cmt">// from an array
Stream<String> s2 = Stream.of("a","b","c");
Stream<Integer> s3 = java.util.Arrays.stream(new Integer[]{class="tok-num">1,class="tok-num">2,class="tok-num">3});

class=class="tok-str">"tok-cmt">// numeric range
IntStream range = IntStream.range(class="tok-num">0, class="tok-num">100);   class=class="tok-str">"tok-cmt">// class="tok-num">0..class="tok-num">99
IntStream closed = IntStream.rangeClosed(class="tok-num">1, class="tok-num">10);   class=class="tok-str">"tok-cmt">// class="tok-num">1..class="tok-num">10

class=class="tok-str">"tok-cmt">// generate / iterate
Stream<Double> randoms = Stream.generate(Math::random).limit(class="tok-num">10);
Stream<Integer> naturals = Stream.iterate(class="tok-num">1, n -> n + class="tok-num">1).limit(class="tok-num">100);

class=class="tok-str">"tok-cmt">// from a function (Java class="tok-num">9+ iterate with predicate)
Stream<Integer> finite = Stream.iterate(class="tok-num">1, n -> n <= class="tok-num">100, n -> n + class="tok-num">1);

class=class="tok-str">"tok-cmt">// from lines of a file
try (Stream<String> lines = java.nio.file.Files.lines(java.nio.file.Path.of("data.txt"))) {
    lines.forEach(System.out::println);
}

2. The Pipeline Pattern

A typical pipeline: source → filter → map → terminal. Each intermediate operation returns a new stream, so they chain fluently:

java
import java.util.List;
import java.util.stream.Collectors;

List<String> names = List.of("alice","bob","carol","dave","eve");

List<String> upperLong = names.stream()
    .filter(n -> n.length() > class="tok-num">3)      class=class="tok-str">"tok-cmt">// keep alice, carol, dave
    .map(String::toUpperCase)         class=class="tok-str">"tok-cmt">// ALICE, CAROL, DAVE
    .sorted()                         class=class="tok-str">"tok-cmt">// ALICE, CAROL, DAVE
    .collect(Collectors.toList());    class=class="tok-str">"tok-cmt">// materialise

class=class="tok-str">"tok-cmt">// the same with .toList() (Java class="tok-num">16+) - returns an immutable list
List<String> result = names.stream()
    .filter(n -> n.length() > class="tok-num">3)
    .map(String::toUpperCase)
    .toList();
Lazy evaluation

Nothing actually runs until you call a terminal operation. Intermediate operations describe what to do; the terminal operation triggers execution. This means you can build a pipeline and only pay for what you actually consume.

3. Filtering and Slicing

java
List<Integer> nums = List.of(class="tok-num">5, class="tok-num">3, class="tok-num">8, class="tok-num">1, class="tok-num">9, class="tok-num">2, class="tok-num">7, class="tok-num">4);

class=class="tok-str">"tok-cmt">// keep evens
List<Integer> evens = nums.stream()
    .filter(n -> n % class="tok-num">2 == class="tok-num">0)
    .toList();   class=class="tok-str">"tok-cmt">// [class="tok-num">8, class="tok-num">2, class="tok-num">4]

class=class="tok-str">"tok-cmt">// distinct elements
List<Integer> uniq = List.of(class="tok-num">1, class="tok-num">1, class="tok-num">2, class="tok-num">3, class="tok-num">3, class="tok-num">3, class="tok-num">4).stream()
    .distinct()
    .toList();   class=class="tok-str">"tok-cmt">// [class="tok-num">1, class="tok-num">2, class="tok-num">3, class="tok-num">4]

class=class="tok-str">"tok-cmt">// take first N matching
List<Integer> first3 = nums.stream()
    .filter(n -> n > class="tok-num">4)
    .limit(class="tok-num">3)
    .toList();

class=class="tok-str">"tok-cmt">// skip and limit together = pagination
List<Integer> page2 = nums.stream()
    .skip(class="tok-num">2)
    .limit(class="tok-num">2)
    .toList();   class=class="tok-str">"tok-cmt">// [class="tok-num">8, class="tok-num">1]

class=class="tok-str">"tok-cmt">// dropWhile / takeWhile (Java class="tok-num">9+)
List<Integer> prefix = nums.stream()
    .takeWhile(n -> n < class="tok-num">8)
    .toList();   class=class="tok-str">"tok-cmt">// [class="tok-num">5, class="tok-num">3] - stops at class="tok-num">8

4. Mapping and FlatMap

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

class=class="tok-str">"tok-cmt">// map: class="tok-num">1-to-class="tok-num">1 transformation
List<Integer> lengths = names.stream()
    .map(String::length)
    .toList();   class=class="tok-str">"tok-cmt">// [class="tok-num">5, class="tok-num">3, class="tok-num">5]

class=class="tok-str">"tok-cmt">// map to a different type
List<Person> people = names.stream()
    .map(name -> new Person(name, class="tok-num">0))
    .toList();

class=class="tok-str">"tok-cmt">// flatMap: class="tok-num">1-to-many
List<List<Integer>> nested = List.of(List.of(class="tok-num">1,class="tok-num">2,class="tok-num">3), List.of(class="tok-num">4,class="tok-num">5), List.of(class="tok-num">6,class="tok-num">7,class="tok-num">8,class="tok-num">9));
List<Integer> flat = nested.stream()
    .flatMap(List::stream)
    .toList();   class=class="tok-str">"tok-cmt">// [class="tok-num">1,class="tok-num">2,class="tok-num">3,class="tok-num">4,class="tok-num">5,class="tok-num">6,class="tok-num">7,class="tok-num">8,class="tok-num">9]

class=class="tok-str">"tok-cmt">// flatMap on a stream of strings to a stream of chars
List<Character> chars = List.of("hi","bye").stream()
    .flatMapToInt(String::chars)
    .mapToObj(c -> (char) c)
    .toList();

flatMap flattens nested streams — essential when one element produces multiple results.

5. Reductions: reduce, count, sum

java
class=class="tok-str">"tok-cmt">// reduce to a single value
int sum = IntStream.rangeClosed(class="tok-num">1, class="tok-num">10).reduce(class="tok-num">0, Integer::sum);   class=class="tok-str">"tok-cmt">// class="tok-num">55
int product = IntStream.rangeClosed(class="tok-num">1, class="tok-num">5).reduce(class="tok-num">1, (a,b) -> a*b);   class=class="tok-str">"tok-cmt">// class="tok-num">120

class=class="tok-str">"tok-cmt">// count, min, max
long count = Stream.of(class="tok-num">1,class="tok-num">2,class="tok-num">3,class="tok-num">4,class="tok-num">5).count();   class=class="tok-str">"tok-cmt">// class="tok-num">5
int max = Stream.of(class="tok-num">3,class="tok-num">1,class="tok-num">4,class="tok-num">1,class="tok-num">5,class="tok-num">9,class="tok-num">2,class="tok-num">6).max(Integer::compareTo).orElse(class="tok-num">0);   class=class="tok-str">"tok-cmt">// class="tok-num">9

class=class="tok-str">"tok-cmt">// sum, average on numeric streams
IntStream.range(class="tok-num">1, class="tok-num">11).sum();          class=class="tok-str">"tok-cmt">// class="tok-num">55
IntStream.range(class="tok-num">1, class="tok-num">11).average().orElse(class="tok-num">0);   class=class="tok-str">"tok-cmt">// class="tok-num">5.5
IntStream.range(class="tok-num">1, class="tok-num">11).summaryStatistics();
class=class="tok-str">"tok-cmt">// IntSummaryStatistics{count=class="tok-num">10, sum=class="tok-num">55, min=class="tok-num">1, max=class="tok-num">10, average=class="tok-num">5.500000}

class=class="tok-str">"tok-cmt">// anyMatch / allMatch / noneMatch
boolean hasEven = nums.stream().anyMatch(n -> n % class="tok-num">2 == class="tok-num">0);
boolean allPos   = nums.stream().allMatch(n -> n > class="tok-num">0);
boolean noNeg   = nums.stream().noneMatch(n -> n < class="tok-num">0);

6. Collectors

The terminal collect operation turns a stream into a collection or other value. The Collectors class provides the common collectors:

java
import java.util.stream.Collectors;
import java.util.Map;
import java.util.List;

class=class="tok-str">"tok-cmt">// to a list
List<String> list = stream.collect(Collectors.toList());

class=class="tok-str">"tok-cmt">// to an unmodifiable list (Java class="tok-num">16+)
List<String> unmod = stream.toList();

class=class="tok-str">"tok-cmt">// to a set
Set<String> set = stream.collect(Collectors.toSet());

class=class="tok-str">"tok-cmt">// to a specific collection
ArrayList<String> arr = stream.collect(Collectors.toCollection(ArrayList::new));

class=class="tok-str">"tok-cmt">// to a map
Map<String, Integer> map = people.stream()
    .collect(Collectors.toMap(Person::name, Person::age));

class=class="tok-str">"tok-cmt">// joining strings
String csv = Stream.of("a","b","c").collect(Collectors.joining(", "));
class=class="tok-str">"tok-cmt">// "a, b, c"

class=class="tok-str">"tok-cmt">// grouping
Map<Character, List<String>> byLetter =
    words.stream().collect(Collectors.groupingBy(s -> s.charAt(class="tok-num">0)));

class=class="tok-str">"tok-cmt">// partitioning
Map<Boolean, List<Integer>> parts =
    nums.stream().collect(Collectors.partitioningBy(n -> n % class="tok-num">2 == class="tok-num">0));

class=class="tok-str">"tok-cmt">// counting by group
Map<Character, Long> counts =
    words.stream().collect(Collectors.groupingBy(s -> s.charAt(class="tok-num">0), Collectors.counting()));

7. Optional and Search Operations

Search operations return Optional, a value that may or may not be present:

java
import java.util.Optional;

class=class="tok-str">"tok-cmt">// findFirst - the first matching element
Optional<Integer> firstEven = nums.stream().filter(n -> n % class="tok-num">2 == class="tok-num">0).findFirst();
int v = firstEven.orElse(-class="tok-num">1);          class=class="tok-str">"tok-cmt">// -class="tok-num">1 if absent
int v2 = firstEven.orElseThrow();      class=class="tok-str">"tok-cmt">// throws NoSuchElementException if absent

class=class="tok-str">"tok-cmt">// findAny - any matching element, may be faster on parallel streams
Optional<Integer> anyEven = nums.stream().filter(n -> n % class="tok-num">2 == class="tok-num">0).findAny();

class=class="tok-str">"tok-cmt">// min / max return Optional
Optional<Integer> min = nums.stream().min(Integer::compareTo);

class=class="tok-str">"tok-cmt">// consume if present
firstEven.ifPresent(n -> System.out.println("got " + n));

class=class="tok-str">"tok-cmt">// transform the value inside the optional
Optional<Integer> doubled = firstEven.map(n -> n * class="tok-num">2);
Optional<String> asString = firstEven.map(Object::toString);
Avoid Optional.get() without a check

Calling get() on an empty optional throws NoSuchElementException. Prefer orElse, orElseGet, or ifPresent.

8. Numeric Streams

java
import java.util.stream.IntStream;
import java.util.stream.LongStream;
import java.util.stream.DoubleStream;

class=class="tok-str">"tok-cmt">// ranges
IntStream.range(class="tok-num">0, class="tok-num">10).forEach(System.out::println);
IntStream.rangeClosed(class="tok-num">1, class="tok-num">5).sum();   class=class="tok-str">"tok-cmt">// class="tok-num">15

class=class="tok-str">"tok-cmt">// convert object stream to numeric
List<Integer> list = List.of(class="tok-num">1,class="tok-num">2,class="tok-num">3);
int sum = list.stream().mapToInt(Integer::intValue).sum();   class=class="tok-str">"tok-cmt">// class="tok-num">6
double avg = list.stream().mapToInt(Integer::intValue).average().orElse(class="tok-num">0);

class=class="tok-str">"tok-cmt">// convert numeric back to object
List<Integer> boxed = IntStream.range(class="tok-num">0, class="tok-num">5).boxed().toList();

class=class="tok-str">"tok-cmt">// random numbers
import java.util.Random;
new Random().ints(class="tok-num">5, class="tok-num">1, class="tok-num">100).forEach(System.out::println);   class=class="tok-str">"tok-cmt">// class="tok-num">5 random ints in [class="tok-num">1, class="tok-num">100)

9. Parallel Streams

Adding .parallel() runs the pipeline on the common ForkJoinPool. The API is the same; only the execution changes:

java
class=class="tok-str">"tok-cmt">// sequential
long sequential = list.stream().filter(x -> isPrime(x)).count();

class=class="tok-str">"tok-cmt">// parallel - runs on the common ForkJoinPool
long parallel = list.parallelStream().filter(x -> isPrime(x)).count();
class=class="tok-str">"tok-cmt">// or
long parallel2 = list.stream().parallel().filter(x -> isPrime(x)).count();

class=class="tok-str">"tok-cmt">// ordering: forEach with parallel may produce out-of-order output
class=class="tok-str">"tok-cmt">// Use forEachOrdered if order matters.
list.parallelStream().forEachOrdered(System.out::println);

class=class="tok-str">"tok-cmt">// avoid parallel streams with stateful lambdas or shared mutable state
class=class="tok-str">"tok-cmt">// they are NOT a magic "go faster" switch - benchmark before relying on them
Parallel is not free

The overhead of splitting, scheduling, and merging can exceed the gain on small or fast pipelines. Measure before relying on parallel. Use it for CPU-bound pipelines with at least a few thousand elements, and avoid it when ordering matters.

Exercises

  1. Filter a list of integers to keep only primes, then sum them.
  2. Map a list of strings to their lengths and find the maximum.
  3. Use Collectors.groupingBy to group a list of words by their first letter.
  4. Use IntStream.rangeClosed to compute the sum 1+2+...+100.
  5. Use flatMap to flatten a list of lists into one stream.