Introduction to the Stream API in Java

A stream describes a computation over a sequence of elements. You state what you want, and the pipeline decides how to walk the data.

What a stream is

A stream is a sequence of elements supporting a pipeline of operations. It is not a data structure: it stores nothing, and it does not modify its source. It describes work to be done, and does that work only when a terminal operation asks for a result.

The shape of a pipeline

source  ->  intermediate operations  ->  terminal operation
list        filter, map, sorted           collect, forEach, count
List<String> titles = notes.stream()          // source
        .filter(Note::isPublished)             // intermediate
        .map(Note::title)                      // intermediate
        .sorted()                              // intermediate
        .toList();                             // terminal

The same thing without streams

List<String> titles = new ArrayList<>();
for (Note note : notes) {
    if (note.isPublished()) {
        titles.add(note.title());
    }
}
Collections.sort(titles);

The loop describes how; the stream describes what. Neither is always better, but the stream version composes and reads as a single statement of intent.

Creating streams

List<String> names = List.of("Ravi", "Anita", "Meera");

Stream<String> fromCollection = names.stream();
Stream<String> fromValues = Stream.of("a", "b", "c");
Stream<String> fromArray = Arrays.stream(new String[]{"x", "y"});
Stream<String> empty = Stream.empty();

IntStream range = IntStream.range(0, 10);          // 0 to 9
IntStream inclusive = IntStream.rangeClosed(1, 10); // 1 to 10

Stream<Integer> generated = Stream.iterate(1, n -> n * 2).limit(10);
Stream<Integer> bounded = Stream.iterate(1, n -> n < 100, n -> n * 2);  // Java 9
Stream<Double> random = Stream.generate(Math::random).limit(5);

Stream<String> lines = Files.lines(path);          // must be closed
Stream<String> split = Pattern.compile(",").splitAsStream("a,b,c");
Stream.iterate and Stream.generate are infinite. They must be bounded by limit or by the three argument form, or the pipeline never ends.

Laziness

Stream<String> pipeline = names.stream()
        .filter(name -> {
            System.out.println("filtering " + name);
            return name.length() > 4;
        })
        .map(name -> {
            System.out.println("mapping " + name);
            return name.toUpperCase();
        });

System.out.println("nothing has run yet");
List<String> result = pipeline.toList();     // now it runs

Intermediate operations only build the pipeline. Nothing is evaluated until a terminal operation arrives.

Elements flow one at a time

Ravi  -> filter (length 4, dropped)
Anita -> filter (passes) -> map -> ANITA -> collected
Meera -> filter (passes) -> map -> MEERA -> collected

Each element travels the whole pipeline before the next one starts. The stream does not filter everything, then map everything. This is what makes short circuiting possible.

Optional<String> first = Stream.iterate(1, n -> n + 1)   // infinite
        .filter(n -> n % 7 == 0)
        .map(String::valueOf)
        .findFirst();                                     // stops at the first match

A stream can be used once

Stream<String> stream = names.stream();
stream.forEach(System.out::println);
// stream.count();      // IllegalStateException: stream has already been operated upon

Create a new stream from the source each time, or keep the source rather than the stream.

Primitive streams

IntStream marks = IntStream.of(45, 78, 62, 91);

System.out.println(marks.sum());                       // no boxing
System.out.println(IntStream.of(45, 78).average().getAsDouble());
IntSummaryStatistics stats = IntStream.of(45, 78, 62).summaryStatistics();
System.out.println(stats.getMax() + " " + stats.getAverage());

// Converting between them
IntStream lengths = names.stream().mapToInt(String::length);
Stream<Integer> boxed = lengths.boxed();

IntStream, LongStream and DoubleStream avoid wrapper allocation and add numeric operations such as sum and average that the object stream does not have.

A worked example

record Note(String title, String category, int views, boolean published) { }

List<Note> notes = List.of(
        new Note("Java loops", "java", 320, true),
        new Note("SQL joins", "sql", 540, true),
        new Note("Java generics", "java", 180, false),
        new Note("Java streams", "java", 610, true));

Map<String, Long> publishedPerCategory = notes.stream()
        .filter(Note::published)
        .collect(Collectors.groupingBy(Note::category, Collectors.counting()));

System.out.println(publishedPerCategory);   // {java=2, sql=1}

int totalViews = notes.stream().mapToInt(Note::views).sum();
Optional<Note> mostViewed = notes.stream().max(Comparator.comparingInt(Note::views));

Streams do not change the source

List<String> original = new ArrayList<>(List.of("b", "a", "c"));
List<String> sorted = original.stream().sorted().toList();

System.out.println(original);   // [b, a, c] - unchanged
System.out.println(sorted);     // [a, b, c]

When not to use a stream

  • A simple loop over a small list, where a for loop is shorter and clearer.
  • When you need the index of each element.
  • When the body must modify several variables outside the loop.
  • When a checked exception must be thrown from the body.
  • In a very hot loop over primitives, where a plain loop avoids all overhead.

Common mistakes

  • Reusing a stream after a terminal operation.
  • Building a pipeline and forgetting the terminal operation, so nothing runs.
  • Using an unbounded iterate or generate without limit.
  • Modifying the source collection while the stream runs.
  • Using forEach to fill an external list instead of collect.
  • Leaving a Files.lines stream unclosed.

Best practices

  • Keep pipelines short; extract long lambdas into named methods.
  • Use primitive streams for numeric work.
  • Keep operations free of side effects.
  • Use toList() for the common case, since Java 16.
  • Close streams over I/O with try with resources.
  • Choose a loop when it genuinely reads better.

Practice

  1. Convert a loop that filters and collects names longer than four characters into a stream.
  2. Add print statements to a filter and a map and explain the order of the output.
  3. Why does calling two terminal operations on one stream throw?
  4. Produce the first five powers of three using Stream.iterate.
  5. Compute the total and the average of a list of marks without boxing.

Conclusion

A stream is a lazy description of work over a sequence. Build it from a source, chain intermediate operations, finish with a terminal one, and remember that elements flow through one at a time and the source is never modified.

Useful resources

Hand picked references for this topic
Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Java notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.