DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on

Java Stream API for Beginners: From Loops to Pipelines

For a long time, my Java code for "take this list, keep the long names, make them uppercase, collect the result" was six lines of loop, an if and a temporary list. Then I saw this:

names.stream().filter(n -> n.length() > 2).map(String::toUpperCase).toList();
Enter fullscreen mode Exit fullscreen mode

One line. It read almost like English, but it also raised questions. What is a stream? What does String::toUpperCase mean? And why do some methods in this style return something called Optional instead of just the value?

In this post, we'll answer those questions in order: method references and constructor references (the :: syntax), the Stream API, and the Optional class. They fit together, so by the end you'll be able to read and write the one-liner above with confidence. If you haven't read about lambdas, skim that first, because everything here builds on them.

All the snippets assume these imports and go inside a main method unless I show a full class:

import java.util.*;
import java.util.function.*;
import java.util.stream.*;
Enter fullscreen mode Exit fullscreen mode

Part 1: Method references and constructor references

A method reference is a shorter way to write a lambda that does nothing except call one existing method. You write the class (or object), then ::, then the method name, with no brackets.

s -> s.length()         // lambda
String::length          // method reference, same meaning
Enter fullscreen mode Exit fullscreen mode

There are four kinds. Here they are with their lambda twins:

   KIND                          REFERENCE              SAME AS THIS LAMBDA
   1. static method              Integer::parseInt      s -> Integer.parseInt(s)
   2. method of a given object   prefix::concat         s -> prefix.concat(s)
   3. method of "any" object     String::length         s -> s.length()
      of a type
   4. constructor                Person::new            n -> new Person(n)
Enter fullscreen mode Exit fullscreen mode

Let's see them one by one:

// 1. static method
Function<String, Integer> parse = Integer::parseInt;
System.out.println(parse.apply("42") + 1);

// 2. method of a specific object we already have
String prefix = "Hello, ";
Function<String, String> greet = prefix::concat;
System.out.println(greet.apply("Asha"));

// 3. method of an object that will be given to us later
Function<String, Integer> len = String::length;
System.out.println(len.apply("lambda"));

BiFunction<String, String, Boolean> same = String::equalsIgnoreCase;
System.out.println(same.apply("Java", "JAVA"));
Enter fullscreen mode Exit fullscreen mode

Output:

43
Hello, Asha
6
true
Enter fullscreen mode Exit fullscreen mode

Kind 3 is the one that confuses people. In String::length, no object is named, so where does the string come from? From the first parameter of the functional interface. Function<String, Integer> gives us one String, and Java calls .length() on it. In String::equalsIgnoreCase, the first parameter is the object, and the second is the argument: (a, b) -> a.equalsIgnoreCase(b).

Constructor references

A constructor reference uses ::new. It's a lambda that creates an object:

class Person {
    private final String name;

    Person(String name) {
        this.name = name;
    }

    @Override
    public String toString() {
        return "Person(" + name + ")";
    }
}
Enter fullscreen mode Exit fullscreen mode
Function<String, Person> maker = Person::new;
Supplier<List<String>> listMaker = ArrayList::new;
IntFunction<int[]> arrayMaker = int[]::new;

System.out.println(maker.apply("Asha"));

List<String> list = listMaker.get();
list.add("x");
System.out.println(list);

System.out.println(arrayMaker.apply(3).length);
Enter fullscreen mode Exit fullscreen mode

Output:

Person(Asha)
[x]
3
Enter fullscreen mode Exit fullscreen mode

How does Java know which constructor Person::new means? It looks at the functional interface. Function<String, Person> takes a String, so Java picks Person(String). Supplier<List<String>> takes nothing, so ArrayList::new means the no-argument constructor. The same words, ArrayList::new, can mean different constructors depending on the target type.

Behind the scenes

Method references work like lambdas. The compiler doesn't create a separate class for them. It writes an invokedynamic instruction, and the JVM builds the function object at run time. The compiler picks the method by matching the parameter types of the functional interface. If two methods could fit, you get an "ambiguous method reference" error, and the fix is to write a normal lambda.

Use a method reference when it makes the code shorter and clearer. If the lambda does more than one call, or the reference looks cryptic, keep the arrow form.

Part 2: The Stream API

