Introduction to JAVA Stream APIs

Dharshi Balasubramaniyam
Javarevisited
Published in
8 min readMar 29, 2024

In the vast landscape of Java programming, efficiency and elegance are paramount. Every line of code counts, and every algorithm must be finely tuned to ensure optimal performance. Enter Stream APIs — the unsung heroes of modern Java development. With their seamless integration into the language, Stream APIs offer a paradigm shift in how developers approach data processing tasks.

figure 1: JAVA stream APIs

1. What are Java Stream APIs?

  • Java Stream APIs are a set of classes and interfaces introduced in Java 8 that make it easier to work with collections, such as lists or arrays, by providing a straightforward and readable approach to processing elements, instead of writing complex loops and conditionals to iterate over collections.
  • With Stream APIs, you can chain multiple operations together in a fluent style, making it easier to understand the sequence of transformations applied to the data in a collection.

2. Why Use Stream APIs?

  • Stream APIs enable declarative programming, where you specify what you want to achieve rather than how to achieve it.
  • Stream APIs provide a rich set of built-in operations for common data processing tasks which are optimized and can be used directly without writing custom code, saving development time and effort.
  • Stream APIs support parallel processing, allowing operations to be executed concurrently on multiple threads. This can lead to significant performance improvements, especially when dealing with large datasets.
  • Stream APIs use lazy evaluation, meaning intermediate operations are only executed when necessary. This can lead to more efficient use of resources, as operations are only performed on elements that are actually needed in the final result.
  • Stream APIs encourage immutability by not modifying the original data source but instead producing new streams with the desired transformations applied.
  • In functional programming, functions are treated as first-class citizens, meaning they can be passed around as arguments to other functions or returned as results from other functions. Java Stream APIs utilize higher-order functions, like map, filter, and reduce, which can take other functions as arguments.

3. How can create Streams?

3.1. From collections

  • You can create a stream from existing collections like lists, sets, or maps. It allows you to process each element of the collection easily without dealing with traditional loops.
public class StreamFromCollectionsExample {
public static void main(String[] args) {
List<Integer> numbersList = new ArrayList<>();
numbersList.add(1);
numbersList.add(2);
numbersList.add(3);

// Creating a stream from a list
Stream<Integer> streamFromList = numbersList.stream();

// Performing an operation on the stream
streamFromList.forEach(element -> System.out.println(element));
}
}

3.2. From arrays

  • Similar to collections, you can create streams from arrays. It’s useful when you have data stored in an array format.
public class StreamFromArraysExample {
public static void main(String[] args) {
int[] numbersArray = {1, 2, 3, 4, 5};

// Creating a stream from an array
Stream<Integer> streamFromArray = Arrays.stream(numbersArray).boxed();

// Performing an operation on the stream
streamFromArray.forEach(element -> System.out.println(element));
}
}

3.3. Using Stream Factories:

  • Java provides methods like Stream.of() or Arrays.stream() to directly create streams from given values or arrays.
public class StreamExample {
public static void main(String[] args) {
// Creating a stream using Stream.of()
Stream<Integer> streamOfValues = Stream.of(1, 2, 3, 4, 5);

// Performing an operation on the stream
streamOfValues.forEach(element -> System.out.println(element));
}
}

4. Stream Operations

  • In Java’s Stream API, operations are broadly categorized into two types: Intermediate operations and Terminal operations. Let’s break down each:

4.1. Intermediate Operations

  • These operations transform the elements of the stream.
  • They are lazy, meaning they don’t execute until a terminal operation is called.
  • Intermediate operations return a new stream, allowing for chaining.
  • Examples include map, filter, sorted, distinct, flatMap, etc.

4.2. Terminal Operations

  • These operations produce a non-stream result.
  • They execute the stream pipeline and produce a result or a side-effect.
  • Once a terminal operation is invoked, the stream is consumed and cannot be reused.
  • Examples include forEach, collect, reduce, count, min, max, etc.

4.3. Common stream operations

4.3.1. Filtering: This operation allows you to select elements from a collection based on a certain condition.

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

List<Integer> evenNumbers = numbers.stream()
.filter(num -> num % 2 == 0) // intermediate operation
.collect(Collectors.toList()); // terminal operation

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

4.3.2. Mapping: This operation involves transforming each element of a collection according to a given function.

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

