Java Streams vs Loops: The Ultimate Performance Showdown

1. Introduction

In this article, we’ll explore the differences between Java Streams vs Loops with a performance benchmark. These tools are essential for data processing in Java development. While they have distinct characteristics, they often serve similar purposes and can be used interchangeably.

Streams, introduced in Java 8, provide a functional and declarative approach. On the other hand, for-loops follow the traditional imperative method. By the end of this tutorial, we’ll be better equipped to choose the right tool for our programming tasks.

2. Performance Considerations

When evaluating programming solutions, performance is a crucial factor. This holds true for both streams and for-loops, especially when dealing with large datasets.

Let’s dive into a practical benchmarking example to compare execution times for complex operations like filtering, mapping, and summing. We’ll examine both for-loops and streams.

To perform these benchmarks, we’ll use the Java Microbenchmarking Harness (JMH), a specialized tool designed for measuring Java code performance.

2.1. Getting Started

First, let’s set up the necessary dependencies:

<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.37</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.37</version>
</dependency>

Latest versions of JMH Core and JMH Annotation Processor can be found on Maven Central.

2.2. Setting Up the Benchmark

In our benchmark scenario, we’ll work with a list of integers ranging from 0 to 999,999. Our goal is to filter out even numbers, square them, and then calculate their sum. To ensure fairness, we’ll first implement this process using a traditional for-loop:

@State(Scope.Thread)
public static class MyIntState {
    List<Integer> numbers;

    @Setup(Level.Trial)
    public void setUp() {
        numbers = new ArrayList<>();
        for (int i = 0; i < 1_000_000; i++) {
            numbers.add(i);
        }
    }
}

We’re going to give this State class to our benchmarks below. Also, before each benchmark starts, the Setup is going to run.

2.3. Benchmarking with Java For-Loops

Our for-loop method includes going through the list of numbers one by one. We check each number to see if it’s even, then we square it, and finally, we add it to a running total stored in a variable:

@Benchmark
public int forLoopBenchmark(MyIntState state) {
    int sum = 0;
    for (int number : state.numbers) {
        if (number % 2 == 0) {
            sum = sum + (number * number);
        }
    }
    return sum;
}

2.4. Benchmarking with Java Streams

Next, let’s implement the same complex operations using Java streams. We’ll start by filtering the even numbers, mapping them to their squares, and finally calculating the sum:

@Benchmark
public int streamBenchMark(MyIntState state) {
    return state.numbers.stream()
      .filter(number -> number % 2 == 0)
      .map(number -> number * number)
      .reduce(0, Integer::sum);
}

We utilized the terminal operation reduce() to calculate the sum of the numbers. Additionally, there are various methods available to compute the sum.

2.5. Benchmarking

Now that we have set up our benchmark methods, we will proceed to run the benchmark using JMH. We will execute the benchmark multiple times to ensure precise results and calculate the average execution time. To accomplish this, we will include the following annotations in our class:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
public static void main(String[] args) throws RunnerException {
    Options options = new OptionsBuilder()
      .include(PerformanceBenchmark.class.getSimpleName())
      .build();
    new Runner(options).run();
}

Now, let’s run the main method to see the results:

2.6. Analyzing the Results

When we start the benchmark, JMH will tell us the average times it took for both the for-loop and stream methods to run:

Benchmark                              Mode  Cnt         Score         Error  Units
PerformanceBenchmark.forLoopBenchmark  avgt    5   3386967.051 ± 1375122.675  ns/op
PerformanceBenchmark.streamBenchMark   avgt    5  12131689.502 ± 1618739.968  ns/op

From what we can see in our example, the for-loops did a lot better than the streams when we look at performance. Even though streams didn’t do as well as for-loops in this case, this might not always be true, especially when we use parallel streams.

3. Syntax and Readability

As developers, the readability of our code is really important. That’s why, when we’re trying to find the best way to solve a problem, we always think about this.

First off, let’s look at the syntax and how easy streams are to read. Java Streams help us write code that’s shorter and expressive in nature. We can really see this when we’re filtering and mapping data:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
long count = numbers.stream()
  .filter(num -> num > 5)
  .count();

