Java Stream API and Collectors Explained with Examples

The Java Stream API processes a sequence of elements through a pipeline.

Lesson 83 of 127Advanced13 min read

The Java Stream API processes a sequence of elements through a pipeline. A source supplies the elements, intermediate operations describe transformations, and a terminal operation produces a result. For example, you can filter a list of prices, convert cents to labels, and collect those labels without manually maintaining a destination list. A stream is not another collection: it does not store your data and it can be consumed only once.

Build a pipeline from source to result

Calling stream on a collection creates a sequential stream. filter keeps elements accepted by a Predicate; map converts each accepted element using a Function. Neither call alone processes the complete input. A terminal operation such as toList, count, or reduce starts traversal. This lazy behavior lets pipelines avoid unnecessary work, particularly with operations such as findFirst. Write each stage according to its purpose instead of adding transformations merely to shorten a loop.

Distinguish map from filter and flatMap

filter preserves element types while selecting a subset. map keeps one result for each incoming element, even when that result has a different type. flatMap is useful when one input represents several results, such as a group containing several members; it flattens the nested streams into a single sequence. These operations solve different problems. A mapping function that sometimes returns null still creates a null result unless you explicitly filter or otherwise handle it.

Reduce values using an identity

reduce combines elements into one value. For addition, zero is the identity because adding zero leaves the other value unchanged. The accumulator Integer::sum combines the running value with each element. The combination should be associative if you expect reliable parallel behavior. Subtraction is not associative, so replacing sum with subtraction is not a sound general reduction. Primitive streams offer specialized sum, average, and summaryStatistics operations that are often clearer for numeric work.

Collect lists and grouped results

Collectors.groupingBy classifies elements and builds a map from keys to groups. A downstream collector such as counting replaces each group list with a count. HashMap iteration order is not specified, so the example prints selected keys rather than depending on map formatting. Stream.toList returns an unmodifiable list on Java 17. If later editing is required, collect with Collectors.toCollection(ArrayList::new) or copy the result into a modifiable list explicitly.

Handle duplicate map keys deliberately

Collectors.toMap needs a plan when two elements produce the same key. The two-argument form throws IllegalStateException for duplicates. A merge function can keep an existing value, choose the new one, or combine them, according to the actual requirement. Do not silently discard records just to remove an exception. When ordering matters, a map supplier such as LinkedHashMap::new makes that intent explicit instead of relying on a default implementation's incidental order.

Keep pipelines free of hidden mutation

A stream should not modify its source while traversing it. Also avoid accumulating into an external ArrayList inside map or forEach when collect already expresses the result. Such state can break when a pipeline is parallelized. Parallel streams are not an automatic speed improvement; splitting work and combining results have costs. Start with a sequential pipeline, use simple functions, and measure a realistic workload before introducing concurrency.

Practice tasks and checks

Modify PricePipeline to select prices strictly above twenty-eight and predict the resulting list before running it. Next, remove every matching price by using a threshold larger than all source elements; toList should produce an empty list rather than null. Add a second calculation using a fresh stream to count matching prices. Do not reuse the consumed stream from the first calculation. For CategoryCounts, ask for an absent category using getOrDefault with zero as a Long value. Finally, create a toMap collector for repeated categories and decide whether repeated values should be counted, rejected, or replaced. Document that choice before writing the merge function so duplicate handling follows a requirement rather than an exception workaround.

Java Summary

  • Intermediate stream operations are lazy until a terminal operation runs.
  • A consumed stream cannot be reused.
  • Choose duplicate-key and result-mutability policies explicitly.
  • Continue with collection contracts when these examples are clear.

Java Examples

Each example shows the code, the output it produces and what each part does.

Example 1

Filter and transform prices

Save this program as PricePipeline.java. Compile it with javac PricePipeline.java and run it with java PricePipeline.

Expected output:

The filter rejects twelve and seven. map runs only for the remaining prices, producing two labels in their original encounter order. toList triggers the traversal and gathers the results. Printing prices afterward shows that the pipeline did not alter the source list. The result is unmodifiable, so labels.add would throw UnsupportedOperationException. This property describes the returned list, not whether its elements would be mutable if they were objects.