List<Integer> squaredNumbers = numbers.stream()
.map(num -> num * num)
.collect(Collectors.toList());

// Output: [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
System.out.println(squaredNumbers);

4.3.3. Reduction: Reduction combines all elements of a stream to produce a single result.

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

int sum = numbers.stream()
.reduce(0, (n1, n2) -> n1 + n2); // or .reduce(0, Integer::sum);

// Output: 15
System.out.println(sum);

4.3.4. Sorting: Sorting rearranges the elements of a collection in a specified order.

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

List<Integer> sortedNumbers = numbers.stream()
.sorted()
.collect(Collectors.toList());

// Output: [1, 2, 3, 5, 8, 9]
System.out.println(sortedNumbers);

4.3.5. Counting: Counting calculates the number of elements in a collection.

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

long count = names.stream()
.count();

// Output: 4
System.out.println(count);

4.3.6. Grouping: Grouping gathers elements of a collection based on a common property.

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

Map<Integer, List<String>> groupedNamesByLength = names.stream()
.collect(Collectors.groupingBy(String::length));

// Output: {3=[Bob], 5=[Alice, Emily], 4=[John]}
System.out.println(groupedNamesByLength);

4.3.7. Limiting and Skipping

  • Infinite Streams: Imagine a stream of water that never stops flowing. Similarly, an infinite stream in programming is a sequence of data that goes on forever. You can generate this stream of data dynamically, meaning it keeps producing new elements endlessly.
  • Limiting: Think of it like putting a cap on how much water from the stream you want to collect. In programming, you might only want to take the first 10 numbers from an infinite stream of numbers. So, you set a limit to only take the first 10, and then the stream stops there.
  • Skipping: Now, imagine you don’t want the first few numbers from the stream; you want to start collecting data from, say, the 11th number onward. Skipping allows you to do just that. It’s like bypassing the initial part of the stream and starting from a certain point.
public class InfiniteStreamsExample {

public static void main(String[] args) {
// Generating an infinite stream of numbers starting from 1
Stream<Integer> infiniteStream = Stream.iterate(1, i -> i + 1);

// Limiting: Taking only the first 10 elements from the infinite stream
Stream<Integer> limitedStream = infiniteStream.limit(10);

System.out.println("First 10 elements from the infinite stream:");
limitedStream.forEach(System.out::println);

// Generating the infinite stream again as it was consumed in the previous operation
infiniteStream = Stream.iterate(1, i -> i + 1);

// Skipping: Skipping the first 5 elements and taking the next 10 elements
Stream<Integer> skippedStream = infiniteStream.skip(5).limit(10);

System.out.println("\nSkipping the first 5 elements and taking the next 10:");
skippedStream.forEach(System.out::println);
}
}

5. Parallel Streams

  • Sequential execution does one task at a time, while parallel execution does multiple tasks simultaneously.
  • When you use a parallel stream, Java automatically splits the data into smaller parts and assigns them to different processors (cores) in your computer.
  • Each processor works on its chunk of data independently and then the results are combined. This can speed up processing, especially for large datasets, because multiple tasks are being done simultaneously, rather than one after another.
import java.util.Arrays;

public class ParallelStreamExample {
public static void main(String[] args) {
// Create a large array of numbers
int[] numbers = new int[1000000];
for (int i = 0; i < numbers.length; i++) {
numbers[i] = i + 1;
}

// Sequential Stream: Summing up all numbers using a sequential stream
long startTime = System.currentTimeMillis();
long sequentialSum = Arrays.stream(numbers)
.sum();
long endTime = System.currentTimeMillis();
System.out.println("Sequential sum: " + sequentialSum);
System.out.println("Time taken with sequential stream: " + (endTime - startTime) + " milliseconds");

// Parallel Stream: Summing up all numbers using a parallel stream
startTime = System.currentTimeMillis();
long parallelSum = Arrays.stream(numbers)
.parallel()
.sum();
endTime = System.currentTimeMillis();
System.out.println("Parallel sum: " + parallelSum);
System.out.println("Time taken with parallel stream: " + (endTime - startTime) + " milliseconds");
}
}

After running this code, you should see the output showing the sum calculated using both sequential and parallel streams, along with the time taken for each approach. Parallel processing can offer significant performance improvements and scalability but introduces complexity and potential challenges related to concurrency management.

6. Using stream with custom objects