The stream code is like a smooth series of steps. It clearly shows the condition for filtering and the count operation in one smooth flow. Plus, streams usually make the code easier to read because they’re declarative. This means the code is more about what you want to do, not how to do it.

On the other hand, let’s look at the syntax and how easy for-loops are to read. for-loops are a more old-school and direct way of coding:

List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
long count = 0;
for (int number : numbers) {
    if (number > 5) {
        count++;
    }
}

In this case, the code has clear iteration and conditions. While most coders understand this approach, it can sometimes make the code longer, which could make it harder to read, especially when the operations are complex.

4. Parallelism and Concurrency

Parallelism and concurrency are crucial aspects to consider when comparing streams and for-loops in Java. Both approaches offer different capabilities and challenges when utilizing multi-core processors and managing concurrent operations.

Streams are designed to make parallel processing more accessible. Java 8 introduced the concept of parallel streams, which automatically leverage multi-core processors to speed up data processing. We can easily rewrite the benchmark from the previous point to compute the sum concurrently:

@Benchmark
public int parallelStreamBenchMark(MyState state) {
    return state.numbers.parallelStream()
      .filter(number -> number % 2 == 0)
      .map(number -> number * number)
      .reduce(0, Integer::sum);
}

The only thing needed to parallelize the process is to replace stream() with parallelStream() method. On the other side, rewriting the for-loop to compute the sum of numbers in parallel is more complicated:

@Benchmark
public int concurrentForLoopBenchmark(MyState state) throws InterruptedException, ExecutionException {
    int numThreads = Runtime.getRuntime().availableProcessors();
    ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
    List<Callable<Integer>> tasks = new ArrayList<>();
    int chunkSize = state.numbers.size() / numThreads;

    for (int i = 0; i < numThreads; i++) {
        final int start = i * chunkSize;
        final int end = (i == numThreads - 1) ? state.numbers.size() : (i + 1) * chunkSize;
        tasks.add(() -> {
            int sum = 0;
            for (int j = start; j < end; j++) {
		int number = state.numbers.get(j);
	        if (number % 2 == 0) {
		    sum = sum + (number * number);
	        }
            }
            return sum;
        });
    }

    int totalSum = 0;
    for (Future<Integer> result : executorService.invokeAll(tasks)) {
        totalSum += result.get();
    }

    executorService.shutdown();
    return totalSum;
}

We can use Java’s concurrency utilities, such as ExecutorService, to execute tasks concurrently. We divide the list into chunks and process them concurrently using a thread pool. When deciding between streams and for-loops for parallelism and concurrency, we should consider the complexity of our task. Streams offer a more straightforward way to enable parallel processing for tasks that can be parallelized easily. On the other hand, for-loops, with manual concurrency control, are suitable for more complex scenarios that require custom thread management and coordination.

5. Mutability

Now, let’s explore the aspect of mutability and how it differs between streams and for-loops. Understanding how these handle mutable data is essential for making informed choices.

First and foremost, we need to recognize that streams, by their nature, promote immutability. In the context of streams, elements within a collection are not modified directly. Instead, operations on the stream create new streams or collections as intermediate results:

List<String> fruits = new ArrayList<>(Arrays.asList("apple", "banana", "orange"));
List<String> upperCaseFruits = fruits.stream()
  .map(fruit -> fruit.toUpperCase())
  .collect(Collectors.toList());

In this stream operation, the original list remains unchanged. The map() operation produces a new stream where each fruit is transformed to uppercase, and the collect() operation collects these transformed elements into a new list.

Contrastingly, for-loops can operate on mutable data structures directly:

List<String> fruits = new ArrayList<>(Arrays.asList("apple", "banana", "orange"));
for (int i = 0; i < fruits.size(); i++) {
    fruits.set(i, fruits.get(i).toUpperCase());
}

In this for-loop, we directly modify the original list, replacing every element with its uppercase correspondent. This can be advantageous when we need to modify existing data in place, but it also necessitates careful handling to avoid unintended consequences.

6. Conclusion

Both streams and loops have their strengths and weaknesses. Streams offer a more functional and declarative approach, enhancing code readability and often leading to concise and elegant solutions. On the other hand, loops provide a familiar and explicit control structure, making them suitable for scenarios where precise execution order or mutability control is critical.

Scroll to Top