Java Streams API

The Streams API transforms how you work with collections in Java. Instead of writing loops to filter, transform, and aggregate data, you describe what you want and let the stream handle the how. Code becomes shorter and often easier to understand.

Streams arrived in Java 8 alongside lambda expressions. They work together: streams provide the operations, lambdas provide the logic. This tutorial covers how to create streams, the most useful operations, and patterns you’ll use constantly.

What is a Stream?

A stream is a sequence of elements that supports operations like filtering, mapping, and reducing. It’s not a data structure. It doesn’t store elements. Instead, it processes elements from a source (like a list or array) through a pipeline of operations.

Here’s a comparison. The traditional approach to finding names longer than 4 characters:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");
List<String> longNames = new ArrayList<>();

for (String name : names) {
    if (name.length() > 4) {
        longNames.add(name);
    }
}

System.out.println(longNames);  // [Alice, Charlie, David]

The stream approach:

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");

List<String> longNames = names.stream()
    .filter(name -> name.length() > 4)
    .collect(Collectors.toList());

System.out.println(longNames);  // [Alice, Charlie, David]

The stream version declares intent: filter by length, collect to list. The loop version buries intent in mechanics: create list, iterate, check condition, add.

Creating Streams

You can create streams from various sources.

From Collections

List<String> list = Arrays.asList("a", "b", "c");
Stream<String> streamFromList = list.stream();

Set<Integer> set = new HashSet<>(Arrays.asList(1, 2, 3));
Stream<Integer> streamFromSet = set.stream();

From Arrays

String[] array = {"x", "y", "z"};
Stream<String> streamFromArray = Arrays.stream(array);

// For primitive arrays, use specialized streams
int[] numbers = {1, 2, 3, 4, 5};
IntStream intStream = Arrays.stream(numbers);

Using Stream.of()

Stream<String> stream = Stream.of("one", "two", "three");

Stream<Integer> numbers = Stream.of(1, 2, 3, 4, 5);

Generate and Iterate

// Infinite stream of random numbers (use limit!)
Stream<Double> randoms = Stream.generate(Math::random).limit(5);

// Infinite stream starting at 0, incrementing by 2
Stream<Integer> evens = Stream.iterate(0, n -> n + 2).limit(10);
// 0, 2, 4, 6, 8, 10, 12, 14, 16, 18

From Files

// Each line becomes a stream element
try (Stream<String> lines = Files.lines(Paths.get("data.txt"))) {
    lines.forEach(System.out::println);
} catch (IOException e) {
    e.printStackTrace();
}

Stream Operations

Stream operations fall into two categories: intermediate and terminal.

Intermediate operations return another stream. They’re lazy, meaning they don’t execute until a terminal operation runs. You can chain multiple intermediate operations.

Terminal operations produce a result or side effect. They trigger the processing of the stream. After a terminal operation, the stream is consumed and can’t be reused.

Intermediate Operations

filter() – Keeps elements matching a condition.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

List<Integer> evens = numbers.stream()
    .filter(n -> n % 2 == 0)
    .collect(Collectors.toList());

System.out.println(evens);  // [2, 4, 6, 8, 10]

map() – Transforms each element.

List<String> names = Arrays.asList("alice", "bob", "charlie");

List<String> upperNames = names.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());

System.out.println(upperNames);  // [ALICE, BOB, CHARLIE]

flatMap() – Flattens nested structures. Each element becomes zero or more elements.

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

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

System.out.println(flat);  // [1, 2, 3, 4, 5, 6]

distinct() – Removes duplicates.

List<Integer> numbers = Arrays.asList(1, 2, 2, 3, 3, 3, 4);

List<Integer> unique = numbers.stream()
    .distinct()
    .collect(Collectors.toList());

System.out.println(unique);  // [1, 2, 3, 4]

sorted() – Sorts elements.

List<String> names = Arrays.asList("Charlie", "Alice", "Bob");

// Natural order
List<String> sorted = names.stream()
    .sorted()
    .collect(Collectors.toList());
// [Alice, Bob, Charlie]

// Custom order (by length)
List<String> byLength = names.stream()
    .sorted(Comparator.comparingInt(String::length))
    .collect(Collectors.toList());
// [Bob, Alice, Charlie]

// Reverse order
List<String> reversed = names.stream()
    .sorted(Comparator.reverseOrder())
    .collect(Collectors.toList());
// [Charlie, Bob, Alice]

limit() and skip() – Control stream size.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// First 3 elements
List<Integer> firstThree = numbers.stream()
    .limit(3)
    .collect(Collectors.toList());
// [1, 2, 3]

// Skip first 5, take the rest
List<Integer> afterFive = numbers.stream()
    .skip(5)
    .collect(Collectors.toList());
// [6, 7, 8, 9, 10]

// Pagination: skip 4, take 3
List<Integer> page = numbers.stream()
    .skip(4)
    .limit(3)
    .collect(Collectors.toList());
