Java’s streams offer a surprisingly powerful way to write declarative, data-processing code that often feels more like SQL than traditional imperative Java.

Let’s see this in action. Imagine we have a list of Order objects, each with a customerName and a totalAmount. We want to find the total amount spent by customers whose names start with "A".

import java.util.List;
import java.util.Arrays;

class Order {
    String customerName;
    double totalAmount;

    public Order(String customerName, double totalAmount) {
        this.customerName = customerName;
        this.totalAmount = totalAmount;
    }

    public String getCustomerName() {
        return customerName;
    }

    public double getTotalAmount() {
        return totalAmount;
    }
}

public class StreamExample {
    public static void main(String[] args) {
        List<Order> orders = Arrays.asList(
            new Order("Alice", 100.50),
            new Order("Bob", 75.20),
            new Order("Charlie", 150.00),
            new Order("Alice", 50.25),
            new Order("David", 200.00),
            new Order("Anna", 120.75)
        );

        double totalForA = orders.stream()
            .filter(order -> order.getCustomerName().startsWith("A"))
            .mapToDouble(Order::getTotalAmount)
            .sum();

        System.out.println("Total for customers starting with 'A': " + totalForA);
    }
}

When you run this, the output is 271.5. This sequence of operations – stream(), filter(), mapToDouble(), sum() – is the core of functional-style Java with streams. It’s a pipeline. You take your data source (the orders list), turn it into a stream, and then apply a series of transformations and terminal operations.

The problem this solves is making complex data manipulations more readable and less error-prone. Instead of managing loop counters, explicit temporary variables, and conditional logic within loops, you describe what you want to achieve. The stream API handles the how. The filter operation selects only the orders matching our criteria. mapToDouble extracts just the numerical amount from those filtered orders, and sum aggregates them. This declarative style makes the intent crystal clear.

Internally, streams are lazy. Operations like filter and map don’t actually do anything until a terminal operation (like sum, collect, forEach) is called. This allows the Java runtime to optimize the execution. For example, if you have a very large dataset and your filter operation quickly eliminates most elements, the subsequent operations only need to process a much smaller subset. It also enables parallel processing with .parallelStream(), where the stream operations can be executed across multiple threads without you needing to manage thread synchronization explicitly, as long as the operations themselves are thread-safe.

A key nuance often missed is that streams are consumed exactly once. If you try to call a terminal operation on a stream after it’s already been consumed, you’ll get an IllegalStateException. This is by design; it prevents accidental double-processing and encourages creating a new stream for each distinct data processing task. You can, however, collect the results of a stream into a collection (like a List or Map) and then create new streams from that collection, effectively reusing the processed data.

The next logical step is exploring collectors, which allow you to gather stream elements into various data structures and perform more complex aggregations.

Want structured learning?

Take the full Java course →