DEV Community

Cover image for Java 8 Tutorial: Master stream API and beyond
Ryan Thelin for Educative

Posted on

Java 8 Tutorial: Master stream API and beyond

Java 8 was released in 2014, bringing with it a heap of new features now praised as essential by modern developers, such as the lambda expression, concurrency API improvements, Functional Interfaces, and improvements to bulk data handling. While many years have passed since then, Java 8 remains the most used version of Java, with over 60% of professional developers in 2020 reporting Java 8 as their office standard version.



Percentage of developers reporting given Java version as their main application:

Alt Text

As a result, regardless if you're shifting jobs or just starting, proficiency in Java 8 is an essential skill to have in today's tech world. If you’re just switching Java versions now, you may feel like you’re a bit late to the party, but worry not! The features of Java 8 are easier to pick up than you'd think, and today, I'll get you familiar with one of the most important Java 8 updates: Stream API.

Today, we’ll go over:

Get a leg up on Java 8 with hands-on experience

Learn all of the powerful Java 8 features hands-on and at your own pace, without having to restart from scratch.

Java 8 for Experienced Developers: Lambdas, Stream API & Beyond

What is a Stream in Java 8?

In the words of the all-powerful Oracle Interface Package Summary, a stream is “a sequence of elements supporting sequential and parallel aggregate operations”. While a great tongue-twister, it’s not the most digestible definition. Allow me to translate.

Streams are an abstract layer added in Java 8 that allows developers to easily manipulate collections of objects or primitives. It is not a data structure as it does not store data; rather it serves as a transformative medium from the data source to its destination.

Aggregate operations, sometimes called stream operations, are simply operations unique to streams that we can use to transform some (or all) of the stream at once. We’ll see examples of these later on.

Finally, sequential vs parallel refers to the ability to implement concurrency when completing stream operations. The operation can either be applied one at a time by a single thread, or it can be split among multiple threads each applying the operation concurrently.

The Stream is often visualized as a pipeline, as it acts as an intermediate step between the source of data, transforms it in some way, then outputs it in a new form downstream. We’ll revisit this metaphor when we explore Intermediate and Terminal Operations below.

Using the Stream API requires a fundamentally different style of coding than traditional coding - while most coding in Java is written in an imperative style, where the developer instructs what to do and how to complete it, operations from the Stream API require a declarative or event-driven style similar to operations in SQL.

Features of the Stream

Now that we know what the stream is, here’s a quick look at a few of the qualities you can take advantage of when using a stream.

  • Created using a source collection or array
  • Can transform the group but cannot change the data within
  • Easily allows manipulation of entire collections at once
  • Streams can be processed declaratively
  • Neither stores data nor adjusts the data it handles, therefore it is not a data structure.
  • Adjustable via lambda expressions

Comparing Streams and Loops

Streams are often compared to loops, as both are used to create iterative behavior in a program. Compared to loops, streams appear much cleaner in-line due to the cutting of cluttered loop syntax. Streams are arguably easier to understand at a glance thanks to their declarative style.

Below we have two snips of Java code that both achieve the same task of printing a data collection, stream by using streams while loop with a for loop. Take a look to see how they differ! We will break this down a bit more below.

stream

import java.util.stream.*;

class StreamDemo {

    public static void main(String[] args)
    {
        Stream<Integer> stream = Stream.of(1,2,3,4,5,6,7,8,9);
        stream.forEach(p -> System.out.println(p));
    }
}
Enter fullscreen mode Exit fullscreen mode

loop