// [5, 6, 7]

peek() – Performs an action on each element without consuming. Useful for debugging.

List<String> result = Stream.of("one", "two", "three")
    .filter(s -> s.length() > 3)
    .peek(s -> System.out.println("Filtered: " + s))
    .map(String::toUpperCase)
    .peek(s -> System.out.println("Mapped: " + s))
    .collect(Collectors.toList());

// Output:
// Filtered: three
// Mapped: THREE

Terminal Operations

collect() – Gathers elements into a collection or other structure.

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

// To List
List<String> list = names.stream().collect(Collectors.toList());

// To Set
Set<String> set = names.stream().collect(Collectors.toSet());

// To specific collection type
TreeSet<String> treeSet = names.stream()
    .collect(Collectors.toCollection(TreeSet::new));

// Join to String
String joined = names.stream().collect(Collectors.joining(", "));
// "Alice, Bob, Charlie"

// To Map
Map<String, Integer> nameLengths = names.stream()
    .collect(Collectors.toMap(
        name -> name,           // key
        name -> name.length()   // value
    ));
// {Alice=5, Bob=3, Charlie=7}

forEach() – Performs an action on each element.

Stream.of("a", "b", "c").forEach(System.out::println);

count() – Returns the number of elements.

long count = Stream.of(1, 2, 3, 4, 5)
    .filter(n -> n > 2)
    .count();

System.out.println(count);  // 3

findFirst() and findAny() – Return an element wrapped in Optional.

Optional<String> first = Stream.of("apple", "banana", "cherry")
    .filter(s -> s.startsWith("b"))
    .findFirst();

first.ifPresent(System.out::println);  // banana

anyMatch(), allMatch(), noneMatch() – Test conditions.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

boolean hasEven = numbers.stream().anyMatch(n -> n % 2 == 0);   // true
boolean allPositive = numbers.stream().allMatch(n -> n > 0);     // true
boolean noneNegative = numbers.stream().noneMatch(n -> n < 0);   // true

reduce() – Combines elements into a single result.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);

// Sum
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
System.out.println(sum);  // 15

// Using method reference
int sum2 = numbers.stream().reduce(0, Integer::sum);

// Product
int product = numbers.stream().reduce(1, (a, b) -> a * b);
System.out.println(product);  // 120

// Max (returns Optional since stream might be empty)
Optional<Integer> max = numbers.stream().reduce(Integer::max);
max.ifPresent(System.out::println);  // 5

min() and max() – Find extremes.

List<String> names = Arrays.asList("Alice", "Bob", "Christopher");

Optional<String> shortest = names.stream()
    .min(Comparator.comparingInt(String::length));

Optional<String> longest = names.stream()
    .max(Comparator.comparingInt(String::length));

shortest.ifPresent(s -> System.out.println("Shortest: " + s));  // Bob
longest.ifPresent(s -> System.out.println("Longest: " + s));    // Christopher

Primitive Streams

Working with primitives through Stream<Integer> involves boxing overhead. Java provides specialized streams for better performance: IntStream, LongStream, and DoubleStream.

// Creating primitive streams
IntStream intStream = IntStream.of(1, 2, 3, 4, 5);
IntStream range = IntStream.range(1, 10);        // 1 to 9
IntStream rangeClosed = IntStream.rangeClosed(1, 10);  // 1 to 10

// Sum, average, max, min built in
int sum = IntStream.rangeClosed(1, 100).sum();
System.out.println(sum);  // 5050

OptionalDouble avg = IntStream.of(1, 2, 3, 4, 5).average();
avg.ifPresent(System.out::println);  // 3.0

// Statistics in one pass
IntSummaryStatistics stats = IntStream.of(1, 2, 3, 4, 5).summaryStatistics();
System.out.println("Count: " + stats.getCount());    // 5
System.out.println("Sum: " + stats.getSum());        // 15
System.out.println("Min: " + stats.getMin());        // 1
System.out.println("Max: " + stats.getMax());        // 5
System.out.println("Avg: " + stats.getAverage());    // 3.0

Converting between object and primitive streams:

// Object stream to primitive
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
IntStream lengths = names.stream().mapToInt(String::length);

// Primitive to object
Stream<Integer> boxed = IntStream.of(1, 2, 3).boxed();

Grouping and Partitioning

Collectors provides powerful grouping operations.

groupingBy() – Groups elements by a classifier.

List<String> names = Arrays.asList("Alice", "Bob", "Charlie", "David", "Eve");

// Group by length
Map<Integer, List<String>> byLength = names.stream()
    .collect(Collectors.groupingBy(String::length));

System.out.println(byLength);
// {3=[Bob, Eve], 5=[Alice, David], 7=[Charlie]}

// Group by first letter
Map<Character, List<String>> byFirstLetter = names.stream()
    .collect(Collectors.groupingBy(s -> s.charAt(0)));

