What you'll learn

  • The source → intermediate → terminal pipeline
  • filter, map, sorted, distinct and limit
  • Terminal operations like toList, count, anyMatch and findFirst
  • Number streams for sums and averages
  • Laziness and single use

A stream is a pipeline for processing a sequence of elements. Instead of writing how to loop (“create a list, loop, check, add”), you describe what you want (“orders that are new and over 100, as IDs”). The result is shorter, easier to read and harder to get wrong.

List<String> bigNewOrders = orders.stream()
        .filter(o -> o.status().equals("NEW"))
        .filter(o -> o.amount() > 100)
        .map(Order::id)
        .toList();

The same with a loop:

List<String> bigNewOrders = new ArrayList<>();
for (Order o : orders) {
    if (o.status().equals("NEW") && o.amount() > 100) {
        bigNewOrders.add(o.id());
    }
}

The three parts of every pipeline

  1. Source — where elements come from: list.stream(), Stream.of(...), IntStream.range(0, 10), Arrays.stream(array), Files.lines(path).
  2. Intermediate operations — transform the stream and return a new stream: filter, map, sorted, distinct, limit, skip, peek, flatMap.
  3. Terminal operation — produces a result and ends the stream: toList, collect, count, forEach, findFirst, anyMatch, reduce, sum.

Everyday intermediate operations

orders.stream()
      .filter(o -> o.amount() > 50)                                  // keep matching elements
      .map(Order::customer)                                          // transform each element
      .distinct()                                                    // remove duplicates
      .sorted()                                                      // natural order
      .limit(10)                                                     // at most 10
      .toList();

orders.stream()
      .sorted(Comparator.comparingDouble(Order::amount).reversed())  // custom order
      .limit(2)
      .forEach(o -> System.out.println(o.id()));

Everyday terminal operations

long n = orders.stream().filter(o -> o.status().equals("NEW")).count();
boolean any = orders.stream().anyMatch(o -> o.status().equals("CANCELLED"));
boolean all = orders.stream().allMatch(o -> o.amount() > 0);
boolean none = orders.stream().noneMatch(o -> o.amount() < 0);
Optional<Order> first = orders.stream().filter(o -> o.customer().equals("Acme")).findFirst();
List<String> ids = orders.stream().map(Order::id).toList();          // Java 16+

findFirst returns an Optional — because there might be no match.

Number streams

mapToInt, mapToLong and mapToDouble give you streams of primitives with handy maths:

double total = orders.stream().mapToDouble(Order::amount).sum();
OptionalDouble avg = orders.stream().mapToDouble(Order::amount).average();
int max = IntStream.of(3, 9, 4).max().getAsInt();
IntStream.rangeClosed(1, 5).map(n -> n * n).forEach(System.out::println);   // 1 4 9 16 25

Two rules that surprise beginners

Streams are lazy

Intermediate operations don’t run until a terminal operation asks for results — and then only as much as needed:

Stream<String> lazy = orders.stream().map(o -> {
    System.out.println("mapping " + o.id());
    return o.id();
});
System.out.println("Nothing mapped yet...");
lazy.findFirst();   // maps only the first order

This makes filter(...).findFirst() efficient even on huge inputs.

A stream can be used only once

After a terminal operation the stream is closed; calling another throws IllegalStateException. Create a new stream from the source each time.

Keep pipelines clean

  • No side effects in map or filter. Don’t add to outside lists or change objects inside them — collect the result instead.
  • One operation per line makes pipelines easy to read and debug.
  • Extract complex lambdas into well-named methods (.filter(this::isEligible)).
  • Loops are still fine for simple cases, or when you need to break early with complex logic.

The full example

// StreamsBasics.java — creating streams, filter/map/sorted, terminal operations.
import java.util.*;
import java.util.stream.*;

public class StreamsBasics {

    record Order(String id, String customer, double amount, String status) { }

    public static void main(String[] args) {
        List<Order> orders = List.of(
                new Order("PO-1", "Acme", 250.0, "SHIPPED"),
                new Order("PO-2", "Globex", 90.0, "NEW"),
                new Order("PO-3", "Acme", 1200.0, "NEW"),
                new Order("PO-4", "Initech", 40.0, "CANCELLED"));

        // source -> intermediate operations -> terminal operation
        List<String> bigNewOrders = orders.stream()
                .filter(o -> o.status().equals("NEW"))        // keep some
                .filter(o -> o.amount() > 100)
                .map(Order::id)                               // transform each
                .toList();                                    // collect (Java 16+)
        System.out.println(bigNewOrders);                     // [PO-3]

        // Sorting and limiting
        orders.stream()
                .sorted(Comparator.comparingDouble(Order::amount).reversed())
                .limit(2)
                .forEach(o -> System.out.println(o.id() + " " + o.amount()));

        // Numbers: mapToDouble gives sum/average/max without boxing
        double total = orders.stream().mapToDouble(Order::amount).sum();
        OptionalDouble avg = orders.stream().mapToDouble(Order::amount).average();
        System.out.printf("Total %.1f, average %.1f%n", total, avg.orElse(0));

        // Matching and finding
        boolean anyCancelled = orders.stream().anyMatch(o -> o.status().equals("CANCELLED"));
        boolean allPositive = orders.stream().allMatch(o -> o.amount() > 0);
        Optional<Order> firstAcme = orders.stream().filter(o -> o.customer().equals("Acme")).findFirst();
        System.out.println(anyCancelled + " " + allPositive + " " + firstAcme.map(Order::id).orElse("-"));

        // distinct, count
        long customers = orders.stream().map(Order::customer).distinct().count();
        System.out.println(customers + " customers");          // 3 customers

        // Other ways to create streams
        Stream.of("a", "b", "c").map(String::toUpperCase).forEach(System.out::print);
        System.out.println();
        IntStream.rangeClosed(1, 5).map(n -> n * n).forEach(n -> System.out.print(n + " "));
        System.out.println();
        Arrays.stream(new int[] {3, 1, 2}).sorted().forEach(System.out::print);
        System.out.println();

        // Streams are lazy: nothing runs until the terminal operation
        Stream<String> lazy = orders.stream().map(o -> {
            System.out.println("  mapping " + o.id());
            return o.id();
        });
        System.out.println("Nothing mapped yet...");
        System.out.println(lazy.findFirst().orElse(""));       // maps only PO-1

        // A stream can be used only once
        Stream<Order> once = orders.stream();
        once.count();
        try {
            once.count();
        } catch (IllegalStateException e) {
            System.out.println("Streams can't be reused: " + e.getMessage());
        }
    }
}

Try it yourself

From a list of strings like "AX-200:24", "BX-310:0", "CX-420:7" (SKU and stock), produce the SKUs that are in stock, sorted alphabetically.

Show a solution
List<String> rows = List.of("CX-420:7", "BX-310:0", "AX-200:24");
List<String> inStock = rows.stream()
        .map(r -> r.split(":"))
        .filter(parts -> Integer.parseInt(parts[1]) > 0)
        .map(parts -> parts[0])
        .sorted()
        .toList();
System.out.println(inStock);   // [AX-200, CX-420]

Key takeaways

  • Every stream is source → intermediate operations → terminal operation.
  • filter, map, sorted, distinct, limit shape the data; toList, count, anyMatch, findFirst finish it.
  • Use mapToInt/mapToDouble for sums and averages.
  • Streams are lazy and single-use; keep operations free of side effects.

Example files