Chapter 54 of 57

Stream API in Java

When we work with collections such as ArrayList, we often need to perform operations on their elements.

For example, suppose we have a list of student marks:

ArrayList<Integer> marks = new ArrayList<>();

marks.add(85);
marks.add(42);
marks.add(91);
marks.add(67);
marks.add(30);

Now imagine we want to find only the marks greater than 60.

We could use a normal loop:

for (int mark : marks) {
    if (mark > 60) {
        System.out.println(mark);
    }
}

This works perfectly fine. But Java provides another way to process collections using the Stream API.

The Stream API allows us to process data from collections in a clean and readable way using operations such as filtering, sorting, and transforming.

What Is a Stream?

A stream is a sequence of elements that we can process step by step.

For example:

marks.stream()

creates a stream from the marks collection.

We can then perform operations on that stream.

marks.stream()
     .filter(mark -> mark > 60)
     .forEach(mark -> System.out.println(mark));

Output:

85
91
67

This reads almost like English:

Take the marks, keep the marks greater than 60, and print each one.

Creating a Stream

The most common way to create a stream is from a collection.

ArrayList<String> names = new ArrayList<>();

names.add("John");
names.add("Jason");
names.add("Alex");

names.stream();

We can then process the stream.

For example:

names.stream()
     .forEach(name -> System.out.println(name));

Output:

John
Jason
Alex

filter()

The filter() method is used when we want to keep only elements that satisfy a condition.

For example:

ArrayList<Integer> numbers = new ArrayList<>();

numbers.add(10);
numbers.add(25);
numbers.add(30);
numbers.add(45);
numbers.add(50);

We can find numbers greater than 30:

numbers.stream()
       .filter(number -> number > 30)
       .forEach(number -> System.out.println(number));

Output:

45
50

The lambda:

number -> number > 30

acts as the condition.

map()

The map() method is used when we want to transform each element into something else.

For example, suppose we have:

ArrayList<Integer> numbers = new ArrayList<>();

numbers.add(1);
numbers.add(2);
numbers.add(3);
numbers.add(4);

We can create a stream where every number is doubled:

numbers.stream()
       .map(number -> number * 2)
       .forEach(number -> System.out.println(number));

Output:

2
4
6
8

The original list isn't changed. The stream produces transformed values.

sorted()

We can use sorted() to sort elements.

ArrayList<Integer> numbers = new ArrayList<>();

numbers.add(50);
numbers.add(10);
numbers.add(40);
numbers.add(20);

Now:

numbers.stream()
       .sorted()
       .forEach(number -> System.out.println(number));

Output:

10
20
40
50

This is useful when we want to process data in sorted order.

forEach()

forEach() is commonly used as the final operation when we simply want to perform an action for every element.

For example:

names.stream()
     .forEach(name -> System.out.println("Hello, " + name));

Output:

Hello, John
Hello, Jason
Hello, Alex

Here, the lambda determines what should happen to each element.

collect()

Often, we don't just want to print the results. We want to store the processed elements in a new collection.

For this, we can use collect().

For example:

import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;

class Main {
    public static void main(String[] args) {

        ArrayList<Integer> numbers = new ArrayList<>();

        numbers.add(10);
        numbers.add(25);
        numbers.add(40);
        numbers.add(55);

        List<Integer> result = numbers.stream()
                .filter(number -> number > 30)
                .collect(Collectors.toList());

        System.out.println(result);
    }
}

Output:

[40, 55]

Here, we filtered the numbers and stored the result in a new List.

Chaining Stream Operations

One of the most useful features of streams is that we can chain multiple operations together.

For example:

numbers.stream()
       .filter(number -> number > 20)
       .map(number -> number * 2)
       .sorted()
       .forEach(number -> System.out.println(number));

The data moves through each operation:

numbers
   ↓
filter()
   ↓
map()
   ↓
sorted()
   ↓
forEach()

For example, if the original values are:

10, 25, 40, 15

filter() keeps:

25, 40

map() doubles them:

50, 80

sorted() sorts them:

50, 80

and forEach() prints them.

count()

We can use count() to find how many elements match a condition.

long count = numbers.stream()
        .filter(number -> number > 30)
        .count();

System.out.println(count);

If the list contains:

10, 25, 40, 50

the output will be:

2

because 40 and 50 are greater than 30.

findFirst()

Sometimes we only want the first matching element.

For example:

Optional<Integer> result = numbers.stream()
        .filter(number -> number > 30)
        .findFirst();

findFirst() returns an Optional because there might not be any matching element.

We can check the result:

if (result.isPresent()) {
    System.out.println(result.get());
}

The details of Optional are a separate topic, so for now, just understand that findFirst() safely represents the possibility that no value was found.

Stream Does Not Modify the Original Collection

This is an important point.

Suppose we have:

ArrayList<Integer> numbers = new ArrayList<>();

numbers.add(10);
numbers.add(20);
numbers.add(30);

Then:

numbers.stream()
       .map(number -> number * 2)
       .forEach(System.out::println);

prints:

20
40
60

But the original list is still:

10
20
30

A stream processes the data; it doesn't automatically change the original collection.

Stream vs Normal Loop

Both loops and streams can solve many of the same problems.

Using a loop:

for (int number : numbers) {
    if (number > 30) {
        System.out.println(number);
    }
}

Using a stream:

numbers.stream()
       .filter(number -> number > 30)
       .forEach(number -> System.out.println(number));

The stream version can be more expressive when several operations need to be chained together.

However, streams aren't automatically better for every situation. A normal loop can sometimes be simpler, especially for straightforward logic.

A Complete Example

Let's say we have a list of student marks and want to find the passing marks, increase them by 5, sort them, and print the results.

import java.util.ArrayList;

class Main {
    public static void main(String[] args) {

        ArrayList<Integer> marks = new ArrayList<>();

        marks.add(45);
        marks.add(80);
        marks.add(35);
        marks.add(90);
        marks.add(60);

        marks.stream()
                .filter(mark -> mark >= 40)
                .map(mark -> mark + 5)
                .sorted()
                .forEach(mark -> System.out.println(mark));
    }
}

Output:

45
50
65
85
95

Here, the stream performs several operations in sequence:

Original marks
      ↓
Keep passing marks
      ↓
Add 5
      ↓
Sort
      ↓
Print

That's the real power of the Stream API.

The main thing to remember is:

The Stream API provides a convenient way to process collections of data using operations such as filter(), map(), sorted(), collect(), and forEach().

Once you become comfortable with lambdas and collections, streams become much easier to understand because they essentially let you describe what you want to do with your data rather than writing every step manually.