Java迭代器深度解析:从设计模式到集合遍历实战
发布时间:2026/10/15 20:48:10 锦皓数字建站

还在徒手写for循环加下标去硬啃集合吗只要是做Java开发迭代器Iterator就是你绕不开的关卡从遍历集合到面试基础题再到源码阅读它一直藏在看似简单的API背后。这篇博客会把迭代器的设计思路、接口方法、底层实现和实战踩坑全部串起来讲清楚适合刚学完Java集合框架的初学者查漏补缺也适合工作了两三年但没认真看过集合源码的开发者深挖细节花二十分钟读完等于把这块知识点一次性焊死在脑子里。1. 迭代器的设计初衷把“遍历”抽象成标准动作1.1 没有迭代器时集合遍历到底有多痛想理解迭代器的价值先回到那个“没有它”的场景。假设手里有一个ArrayList最直观的遍历方式就是下标加getfor (int i 0; i list.size(); i) { System.out.println(list.get(i)); }这段代码在ArrayList上没毛病因为它的底层是个Object数组get方法就是按索引取值时间复杂度O(1)。可如果换成LinkedList同样的写法就变成灾难每次list.get(i)都要从链表头或尾向后找整体时间复杂度直接飙到O(n^2)数据量一上万就能明显感觉到卡顿。那给LinkedList单独写一套循环可以但问题立刻跟着来每增加一种集合类型所有用到它的地方都得重新写遍历逻辑而且遍历方式和具体数据结构深度绑定。今天用ArrayList明天换成LinkedList调用方代码要跟着改。更麻烦的是有些集合的底层结构非常复杂比如HashMap既有数组又有链表还有红黑树你要是自己写遍历就绕不开哈希寻址逻辑相当于把集合的内部零件全部暴露在业务代码里。迭代器模式解决的就是这个“怎么在不暴露内部结构的前提下给所有集合提供一套统一遍历入口”的问题。它的思路说起来特别朴素把“怎么走”这件事交给集合自己调用方只负责“问还有没有、拿下一个”。接口里定义好hasNext和next两个动作剩下的痛点和细节全部封装到集合返回的迭代器对象里。1.2 迭代器模式的核心思路与Java落地设计模式里的迭代器模式说白了就是提供一种方法顺序访问一个聚合对象中的各个元素而不暴露该对象的内部表示。这句话拆开看就三件事聚合对象也就是集合本身知道自己的元素存哪儿迭代器对象负责记录当前遍历到哪儿以及如何取下一个客户端只跟迭代器打交道完全不需要关心集合长什么样。Java在集合框架里把这个模式落得非常干净。先看最顶层的Iterable接口它是Collection的根接口里面只有一个方法public interface IterableT { IteratorT iterator(); }也就是“我是可以被迭代的你给我一个迭代器”。Iterator接口则定义了一套通用的遍历协议public interface IteratorE { boolean hasNext(); E next(); default void remove() { throw new UnsupportedOperationException(); } default void forEachRemaining(Consumer? super E action) { ... } }所有集合类都返回自己的迭代器实现但对外暴露的永远是这套统一接口。客户端代码里你不需要知道底层是ArrayList还是TreeSet拿到Iterator之后写一遍遍历逻辑到处都能用。这就是迭代器最核心的价值把遍历的控制权交给集合内部把遍历的便利性交给调用方。自定义集合类时实现Iterable接口也是这个思路让调用方可以无脑用统一的遍历方式而不是各自造轮子。2. Iterator接口四大核心方法细节都值得抠2.1 hasNext与next最基础的一对搭档Iterator接口里使用频率最高、也是唯一一对“强制”要配合的方法就是hasNext和next。hasNext判断集合里还有没有下一个元素next负责取出下一个元素并让迭代器前进一个位置。最基本的用法长这样ListString languages new ArrayList(); languages.add(Java); languages.add(Python); languages.add(Go); IteratorString iterator languages.iterator(); while (iterator.hasNext()) { String language iterator.next(); System.out.println(language); }hasNext返回true才调用next这两个方法一前一后配合就能保证迭代过程不会越界。有些人写代码时习惯只调next不调hasNext程序在元素用完时会立刻抛NoSuchElementException。我见过一个真实案例某开发者拿迭代器遍历集合循环条件写的是while (true)循环体里直接next结果集合遍历完没跳出循环异常被捕获后处理逻辑直接错乱排查了半天才反应过来是迭代器用错了姿势。next方法还有个隐藏细节它返回元素并且会让游标前进。比如一个ArrayList里有三个元素第一次next拿到第一个第二次next拿到第二个游标始终指向“下一个待返回的位置”。连续调用两次next而不调用hasNext一旦游标越过最后一个元素的位置下一次next就异常。所以稳妥的写法永远是hasNext当判断条件next当执行动作。2.2 remove方法一个隐藏的跑路陷阱Iterator接口里有个默认方法remove用于删除“最近一次next返回的元素”。它和直接调集合的remove长得像但行为完全不同。方法定义要求很严格调用remove之前必须先调用一次next否则直接抛IllegalStateException。原因是迭代器内部需要知道“最近返回的是哪个元素”没有next这一步迭代器根本不知道你要删谁。正确删除姿势如下IteratorString iterator languages.iterator(); while (iterator.hasNext()) { String language iterator.next(); if (Python.equals(language)) { iterator.remove(); } }有人会问我不就删个元素吗直接用languages.remove(language)不行吗在for-each循环里还真不行后面会专门讲ConcurrentModificationException。简单说迭代过程中通过集合自身改结构会让迭代器的状态和集合的真实状态对不上轻则漏元素重则直接抛异常。用迭代器自身的remove就安全得多它会同步维护迭代器内部状态不会破坏遍历节奏。还有一点要说明remove方法不是所有迭代器都支持。比如某些只读集合返回的迭代器remove默认实现是抛UnsupportedOperationException。如果你写的代码要兼容各种来源的集合调remove之前最好先确认这个迭代器是否支持删除操作或者干脆不用迭代器的remove改用其他安全方案。2.3 forEachRemainingJava 8带来的优雅补充Java 8给Iterator加了一个default方法forEachRemaining意思是“把剩余所有元素一股脑处理掉”。典型用法languages.iterator().forEachRemaining(System.out::println);它等价于不断执行hasNext和next然后在每个元素上调用传入的动作。这个方法的用处在于你已经通过迭代器处理了一部分元素剩下的想一次性交给一段逻辑去消费。配合方法引用代码能写得非常简洁。但使用时有几个注意点。第一它是一次性的遍历完就没了想重复遍历集合需要重新获取迭代器。第二动作里不要再修改集合结构否则还是会踩ConcurrentModificationException它并不比普通循环更豁免异常。第三如果动作里抛了异常遍历会中断剩余元素就永远消费不到了。我在代码审查时经常看到有人把forEachRemaining和Stream的forEach搞混。前者是Iterator接口的方法后者是Stream接口的方法名字有点像但一个是迭代器的“剩余元素”语义一个是整个流的“每一个元素”语义。用错对象编译器会帮你指出来但逻辑上是否合理就得自己掂量了。3. 常见集合的迭代器底层实现各怀绝技3.1 ArrayList的迭代器Itr游标加预期版本号翻开JDK源码在ArrayList内部可以找到名为Itr的内部类。它有三个关键字段private class Itr implements IteratorE { int cursor; // 下一个要返回的元素索引 int lastRet -1; // 最近一次返回的元素索引-1表示还没有 int expectedModCount modCount; // 期望的修改次数 }先说cursor它就是迭代器的游标。每次调用next先把原cursor位置记给lastRet然后cursor加1。所以第一次next取elementData[0]cursor变成1第二次next取elementData[1]cursor变成2。而hasNext做的事情非常直白public boolean hasNext() { return cursor ! size; }cursor等于size就说明已经走到集合末尾了。这套实现的时间复杂度是O(1)内存开销只有两个int非常轻量。再看expectedModCount。ArrayList内部维护一个modCount字段记录结构性修改的次数add、remove、clear这些操作都会让它自增。迭代器创建时把这个值复制一份存下来每次next都拿当前modCount和expectedModCount做对比不一致就立刻抛ConcurrentModificationException。这就是fail-fast快速失败机制的实现基础。调试时利用这个异常可以快速定位“不该在遍历中修改集合”的代码位置比出现诡异结果后再反复推演高效得多。ArrayList迭代器的next实现直接通过内部类访问外部类的elementData数组而不是走get方法本质上就是一次数组索引取值。所以遍历ArrayList时用迭代器和用普通for循环加get性能几乎没有差别某些场景下迭代器因为逻辑更内聚写出来的代码反而更稳。3.2 LinkedList的迭代器ListItr双向游走的艺术LinkedList的迭代器实现叫ListItr它不只实现了Iterator接口还实现了ListIterator接口。ListIterator在Iterator基础上额外提供了反向遍历能力ListIteratorString listIterator linkedList.listIterator(); while (listIterator.hasNext()) { // 正向走 } while (listIterator.hasPrevious()) { // 反向走 }对应的方法有hasPrevious、previous、nextIndex、previousIndex。LinkedList底层是双向链表每个节点既指向后继又指向前驱所以ListItr可以自由地在两个方向上移动游标这也是为什么LinkedList很适合用listIterator来正向反向同时处理数据。ListItr内部维护Node指针每次next或previous都是直接在链表节点上跳跃时间复杂度O(1)。对比一下用list.get(i)遍历LinkedList每个get都要从头或尾走一次整体O(n^2)用迭代器全程只需沿着节点走一遍O(n)。这个差距在列表长度过万之后体感非常明显。我当年优化过一段链表遍历代码单纯把for循环换成迭代器耗时直接下降了两个数量级从那以后我写LinkedList遍历再也没写过get(i)。ListItr还支持set和add方法可以在迭代过程中替换元素或者插入元素。set会替换lastRet指向的节点值add会在当前游标位置前方插入一个节点并且更新游标。这比remove更进阶但规则一样set必须先调用nextadd倒是随时可调。用的时候务必确认你调的是迭代器的方法而不是集合的方法否则又回到异常的路口了。3.3 HashMap的迭代器遍历顺序的玄学HashMap的迭代器是另一个世界。HashMap底层是数组加链表加红黑树它没有“按插入顺序”的概念迭代顺序完全由哈希值分布决定。也就是说当遍历一个HashMap时看到的元素顺序既不等于插入顺序也不等于自然排序而是桶数组的索引从小到大、桶内再按链表或树顺序排出来的结果。HashMap内部的迭代器基类是HashIterator它持有NodeK,V next; // 下一个节点 int expectedModCount; // 期望修改次数 int index; // 当前桶索引遍历过程是在桶数组上一步步移动先定位当前数组下标把该桶里的节点走完再跳到下一个非空桶。这个过程不管用keySet().iterator()、values().iterator()还是entrySet().iterator()底层逻辑都一样它们只是HashIterator的三个子类返回的节点类型不同。遍历HashMap时最需要记住一点别指望顺序稳定。如果你的业务逻辑依赖遍历顺序比如按添加顺序处理缓存、按顺序导出数据HashMap的迭代器一定会给你挖坑。解决方案是换成LinkedHashMap它的迭代器会按插入顺序返回或者用TreeMap按key排序。很多人在排查“为什么HashMap遍历顺序乱了”时绕了半天最后发现是选错了集合类型迭代器只是忠实反映了HashMap本身的存储布局。另外HashMap迭代器同样有fail-fast机制。遍历过程中往HashMap里放新元素如果恰好触发了扩容下一次next立刻抛ConcurrentModificationException。遍历中修改Map要做删除请用迭代器自身的remove或者在遍历结束后统一处理。4. 迭代器实战三个高频坑位逐个排查4.1 ConcurrentModificationException为什么改一下就翻车这个异常算是Iterator使用者的第一大噩梦面试里也几乎必问。先看一段会翻车的代码ListString list new ArrayList(List.of(a, b, c, d)); for (String item : list) { if (b.equals(item)) { list.remove(item); } }运行这段代码大概率抛ConcurrentModificationException但不是每次迭代都一定触发这跟删除的位置有关。触发条件是这样的for-each的底层是迭代器迭代器内部保存了expectedModCount创建时它等于集合的modCount。调用list.remove时modCount会自增导致modCount和expectedModCount不再一致迭代器在每次next时发现不一致立刻抛异常。如果你删除的是最后一个元素可能循环恰好结束异常没被触发但这属于侥幸一旦后续还有代码继续用这个迭代器照样会出问题。正确做法是IteratorString iterator list.iterator(); while (iterator.hasNext()) { String item iterator.next(); if (b.equals(item)) { iterator.remove(); } }因为iterator.remove会先把expectedModCount同步到modCount再执行删除两者始终保持一致不会触发快速失败。fail-fast机制的理念就是这样它不保证异常一定发生但一旦发生就是在告诉你遍历过程中出现了不安全的结构修改尽早暴露总比拿到错乱结果强。如果这种场景出现得比较多还有几个替代方案。不要求实时删除的话可以先把要删的元素收集到另一个列表遍历完之后统一调用removeAll如果集合本身处在并发读写的环境里用CopyOnWriteArrayList就不会抛这个异常因为它在写操作时复制底层数组迭代器始终操作旧数组遍历和修改互不干扰。但要注意CopyOnWriteArrayList的写操作开销较大写入频繁的场景要慎重。4.2 迭代中删除元素单条件删和边删边插的细节删除元素在迭代中是个高频需求但细节比表面看起来多。第一层用for-each删上面已经说过了会异常。第二层用索引for循环删不抛异常但结果会有问题ListString list new ArrayList(List.of(a, b, c, d)); for (int i 0; i list.size(); i) { if (b.equals(list.get(i))) { list.remove(i); } }这段代码不会抛异常但如果删除目标不止一个就会出现元素跳跃。因为remove之后后面的元素全部前移i又继续递增你以为看过了每一个元素实际中间一些元素被跳过了。所以索引遍历时想安全删除一般要倒着从尾部向头部删。迭代器方案就没有这个烦恼因为它维护的lastRet和游标状态是连动的删完当前元素游标自动指向下一个位置不会漏。还有一种边遍历边插入的需求比如在遇到某个元素后需要往集合里追加一批数据。普通Iterator不支持add增强for循环更不可能这时候要换成ListIteratorListIteratorString listIterator list.listIterator(); while (listIterator.hasNext()) { String item listIterator.next(); if (c.equals(item)) { listIterator.add(c2); } }ListIterator的add会把新元素插在当前游标位置之后并且保持后续遍历正常进行。这个特性在做分页加载、列表中间插值这类场景时特别好用比先遍历收集再统一add容易理解得多。4.3 迭代器与索引访问的效率对比不是所有遍历都该用同一个模板经常有人问既然迭代器这么统一是不是所有集合都用迭代器遍历就好了答案没那么简单要从数据结构来区分。ArrayList是连续数组存储索引访问是O(1)迭代器也差不多是O(1)两者性能差距很小。经验上迭代器可能略慢一丢丢因为多了modCount检查、游标维护和虚方法调用但现代JIT编译后这点开销可以忽略。普通for循环的代码更直白如果遍历时还需要用下标做其他操作比如按位置更新数组用for循环加索引自然更顺手。LinkedList就完全不一样了索引访问是O(n)迭代器是O(1)。一旦列表长度来到几千几万用索引遍历的性能问题几乎是必然的。所以有个朴素的建议当List实现类不确定时优先用迭代器或者增强for循环至少不会被结构差异坑到。不管ArrayList还是LinkedList都用这个写法最坏情况也就是ArrayList上损失微不足道的性能。还有一个经验之谈遍历过程中既需要计数器又需要按条件删除可以写成普通for循环配合从后往前删除也可以直接用迭代器加一个局部自增变量。我个人更倾向于后者因为逻辑更集中并且能直接利用remove的同步状态省掉不少边界判断。5. 从Iterator到增强for循环与Stream看懂语法糖和演变路径5.1 增强for循环不是魔法是迭代器穿了件马甲增强for循环用起来非常舒服for (String item : list) { System.out.println(item); }很多初学者以为它就是普通的for循环其实对于Collection来说这是个语法糖编译之后会变成使用Iterator的代码。简单验证一下用javac编译这段代码再用反编译工具查看字节码会看到类似这样的内容Iterator var1 list.iterator(); while (var1.hasNext()) { String item (String) var1.next(); System.out.println(item); }理解这一层就能解释很多奇怪的现象为什么for-each里不能直接list.remove因为它内部就是Iterator遍历时改集合自然触发ConcurrentModificationException为什么for-each里拿不到迭代器因为语法上没有给你暴露这个对象。所以如果你需要在遍历中删除元素建议直接把for-each换成显式Iterator写法别想着在for-each上硬抠方案。顺便补充数组的for-each不涉及迭代器它会编译成普通下标循环因为数组没有iterator方法语言层面上直接处理索引就行。这跟集合类不同但使用体验是一致的。5.2 Iterator、for-each、Stream三选一怎么选现在Java里遍历一个集合可以写很多种风格选择起来按场景分就行。如果只是简单读一遍元素不需要删除、不需要下标、不需要修改for-each是最清晰的选择可读性好几乎不会写错。如果需要遍历中删除元素、替换元素或者边遍历边插入直接上Iterator或者ListIterator。这是最贴近底层语义的方式代码稳定坑也最可控。如果要做过滤、映射、聚合这类多步骤操作Stream是现代写法紧凑而且天然支持并行。但这里有个常见误区以为Stream遍历比普通循环快其实不是。Stream在CPU密集的简单遍历上通常还要慢一点因为它要维护流水线的抽象。它的价值在于表达力而不是原始性能。另外Stream流是一次性的遍历完就不能再用了这点和迭代器用完即弃是相似的。还有一点值得知道Stream的forEach遍历Collection时底层实际上要借助它的spliteratorSpliterator可以理解成Iterator的并行化变种。所以归根结底迭代器的设计思想在整个集合框架里没有过时它只是慢慢隐身到了更上层的API后面。理解了Iterator再去看Spliterator、看Stream的实现会觉得非常自然。我个人在实际项目里的习惯是默认用for-each涉及删除立刻换成Iterator涉及复杂集合处理才用Stream。遇到自定义集合类或者写框架工具的时候一定实现Iterable接口并返回一个内部迭代器而不是在外面另写遍历方法。这样调用方拿到集合后用统一方式遍历代码整洁度会提升不少。迭代器这套东西看起来基础其实暗含了设计模式、fail-fast机制和语法糖背后的编译原理花点时间翻一翻JDK源码尤其是ArrayList的Itr和HashMap的HashIterator收获比我这篇博客里写到的还要多。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。