Backend Development 16 min read

Ten Reasons to Prefer Traditional for Loop Over Stream.forEach for List Traversal in Java

Through benchmark tests, memory analysis, and code examples, this article presents ten compelling reasons why using a traditional for loop to traverse Java Lists often outperforms Stream.forEach in terms of performance, memory usage, control flow, exception handling, mutability, debugging, readability, and state management.

Selected Java Interview Questions
Selected Java Interview Questions
Selected Java Interview Questions
Ten Reasons to Prefer Traditional for Loop Over Stream.forEach for List Traversal in Java

Introduction

Many articles mock Java developers for still using a for loop to iterate over a List , claiming that the modern Stream.forEach() is superior. This article examines whether for is truly better and lists ten reasons to prefer for over Stream.forEach() .

Reason 1 – Better Performance

Benchmark tests using JMH compare a simple for loop with Stream.forEach() on a List<Integer> of various sizes. The test code is shown below:

@State(Scope.Thread)
public class ForBenchmark {
    private List
ids;
    @Setup
    public void setup() {
        ids = new ArrayList<>();
        // populate 10, 100, 1k, 10k, 100k elements
        IntStream.range(0, 10).forEach(i -> ids.add(i));
    }
    @Benchmark
    public void testFor() {
        for (int i = 0; i < ids.size(); i++) {
            Integer id = ids.get(i);
        }
    }
    @Benchmark
    public void testStreamforEach() {
        ids.stream().forEach(x -> {
            Integer id = x;
        });
    }
    @Test
    public void testMyBenchmark() throws Exception {
        Options options = new OptionsBuilder()
                .include(ForBenchmark.class.getSimpleName())
                .forks(1)
                .threads(1)
                .warmupIterations(1)
                .measurementIterations(1)
                .mode(Mode.Throughput)
                .build();
        new Runner(options).run();
    }
}

The JMH results (ops/s) are summarized in the table:

Method

10

100

1k

10k

100k

forEach

45,194,532

17,187,781

2,501,802

200,292

20,309

for

127,056,654

19,310,361

2,530,502

202,632

19,228

Improvement

+181%

+12%

+1%

-1%

-5%

For small lists (up to 10 k elements) the for loop is noticeably faster; only when the list exceeds 100 k elements does Stream.forEach() catch up.

In small lists (≤10 k elements) for outperforms Stream.forEach() .

Reason 2 – Lower Memory Consumption

Stream.forEach() creates additional objects (the stream, intermediate containers), leading to higher heap usage. GC logs from two runs illustrate the difference.

Using for : List ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList()); int sum = 0; for (int i = 0; i < ids.size(); i++) { sum += ids.get(i); } System.gc(); // GC log shows 392 540K used, 0.20 s pause

Using stream : List ids = IntStream.range(1,10000000).boxed().collect(Collectors.toList()); int sum = ids.stream().reduce(0, Integer::sum); System.gc(); // GC log shows 539 341K used, 0.38 s pause

The for version uses about 37 % less memory and finishes GC 85 % faster.

Reason 3 – Easier Control Flow

A for loop can use break , continue and return to exit or skip iterations, which is impossible inside Stream.forEach() . The following example shows that a return inside forEach does not stop the loop.

List
ids = IntStream.range(1,4).boxed().collect(Collectors.toList());
ids.stream().forEach(i -> {
    System.out.println("forEach-"+i);
    if (i > 1) {
        return; // loop continues
    }
});
System.out.println("==");
for (int i = 0; i < ids.size(); i++) {
    System.out.println("for-"+ids.get(i));
    if (ids.get(i) > 1) {
        return; // loop stops
    }
}

Output demonstrates the difference.

Reason 4 – More Flexible Variable Access

Variables used inside Stream.forEach() must be effectively final, preventing modification of external state. With a for loop you can freely update variables such as a running sum.

int sum = 0;
for (int i = 0; i < ids.size(); i++) {
    sum++;
}
// Stream version would not compile unless sum is wrapped in an AtomicReference

Reason 5 – Simpler Exception Handling

Inside a for loop you can let checked exceptions propagate, whereas Stream.forEach() forces you to catch or wrap them.

for (int i = 0; i < ids.size(); i++) {
    System.out.println(div(i, i - 1)); // may throw
}
ids.stream().forEach(x -> {
    try {
        System.out.println(div(x, x - 1));
    } catch (Exception e) {
        throw new RuntimeException(e);
    }
});

Reason 6 – Ability to Modify Collections

A classic for loop can add or remove elements from the underlying list safely; doing so inside Stream.forEach() throws ConcurrentModificationException .

// for loop – works
for (int i = 0; i < ids.size(); i++) {
    if (i < 1) {
        ids.add(i);
    }
}
System.out.println(ids);
// stream – fails
ids.stream().forEach(x -> {
    if (x < 1) {
        ids.add(x);
    }
});

Reason 7 – Better Debugging Experience

Step‑by‑step debugging of a traditional for loop is straightforward, while a lambda‑based Stream.forEach() hides the iteration logic, making breakpoints harder to place.

Reason 8 – Improved Readability

Procedural for code is often easier to understand at a glance than heavily chained functional streams, especially for developers unfamiliar with functional idioms.

Reason 9 – Simpler State Management

Maintaining mutable state across iterations (e.g., a flag) is trivial with a for loop; with streams you typically need thread‑safe wrappers like AtomicBoolean .

boolean flag = true;
for (int i = 0; i < 10; i++) {
    if (flag) {
        System.out.println(i);
        flag = false;
    }
}
// Stream version requires AtomicBoolean
AtomicBoolean flag1 = new AtomicBoolean(true);
IntStream.range(0,10).forEach(x -> {
    if (flag1.get()) {
        flag1.set(false);
        System.out.println(x);
    }
});

Reason 10 – Direct Index Access

When you need to modify elements in place, a for loop lets you use the index directly; with streams you must create a new collection via map and collect the result.

for (int i = 0; i < ids.size(); i++) {
    ids.set(i, ids.get(i) * 2);
}
ids = ids.stream().map(x -> x * 2).collect(Collectors.toList());

Conclusion

The article presents ten technical arguments favoring the traditional for loop over Stream.forEach() for List traversal, while acknowledging that the best choice depends on the specific scenario. It also reminds readers to think critically about trends and choose the most appropriate tool for the job.

debuggingJavaperformancebenchmarkmemoryStreamfor loop
Selected Java Interview Questions
Written by

Selected Java Interview Questions

A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.