class ArrayTraverse {
    public static void main(String args[]) {
        int my_array[]={1,2,3,4,5,6,7,8,9};
        for(int i:my_array) {
            System.out.print(i+" ");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice that when we use the auto-iterating forEach() stream operation, we’re able to cut down lines and make our code more readable.

With the for loop, most of the code is devoted to creating iteration rather than printing the array. This adds a lot of extra time for the developer. Since the stream operations of the API library handle iteration, your code can plainly express the logic of the computation instead of managing every little byte of the control flow.

The downside of adopting this style, however, is that transitioning can be difficult for industry veterans who have used the imperative style for years. If you’re struggling to pick it up, you’re not alone!

Another downside to note is that streams, especially parallel streams, cost considerably more overhead than a for loop. Keep this in mind if overhead is a concern in your program.

Advantages and Disadvantages of a Stream

Advantages:

  • Less visual clutter in code
  • No need to write an iterator
  • Can write the “what” rather than the “how”, understandable at a glance
  • Execute as fast as for-loops (or faster with parallel operations)
  • Great for large lists

Disadvantages:

  • Large overhead cost
  • Overkill for small collections
  • Hard to pick-up if used to traditional imperative style coding

Keep the learning going.

Don’t stop with just streams. Learn more advanced Java concepts like Java 8 lambda expressions, Java Time, and more all with interactive coding examples. Say bye-bye to HelloWorld and hello to lessons and tutorials tailored to your skill level!

Java 8 for Experienced Developers: Lambdas, Stream API & Beyond



Alt Text

Java 8 Stream API Pipeline: Intermediate and Terminal Operations

Aggregate operations come in two types; intermediate and terminal. Each stream has zero or more intermediate operations and one terminal operation, as well as a data source at the farthest point upstream such as an array or list.

Intermediate operations take a stream as input and return a stream after completion, meaning several operations can be done in a row.

In our metaphor from before, these operations are like pipe segments with water-like data entering and exiting without interrupting the stream’s flow. While they may redirect the stream in a new direction or change its form, it can still flow. Common examples of an intermediate operation are filter(), forEach(), and map(). Let's discuss them below.

filter()

This method takes a stream and selects a portion of it based on a passed criteria. In our metaphor, filter() would be either a pipe junction or a valve, delegating part of the stream to go a separate way.
For example, below we use filter() on a stream of integers to return a stream with integers greater than 10.


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

class StreamDemo {

    public static void main(String[] args) {

        //Created a list of integers
        List<Integer> list = new ArrayList<>();
        list.add(1);
        list.add(12);
        list.add(23);
        list.add(45);
        list.add(6);

        list.stream()                           // Created a stream from the list
                .filter(num -> num > 10)        //filter operation to get only numbers greater than 10
                .forEach(System.out::println);  // Printing each number in the list after filtering.

        //Again printing the elements of List to show that the original list is not modified.
        System.out.println("Original list is not modified");
        list.stream() 
                .forEach(System.out::println);
    }
}
Enter fullscreen mode Exit fullscreen mode

It’s important to note that this does not alter the original stream, making it effective for search tasks with static data like searching a database.

map()

This method takes a stream and another method as input, applying that function to each element in the stream. These results are then used to populate a new stream that is sent downstream.

For example, below we use map() on a stream of names to apply the toUpperCase method to each name. This makes a new stream with each person’s name capitalized. This is only one type of map operation. Others are MaptoInt(), which converts the stream to integers and flatMap(), which combines all stream elements together.

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

class StreamDemo {

    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        list.add("Dave");
        list.add("Joe");
        list.add("Ryan");
        list.add("Iyan");
        list.add("Ray");
        // map() is used to convert each name to upper case.
        // Note: The map() method does not modify the original list.
        list.stream()
                .map(name -> name.toUpperCase()) //map() takes an input of Function<T, R> type.
                .forEach(System.out::println);   // forEach() takes an input of Consumer type.

    }
}
Enter fullscreen mode Exit fullscreen mode

Terminal operations return something other than a stream, such as a primitive or an object. This means that, while many intermediate operations can be done in series, there can be only one terminal operation.
In our metaphor, these operations are visualized as the end-of-the-line; a stream comes in, but its flow is stopped, leaving the data as a different type. While this data could be put back into stream form, it would not be the same stream as the input from our terminal operation.

The most common example of a terminal operation is the forEach() function.

forEach()

This method takes a stream as input iterates through the stream, completes an action on each element within,and outputs the result of that action. The key difference between forEach() and map() is that the former returns data in a non-stream form, making it terminal.

Let’s look at our previous example again. We can now note that the output is a group of individually printed integers, not a stream of collected integers.

import java.util.stream.*;

class StreamDemo {

    public static void main(String[] args)
    {
        Stream<Integer> stream = Stream.of(1,2,3,4,5,6,7,8,9);
        stream.forEach(p -> System.out.println(p));
    }
}
Enter fullscreen mode Exit fullscreen mode

What should you learn next?

Development teams across the industry have made it clear, Java 8 is well-liked and it is here to stay. If you’re on a journey to master Java 8, this is just the beginning with so many exciting features left to explore.

To help you along that journey, we’re excited to offer Java 8 for Experienced Developers: Lambdas, Stream API & Beyond, a course authored by veteran Java developer Saurav Aggarwal filled with tips and interactive examples on topics like augmenting your streams with lambda expressions, deep-diving into advanced Stream API, collection management, CompletableFuture concurrency, and more.

Wherever you go next, I wish you luck as you continue your Java 8 journey!

Continue Reading about Java

Top comments (0)