Java Streams: A Beginner's Guide

Hello there, future Java wizards! Today, we're going to embark on an exciting journey into the world of Java Streams. Don't worry if you're new to programming – I'll be your friendly guide, and we'll take this step by step. By the end of this tutorial, you'll be streaming data like a pro!

Java - Streams

What is a Stream in Java?

Imagine you're at a conveyor belt sushi restaurant. The sushi plates are continuously moving past you, and you can pick and choose what you want. That's essentially what a Stream is in Java – it's a sequence of elements that you can process one by one, without having to store them all in memory at once.

Streams were introduced in Java 8 to make working with collections of data easier and more efficient. They allow us to perform operations on data in a more declarative way, telling Java what we want to do rather than how to do it.

Generating Streams in Java

Let's start by creating our first Stream. There are several ways to do this, but we'll begin with a simple example:

import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;

public class StreamExample {
    public static void main(String[] args) {
        // Creating a Stream from a List
        List<String> fruits = Arrays.asList("apple", "banana", "cherry", "date");
        Stream<String> fruitStream = fruits.stream();

        // Creating a Stream directly
        Stream<Integer> numberStream = Stream.of(1, 2, 3, 4, 5);
    }
}

In this example, we've created two Streams. The first one, fruitStream, is created from a List of fruits. The second, numberStream, is created directly using the Stream.of() method.

Common Stream Operations

Now that we have our Streams, let's look at some common operations we can perform on them.

forEach Method

The forEach method is like a friendly robot that goes through each element in your Stream and does something with it. Let's use it to print our fruits:

fruits.stream().forEach(fruit -> System.out.println(fruit));

This will print:

apple
banana
cherry
date

Here, fruit -> System.out.println(fruit) is called a lambda expression. It's a shorthand way of telling Java what to do with each element.

map Method

The map method is like a magic wand that transforms each element in the Stream. Let's use it to make all our fruits uppercase:

List<String> upperCaseFruits = fruits.stream()
                                     .map(String::toUpperCase)
                                     .collect(Collectors.toList());
System.out.println(upperCaseFruits);

This will print:

[APPLE, BANANA, CHERRY, DATE]

The String::toUpperCase is a method reference, another neat Java feature that's like saying "use the toUpperCase method on each String".

filter Method

The filter method is like a bouncer at a club, only letting certain elements through. Let's use it to keep only the fruits that start with 'a':

List<String> aFruits = fruits.stream()
                             .filter(fruit -> fruit.startsWith("a"))
                             .collect(Collectors.toList());
System.out.println(aFruits);

This will print:

[apple]

limit Method

The limit method is like saying "I only want this many, thanks!" It restricts the Stream to a certain number of elements:

List<String> firstTwoFruits = fruits.stream()
                                    .limit(2)
                                    .collect(Collectors.toList());
System.out.println(firstTwoFruits);

This will print:

[apple, banana]

sorted Method

The sorted method is like arranging your bookshelf. It puts the elements in order:

List<String> sortedFruits = fruits.stream()
                                  .sorted()
                                  .collect(Collectors.toList());
System.out.println(sortedFruits);

This will print:

[apple, banana, cherry, date]

Parallel Processing

One of the cool things about Streams is that they can easily be processed in parallel, potentially speeding up operations on large datasets. You can create a parallel stream like this:

fruits.parallelStream().forEach(fruit -> System.out.println(fruit + " " + Thread.currentThread().getName()));

This might print something like:

banana ForkJoinPool.commonPool-worker-1
apple main
date ForkJoinPool.commonPool-worker-2
cherry ForkJoinPool.commonPool-worker-3

The order might be different each time you run it, because the fruits are being processed in parallel!

Collectors

Collectors are special tools that help us gather the results of Stream operations. We've been using collect(Collectors.toList()) in our examples to turn our Stream results back into Lists. There are many other useful Collectors:

// Joining elements into a String
String fruitString = fruits.stream().collect(Collectors.joining(", "));
System.out.println(fruitString);  // Prints: apple, banana, cherry, date

// Counting elements
long fruitCount = fruits.stream().collect(Collectors.counting());
System.out.println(fruitCount);  // Prints: 4

// Grouping elements
Map<Character, List<String>> fruitGroups = fruits.stream()
    .collect(Collectors.groupingBy(fruit -> fruit.charAt(0)));
System.out.println(fruitGroups);  // Prints: {a=[apple], b=[banana], c=[cherry], d=[date]}

Statistics

Streams can also help us calculate statistics on numeric data:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5);
IntSummaryStatistics stats = numbers.stream().mapToInt(Integer::intValue).summaryStatistics();
System.out.println("Count: " + stats.getCount());
System.out.println("Sum: " + stats.getSum());
System.out.println("Min: " + stats.getMin());
System.out.println("Max: " + stats.getMax());
System.out.println("Average: " + stats.getAverage());

This will print:

Count: 5
Sum: 15
Min: 1
Max: 5
Average: 3.0

Java Streams Example

Let's put it all together with a more complex example. Imagine we have a list of students with their ages and grades:

class Student {
    String name;
    int age;
    double grade;

    Student(String name, int age, double grade) {
        this.name = name;
        this.age = age;
        this.grade = grade;
    }
}

public class StreamExample {
    public static void main(String[] args) {
        List<Student> students = Arrays.asList(
            new Student("Alice", 22, 90.5),
            new Student("Bob", 20, 85.0),
            new Student("Charlie", 21, 92.3),
            new Student("David", 23, 88.7)
        );

        // Get the average grade of students over 21
        double averageGrade = students.stream()
            .filter(student -> student.age > 21)
            .mapToDouble(student -> student.grade)
            .average()
            .orElse(0.0);

        System.out.println("Average grade of students over 21: " + averageGrade);

        // Get the names of the top 2 students by grade
        List<String> topStudents = students.stream()
            .sorted((s1, s2) -> Double.compare(s2.grade, s1.grade))
            .limit(2)
            .map(student -> student.name)
            .collect(Collectors.toList());

        System.out.println("Top 2 students: " + topStudents);
    }
}

This example demonstrates how we can chain multiple Stream operations together to perform complex data processing tasks in a concise and readable way.

And there you have it, folks! You've just taken your first steps into the world of Java Streams. Remember, practice makes perfect, so don't be afraid to experiment with these concepts. Before you know it, you'll be streaming data like a pro! Happy coding!

Credits: Image by storyset