1. 概述

在 Java 中遍历集合的方式有很多种。本文重点对比两种看起来很像的方法:Collection.forEach()Collection.stream().forEach()

在大多数情况下,两者的行为看起来是一致的,但它们之间还是存在一些细微但重要的差异,尤其是在执行顺序、并发修改和自定义迭代器方面。


2. 一个简单的 List 示例

我们先定义一个用于遍历的列表:

List<String> list = Arrays.asList("A", "B", "C", "D");

最传统的方式是使用增强型 for 循环:

for (String s : list) {
    // do something with s
}

如果你偏好函数式风格,也可以使用 forEach()

✅ 直接在集合上调用:

Consumer<String> consumer = s -> System.out.println(s);
list.forEach(consumer);

✅ 或者通过 Stream 调用:

list.stream().forEach(consumer);

两种方式都会输出:

A
B
C
D

在这个简单场景下,两者的行为没有区别。


3. 执行顺序

这是两者之间最显著的区别之一:

  • Collection.forEach() 会使用集合的迭代器(如果有自定义的),所以执行顺序是确定的
  • Stream.forEach() 的执行顺序是不确定的,尤其是在并行流中。

3.1 并行流中的行为差异

当我们使用并行流时,多个线程会并发执行,顺序自然无法保证。

示例:

list.forEach(System.out::print); // 顺序输出:ABCD
System.out.print(" ");
list.parallelStream().forEach(System.out::print); // 输出顺序每次可能不同

输出示例 1:

ABCD CDBA

输出示例 2:

ABCD DBCA

可以看到,forEach() 保持插入顺序,而 parallelStream().forEach() 每次结果不同。

3.2 自定义迭代器的影响

我们定义一个逆序迭代的 List:

class ReverseList extends ArrayList<String> {
    @Override
    public Iterator<String> iterator() {
        int startIndex = this.size() - 1;
        List<String> list = this;

        return new Iterator<>() {
            private int currentIndex = startIndex;

            @Override
            public boolean hasNext() {
                return currentIndex >= 0;
            }

            @Override
            public String next() {
                String next = list.get(currentIndex);
                currentIndex--;
                return next;
            }

            @Override
            public void remove() {
                throw new UnsupportedOperationException();
            }
        };
    }
}

然后测试:

List<String> myList = new ReverseList();
myList.addAll(list);

myList.forEach(System.out::print);      // 输出:DCBA
myList.stream().forEach(System.out::print); // 输出:ABCD

⚠️ 原因:forEach() 使用了自定义的逆序迭代器,而 stream().forEach() 忽略了迭代器,直接按索引顺序处理元素。


4. 集合修改行为

很多集合(如 ArrayListHashSet)在遍历时不允许结构性修改(添加或删除元素),否则会抛出 ConcurrentModificationException 异常。

两者的区别在于:

  • Collection.forEach() 是 fail-fast,一旦发现修改会立即抛异常
  • Stream.forEach() 的异常检查发生在流处理的后期,所以会继续处理部分元素后再抛异常

4.1 删除元素的示例

定义一个删除操作:

Consumer<String> removeElement = s -> {
    System.out.println(s + " " + list.size());
    if (s != null && s.equals("A")) {
        list.remove("D");
    }
};

✅ 使用 forEach()

list.forEach(removeElement);

输出:

A 4
Exception in thread "main" java.util.ConcurrentModificationException

⚠️ 只处理了第一个元素就抛出异常。

✅ 使用 stream().forEach()

list.stream().forEach(removeElement);

输出:

A 4
B 3
C 3
null 3
Exception in thread "main" java.util.ConcurrentModificationException

⚠️ 多处理了几个元素才抛异常。

✅ 注意:**Java 不保证一定会抛出 ConcurrentModificationException**,所以永远不要依赖这个异常来编写逻辑。

4.2 修改元素的行为

我们可以在遍历时修改元素:

list.forEach(e -> {
    list.set(3, "E");
});

使用 forEach()stream().forEach() 都不会报错,但在流处理中,Java 要求操作必须是 non-interfering(无干扰),也就是说在流处理期间不应该修改源集合。

⚠️ 修改流中的元素可能会导致不可预测的行为,尤其是在并行流中。


5. 总结

特性 Collection.forEach() Stream.forEach()
执行顺序 使用迭代器,顺序确定 顺序不确定,尤其在并行流中
自定义迭代器 会使用 忽略
修改集合 fail-fast,立即抛异常 后期才检查,可能处理部分元素后抛异常
修改元素 允许,但不推荐 不符合流设计原则,可能导致问题

建议:

  • 如果你只是想遍历集合,**优先使用 Collection.forEach()**;
  • 如果你需要流式处理(如 filter/map/reduce),才使用 Stream.forEach()

⚠️ 不要依赖这两种方法的异常行为或执行顺序来编写关键逻辑。

完整代码可在 GitHub 上查看。


原始标题:The Difference Between stream().forEach() and forEach() | Baeldung