ArrayList.iterator() 只有一行实现:return new Itr();。按字面读,每轮 for-each 都新建一个对象。
实测 1250 万轮遍历:for-each 的分配是 0 字节,把迭代器存进字段的是 400 MB。差值来自 C2 的逃逸分析。
把同一段代码丢给 C1,0 字节这一列就没有了。分配消除是当前那个编译版本的性质,同一份代码换个层级,这一列就变。

一、一行 new 和 0 字节的分配

ArrayListiterator() 在 JDK 25 里长这样,java.base/java/util/ArrayList.java:1029

1
2
3
public Iterator<E> iterator() {
return new Itr();
}

Itr 定义在同一个文件 :1036,三个 int 字段加一个构造函数:

1
2
3
4
5
6
7
private class Itr implements Iterator<E> {
int cursor; // index of next element to return
int lastRet = -1; // index of last element returned; -1 if no such
int expectedModCount = modCount;

// prevent creating a synthetic constructor
Itr() {}

按这份源码的字面意思,一次 for (int x : list) 遍历 8 个元素会新建 1 个 Itr。1250 万轮就是 1250 万个对象。

javac 把 for-each 编译成了迭代器调用。用 javap -c 看同一段代码的两个版本:

1
2
3
4
5
6
7
8
9
static long foreachList(java.util.List<java.lang.Integer>);
2: aload_0
3: invokeinterface #7, 1 // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
8: astore_3
9: aload_3
10: invokeinterface #13, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z
15: ifeq 41
18: aload_3
19: invokeinterface #19, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;

同一份 javap 输出里,int[] 的 for-each 编译成了下标循环,没有迭代器对象:

1
2
3
4
5
6
7
static long foreachArray(int[]);
5: arraylength
6: istore 4
11: iload 5
13: iload 4
15: if_icmpge 36
21: iaload

数组上的 for-each 不分配对象,这件事在编译期就定了。集合上的 for-each 里躺着一条 new Itr() 指令,每次编译都在,会不会执行得看运行期。

系列总览那篇(被语言吃掉的那些模式)讲的是语法层面的接管:for-each 把 hasNext / next 的手写循环交给编译器。编译器接管了语法,没接管分配。new 就写在源码里,JIT 得在运行期证明这个对象用不着,才能把它删掉。

二、实验设计与口径

实验测的是同一个集合上的同一次遍历,只改迭代器引用的去向。集合用 ArrayList<Integer>,8 个元素,值取 0 到 7,落在 Integer 缓存区间里,排除装箱带来的额外分配。一轮 = 一次 iterator() 调用加走完 8 次 hasNext / next

三种用法:

  • 不逃逸for (int x : l) sum += x;,迭代器是方法内的临时变量。
  • 逃逸(字段)Iterator<Integer> it = l.iterator(); ESCAPED = it; 存进静态字段,再作为参数传进 drain(Iterator)
  • 逃逸(lambda)CAPTURED = () -> SINK = drain(it); lambda 本身存进静态字段,再调用一次。

三个对照:int[] 的 for-each(没有迭代器对象)、List.of(...) 的 for-each(换一份迭代器实现 ImmutableCollections.ListItr)、以及把迭代器只作为参数传给方法但不存字段(argescape)。

另加一组跨线程:迭代器在调用线程创建,交给每次新建的虚拟线程遍历。

集合取 8 个元素,是为了让每轮遍历的开销露出来。一次遍历分配一个迭代器,这个开销按元素个数摊薄:8 个元素摊到每元素 4 字节,26 万个元素摊到 0.0001 字节。第三节把长度从 4 扫到 262144,量这条曲线在什么位置变得无所谓。

测量口径:

  • 耗时:System.nanoTime() 包住整个循环,先热 5 轮再计时 5 轮,取中位数。每轮的值都记进日志,不只看中位数。
  • 分配字节:com.sun.management.ThreadMXBean.getThreadAllocatedBytes(currentThread) 在每轮前后各读一次。这个计数来自 JVM 的分配计数器,不受 GC 影响。
  • 规模:主表每种用法跑 1250 万轮,合计 1 亿次元素访问。虚拟线程那组每轮要建线程,降到 20 万轮。
  • 机器:Apple M1 Pro,macOS Darwin 25.6.0,JDK 21.0.8+12-LTS-250 与 25+37-LTS-3491,都是 arm64,默认 G1 与默认分层编译。
  • 每个用法都在独立 JVM 里跑。同一个 JVM 里跑多个用法会让它们互相污染编译配置,这一点在第三节会看到后果。

这份测量的局限:本机没有 JMH,这是单机微基准,只用来判断量级,不能当硬件上限。计时循环里包着逃逸版本每轮 400 MB 的分配,G1 的回收成本算在耗时里,这一部分没有单独拆出来。

最直接的那条证据这次拿不到。-XX:+PrintEliminateAllocations 在 product 版 JVM 上会被拒绝:

1
2
3
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintEliminateAllocations -version
Error: VM option 'PrintEliminateAllocations' is develop and is available only in debug version of VM.
Improperly specified VM option 'PrintEliminateAllocations'

本机四个 JDK(11、17、21、25)都是 product 版,没有 debug 构建。替代方案是四条:分配字节计数、-XX:-EliminateAllocations 开关、编译层级开关、以及 -XX:+PrintCompilation 的层级轨迹。字节码一个字不改,只换 VM 开关,分配从 0 变成 400 MB,这个差值只能来自编译器的分配消除。

内联证据来自 -XX:+PrintInlining。标量替换的前提是构造与读取全部内联展开,下面这段输出说明 Itr 的构造函数和内层方法都进了内联:

1
2
3
4
5
@ 13  java.util.ArrayList::iterator (9 bytes) inline (hot)
@ 5 java.util.ArrayList$Itr::<init> (31 bytes) inline (hot)
@ 22 java.util.ArrayList$Itr::hasNext (20 bytes) inline (hot)
@ 32 java.util.ArrayList$Itr::next (66 bytes) inline (hot)
@ 1 java.util.ArrayList$Itr::checkForComodification (23 bytes) inline (hot)

复现用的命令是一个单文件程序,编译和运行各一行:

1
2
3
4
5
6
$ JH25=$(/usr/libexec/java_home -v 25)
$ $JH25/bin/javac -d out25 IteratorBench.java
$ $JH25/bin/java -cp out25 IteratorBench foreach 12500000 5 5 8
mode=foreach jvm=25 reps=12500000 elems=100000000 medMs=66 minMs=65 medBytes=0 nsPerElem=0.66 bytesPerElem=0.000 msList=[65, 66, 66, 66, 66] bytesList=[0, 0, 0, 0, 0] sink=3500000000
$ $JH25/bin/java -cp out25 IteratorBench escape 12500000 5 5 8
mode=escape jvm=25 reps=12500000 elems=100000000 medMs=104 minMs=103 medBytes=400000000 nsPerElem=1.04 bytesPerElem=4.000 msList=[105, 104, 104, 103, 104] bytesList=[400000000, 400000000, 400000000, 400000000, 400000000] sink=3500000000

参数依次是:用法、轮数、热轮数、计时轮数、集合长度。

三、实测

集合 8 个元素,一轮 = 一次 iterator() 加走完 8 个元素,1250 万轮 = 1 亿次元素访问。每种用法在独立 JVM 里热 5 轮、计时 5 轮,下表取中位数;括号里是那一列的单位。

用法 分配字节/轮 (21) (25) 分配字节/元素 (21) (25) ns/元素 (21) (25) 中位耗时 ms (21) (25)
for-each(ArrayList 0 0 0.00 0.00 0.78 0.66 78 66
for-each(int[] 0 0 0.00 0.00 0.66 0.65 66 65
for-each(List.of 0 0 0.00 0.00 0.68 0.65 68 65
逃逸:存静态字段 + 传参 400000000 400000000 4.00 4.00 1.20 1.04 120 104
逃逸:List.of 的迭代器 400000000 400000000 4.00 4.00 1.16 0.93 116 93
逃逸:lambda 捕获 600000000 600000000 6.00 6.00 1.59 1.17 159 117

迭代器分配与逃逸:分配字节、耗时、编译层级与集合长度

三个不逃逸的写法都是 0 字节

三个不逃逸的写法在分配这一列上都是 0 字节,5 轮全是 0。ArrayListItrList.ofListItr 是两个不同的类、两份不同的实现,分配计数都是 0。按源码字面读,1250 万轮该造出 1250 万个迭代器,实测一个都没有。

耗时这一列上,集合版本贴在 int[] 的下标循环上:JDK 25 上 0.66 对 0.65 ns/元素,差 1.5%;JDK 21 上 0.78 对 0.66,差 15.4%。指针跟随、边界判断、Integer.intValue() 这些开销还在,迭代器对象不在了。

逃逸之后,每次遍历固定 32 字节

逃逸那三行的分配字节是整数:每轮 400000000 字节,两个 JDK 一致。除以 8 个元素是 4.00 字节/元素,乘 1250 万轮正好 400000000。这就是源码里那条 new Itr(),前面它躺在字节码里不执行,现在每轮执行一次。List.of 的迭代器被存进字段时也是同样的 400000000 字节,对象体积一样,类的来源不同。

耗时从 0.66 涨到 1.04 ns/元素(JDK 25),1.58 倍。这个倍数里除了分配本身,还包括这 400 MB 垃圾的回收成本,G1 的 young GC 就停在计时循环里(本节最后有次数)。

对象体积是量出来的

有了分配计数,对象体积可以直接换算。单独跑一个探针,每次分配都存进静态字段保证活到测量之后,计数除以次数:

表达式 分配字节 / 次 布局
() -> {}(不捕获) 0.0 不捕获的 lambda 实例被 invokedynamic 的 call site 缓存,不重复分配
() -> SINK(it)(捕获一个引用) 16.0 12 字节对象头 + 4 字节被捕获的引用
l.iterator()ArrayList 32.0 12 + 3 个 int + this$0 引用,补齐到 32
imm.iterator()List.of 32.0 12 + 1 个引用 + 2 个 int + 1 个 boolean,补齐到 32

两个 JDK 上的读数一致。这也解释了 capture 那一列每轮多出来的 16 字节:32 字节的迭代器,加上捕获它的 lambda 实例。迭代器本来能停在方法里,只要被 lambda 捕获,lambda 类就多了一个字段,实例缓存不了,每次都要新建。

反证一:关掉标量替换

开关 用法 分配字节/轮 (21) (25) ns/元素 (21) (25)
默认 for-each 0 0 0.78 0.66
-XX:-EliminateAllocations for-each 400000000 400000000 0.92 0.95
-XX:-DoEscapeAnalysis for-each 400000000 400000000 0.99 0.96
默认 逃逸 400000000 400000000 1.20 1.04
-XX:-EliminateAllocations 逃逸 400000000 400000000 1.14 1.04
默认(drain 可内联) 传参 0 0 0.78 0.61
-XX:CompileCommand=dontinline 传参 400000000 400000000 1.31 1.31

-XX:-EliminateAllocations 只关掉标量替换。字节码一个字没改,for-each 的分配从 0 变成 400000000 字节/轮,耗时从 0.66 涨到 0.95 ns/元素(JDK 25)。-XX:-DoEscapeAnalysis 关得更靠上游,连逃逸分析本身都不跑,结果在同一量级:400000000 字节/轮、0.96 ns/元素。逃逸那一列在这两个开关下不动(400000000 字节/轮),它本来就在分配。

表里最后两行是另一条边界。同一个 drain(it) 传参,什么都不改,只把 drain 标成不可内联,分配从 0 变成 400000000 字节/轮。逃逸分析跑在内联之后的代码上,方法一旦内联不进来,参数就落到堆上。迭代器可以只在方法之间流动而仍然不逃逸,前提是 JIT 把沿途每个调用都内联展开。

反证二:跑在 C1 上就没有消除

-XX:TieredStopAtLevel=1 把编译停在第 1 层,只用 C1。同一个程序、同一份字节码,分配字节从 0 变成每轮 400 MB,两个 JDK 一致,30 轮测量里一次例外都没有。耗时从 0.66 涨到 4.96 ns/元素。

反过来,-XX:-TieredCompilation 只允许 C2 编译,分配是 0 字节,两个 JDK 各 3 次运行、每次 5 轮都没有例外。

原因在分层编译的分工上:逃逸分析和标量替换是 C2 的优化,C1 不做。同一段循环,先被 C1 编译(tier 3,带 profiling),再被 C2 编译(tier 4)。跑在哪个版本上,分配就不一样。-XX:+PrintCompilation 的轨迹能看到这次交接:

1
2
3
4
5
6
=== JDK25 编译层级轨迹(-XX:+PrintCompilation,只看 Plain::plain)===
38 88 % 3 Plain::plain @ 24 (69 bytes)
39 89 3 Plain::plain (69 bytes)
39 90 % 4 Plain::plain @ 24 (69 bytes)
41 88 % 3 Plain::plain @ 24 (69 bytes) made not entrant: OSR invalidation of lower levels
289 90 % 4 Plain::plain @ 24 (69 bytes) made not entrant: uncommon trap

第 3 行是 tier 3 的 OSR 编译,也就是第一次把这个循环编译成机器码的那个版本,它分配。第 4 行的 % 4 是 tier 4(C2)的 OSR 编译,第 5 行的 made not entrant: OSR invalidation of lower levels 表示 tier 3 那个版本被换掉。之后同一个循环是 0 字节。

这和 -XX:-EliminateAllocations 那一行不是一回事。C2 不做标量替换时,每轮耗时 95 ms(0.95 ns/元素);C1 跑同样的分配时是 496 ms。两个都分配 400 MB,速度差 5.2 倍。分配字节相同,代码质量差在别处。

编译配置 跑的是谁 分配字节/轮 ns/元素 每轮耗时 ms
-XX:TieredStopAtLevel=1 只有 C1 400000000 4.96 496
默认分层(5 轮之后) C2 0 0.66 66
默认分层(还没轮到 C2) C1(tier 3) 400000000 2.29 229
默认 + -XX:-EliminateAllocations C2,不做标量替换 400000000 0.95 95
默认 + 逃逸用法 C2,对象逃逸 400000000 1.04 104

同一份代码,8 次运行里有 2 到 3 次没消除

用法 JDK 21 测到 0 字节 测到 400 MB JDK 25 测到 0 字节 测到 400 MB
for-each(ArrayList 6/8 2/8 5/8 3/8
for-each(List.of 7/8 1/8 6/8 2/8
逃逸:存静态字段 + 传参 0/8 8/8 0/8 8/8

上面三节测的是「这台机器、这一次运行」的结果。把同样的参数拆成独立 JVM 各跑一次,每次热 5 轮只测 1 轮,结果分成了两堆:大多数运行从第一轮起就是 0 字节,少数运行前 3 轮是 400 MB,第 4 轮跳回 0。后者就是 C1 版本被 C2 替换掉的那个时刻。

另写一个最小程序(main 里直接调用,没有方法引用分派)跑 16 次,复现了同一件事:JDK 21 有 5 次、JDK 25 有 0 次出现「前 3 轮每轮 400 MB、耗时 229 ms,之后降到 0 字节、66 ms」,每次都在同一轮数上切换。

运行 第 1 轮 第 2 轮 第 3 轮 第 4 轮 第 5 轮
JDK 21 典型失败 400 MB / 229 ms 400 MB / 229 ms 400 MB / 228 ms 0 / 76 ms 0 / 77 ms
JDK 25 正常 0 / 66 ms 0 / 65 ms 0 / 65 ms 0 / 65 ms 0 / 66 ms

逃逸那一列 16 次全部是 400 MB,没有一次例外,也没有一次中途改变。不逃逸的对象能不能被消掉取决于编译状态,逃逸的对象没有这个不确定性。

一次跑出 0 字节不能当成保证:要断言「这段代码不分配」,得先确认它跑在 C2 上,再看多轮结果是否一致。本文主表里每一轮的分配值都相同(要么 5 轮全 0,要么 5 轮全 400 MB),没有混合状态,中位数才有意义。

逃逸的去处:lambda 与跨线程

用法(20 万轮) 主线程分配字节/轮 (21) (25) ns/元素 (21) (25) 中位耗时 ms (21) (25)
迭代器交给虚拟线程 107172208 113580672 715 670 1144 1072
对照:线程里没有迭代器 99175752 105566144 641 865 1025 1384

每轮新建一个虚拟线程再 join,每元素成本从纳秒级跳到几百纳秒:JDK 25 上 670 ns/元素,是同一线程逃逸版本(1.04 ns/元素)的 644 倍。对照组把线程里的遍历换成数组下标求和,还有 865 ns/元素(JDK 21 上 641 对 715,方向在两个 JDK 之间是反的)。

迭代器那一份分配被线程成本淹没了。这一列里两个版本的差值在噪声里,测不出迭代器分配的影响;能确定的是,跨线程传递迭代器的成本大头在线程上。分配字节这一列统计主线程,包含 Thread 对象、lambda 和结果数组,对照组给出这批脚手架的量,两者之差才是迭代器。

集合变长以后,这件事自己消失

集合长度 轮数 for-each 分配/轮 (21) (25) 逃逸 分配/轮 (21) (25) for-each ns/元素 (21) (25) 逃逸 ns/元素 (21) (25)
4 25000000 0 0 800000000 800000000 1.03 0.81 1.97 1.54
16 6250000 0 0 200000000 200000000 0.66 0.66 0.79 0.80
1024 97656 3124992 0 3124992 3124992 2.30 0.65 0.65 0.64
262144 381 0 0 12192 12192 0.66 0.64 0.65 0.66

分配这一列按 32 除以长度走:长度 4 时每元素摊到 8.00 字节,长度 16 时 2.00,长度 1024 时 0.0312,长度 262144 时 0.00012。同一个 32 字节的迭代器,摊进 1 亿次元素访问就没了。

耗时那一列同样收敛。长度 4 时两个用法是 0.81 对 1.54 ns/元素;长度 262144 时是 0.64 对 0.66,两条线合上了。

这张表还拍到两次编译层级切换。JDK 25 长度 4 的那一次,第一个计时轮是 800 MB、之后两轮是 0,原始日志里是 bytesList=[800000000, 0, 0];JDK 21 长度 1024 的那一次反过来,三轮全是 3,124,992 字节,也就是 97,656 个迭代器,全程没等到 C2。同一台机器上的两次运行,一个在第三轮切换成功,一个整轮没等到。

这些垃圾真的要回收

逃逸版本一轮 1250 万次遍历分配 400 MB,够触发好几次 G1 的 young GC。打开 -Xlog:gc 单独跑一轮:

1
2
3
[0.005s][info][gc] Using G1
[0.059s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 49M->1M(258M) 0.775ms
[0.101s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 119M->1M(258M) 0.556ms

一轮里 young GC 停在 6 次(JDK 21)与 6 次(JDK 25),每次回收前堆里约 150 MB,暂停时间在 0.5 ms 到 3 ms 之间。for-each 版本在这段时间里没有任何东西要回收。

遍历次数越多,逃逸版本的垃圾按比例增长,for-each 版本这一列一直是 0。

四、JDK 源码里的几份迭代器

ArrayList.Itr 是重写版

ArrayList 没有用 AbstractList 的迭代器,自己写了一份。注释写在类定义上方,JDK 25 ArrayList.java:1034

1
2
3
4
/**
* An optimized version of AbstractList.Itr
*/
private class Itr implements Iterator<E> {

被替换掉的那份在 java.base/java/util/AbstractList.java:345ArrayList.Itrnext() 直接读 elementData,跳过了 AbstractList.get(int) 的虚调用。接口一样,每次遍历省一次虚调用,代价是多维护一份代码。

Itr 的 32 字节是怎么来的

Itr 是非静态内部类,javap -p 能看到一个合成字段:

1
2
3
4
5
class java.util.ArrayList$Itr implements java.util.Iterator<E> {
int cursor;
int lastRet;
int expectedModCount;
final java.util.ArrayList this$0;

对象头 12 字节(压缩指针)+ 三个 int 共 12 字节 + this$0 引用 4 字节 = 28 字节,按 8 字节对齐后是 32 字节。实测逃逸那一列每轮 32 字节,与布局吻合。把它改成 static class 能省掉 this$0,但它要读外层 ArrayListsizeelementDatamodCount 三个字段,静态化之后每次访问都要多传一个参数。

List.of 用的是另一份实现

List.of(...) 返回 ImmutableCollections.List12ListN,它们的 iterator()java.base/java/util/ImmutableCollections.java:301

1
2
3
4
@Override
public Iterator<E> iterator() {
return new ListItr<E>(this, size());
}

ListItr:363,字段带 @Stable

1
2
3
4
5
6
7
8
9
10
11
12
static final class ListItr<E> implements ListIterator<E> {

@Stable
private final List<E> list;

@Stable
private final int size;

@Stable
private final boolean isListIterator;

private int cursor;

另一份实现、另一个类、另一组字段,跟 ArrayList.Itr 没有继承关系。两份实现在 C2 上的结果一样:for-each 里 0 字节,逃逸时每轮 32 字节。标量替换判定的是「这个对象有没有逃出方法」,跟它属于哪个类无关。 JDK 21 的 ImmutableCollections.java 与 JDK 25 逐行相同(:344:363),差别不在源码里。

Spliterators.iterator 每次调用都分配

java.base/java/util/Spliterators.java:654 是从 SpliteratorIterator 的工厂:

1
2
3
4
5
public static<T> Iterator<T> iterator(Spliterator<? extends T> spliterator) {
Objects.requireNonNull(spliterator);
class Adapter implements Iterator<T>, Consumer<T> {
boolean valueReady = false;
T nextElement;

方法体末尾 :698return new Adapter();Adapter 有状态(valueReadynextElement),没法共享,每次调用新建一个。在 for-each 里它同样可能被标量替换,但这个工厂的用途就是把迭代器返回给调用方,它必然逃逸,分配躲不掉。

能共享的迭代器长什么样

java.base/java/util/Collections.java:4631

1
2
3
public static <T> Iterator<T> emptyIterator() {
return (Iterator<T>) EmptyIterator.EMPTY_ITERATOR;
}

空迭代器没有状态,一个静态实例发给所有调用方,零分配。ItrListItrAdapter 都有 cursor 之类的状态,一个实例同时只能服务一次遍历,共享不了。能不能省掉这次分配,取决于两件事:它逃不逃逸,它有没有状态。

五、什么时候该在意这 32 字节

先看集合长度,成本按 32 字节除以元素个数摊,长度到几百以上这一项就不值得看一眼。长度是个位数,再问迭代器会不会离开当前方法。会逃逸,就只能改写法,for-eachIterable.forEachforEachRemaining 这三种都不把迭代器交出去。不逃逸,还要确认这段代码有没有热到进 C2:实测 C1 编译的版本每轮分配 400 MB,C2 的版本是 0。三个条件都满足,这 32 字节可以忘掉。

长度 8 时逃逸版本是 1.04 ns/元素(JDK 25),长度 262144 时两种写法都是 0.66 上下,差 3.0%,长度这一维自己就把问题消掉了。同一段循环在 C1 与 C2 之间差 400 MB 每轮,编译层级这一维绕不过去。16 次独立运行里 for-each 有 5 次没有消除。

不用管的情况

  • 集合够长。 一次遍历只分配一个迭代器,成本被元素个数摊薄。26 万个元素的集合,每元素摊到 0.00012 字节。
  • 代码只跑在 C2 上。 服务端 JVM 默认分层编译,热点循环最终都会进 C2,逃逸分析在那里生效。前提是这段代码热到了被重新编译的程度。
  • 每次遍历干的活比几次虚调用重。 迭代器开销是每元素两次调用加一次 Integer.intValue()。元素上只要有一次字符串拼接或者 map 查找,这部分就淹没了。

需要管的情况

  • 短集合 × 高频遍历。 8 元素集合遍历一亿次,逃逸版本一轮丢掉 400 MB 垃圾,for-each 版本是 0。
  • 迭代器被存起来或者传出去。 存进字段、装进集合、作为回调参数传进不可内联的方法、塞进 lambda、交给别的线程,任何一种都会把它钉在堆上。调 JVM 参数救不回来,只能改写法。
  • 冷启动路径。 一个只在启动时跑几十毫秒的遍历逻辑,可能从头到尾都在 C1 甚至解释器里。这种代码的分配量按 C1 的规则算:每一轮都分配。
  • 跨线程传递。 跨线程交给别人遍历时,每元素 670 ns(JDK 25),把线程里的遍历换成数组下标求和的对照组是 865 ns。两者差值落在噪声里,这一层该省的是线程。

自己写迭代器时,这笔账一样算

业务代码里手写 Iterator 的地方多在分页游标上:一次拿一批,取完再拉下一页。这类迭代器自带状态(游标、当前批次、连接或语句句柄),按上面的判据落位:

  • 方法把迭代器交给调用方,比如 Iterable<Row> rows(),它必然逃逸,每次遍历一次分配。32 字节跟它持有的资源比起来不算什么,优化方向在别处。
  • 迭代器只在本方法里消费,比如 for (Row r : page),它有机会被标量替换。能不能成功,看它的字段数量和构造过程:ArrayList.Itr 能被拆掉,靠的是三个 int 加一个外层引用,拆完就是一串局部变量。

拿不准就用同一套办法量:把这段遍历跑几百万轮,前后各读一次 getThreadAllocatedBytes,差值为 0 就是被消掉了。这比读汇编便宜得多。

一条判断顺序

总结

  • ArrayList.iterator() 的实现是 return new Itr();(JDK 25 ArrayList.java:1029),1250 万轮遍历、1 亿次元素访问的实测分配是 0 字节,5 轮全 0。
  • 逃逸之后每轮固定 400,000,000 字节(4.00 字节/元素):Itr 是非静态内部类,12 字节对象头 + 3 个 int + this$0 引用,补齐到 32。探针实测 32.0 字节,两个 JDK 一致。
  • 同一份字节码加 -XX:-EliminateAllocations,for-each 从 0 变成每轮 400,000,000 字节,耗时从 0.66 涨到 0.95 ns/元素(JDK 25)。
  • -XX:TieredStopAtLevel=1(只有 C1)跑同一段循环,每轮 400 MB、4.96 ns/元素,30 轮测量无例外;-XX:-TieredCompilation(只有 C2)是 0 字节、0.66 ns/元素。C1 不做逃逸分析。
  • 默认分层编译下会出现两种状态:C1 版本每轮 400 MB / 229 ms,C2 版本 0 / 66 ms,切换点由编译进度决定。16 次最小程序运行里有 5 次拍到前 3 轮是 C1 版本。
  • 独立 JVM 重复运行:for-each 在 JDK 21 上 8 次里 2 次、JDK 25 上 8 次里 3 次没有消除;逃逸用法 16 次全部 400 MB,一次例外都没有。
  • 传参的边界在内联上:drain 可内联时分配 0 字节,-XX:CompileCommand=dontinline 之后变成每轮 400,000,000 字节。
  • List.of 用另一份实现(ImmutableCollections.ListItr),C2 上 for-each 同样是 0,逃逸时同样每轮 400 MB。判定依据是逃不逃逸,跟类无关。
  • 捕获一个引用的 lambda 是 16 字节,不捕获的由 invokedynamic 缓存、0 字节;capture 那一列每轮 48 字节/遍历 = 迭代器 32 + lambda 16。
  • 集合长度到 262144 时每元素摊到 0.00012 字节,两种写法的耗时差 3.0%,这个尺寸上不必优化。
  • 逃逸版本一轮 1 亿次元素访问触发 6 次 G1 young GC,for-each 版本 0 次。
  • -XX:+PrintEliminateAllocations 属于 develop 选项,product 版 JVM 直接拒绝启动;判断有没有发生标量替换,用分配字节计数加编译层级对照。

参考资料

系列索引:设计模式系列