System.out.println(byFirstLetter);
// {A=[Alice], B=[Bob], C=[Charlie], D=[David], E=[Eve]}

You can chain downstream collectors for more complex grouping:

// Group by length, count each group
Map<Integer, Long> countByLength = names.stream()
    .collect(Collectors.groupingBy(String::length, Collectors.counting()));

System.out.println(countByLength);  // {3=2, 5=2, 7=1}

// Group by length, join names
Map<Integer, String> joinedByLength = names.stream()
    .collect(Collectors.groupingBy(
        String::length,
        Collectors.joining(", ")
    ));

System.out.println(joinedByLength);  // {3=Bob, Eve, 5=Alice, David, 7=Charlie}

partitioningBy() – Splits into two groups based on a predicate.

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

Map<Boolean, List<Integer>> evenOdd = numbers.stream()
    .collect(Collectors.partitioningBy(n -> n % 2 == 0));

System.out.println("Even: " + evenOdd.get(true));   // [2, 4, 6, 8, 10]
System.out.println("Odd: " + evenOdd.get(false));   // [1, 3, 5, 7, 9]

Parallel Streams

Streams can process elements in parallel across multiple CPU cores. Converting is simple:

// Sequential
long count1 = list.stream()
    .filter(x -> x > 5)
    .count();

// Parallel
long count2 = list.parallelStream()
    .filter(x -> x > 5)
    .count();

// Or convert an existing stream
long count3 = list.stream()
    .parallel()
    .filter(x -> x > 5)
    .count();

But parallel isn’t always faster. Overhead costs can outweigh benefits for small collections. And some operations don’t parallelize well. Use parallel streams when:

  • The collection is large (thousands of elements)
  • Processing each element is CPU-intensive
  • The operations are stateless and don’t depend on order

Measure performance before assuming parallel is better.

Common Patterns

Transform a List

// Convert list of objects to list of a single property
List<User> users = getUsers();
List<String> emails = users.stream()
    .map(User::getEmail)
    .collect(Collectors.toList());

Filter and Transform

// Get uppercase names of active users
List<String> activeNames = users.stream()
    .filter(User::isActive)
    .map(User::getName)
    .map(String::toUpperCase)
    .collect(Collectors.toList());

Find Single Element

// Find user by email
Optional<User> user = users.stream()
    .filter(u -> u.getEmail().equals("alice@example.com"))
    .findFirst();

user.ifPresent(u -> System.out.println("Found: " + u.getName()));

Check Existence

// Check if any user is admin
boolean hasAdmin = users.stream()
    .anyMatch(u -> u.getRole().equals("ADMIN"));

Create Lookup Map

// Map from user ID to user
Map<Long, User> userById = users.stream()
    .collect(Collectors.toMap(User::getId, user -> user));

// Or using Function.identity()
Map<Long, User> userById2 = users.stream()
    .collect(Collectors.toMap(User::getId, Function.identity()));

Sum a Property

// Total price of all orders
double total = orders.stream()
    .mapToDouble(Order::getPrice)
    .sum();

// Or with reduce
double total2 = orders.stream()
    .map(Order::getPrice)
    .reduce(0.0, Double::sum);

Lazy Evaluation

Stream operations are lazy. Intermediate operations don’t execute until a terminal operation is called. This matters for performance and for understanding behavior.

List<String> names = Arrays.asList("Alice", "Bob", "Charlie");

Stream<String> stream = names.stream()
    .filter(s -> {
        System.out.println("Filtering: " + s);
        return s.length() > 3;
    })
    .map(s -> {
        System.out.println("Mapping: " + s);
        return s.toUpperCase();
    });

System.out.println("Stream created, nothing executed yet");

// Terminal operation triggers processing
List<String> result = stream.collect(Collectors.toList());

// Output:
// Stream created, nothing executed yet
// Filtering: Alice
// Mapping: Alice
// Filtering: Bob
// Filtering: Charlie
// Mapping: Charlie

Notice that elements are processed one at a time through the entire pipeline, not in separate passes. Bob gets filtered out before Charlie even starts processing.

Streams are Single-Use

Once consumed, a stream can’t be reused:

Stream<String> stream = Stream.of("a", "b", "c");
stream.forEach(System.out::println);

// This throws IllegalStateException
stream.forEach(System.out::println);  // Error: stream has already been operated upon

If you need to process the same data multiple ways, create a new stream each time or collect to a collection first.


Previous: Java Lambda Expressions

Related: Java Collections Framework Overview | Introduction to Java Generics | ArrayList in Java

Sources

  • Oracle. “Stream (Java SE 21).” docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Stream.html
  • Oracle. “Collectors (Java SE 21).” docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html
  • Bloch, Joshua. “Effective Java.” 3rd Edition, 2018
Scroll to Top