java-stream-api-intro-filter-map-foreach

Introduction to Stream API: filter, map and forEach

  • 4 min

A Stream is a sequence of elements on which we chain processing operations.

Suppose you have a list of users and you are asked: “Give me the names of active users, converted to uppercase and sorted alphabetically”.

The Old Approach (Imperative - Java < 8): You have to tell the computer HOW to do it step by step.

List<String> result = new ArrayList<>();
for (User u : users) {
    if (u.isActive()) {                // 1. Filter
        String name = u.getName();
        result.add(name.toUpperCase()); // 2. Transform
    }
}
Collections.sort(result);           // 3. Sort
for (String s : result) {
    System.out.println(s);             // 4. Print
}
Copied!

This is ugly. We mix business logic (“is active”) with iteration logic (for, if).

The Modern Approach (Declarative - Java 8+): You just say WHAT you want to achieve.

users.stream()
    .filter(u -> u.isActive())        // 1. Filter
    .map(u -> u.getName().toUpperCase()) // 2. Transform
    .sorted()                         // 3. Sort
    .forEach(System.out::println);    // 4. Print
Copied!

Welcome to the Stream API.

What is a stream?

A Stream is not a data structure. It does not store data. It is an assembly line (pipeline) through which data passes to be processed.

Think of a conveyor belt in a factory:

  1. Source: Parts come out (the original List).
  2. Intermediate Operations: Robots that manipulate the parts (Filter, Paint, Cut).
  3. Terminal Operation: The final packaging.

Immutability: Stream operations do not modify the source collection themselves. However, a lambda with side effects could change objects or external state, so it is best to avoid it.

The 3 phases of a stream

Every Stream always has this structure:

The source (.stream())

We convert a Collection (List, Set) into a stream. users.stream()

Intermediate operations (transformers)

These are methods that return another Stream. They can be chained infinitely. This is where we use the Functional Interfaces we learned in the previous article.

  • .filter(Predicate): Lets through only the elements that meet the condition.

    Example: .filter(u -> u.getAge() > 18)

  • .map(Function): Transforms each element into something else.

    Example: Input User -> Output String (their name). Analogy: Input Orange -> Output Juice.

  • .sorted(): Sorts the stream (using Comparable or a Comparator).

  • .distinct(): Removes duplicates (using equals/hashCode).

Terminal operation (the trigger)

These are methods that close the Stream and produce a result (a value, a new list, or a side effect). Without a terminal operation, the Stream does NOT START.

  • .forEach(Consumer): Executes an action for each element (e.g., print).
  • .count(): Counts how many elements have survived.
  • .collect(...): Saves the result in a new List (we will see this in the next post).

Full example: sales analysis

Suppose a list of Sale (product, amount). We want the amounts of sales greater than €100, adding VAT.

List<Sale> sales = repository.getSales();

sales.stream() // 1. Open the tap
    .filter(v -> v.getAmount() > 100)      // Predicate: Only expensive ones
    .map(v -> v.getAmount() * 1.21)        // Function: Sale -> Double (with VAT)
    .forEach(price -> System.out.println("Final price: " + price)); // Consumer
Copied!

Key concept: laziness

Streams are lazy (Lazy Evaluation). When you write .filter(...) or .map(...), Java is not executing anything yet. It is just “taking note” of the instructions.

Only when you call the Terminal Operation (like .forEach or .count), the water starts to flow and all operations are executed at once.

This allows for brutal optimizations: if you only ask for the first element (.findFirst()), the Stream will stop processing the rest of the list as soon as it finds one, even if the list has 1 million items.

A common mistake: reusing a stream

A Stream is single-use. Once the water has passed and reached the terminal operation, the Stream is closed.

Stream<String> myStream = list.stream().filter(s -> s.length() > 3);

myStream.forEach(System.out::println); // ✅ Works
myStream.count(); // ❌ EXCEPTION: Stream has already been operated upon or closed
Copied!

If you need to process the data again, you must create a new Stream from the source (list.stream()).