What is a stream?

A stream is a pipeline for processing a sequence of data, usually from a collection. It's not a data structure. It doesn't store anything, and it never changes the original collection. Think of a factory conveyor belt: items come in, pass through stations and come out the other end.

Every stream pipeline has three parts:

   SOURCE            INTERMEDIATE operations           TERMINAL operation
   (where data   -->  (each one returns a stream)  -->  (produces a result
    comes from)                                          and starts the work)

   list.stream()  --> filter(...) --> map(...) --> sorted() --> collect(...) / count() / forEach(...)
Enter fullscreen mode Exit fullscreen mode
  • Source: list.stream(), Arrays.stream(array), Stream.of(...), IntStream.rangeClosed(1, 5) and others.
  • Intermediate operations: filter, map, sorted, distinct, limit, skip, flatMap. Each one returns a new stream, so you can chain them.
  • Terminal operation: collect, toList, forEach, count, sum, reduce, min, max, findFirst, anyMatch and others. It ends the pipeline.

From a loop to a stream

Here's the task from my intro, done the old way and the stream way:

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

// the old way
List<String> result = new ArrayList<>();
for (String n : names) {
    if (n.length() > 2) {
        result.add(n.toUpperCase());
    }
}

// the stream way
List<String> result2 = names.stream()
        .filter(n -> n.length() > 2)
        .map(String::toUpperCase)
        .toList();

System.out.println(result2);
Enter fullscreen mode Exit fullscreen mode

Output:

[MEERA, RAVI, ASHA]
Enter fullscreen mode Exit fullscreen mode
  • filter keeps the items where the condition is true (a Predicate).
  • map transforms each item into something else (a Function).
  • toList() collects the result into a list. (It needs Java 16 or newer. In older versions, write .collect(Collectors.toList()).)

The stream version says what you want. The loop says how to do it step by step.

The operations you'll use most

List<Integer> nums = List.of(5, 3, 8, 3, 1, 9, 8);

System.out.println(nums.stream().distinct().sorted().limit(4).toList());
System.out.println(nums.stream().filter(n -> n > 4).count());
System.out.println(nums.stream().mapToInt(Integer::intValue).sum());
System.out.println(nums.stream().reduce(0, Integer::sum));
System.out.println(nums.stream().max(Integer::compare));
System.out.println(nums.stream().anyMatch(n -> n > 8));
Enter fullscreen mode Exit fullscreen mode

Output:

[1, 3, 5, 8]
4
37
37
Optional[9]
true
Enter fullscreen mode Exit fullscreen mode

Let's read them:

  • distinct().sorted().limit(4) removed duplicates, sorted, then kept the first four.
  • count() counted the numbers bigger than 4 (5, 8, 9, 8).
  • mapToInt(...).sum() and reduce(0, Integer::sum) both add everything up. reduce repeatedly combines two values into one, starting from 0.
  • max(...) returns an Optional, which we'll meet in the next part. Here it printed as Optional[9].
  • anyMatch checks whether at least one item passes. (allMatch and noneMatch work the same way.)

A tip: for numbers, prefer mapToInt(...) and the primitive streams (IntStream, LongStream, DoubleStream). They avoid the boxing cost we discussed in the wrapper classes topic, and they give you sum(), average() and similar methods for free.

Collecting results: Collectors

collect takes a collector, a recipe for building the final result:

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

System.out.println(names.stream().distinct()
        .collect(Collectors.joining(", ", "[", "]")));

Map<Integer, List<String>> byLength = names.stream().distinct()
        .collect(Collectors.groupingBy(String::length, TreeMap::new, Collectors.toList()));
System.out.println(byLength);

Map<String, Long> frequency = names.stream()
        .collect(Collectors.groupingBy(n -> n, TreeMap::new, Collectors.counting()));
System.out.println(frequency);
Enter fullscreen mode Exit fullscreen mode

Output:

[Meera, Jo, Ravi, Asha]
{2=[Jo], 4=[Ravi, Asha], 5=[Meera]}
{Asha=1, Jo=2, Meera=1, Ravi=1}
Enter fullscreen mode Exit fullscreen mode
  • joining glued the names into one string.
  • groupingBy made a map from each key (the length) to the list of items that share it.
  • counting counted the items in each group.

