Java循环你真的用明白了吗
01
前言
这天,review项目的老代码,发现这么一段代码:
List<Object> paramList = request.getParamList();
paramList.forEach(x -> {... ...}); // 一些简单操作
paramList.forEach(x -> {... ...}); // 又一些简单操作
paramList.forEach(x -> {... ...}); // 双一些简单操作哎,这就一个集合遍历,为啥要遍历三遍嘞?如果集合数据量比较多,合并为一个遍历,接口性能会不会就能提升一些?
paramList.forEach(x -> {
... ...// 一些简单操作
... ...// 又一些简单操作
... ...// 双一些简单操作
});送上门的优化,不能不要啊!于是就在本地试一下。结果发现:一次循环遍历处理,果然比三次循环处理提升了不少了(毫秒级别)。
于是兴冲冲修改部署测试环境,却发现性能没啥提升。为什么嘞?本地测试明明提升不小,有意思了,那就去探究一下。
02
本地测试
测试环境
jdk 版本:1.8.0_91(64 位)
测试硬件:Intel i7-1185G7(4 核 8 线程)、16GB DDR4、256GB SSD
测试数据:ArrayList 存储 1000 万条 Integer 数据(元素值 0~9999999)
测试方法:空循环遍历一千万数据,每次只执行单次,代码多次执行取平均值。
测试指标:遍历全部元素的总耗时(单位:毫秒)
测试循环类型
// 1. 普通for循环
for (int i = 0; i < list.size(); i++) {}
// 2.增强for循环
for (Integer num : list) {}
// 3. list.forEach
list.forEach(num -> {});
// 4. stream.forEach
list.stream().forEach(num -> {});测试代码
public static void main(String[] args) {
// 初始化数据
List<Integer> list = new ArrayList<>();
for (int i = 0; i < 1000_0000; i++) {
list.add(i);
}
long s = System.currentTimeMillis();
... ... //执行测试空循环遍历
System.out.println("cost: " + (System.currentTimeMillis() - s));
}测试结果
| 循环方式 | 执行耗时(ms) |
|---|---|
根据结果得到一个问题:同样是顺序循环遍历为什么差距这么大呢?forEach耗时甚至是普通for循环的10倍以上,接下来我们分析一下。
03
底层分析
普通 for 循环是最底层的手动迭代模式,arr[i]直接通过「基地址 + 偏移量」计算元素内存地址。
源码:
public E get(int index) {
Objects.checkIndex(index, size); // 边界检查
return elementData(index); // 直接访问底层数组
}
// 底层数组访问(无额外逻辑)
E elementData(int index) {
return (E) elementData[index];
}字节码:
0: iconst_0 // 推送 int 常量 0 到操作数栈
1: istore_1 // 把栈顶的 0 存入局部变量表索引 1 → 初始化循环变量 i=0
2: iload_1 // 加载 i 的值(0)到操作数栈
3: aload_0 // 加载局部变量表索引0的值
4: invokeinterface #15, 1 // 调用list.size()方法(常量池#15对应接口方法, 1 表示方法参数个数)
9: if_icmpge 18 // 比较栈中两个 int 值:若 i ≥ list.size(),循环结束;否则继续执行
12: iinc 1, 1 // i 自增
15: goto 2 // 继续循环
18: return // 返回普通for循环,代码非常简洁, 无方法调用开销,无额外对象创建、无多余方法调用,仅通过基础语法层面的索引操作完成遍历。
增强 for 循环是迭代器(Iterator)的语法糖,jdk5 引入,核心依赖Iterable接口(所有可遍历集合均实现该接口)。
源码:
// ArrayList的迭代器实现
private class Itr implements Iterator<E> {
int cursor; // 下一个要返回的元素索引
int lastRet = -1; // 上一个返回的元素索引(用于remove)
int expectedModCount = modCount; // 期望的修改次数(防止并发修改)
public boolean hasNext() {
return cursor != size; // 索引未到集合大小则有下一个元素
}
public E next() {
checkForComodification(); // 校验并发修改
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1; // 移动指针
return (E) elementData[lastRet = i]; // 直接访问数组,返回元素
}
// 并发修改校验
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}字节码:
0: aload_0 // 加载局部变量表索引0的值(当前对象: List 实现类实例)
1: invokeinterface #11, 1 // 调用 List 接口的 iterator() 方法(常量池#11对应方法),返回 Iterator 接口实例
6: astore_1 // 把栈顶的 Iterator 实例存入局部变量表
7: aload_1 // 加载 Iterator 实例到栈
8: invokeinterface #12, 1 // 调用 Iterator 的 hasNext() 方法(常量池#12对应方法): 返回是否还有下一个元素
13: ifeq 29 // 判断栈顶hasNext()的返回值:若为 0:循环结束,返回;否则,继续执行
16: aload_1 // 加载局部变量Iterator 实例到栈
17: invokeinterface #13, 1 // 调用 Iterator 的 next() 方法(常量池#13对应方法): 获取下一个元素,返回 Object 类型
22: checkcast #14 // Object类型强转Integer (常量池#14对应 Integer.class)
25: astore_2 // 把强转后的 Integer 元素存入局部变量表索引2 → 保存当前遍历元素
26: goto 7 // 继续循环
29: return // 返回编译器会自动将增强 for 循环转换为「获取迭代器→hasNext()判断→next()获取元素」的逻辑,无需手动管理索引。
List.forEach是 jdk8 引入的函数式遍历,定义在Iterable接口中(List继承自Iterable),核心依赖Consumer函数式接口。
源码:
public void forEach(Consumer<? super E> action) {
Objects.requireNonNull(action);
final int expectedModCount = modCount;
final E[] elementData = (E[]) this.elementData;
final int size = this.size;
for (int i=0; modCount == expectedModCount && i < size; i++) {
// 调用Consumer处理当前元素
action.accept(elementData[i]);
}
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}字节码:
0: aload_0 // 加载局部变量表索引0的值(List<Integer> 实例)
1: invokedynamic #16, 0 // 动态绑定 Lambda 表达式(核心指令,jdk 7+ 用于 Lambda/函数式接口绑定):
// - #16:常量池索引,对应「Lambda 引导方法(Bootstrap Method)」和相关元数据(如目标接口、方法签名)
// - 0:参数个数 → 表示 Lambda 未捕获外部变量
// - 目标:生成一个 Consumer<Integer> 接口实例(因 forEach 参数是 Consumer),绑定 Lambda 的核心逻辑(即下面的 lambda$test3$0 方法)
// - 执行后,栈顶压入生成的 Consumer 实例
6: invokeinterface #17, 2 // 调用 List 接口的 forEach 方法(常量池#17对应方法):
// - 方法签名:List.forEach(Consumer<? super E>) → void(接收 Consumer 接口,遍历集合元素并执行 accept 方法)
// - 参数2个:接口方法调用的参数个数 = 调用者(this,List实例) + 显式参数(Consumer实例)
// - 执行逻辑:List 遍历每个元素,调用 Consumer 的 accept 方法(即 Lambda 逻辑)
11: return // 返回
// 私有静态方法 lambda$test3$0:
private static void lambda$test3$0(java.lang.Integer);
Code:
0: return // 空逻辑方法,直接返回如上,lambda 表达式会编译为匿名内部类实例,每次循环调用 action.accept(elementData[i])。
Stream.forEach是 jdk8 引入的流式遍历终端操作,基于「Stream 管道模型」和「惰性求值」机制。核心依赖三个组件:
• Stream:流式操作的载体,封装数据来源(集合 / 数组)和中间操作链;
• Spliterator:分割迭代器,负责数据的分割与遍历(支持并行);
• Sink:数据转发器,连接中间操作(如filter)与终端操作(forEach),传递元素并执行逻辑。其底层逻辑是「管道化处理」:中间操作(如filter)会被封装为Sink链,终端操作forEach触发时,Spliterator遍历数据并通过Sink链传递,最终由Consumer处理元素。
源码:
// Stream.forEach的底层实现(ReferencePipeline.forEach)
public void forEach(Consumer<? super P_OUT> action) {
Objects.requireNonNull(action);
// 封装终端操作的Sink(最终处理元素)
Sink<P_OUT> sink = new Sink.ChainedReference<P_OUT, P_OUT>(Sink.cancellationRequested()) {
@Override
public void accept(P_OUT u) {
action.accept(u); // 调用用户传入的Consumer
}
};
// 触发遍历:Spliterator遍历数据并通过Sink链传递
evaluate(sink);
}
// 执行遍历
final <S extends Sink<P_OUT>> S evaluate(S sink) {
return isParallel() ? parallelEvaluate(sink) : sequentialEvaluate(sink);
}
// 串行遍历
private <S extends Sink<P_OUT>> S sequentialEvaluate(S sink) {
// 构建Sink链(将中间操作的Sink与终端Sink链接)
Sink<P_OUT> chainedSink = opWrapSink(StreamOpFlag.NOT_CANCELABLE, sink);
// Spliterator遍历数据,通过Sink链传递
spliterator().forEachRemaining(chainedSink);
// 结束
chainedSink.end();
return sink;
}字节码:
0: aload_0 // 加载局部变量表索引0的值(List<Integer> 实例)
1: invokeinterface #18, 1 // 调用 List 接口的 stream() 方法(常量池#18对应方法):
// - 方法签名:List.stream() → Stream<E>(将 List 转为顺序流 Stream<Integer>)
// - 参数个数1:接口方法调用的参数包含调用者 this(无额外显式参数)
// - 执行后,栈顶压入返回的 Stream<Integer> 实例
6: invokedynamic #19, 0 // 动态绑定 Lambda 表达式(jdk 7+ 新增,用于函数式接口实例化):
// - #19:常量池索引,对应「Lambda 引导方法(Bootstrap Method)」+ 元数据(目标接口 Consumer、方法签名)
// - 0:参数个数 → 表示 Lambda 未捕获任何外部变量(无状态 Lambda)
// - 目标:生成 Consumer<Integer> 接口实例,绑定 Lambda 核心逻辑(即下面的 lambda$test4$1 方法)
// - 执行后,栈顶压入生成的 Consumer 实例
11: invokeinterface #20, 2 // 调用 Stream 接口的 forEach 方法(常量池#20对应方法):
// - 方法签名:Stream.forEach(Consumer<? super T>) → void(遍历流中元素,执行 Consumer 的 accept 方法)
// - 参数个数2:接口方法调用的参数 = 调用者(stream 实例) + 显式参数(Consumer 实例)
// - 执行逻辑:Stream 遍历每个元素,调用 Consumer.accept(Integer)(即 Lambda 逻辑)
16: return // 返回
// 私有静态方法 lambda$test4$1:
private static void lambda$test4$1(java.lang.Integer);
Code:
0: return // 空逻辑方法,直接返回可以看到,stream.forEach相比list.forEach还多了维护流水线相关操作。
四种循环方式对比
| 循环类型 | 底层依赖组件 | 遍历方式 | 核心开销来源 | 并发修改校验方式 |
|---|---|---|---|---|
04
结果总结
插入知识点:JIT 是 Just-In-Time Compiler 的缩写「即时编译器」,是 Java 虚拟机(JVM)中核心的性能优化组件 —— 它的核心目标是解决 “解释执行 Java 字节码速度慢” 的问题,通过将 “频繁执行的热点代码” 编译为高效的机器码,让 Java 程序运行速度接近原生语言(如 C/C++)。JIT 是 “惰性优化”—— 不启动就优化所有代码,只优化 “频繁执行的热点代码”,平衡 “启动速度” 和 “运行速度”。
通过底层代码和字节码的分析,可以总结测试现象的原因:
核心优势:无额外开销 + JIT 深度优化
• 关键步骤(无冗余):
1. 循环条件判断:i < list.size() → size是 ArrayList 的成员变量,直接读取(耗时可忽略);
2. 数组访问:list.get(i) → 直接通过下标elementData[i]获取元素(O (1),无中间操作);
3. 拆箱:Integer → int(所有遍历都需执行,无差异);
4. 循环变量自增:i++ → 局部变量操作(耗时可忽略)。
• JIT 优化放大优势:
边界检查消除:JVM 可证明i < size不会越界,删除get(i)中的数组边界检查;
循环展开:将多次循环合并为一次(减少循环判断 / 自增的次数);
缓存友好:连续内存访问,CPU 缓存命中率接近 100%。
耗时核心:仅数组访问 + 拆箱,无任何额外开销。
核心开销:迭代器的「fail-fast 检查」+ 方法调用
• 关键步骤(比普通 for 多 2 个核心开销):
1. 迭代器创建:new Itr() → 初始化cursor=0、expectedModCount=modCount(耗时低,但需创建对象);
2. 循环条件:it.hasNext() → 仅判断cursor != size(简单,耗时低);
3. 元素获取:it.next() → 核心开销点:
• checkForComodification():判断modCount == expectedModCount(防止遍历中修改集合,一千万次 int 比较的累积开销);
• cursor++ + 数组访问(和普通 for 一致);
4. 拆箱(和普通 for 一致)。
• 优化局限性:
• 迭代器的hasNext()/next()是方法调用(即使 JIT 内联,仍比普通 for 的直接操作多一层开销);
• modCount检查无法消除(fail-fast 机制的必须步骤)。
耗时核心:一千万次modCount检查 + 迭代器方法调用的累积开销。
核心开销:Consumer 接口方法调用 + 循环内modCount检查
• 关键步骤(比增强 for 多 1 个核心开销):
1. Consumer 对象创建:若用 lambda 表达式(如 e -> {}),会编译为匿名内部类实例(jdk8 用 invokedynamic 优化,但仍有对象创建开销);
2. 并发修改检查:同增强 for 循环(modCount == expectedModCount);
3. 接口方法调用:JVM 每次执行 accept(x) 时,都需要通过「动态分派」找到 Lambda 动态生成的 accept() 实现(虽然类已初始化,但方法调用仍需查表),jdk 8 的 JIT 对「动态生成的 Lambda 方法」内联支持极差 ,因为 Lambda 的 accept() 是动态生成的,JIT 无法在编译期识别其逻辑,只能按普通虚方法处理,无法消除调用开销。
4. 自动拆箱(和其他方式一致)。
耗时核心:一千万次accept()方法调用 + 循环内modCount检查。
核心开销:Stream 框架额外开销 + Spliterator 管理
• 关键步骤(比 list.forEach 多框架级开销):
1. Stream 创建:list.stream() → 生成 Spliterator(ArrayList 的索引型 Spliterator)、创建 Stream 管道(ReferencePipeline)→ 固定框架开销;
2. Spliterator 遍历:spliterator.tryAdvance(action) → 需管理 Spliterator 的索引范围、状态(比直接下标遍历多一层包装);
3. 元素处理:action.accept() → 和 list.forEach () 一致的方法调用开销;
4. modCount检查:Spliterator 同样会维护expectedModCount,防止并发修改;
5. 拆箱(和其他方式一致)。
• 优化局限性:
• Stream 框架代码复杂,JIT 难以像普通 for 那样做深度优化(如循环展开、边界检查消除);
• 即使是串行 Stream,仍会加载并行相关的逻辑(无实际执行,但有初始化开销)。
耗时核心:Stream 框架初始化 + Spliterator 管理开销。
性能差距的核心根源总结
四种遍历的耗时排序(普通 for < 增强 for < list.forEach < stream.foreach),本质是 「额外开销的叠加」+「JIT 优化的递减」:
| 性能影响因素 | 普通 for | 增强 for | list.forEach | stream.foreach |
|---|---|---|---|---|
jdk 8 中,forEach和stream.forEach的慢本质是「函数式编程的抽象层开销」在大数据量下的持续叠加 ——Lambda 的虚方法调用、Stream 的 Spliterator 遍历,这些开销在 jdk 8 中无法被 JIT 优化消除;而普通 for 循环因「无抽象层 + JIT 深度优化」,成为大数据量遍历的最优选择。
05
再次测试
还是jdk8下,每种循环都先执行一遍空循环遍历,再次执行,记录第二次耗时。
结果: 普通for循环和增强for循环基本没什么变化。 但是forEach和stream.forEach的耗时却增加很多,和增强for循环基本相同了。
为什么呢? 这是JIT的功劳。
JIT 对 list.forEach 的核心优化(第二次提速关键)
根据上边分析可知:list.forEach 第一次慢的核心是「接口多态调用」和「Consumer 对象创建开销」,第二次 JIT 会针对性解决这两个问题:
第一次执行时,Consumer 是 lambda 表达式对应的匿名内部类实例(如 Lambda$1@xxx),action.accept() 是 接口多态调用(JIT 不确定 Consumer 的具体实现,无法内联,只能通过接口查找实现类,开销高)。
第二次执行前,JIT 会通过「类型 profile」(记录方法调用的实际类型)发现:当前 Consumer 只有唯一实现(就是那个空逻辑的 lambda)。此时 JIT 会做「单态去虚拟化」—— 直接将多态调用替换为「具体实现类的方法调用」,甚至进一步内联。
优化前后对比(伪代码):
• 第一次(解释执行):action.accept(element) → 接口查找实现 → 调用 Lambda$1.accept()(多态开销);
• 第二次(编译执行):JIT 直接内联 Lambda$1.accept() 的逻辑(空逻辑),此时 list.forEach 的核心开销只剩「循环控制」和「modCount 检查」,与增强 for 循环(Itr.hasNext()+Itr.next())的开销接近。
第一次执行时,lambda 会被实例化为 Consumer 对象(堆上分配),有对象创建和垃圾回收的潜在开销。
第二次 JIT 会通过「逃逸分析」发现:这个 Consumer 对象只在 list.forEach 方法内使用,没有 “逃逸” 到方法外部(比如没有被存储到全局变量、没有被其他线程访问)。此时 JIT 会做「栈上分配」或「标量替换」—— 直接消除 Consumer 对象的堆分配,把对象的字段拆成局部变量(甚至直接消除对象),进一步降低开销。
JIT 对 stream.forEach 的核心优化(第二次提速关键)
stream.forEach 第一次慢的原因是「Stream 初始化 + Spliterator 多层调用」,第二次 JIT 会优化这两层额外开销:
第一次执行 list.stream() 时,会创建 ReferencePipeline.Head(Stream 管道头)、ArrayListSpliterator(拆分迭代器)等对象,初始化逻辑有一定开销。
第二次 JIT 会:
• 通过「逃逸分析」消除 Stream 相关对象的堆分配(这些对象仅在遍历期间使用,无逃逸);
• 编译优化 Stream 初始化的冗余逻辑(比如合并常量、消除不必要的参数检查),使得 list.stream() 的初始化开销几乎可以忽略。
stream.forEach 依赖 ArrayListSpliterator.tryAdvance(action) 迭代,第一次执行时这是一层额外的方法调用(tryAdvance() → accept())。
第二次因为 tryAdvance() 是热点方法,且 ArrayListSpliterator 是具体类(非接口),JIT 会直接将其「内联」到 Stream 的 forEach 终端操作中,此时两层方法调用(tryAdvance()+accept())被合并为一层,甚至直接内联为循环内的逻辑,开销大幅降低。
Consumer.accept() 的多态调用同样被 JIT 去虚拟化并内联,消除接口调用开销。优化后其开销与 list.forEach、增强 for 循环基本一致。
06
再再次测试
这次使用jdk17版本,其他条件不变,还是只测试一遍空循环遍历, 得到结果:
| 循环方式 | 单次执行耗时(ms) |
|---|---|
可以发现,jdk17下,forEach和stream.forEach的性能已经和增强for循环基本相同了,那jdk偷偷做了哪些升级呢?
性能提升原因分析
JDK 17 作为长期支持(LTS)版本,针对 Stream API 和 Lambda 表达式的执行效率实现了全方位优化,核心原因可归结为:
JDK 17 默认沿用 HotSpot 的 C2 编译器(服务端模式),并继承了 JDK 9-16 期间的优化成果,其对函数式编程相关优化逻辑进行了大幅增强:
• lambda 内联优化:C2 能将 Consumer.accept() 等函数式接口方法与 Stream 内部迭代逻辑直接内联,消除虚函数调用的性能开销;
• Stream 流水线扁平化:智能识别无中间操作的 Stream(如 DATA.stream().forEach(...)),直接优化为普通迭代逻辑,跳过 Stream 流水线封装带来的额外开销;
• 逃逸分析增强:将简单 Consumer 等函数式接口实例判定为“不逃逸”,通过标量替换技术避免堆分配(效果等同于分配在栈上),减少垃圾回收(GC)压力。
• GraalVM 可选增强:若部署环境采用 GraalVM,可进一步利用其先进的全局优化算法,在特定场景下获得比 C2 更优的函数式代码执行性能。
JDK 17 继承了自 JDK 9 以来对 Stream 内部实现的持续微调:
• 流水线简化:针对无中间操作或简单链式调用(如 stream().forEach()),直接复用集合的迭代器,避免额外封装;
• Spliterator 优化:改进了迭代器的分割与遍历逻辑,提升遍历效率;
• 类结构精简:内部 ReferencePipeline 等类简化,减少了对象创建的隐性成本。
JDK 17 对包装类(如 Integer)的自动装箱/拆箱处理更高效,结合 JIT 的类型推断,减少了部分场景下的方法调用开销。若使用基本类型流(IntStream),优化效果更显著,可直接访问底层基本类型数据,避免 intValue() 拆箱开销。
jdk17 优化了 Fork/Join 框架(并行 Stream 的底层依赖)的线程调度:
• 减少线程创建和切换开销;
• 优化任务拆分策略,避免小任务的过度拆分;
• 利用 CPU 缓存局部性,提升并行执行效率。
JDK 17 在逃逸分析、方法内联、循环展开等方面均有增强,这些优化对所有循环类型都有增益,但对 Stream 这类复杂逻辑的提升更为明显。
到这也就知道了,文章开头的优化没什么效果的原因: 测试环境使用的是jdk17且参数数据量比较小,这部分代码耗时占接口调用占比极小。(草率了)
07
场景选择
结合四种循环的特性、jdk 版本差异与性能表现,开发中可遵循 “性能优先、兼顾可读性” 的原则,按以下场景选型:
优先 传统 for 循环 的场景
1. 遍历数组或支持随机访问的集合(ArrayList),且需要索引(如修改元素、跳过 / 反向遍历);
2. 对性能要求极致(如高频遍历、海量数据处理),且 jdk 版本低于 11;
3. 需手动控制循环进度(如多线程场景下的分段遍历)。
优先 增强 for 循环 的场景
1. 遍历集合(List、Set)或数组,无需索引,追求代码简洁;
2. 需兼容低版本 jdk,且可读性优先级高于极致性能;
3. 遍历非随机访问集合(如 LinkedList),传统 for 的get(index)会导致 O (n²) 性能问题,增强 for(迭代器)更高效。
优先 List.forEach 的场景
1. jdk8 + 环境,遍历 List 且无需索引,追求函数式编程风格;
2. 遍历逻辑简单(如打印、简单过滤),方法引用(out::println)可进一步简化代码;
3. 需兼顾可读性与性能(jdk11 + 环境下性能接近增强 for)。
优先 Stream.forEach 的场景
1. 需对数据进行链式操作(过滤、映射、排序、聚合),如stream().filter().map().forEach();
2. 海量数据处理,可通过parallelStream()利用多核 CPU(注意线程安全与锁开销);
3. jdk17 + 环境,性能已接近传统循环,且代码可读性、扩展性更优;
4. 函数式编程场景(如结合 Optional、Stream API 构建流式数据处理管道)。
08
总结一下
Java 循环的演进,本质是 “性能与可读性的平衡”:从传统 for 的极致性能,到增强 for 的简洁,再到 List.forEach 与 Stream.forEach 的函数式灵活,每一种循环都对应特定的开发场景。随着 jdk 版本的升级(尤其是 jdk17 的性能飞跃),函数式遍历的性能短板已大幅弥补,开发中可更自由地在 “性能” 与 “代码优雅” 之间做出选择。最终,循环选型的核心不是 “哪一种更快”,而是 “哪一种更适合当前场景”—— 在满足性能需求的前提下,简洁、易维护的代码才是长期价值的关键。