Code readability, performance, and limitations of Streams
The release of Java 8 was a momentous occasion in Java’s history. Streams and Lambdas were introduced, and they’re now being used widely. If you don’t know about Streams or have never heard of it, it’s completely fine. In most cases, loops will meet your needs, and you’ll have no trouble without Streams.
Then why do we need Streams? Can they replace or have benefits over loops? In this article, we will look into the code, compare performance, and see how well Streams do as a replacement for loops.
Code Comparison
Streams increase code complexity as they need classes, interfaces, and imports; loops are, in contrast, built-in by nature. This is true in some points, but not necessarily. Code complexity is a lot more than how many things you need to know. It’s more about how readable the code is. Let’s look at some examples.
List of item names with a type
Let’s say that we have a list of items and want the list of names of specific item types. Using loops, you will write the following:
List<String> getItemNamesOfType(List<Item> items, Item.Type type) {
List<String> itemNames = new ArrayList<>();
for (Item item : items) {
if (item.type() == type) {
itemNames.add(item.name());
}
}
return itemNames;
}
Reading the code, you’ll see that a new ArrayList should be instantiated, and type check and add() call should be made in every loop. On the other hand, here’s the stream version of the same result:
List<String> getItemNamesOfTypeStream(List<Item> items, Item.Type type) {
return items.stream()
.filter(item -> item.type() == type)
.map(item -> item.name())
.toList();
}
With the help of Lambda, you can immediately catch that we’re first choosing the items with the given type, then getting the list of names of the filtered items. In this kind of code, the line-by-line flow aligns well with the logical flow.
Generate a random list
Let’s look at another example. In the Time Comparison section, we’ll review key Streams methods and compare their execution time with loops. For this, we need a random list of Items. Here is a snippet with a static method that gives a random Item:
public record Item(Type type, String name) {
public enum Type {
WEAPON, ARMOR, HELMET, GLOVES, BOOTS,
}
private static final Random random = new Random();
private static final String[] NAMES = {
"beginner",
"knight",
"king",
"dragon",
};
public static Item random() {
return new Item(
Type.values()[random.nextInt(Type.values().length)],
NAMES[random.nextInt(NAMES.length)]);
}
}
Now, let’s make a list of random Items using loops. The code will look like this:
List<Item> items = new ArrayList<>(100);
for (int i = 0; i < 100; i++) {
items.add(Item.random());
}
The code with Streams looks like this:
List<Item> items = Stream.generate(Item::random).limit(length).toList();
A wonderful and easy-to-read code. Furthermore, the List returned by toList() method is unmodifiable, giving you immutability so you can share it anywhere in the code without worrying about side effects. This makes the code less error-prone, and the readers understand your code more easily.
Streams provide a variety of helpful methods that let you write concise codes. The most popular ones are:
- allMatch()
- anyMatch()
- count()
- filter()
- findFirst()
- forEach()
- map()
- reduce()
- sorted()
- limit()
- And more in Stream Javadoc
Performance
Streams behave like loops in normal circumstances and have little or no effect on execution time. Let’s compare some major behaviors in Streams with loop implementations.
Iterate elements
When you have a collection of elements, there are a plethora of cases where you iterate all the elements inside the collection. In Streams, methods like forEach(), map(), reduce(), and filter() do this kind of whole-element iteration.
Let’s think of a case where we want to count each type of item inside a list. The code with the for loop will look like this:
public Map<Item.Type, Integer> loop(List<Item> items) {
Map<Item.Type, Integer> map = new HashMap<>();
for (Item item : items) {
map.compute(item.type(), (key, value) -> {
if (value == null) return 1;
return value + 1;
});
}
return map;
}
The code with Streams looks like this:
public Map<Item.Type, Integer> stream(List<Item> items) {
return items.stream().collect(Collectors.toMap(
Item::type,
value -> 1,
Integer::sum));
}
They look quite different, but how will they perform? Below is the table of average execution times of 100 tries:
As we can see in the above comparison table, Streams and loops show little execution time difference in iterating the whole list. This is the same for other Stream methods like map(), forEach(), reduce(), etc., in most cases.
Optimization with parallel stream
So, we found that Streams don’t perform better or worse than loops when iterating the list. However, there is an amazing thing about Streams that loops do not have: we can easily perform multi-thread computing with streams. All you have to do is to use parallelStream() instead of stream().
To see how much impact we can gain from this, let’s look at the following example where we mock the long-taking task as follows:
private void longTask() {
// Mock long task.
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
Looping through the list will look like this:
protected void loop(List<Item> items) {
for (Item item : items) {
longTask();
}
}
Streams will look like this:
protected void stream(List<Item> items) {
items.stream().forEach(item -> longTask());
}
And finally, parallel streams will look like this:
protected void parallel(List<Item> items) {
items.parallelStream().forEach(item -> longTask());
}
Notice that only stream() has changed to parallelStream().
Here is the comparison:
As expected, loops and Streams show little difference. Then what about parallel streams? Sensational! It’s saving more than 80% of the execution time compared to other implementations! How is this possible?
Regarding tasks that take a long time to finish and should be done for each element in the list independently, they can run simultaneously, and we can expect significant improvement. This is what parallel streams are doing. They distribute them into multiple threads and make them run simultaneously.
Parallel streams are only sometimes a winner that you can use everywhere instead of loops or Streams. It is only useful when the tasks are independent. If the tasks are not independent and have to share the same resources, you’ll have to keep them safe with a lock, mainly by synchronized keyword in Java, and make them run slower than normal iterations.
Limitations
Streams, however, also have limitations. One case is conditional loops, and another one is repetitions. Let’s see what they mean.
Conditional loops
When we want to repeat until the condition is true but are not sure how many iterations it will take, we normally use the while loop.
boolean condition = true;
while (condition) {
...
condition = doSomething();
}
The code that behaves the same using Streams looks like this:
Stream.iterate(true, condition -> condition, condition -> doSomething())
.forEach(unused -> ...);
You can see that some boilerplate parts bother the reading, such as condition -> condition that checks whether the condition is true, and unused parameter inside the forEach(). Considering this, conditional loops are better written in while loops.
Repetition
Repetition is one of the main reasons for the for loop’s existence. Let’s say we want to repeat the process ten times. With the for loop, it can be easily written as:
for (int i = 0; i < 10; i++) {
...
}
In Streams, one way to achieve this is to make an IntStream that contains [0, 1, 2, … , 9] and iterate it.
IntStream.range(0, 10).forEach(i -> ...);
Although the code may look concise and proper, it looks more focused on the values of the range 0 to 10 (exclusive), where the for loop code can be read repeat ten times as it’s more general to write repeat in this way: starting from 0 and ending having the number of repetition times.
Summary
We’ve gone through some comparisons between Streams and loops. So… can Streams replace loops? Well, as always, it depends on the situation! However, Streams can usually provide you with more concise, easy-to-read code and optimizations.
So, what are you waiting for? Go ahead and start writing your codes with Streams!
The codes written for this article can be found on my GitHub.
Can Streams Replace Loops in Java? was originally published in Better Programming on Medium, where people are continuing the conversation by highlighting and responding to this story.