Look closely at TreeMap::new. That's a constructor reference from earlier, used to say "put the result in a TreeMap," so the keys come out sorted.

flatMap: flattening nested lists

When each item turns into several items, or you have a list of lists, use flatMap:

List<List<Integer>> nested = List.of(List.of(1, 2), List.of(3), List.of(4, 5));

List<Integer> flat = nested.stream()
        .flatMap(List::stream)
        .toList();

System.out.println(flat);
Enter fullscreen mode Exit fullscreen mode

Output:

[1, 2, 3, 4, 5]
Enter fullscreen mode Exit fullscreen mode

map would give you a stream of streams. flatMap merges them into one.

Streams are lazy

This is the most surprising thing about streams. Intermediate operations do nothing until a terminal operation starts the pipeline:

Stream<String> s = Stream.of("a", "bb", "ccc")
        .filter(x -> {
            System.out.println("filter " + x);
            return x.length() > 1;
        });

System.out.println("Nothing printed yet");
s.forEach(x -> System.out.println("got " + x));
Enter fullscreen mode Exit fullscreen mode

Output:

Nothing printed yet
filter a
filter bb
got bb
filter ccc
got ccc
Enter fullscreen mode Exit fullscreen mode

Two lessons are hidden here:

  1. Nothing ran when we built the pipeline. The filter code only ran after forEach, the terminal operation, started the work.
  2. Each item travels through the whole pipeline before the next one starts. It's not "filter everything, then print everything." It's item by item:
   "a"    -> filter (fails)  -> stops here
   "bb"   -> filter (passes) -> forEach prints "got bb"
   "ccc"  -> filter (passes) -> forEach prints "got ccc"
Enter fullscreen mode Exit fullscreen mode

Laziness also allows short-circuiting. Operations like findFirst and limit can stop early:

Stream.of(1, 2, 3, 4, 5)
        .filter(n -> {
            System.out.println("check " + n);
            return n % 2 == 0;
        })
        .findFirst()
        .ifPresent(n -> System.out.println("first even: " + n));
Enter fullscreen mode Exit fullscreen mode

Output:

check 1
check 2
first even: 2
Enter fullscreen mode Exit fullscreen mode

It never even looked at 3, 4 or 5.

Streams can be used only once

After a terminal operation, the stream is finished:

Stream<Integer> s = Stream.of(1, 2, 3);
s.forEach(System.out::println);
s.forEach(System.out::println);
Enter fullscreen mode Exit fullscreen mode
1
2
3
Exception in thread "main" java.lang.IllegalStateException: stream has already been operated upon or closed
Enter fullscreen mode Exit fullscreen mode

If you need it again, create a new stream from the source.

Behind the scenes

When you chain filter(...) and map(...), Java doesn't process any data. It just builds a chain of stages, like a recipe. Only when the terminal operation runs does it pull items from the source through all the stages. Because of this, the stages can be fused into one pass over the data, and short-circuit operations can stop early. A few operations, like sorted(), need to see all items before they can pass any on, so they act as a wall in the pipeline.

Also, the lambdas you write become the standard functional interfaces we learned about: Predicate for filter, Function for map, Consumer for forEach.

Two rules keep streams safe: don't change the source while a pipeline runs, and avoid side effects inside the lambdas (like adding to an outside list). Use collect to build results instead.

You may also see .parallelStream(), which splits the work across several threads. It can speed up big, CPU-heavy jobs, but for small data it's usually slower, and shared mutable state can cause bugs. Don't reach for it by default.

Part 3: The Optional class

The problem: null

You've probably met this error many times:

Exception in thread "main" java.lang.NullPointerException
Enter fullscreen mode Exit fullscreen mode

It happens when a method returns null ("no result") and the caller forgets to check. The method signature String findEmail(String user) doesn't tell you that null is possible. You only find out when the program crashes.

Optional<T> fixes this by making "maybe no value" visible in the type. It's a small container that either holds one value or is empty. When you see Optional<String> as a return type, you know right away: "there might be nothing here, and I must deal with that."

Creating an Optional

Optional<String> a = Optional.of("Asha");           // value must NOT be null
Optional<String> b = Optional.ofNullable(null);     // null becomes "empty"
Optional<String> c = Optional.empty();              // explicitly empty