Suppose we have a class called Product representing products in a store. Each product has attributes such as id, name, price, and category. We want to perform various operations using streams on a list of Product objects.

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

class Product {
private int id;
private String name;
private double price;
private String category;

// All args contructor, getters, setters and toString method
}

public class Main {
public static void main(String[] args) {
// Create a list of Product objects
List<Product> products = Arrays.asList(
new Product(1, "Laptop", 1200.00, "Electronics"),
new Product(2, "Chair", 75.50, "Furniture"),
new Product(3, "Headphones", 50.00, "Electronics"),
new Product(4, "Table", 150.00, "Furniture"),
new Product(5, "Mouse", 20.00, "Electronics")
);

// Filter products by category
List<Product> electronics = products.stream()
.filter(p -> p.getCategory().equals("Electronics"))
.collect(Collectors.toList());
System.out.println("Electronics: " + electronics);

// Map products to their names
List<String> productNames = products.stream()
.map(Product::getName)
.collect(Collectors.toList());
System.out.println("Product Names: " + productNames);

// Calculate the total price of all products
double totalPrice = products.stream()
.mapToDouble(Product::getPrice)
.sum();
System.out.println("Total Price: $" + totalPrice);

// Find the cheapest product
Product cheapestProduct = products.stream()
.min((p1, p2) -> Double.compare(p1.getPrice(), p2.getPrice()))
.orElse(null);
System.out.println("Cheapest Product: " + cheapestProduct);

// Sort products by price in descending order, and if prices are the same, sort by ID
List<Product> sortedByPriceDesc = products.stream()
.sorted(Comparator.comparingDouble(Product::getPrice).reversed()
.thenComparingInt(Product::getId))
.toList();
System.out.println("Sorted by price (descending) and then by ID: " + sortedByPriceDesc);

// Group products by category
products.stream()
.collect(Collectors.groupingBy(Product::getCategory))
.forEach((category, productList) -> {
System.out.println(category + ": " + productList);
});
}
}

7. Performance Considerations:

  1. Size of Data: While streams can improve readability, they might not always offer better performance, especially for small collections. For small datasets, traditional looping might be more efficient.
  2. Parallel Streams Overhead: Although parallel streams can improve performance for CPU-bound tasks, they also introduce overhead due to parallelization. Benchmarking should be done to ensure the benefits outweigh the overhead.
  3. Avoid Excessive Intermediate Operations: Chaining too many intermediate operations might result in unnecessary overhead, as each intermediate operation typically involves iterating over the data.
  4. Primitive Specializations: For primitive data types, consider using specialized streams like IntStream, LongStream, and DoubleStream, which can offer better performance compared to streams of boxed primitives.

Conclusion

So guys, we have reached the end of this article, Thank you for taking the time to read this article! I hope you found the information helpful and gained some valuable insights into the topic. From understanding what streams are to exploring the best practices, we’ve covered a lot.

Let’s recap the key points we’ve covered:

  • Java Stream APIs: Streams provide a powerful way to process collections of data in a functional style, offering benefits such as concise code, improved readability, and potential for parallel processing.
  • Basic Operations: We’ve learned about common stream operations like filtering, mapping, reduction, and sorting, which enable developers to manipulate data with ease and efficiency.
  • Advanced Topics: Topics such as working with infinite streams, parallel streams, error handling, and integration with functional interfaces have been introduced, providing insights into more sophisticated stream usage scenarios.
  • Best Practices: We’ve discussed best practices for utilizing streams effectively, including considerations for performance, readability, and maintainability of code.

Now that you have a solid understanding of Java Stream APIs, I encourage you to explore further and apply these concepts to your own projects. Experiment with different stream operations, leverage parallel processing when applicable, and strive to write clean and concise code using streams.

In addition to exploring stream APIs discussed here, I invite you to delve into other insightful articles I’ve written on various Java programming topics. If you’re interested in optimizing code performance, please check out my other articles.

If you found my articles useful, please consider giving it claps and sharing it with your friends and colleagues.

Keep learning, exploring, and creating amazing things with JAVA!

Happy coding!

-Dharshi Balasubramaniyam-

--

--

Dharshi Balasubramaniyam
Javarevisited

BSc (hons) in Software Engineering, UG, University of Kelaniya, Sri Lanka. Looking for software engineer/full stack developer internship roles.