Java
import java.util.List;
public class PricePipeline {
    public static void main(String[] args) {
        List<Integer> prices = List.of(12, 28, 7, 35);
        List<String> labels = prices.stream()
            .filter(price -> price >= 20)
            .map(price -> "Price " + price)
            .toList();
        System.out.println(labels);
        System.out.println(prices);
    }
}
Output
[Price 28, Price 35]
[12, 28, 7, 35]
Example 2

Sum values with a reduction

Save this program as PointReduction.java. Compile it with javac PointReduction.java and run it with java PointReduction.

Expected output:

The first reduction begins with zero, adds nine, then four, then eleven, yielding twenty-four. An empty stream contributes no elements, so it returns the identity zero. Using an identity makes the result an int rather than an Optional<Integer>. If you need to distinguish an empty collection from a legitimate zero total, choose a different return contract or use the identity-free reduction and handle its Optional result.

Java
import java.util.List;
public class PointReduction {
    public static void main(String[] args) {
        List<Integer> points = List.of(9, 4, 11);
        int total = points.stream().reduce(0, Integer::sum);
        int emptyTotal = List.<Integer>of().stream().reduce(0, Integer::sum);
        System.out.println(total);
        System.out.println(emptyTotal);
    }
}
Output
24
0
Example 3

Count groups with a downstream collector

Save this program as CategoryCounts.java. Compile it with javac CategoryCounts.java and run it with java CategoryCounts.

Expected output:

The classifier uses each category string as its group key. counting counts elements in each group and returns Long values, so book maps to two and game maps to one. Selecting keys gives deterministic output despite unspecified map iteration order. A missing key would return null from get; use getOrDefault when a zero count is the intended meaning for categories absent from the source.

Java
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class CategoryCounts {
    public static void main(String[] args) {
        List<String> categories = List.of("book", "game", "book", "map");
        Map<String, Long> counts = categories.stream()
            .collect(Collectors.groupingBy(
                category -> category, Collectors.counting()));
        System.out.println(counts.get("book"));
        System.out.println(counts.get("game"));
    }
}
Output
2
1

Java FAQ

Can I reuse the same stream?

No. After a terminal operation has consumed it, another terminal operation normally throws IllegalStateException. Obtain a new stream from the collection for another calculation. Reusing the collection is fine; reusing that consumed traversal object is not.

Is forEach the same as map?

No. map transforms elements and returns a stream for further processing. forEach is a terminal action returning void. Use map followed by collect when building a result, and reserve forEach for deliberate actions such as displaying values.

How do I sum a field of objects with a stream?

Map to a primitive stream and sum it: orders.stream().mapToInt(Order::quantity).sum(). For a total per group, use a downstream collector such as Collectors.groupingBy(Order::customer, Collectors.summingInt(Order::quantity)).

How do I join strings with a stream?

Use Collectors.joining. names.stream().collect(Collectors.joining(", ")) gives "Ana, Ben, Cal", and the three-argument form adds a prefix and suffix, such as joining(", ", "[", "]"). For a list that's already built, String.join(", ", names) is shorter.

What does Collectors.partitioningBy do?

It splits elements into two groups using a predicate and returns a Map<Boolean, List<T>>, with true holding the matches and false the rest. Both keys are always present, even when a group is empty, which makes it handy for pass and fail reports.

Java Exercises

Answer each one and check it instantly. If you get stuck, open the hint before the answer.

Multiple choiceeasy

Which operation starts traversal in PricePipeline?

Multiple choicemedium

What does reducing an empty integer stream with zero and Integer::sum return?

Java Quiz

2 questions to check what you learned.

  1. Which collector counts elements for each classification key?

    • groupingBy with counting
    • joining
    • toList alone
  2. Why should you avoid modifying the source in a pipeline?

    • Traversal requires a stable source contract
    • Streams always copy every source
    • Modification automatically sorts the source