System.out.println(a);
System.out.println(b);
Enter fullscreen mode Exit fullscreen mode

Output:

Optional[Asha]
Optional.empty
Enter fullscreen mode Exit fullscreen mode

Use ofNullable when the value might be null. Optional.of(null) throws a NullPointerException immediately.

Using an Optional

Don't pull the value out and test it by hand. Use the methods that handle both cases for you:

Optional<String> name = Optional.of("Asha");
Optional<String> none = Optional.empty();

System.out.println(name.map(String::toUpperCase).orElse("unknown"));
System.out.println(none.map(String::toUpperCase).orElse("unknown"));

name.ifPresent(n -> System.out.println("Hello " + n));
none.ifPresentOrElse(
        n -> System.out.println("Hello " + n),
        () -> System.out.println("No name"));
Enter fullscreen mode Exit fullscreen mode

Output:

ASHA
unknown
Hello Asha
No name
Enter fullscreen mode Exit fullscreen mode

The main tools:

  • map(...) transforms the value if there is one, and does nothing if empty.
  • filter(...) keeps the value only if it passes a test.
  • orElse(default) gives the value, or a default.
  • orElseGet(supplier) gives the value, or calls a supplier to make a default.
  • orElseThrow(...) gives the value, or throws an exception you choose.
  • ifPresent(...) and ifPresentOrElse(...) run code depending on the case.
  • isPresent() and isEmpty() check without reading.
  • get() returns the value, but throws NoSuchElementException if empty. Avoid it.

A real example: a method that returns Optional

static Optional<String> findEmail(String user) {
    Map<String, String> emails = Map.of("asha", "asha@example.com");
    return Optional.ofNullable(emails.get(user));
}
Enter fullscreen mode Exit fullscreen mode
System.out.println(findEmail("asha").orElse("no email"));
System.out.println(findEmail("ravi").orElse("no email"));

findEmail("ravi").ifPresentOrElse(
        e -> System.out.println("Email: " + e),
        () -> System.out.println("Missing"));
Enter fullscreen mode Exit fullscreen mode

Output:

asha@example.com
no email
Missing
Enter fullscreen mode Exit fullscreen mode

Notice that the caller can't forget the empty case. The type forces the question.

Chaining instead of nested null checks

Optional shines when you chain steps that each might fail:

static String clean(String input) {
    return Optional.ofNullable(input)
            .map(String::trim)
            .filter(s -> !s.isEmpty())
            .map(String::toUpperCase)
            .orElse("EMPTY");
}
Enter fullscreen mode Exit fullscreen mode
System.out.println(clean("  hi "));
System.out.println(clean("   "));
System.out.println(clean(null));
Enter fullscreen mode Exit fullscreen mode

Output:

HI
EMPTY
EMPTY
Enter fullscreen mode Exit fullscreen mode

The same logic with if (input != null) { ... } would take several nested checks. Here, if any step fails (null, or blank after trimming), the whole chain turns into "empty" and falls to orElse.

orElse vs orElseGet: a hidden trap

static String expensive() {
    System.out.println("computing default");
    return "default";
}
Enter fullscreen mode Exit fullscreen mode
Optional<String> present = Optional.of("value");

System.out.println(present.orElse(expensive()));
System.out.println(present.orElseGet(() -> expensive()));
Enter fullscreen mode Exit fullscreen mode

Output:

computing default
value
value
Enter fullscreen mode Exit fullscreen mode

Even though the Optional had a value, the first line still ran expensive(). With orElse, Java computes the argument before the call, whether it's needed or not. With orElseGet, you pass a Supplier, and it runs only if the Optional is empty. For cheap constants, orElse is fine. For anything that does real work, use orElseGet.

Optional and streams

You already met Optional in the stream section: findFirst, max, min and reduce (the version without a starting value) all return one, because a stream might be empty:

List<Integer> nums = List.of(5, 3, 8, 3, 1, 9, 8);

System.out.println(nums.stream().filter(n -> n > 100).findFirst());
System.out.println(nums.stream().mapToInt(Integer::intValue).average());
System.out.println(nums.stream().mapToInt(Integer::intValue).average().orElse(0));
Enter fullscreen mode Exit fullscreen mode

Output:

Optional.empty
OptionalDouble[5.285714285714286]
5.285714285714286
Enter fullscreen mode Exit fullscreen mode

Primitive streams have their own versions: OptionalInt, OptionalLong and OptionalDouble.

Behind the scenes

Optional isn't magic. It's a small final class with one field holding the value, which is null when empty. The empty case is one shared object, so Optional.empty() doesn't create a new one each time.

   Optional.of("Asha")   ->  [ Optional | value --> "Asha" ]
   Optional.empty()      ->  the shared EMPTY object [ Optional | value = null ]
Enter fullscreen mode Exit fullscreen mode

Because it's an extra object, it has a small cost. And it's meant for one job only: return types that mean "might have no result." That leads to a few guidelines:

  • Don't use Optional for fields, method parameters or items inside collections. Just use normal types, or overloads.
  • Don't return an Optional of a collection. Return an empty list instead.
  • Never return null from a method whose return type is Optional.
  • Don't write if (opt.isPresent()) { opt.get() }. That's just a null check in disguise. Use map, orElse or ifPresent.

Which one should I use?

  • Lambda or method reference? Use a method reference when the lambda only calls one method and the result is clearer (String::toUpperCase). Use a lambda when there's more logic.
  • Loop or stream? Use a stream for transform-filter-collect jobs, since it reads clearly. Keep a loop when you need break, an index, several changing variables or the simplest possible code. Don't force a stream into a job where it makes the code harder to read.
  • toList() or collect(Collectors.toList())? On Java 16 or newer, toList() is shorter, but the result can't be modified. If you need a changeable list, use collect(Collectors.toCollection(ArrayList::new)).
  • map or mapToInt? For numeric work (sums, averages), mapToInt avoids boxing and gives you sum() and average().
  • orElse, orElseGet or orElseThrow? orElse for cheap defaults, orElseGet for computed ones and orElseThrow when "empty" is truly an error.
  • Optional or null? Use Optional as a return type when "no result" is a normal answer. Don't use it everywhere.

Common mistakes

  1. Forgetting the terminal operation. list.stream().filter(...).map(...) alone does nothing, because the pipeline never starts. Always end with toList(), collect, forEach, count or similar.
  2. Reusing a stream. After a terminal operation, it's closed. You'll get IllegalStateException. Make a new stream from the source.
  3. Changing things from inside a stream. Adding to an outside list in forEach, or modifying the source collection, causes confusing bugs. Let collect build your result.
  4. Calling get() on an Optional without checking, or using Optional.of(null). Both throw exceptions. Prefer orElse... and ofNullable.
  5. Using orElse with an expensive call. It always runs, even when the value is present. Use orElseGet.
  6. Writing huge, unreadable chains, or using parallelStream() by default. If a pipeline needs a comment to explain it, split it into named steps. Parallel streams help only for large, CPU-heavy work.

Quick recap

  • A method reference (Class::method) is a short lambda. There are four kinds: static, specific object, "any object of a type" and constructor (::new).
  • A constructor reference picks the constructor that matches the functional interface's parameters.
  • A stream is a lazy pipeline: source → intermediate operations → terminal operation. It doesn't store data or change the source, and it can be used only once.
  • Nothing runs until the terminal operation starts, and each item flows through the whole pipeline in turn.
  • Common tools: filter, map, flatMap, sorted, distinct, limit, reduce, collect with Collectors like groupingBy, counting and joining.
  • Optional<T> is a container that holds one value or nothing. It makes "maybe no result" visible in a method's return type.
  • Use map, filter, orElse, orElseGet, orElseThrow and ifPresent instead of get(). Use orElseGet when the default is expensive.
  • Use Optional for return types, not for fields, parameters or collection items.

Try it yourself

Here's a small practice idea: take a list of words, such as the lines of a short paragraph split by spaces. Use a stream to remove blanks, make everything lowercase, count how often each word appears with groupingBy and counting into a TreeMap, and print them sorted. Then write a method Optional<String> longestWord(List<String> words) that returns the longest word, and print it with orElse("none").

For a bonus experiment, add a System.out.println inside a filter and a map, and watch how the items flow one by one. Then add .limit(2) and see how many items get processed.

If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.

Next in this series: var, records and sealed classes.

Top comments (0)