news 2026/10/14 1:10:11

迭代器模式深度解析:从集合框架源码到并发修改的坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
迭代器模式深度解析:从集合框架源码到并发修改的坑

迭代器(Iterator)可能是23种设计模式里最"隐形"的一个——你看不到它,但它每天都在干活。写过几天Java的人基本都用过增强for循环,也知道Iterator接口的存在,但真正问起"迭代器模式到底解决了什么问题、为什么集合框架非要这么设计",能讲清楚的人其实不多。这篇东西我用实际源码和手写代码展开讲,从集合框架的设计初衷聊到二叉树遍历,从fail-fast机制聊到并发修改的坑,争取一次把迭代器模式讲透。

适合谁看?准备面试的初中级Java开发者、写业务代码想提升抽象能力的后端工程师,还有刚学设计模式觉得"这模式有啥用"的同学。迭代器模式不挑语言,但本文的示例全部基于Java,版本以JDK 8+为主,源码层面会提到JDK 17的部分实现,不影响理解。

1. 迭代器模式到底是干什么的

1.1 从一段最普通的代码说起

List<String> list = new ArrayList<>(); list.add("A"); list.add("B"); list.add("C"); for (int i = 0; i < list.size(); i++) { System.out.println(list.get(i)); }

这段代码很常见,但它的坏味道藏在细节里:调用方知道list是ArrayList,知道它有get(int)方法,还知道size()的含义。换句话说,调用方和集合的具体实现绑死了。哪天你要把它换成LinkedList,get(i)就从O(1)变成O(n),两层循环就是O(n²)的灾难。

迭代器模式要解决的就是这种耦合。它把"怎么存储"和"怎么遍历"拆开:集合只管内部结构,遍历的逻辑全部收敛到迭代器对象里。调用方只依赖一个统一的Iterator接口,根本不关心背后是数组、链表还是树。

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C")); Iterator<String> it = list.iterator(); while (it.hasNext()) { System.out.println(it.next()); }

你可能会说,这不就是换个写法吗?表面看是,但本质区别是:第二段代码里,调用方对集合的认知从"ArrayList"下降为"Iterable"。替换实现类的时候,一行代码都不用改。这就是面向接口编程的意义——迭代器模式是它的样板间。

1.2 四个角色与一个核心契约

迭代器模式的标准结构由四部分组成:

角色职责Java中的对应
迭代器接口(Iterator)定义访问、遍历元素的抽象方法java.util.Iterator
具体迭代器(ConcreteIterator)实现遍历算法,记录当前游标位置集合内部类,如ArrayList.Itr
聚合接口(Aggregate)定义创建迭代器的方法java.lang.Iterable
具体聚合(ConcreteAggregate)返回具体的迭代器实例ArrayList、HashSet等

核心契约在Iterator接口上,JDK迭代器有三个方法必须说清楚:

public interface Iterator<E> { boolean hasNext(); // 是否还有下一个元素 E next(); // 返回当前元素并移动游标 default void remove() { throw new UnsupportedOperationException("remove"); } }

hasNext()和next()是配对出现的,缺一不可。光有next()没有hasNext(),你遍历时就不知道边界在哪,要么越界要么死循环。这两个方法的存在,规定了迭代器是一个"单向、只进不退"的游标:它只记得当前走到哪了,不记得之前走过什么。

注意:next()和remove()的顺序有讲究。必须先next()再remove(),删除的是"刚被next()返回的那个元素",而不是游标当前指向的元素。这个细节很多人踩过坑,后面专门讲。

2. 为什么说迭代器是集合框架的"幕后总导演"

2.1 Iterable和Iterator,一对容易被搞混的父子

面试里高频问题之一:Iterable和Iterator什么区别?

一句话版本:Iterable是集合的"能力声明",意思是"我这个集合可以被遍历";Iterator是具体的"遍历工具",负责真正干活。集合实现Iterable接口,返回一个Iterator实例给你用。

public interface Iterable<T> { Iterator<T> iterator(); default void forEach(Consumer<? super T> action) { ... } }

这里有个容易忽略的设计细节:为什么不让集合直接实现Iterator?因为那样集合就自带游标状态了——意味着同一个集合对象同时只能有一个遍历在进行,而且集合自己得维护"当前遍历位置",和它的存储逻辑纠缠在一起。把遍历状态封装到独立的迭代器对象里,多个迭代器可以互不干扰地同时遍历同一个集合。你想想:一个ArrayList套两层for循环各拿各的iterator(),能正常跑,靠的就是这个设计。

2.2 增强for循环其实是个语法糖

很多人写了几年for (String s : list),不知道背后被编译器改成了什么。看编译后的字节码就明白,增强for循环对Iterable类型会被展开成这样的逻辑:

for (Iterator<String> it = list.iterator(); it.hasNext(); ) { String s = it.next(); // 循环体 }

所以增强for循环不是JVM原生支持的魔法,它就是iterator()+hasNext()+next()的语法糖。这也解释了为什么数组不能直接放进Iterable——数组虽然能用增强for,但编译器对数组走的是另一种展开方式,基于下标遍历。

理解了这层关系,你就能解释很多"奇怪"的现象:

  • 为什么在增强for循环里给集合add会抛ConcurrentModificationException——因为底层迭代器察觉到了结构性修改。
  • 为什么自己写的类实现了Iterable之后,突然就能用增强for了——因为编译器认这个接口。
  • 为什么有些集合(比如LinkedList)的迭代器遍历比get(i)快很多——因为迭代器是"顺藤摸瓜",而get(i)每次都要从头找。

2.3 迭代器模式带来的三个价值

把这些问题归结起来,迭代器模式的实际价值有三条:

第一,解耦存储与遍历。集合内部是数组、链表、散列表还是红黑树,调用方一概不关心。这是它对"开闭原则"的贡献:新增一种集合类型,只要实现Iterable并提供一个迭代器,所有依赖迭代器的代码都能立即工作。

第二,统一遍历接口。不同数据结构有不同的遍历节奏,但对外都收敛为hasNext()/next()两个方法。这也给上层的高级操作铺了路——Collections.sort、Stream的spliterator,都建立在这层统一抽象之上。

第三,支持多种遍历方式。如果你想让同一个集合既能顺序遍历又能倒序遍历、既能按深度优先又能按广度优先,不需要改集合本身,多写几个迭代器类就行。迭代器模式天然把"遍历算法的扩展"和"集合结构"解耦,这是其他遍历方式(比如直接暴露get(i))做不到的。

3. 从零手写一个迭代器,把原理彻底落地

3.1 第一步:定义一个最简单的聚合接口

理论讲再多不如动手写。我带你从零实现一个极简的"书架"类,它内部用数组存书,对外提供迭代器。先用最原始的版本:

public class BookShelf implements Iterable<Book> { private Book[] books; private int size = 0; public BookShelf(int maxSize) { this.books = new Book[maxSize]; } public void addBook(Book book) { if (size >= books.length) { throw new IllegalStateException("书架满了"); } books[size++] = book; } public int getSize() { return size; } @Override public Iterator<Book> iterator() { return new BookShelfIterator(); } }

Book就是个简单的POJO,有title和author两个字段就行。这里BookShelf只实现了Iterable,没有暴露get(int)——这正是迭代器模式的态度:外部只能通过迭代器访问,内部细节全部隐藏。

3.2 第二步:实现具体迭代器,用内部类还是匿名类?

BookShelfIterator最关键。它必须维护游标位置,实现hasNext()和next():

private class BookShelfIterator implements Iterator<Book> { private int index = 0; @Override public boolean hasNext() { return index < size; } @Override public Book next() { if (index >= size) { throw new NoSuchElementException("没有更多元素了"); } return books[index++]; } }

两个设计细节值得注意:

一是这个迭代器用成员内部类实现,而不是外部独立类。为什么?因为它需要直接访问BookShelf的私有字段books和size。用内部类就不用额外传参,也不用写getter,代码最简洁。JDK里ArrayList.Itr也是这么干的——内部类是集合类实现迭代器的事实标准。

二是在next()里做了边界检查。有人觉得多余:"我都有hasNext()了,调用方还能越界?"真能。比如调用方写it.next()但不先调hasNext(),或者两个线程共用一个迭代器,都会走到越界分支。规范里next()的行为是"没有元素时抛NoSuchElementException",这是Iterator接口文档明确要求的,照做就行。

BookShelf shelf = new BookShelf(5); shelf.addBook(new Book("Java编程思想", "Bruce Eckel")); shelf.addBook(new Book("Head First设计模式", "Eric Freeman")); Iterator<Book> it = shelf.iterator(); while (it.hasNext()) { Book b = it.next(); System.out.println(b.getTitle() + " - " + b.getAuthor()); }

跑一下就能看到两本书被打印出来。到这里,一个最小可用的迭代器模式就落地了。你自己实现的BookShelf能用在增强for循环里,因为实现了Iterable。

3.3 第三步:复杂迭代器——二叉树的中序遍历

数组的迭代器太简单,体现不出模式威力。换个场景:二叉树。你想要一个迭代器,能按中序遍历(左-根-右)输出所有节点,而且不暴露树的节点是怎么组织的。

public class BinaryTree<T> implements Iterable<T> { private TreeNode<T> root; private static class TreeNode<T> { T value; TreeNode<T> left; TreeNode<T> right; TreeNode(T value) { this.value = value; } } @Override public Iterator<T> iterator() { return new InOrderIterator(); } private class InOrderIterator implements Iterator<T> { private final Deque<TreeNode<T>> stack = new ArrayDeque<>(); private TreeNode<T> current; InOrderIterator() { this.current = root; } @Override public boolean hasNext() { return current != null || !stack.isEmpty(); } @Override public T next() { if (!hasNext()) { throw new NoSuchElementException(); } while (current != null) { stack.push(current); current = current.left; } TreeNode<T> node = stack.pop(); current = node.right; return node.value; } } }

这里用的是迭代版中序遍历,用显式栈模拟递归。注意几个实现要点:

  • hasNext()判断的是"当前节点不为空或栈不为空",两个条件缺一不可。只判断栈不为空会漏掉当前节点本身。
  • next()里的while循环负责一路压入左子树,把最左边的节点弹出来返回,再把游标切到右子树。这个算法网上讲烂了,但配合迭代器写出来,你就能体会到"状态封装"的意义——调用方每次只问"有没有下一个",复杂的遍历状态全在迭代器内部维护。
  • 迭代器是一次性的。遍历完hasNext()为false后,这个迭代器就废了,不能重置。标准Iterator接口设计如此,想重新遍历就调用集合的iterator()再拿一个新的。

这个例子的意义在于:迭代器模式并不局限于"数组+下标"这种简单结构。任何"有顺序地访问所有元素"的需求——树、图、嵌套集合——都可以用迭代器封装。你甚至可以做一个ReverseIterator,从右到左遍历,集合类本身只加一个方法reverseIterator()就够了,现有的所有遍历代码不受任何影响。

4. 实战中迭代器模式还能怎么用

4.1 场景一:自定义业务集合对象

真实业务里,你自己的领域对象集合往往不是简单的List。比如权限系统里的菜单树,每个菜单有子菜单,你想让外部代码能用增强for循环遍历所有菜单节点,而不用暴露树的内部结构:

Menu root = buildMenuTree(); for (Menu menu : root) { System.out.println(menu.getName()); }

只要让Menu实现Iterable<Menu>,返回一个广度优先的迭代器,外部代码就能拿到这种"看起来像集合"的体验。这对代码的可读性提升非常明显——调用方不需要知道树是怎么遍历的,它只关心"给我所有菜单"。

我在实际项目中见过类似的实现:报表模块的数据源包含多个Sheet、每个Sheet有多个行列,团队给数据源实现了一个迭代器,外部统一用hasNext()/next()读取单元格。这样上层导出逻辑完全不用关心数据源来自Excel、数据库还是接口返回,想切换数据源只换迭代器就行。这是迭代器模式和策略模式的合体应用,本质还是"存储与遍历解耦"。

4.2 场景二:惰性迭代与分页遍历

迭代器的另一个大杀器是惰性求值——元素不是一次性全部加载,而是"用多少取多少"。常见的例子是数据库分页查询:

public class PagedQueryIterator<T> implements Iterator<T> { private final PagedDataFetcher<T> fetcher; private final Iterator<T> currentPage; private int page = 0; private boolean hasMore = true; public PagedQueryIterator(PagedDataFetcher<T> fetcher, int pageSize) { this.fetcher = fetcher; this.currentPage = fetcher.fetch(page, pageSize).iterator(); this.hasMore = currentPage.hasNext(); } @Override public boolean hasNext() { if (currentPage.hasNext()) { return true; } if (!hasMore) { return false; } page++; List<T> nextData = fetcher.fetch(page, pageSize); if (nextData.isEmpty()) { hasMore = false; return false; } // 这里需要重新构造currentPage,实际实现会用一个可替换的内部迭代器 return true; } @Override public T next() { if (!hasNext()) { throw new NoSuchElementException(); } return currentPage.next(); } }

这个思想在真实框架里很常见——数据量太大时,一次性把全部数据放进内存既不现实也没必要。用迭代器做惰性加载,调用方永远只感知到"一个可以遍历的东西",但背后可能每取100条才查一次数据库。这是迭代器模式的进阶玩法,理解了这个,你再看Stream API的中间操作就顺了——它们本质上也是"惰性迭代"思想的延伸。

4.3 场景三:统一多个异构数据源

我给某项目做过一个数据同步模块,数据源有三种:数据库查询结果、远程接口返回的JSON数组、本地缓存的CSV解析结果。三种数据的结构完全不同,但对外要统一提供一个"逐条读取"的接口。

做法就是给每种数据源各写一个迭代器:

  • DbResultIterator:包装ResultSet,next()时把当前行转成统一对象。
  • ApiResponseIterator:内部维护游标和数组,next()时从解析好的JSON数组取元素。
  • CsvFileIterator:按行读取,每次next()解析一行CSV。

对外暴露的DataSource接口就一个方法:Iterator<RecordDTO> iterator()。上层同步引擎拿到迭代器后什么都不用改,就能处理所有类型的数据源。后来加一个新的消息队列数据源,只写了一个MqMessageIterator,同步引擎一行代码没动。

这就是迭代器模式在架构层面的价值:它让你的系统天然具备"可扩展的数据接入能力",而且扩展成本极低。很多框架设计本质就是这种思路——你实现一个接口(Iterator、Handler、Adapter),框架负责在合适的时候调用你,至于框架内部怎么组织数据,你不知道也不用知道。

5. 迭代器的坑,踩过一次就长记性

5.1 ConcurrentModificationException是怎么来的

最常见的迭代器问题就是在遍历时改动集合。比如这段代码:

List<String> list = new ArrayList<>(Arrays.asList("A", "B", "C", "D")); for (String s : list) { if (s.equals("B")) { list.remove(s); // 运行时抛 ConcurrentModificationException } }

很多人不理解:我只是删了一个元素,为什么会报并发修改异常?明明没有多线程啊。

答案藏在ArrayList的迭代器内部。ArrayList有一个modCount字段,记录结构被修改的次数(add、remove、clear都会加1)。迭代器在初始化时把这个值记下来:

private class Itr implements Iterator<E> { int expectedModCount = modCount; // 迭代器创建时的快照 ... public E next() { checkForComodification(); ... } final void checkForComodification() { if (modCount != expectedModCount) throw new ConcurrentModificationException(); } }

每次next()之前,迭代器都会对比modCount和expectedModCount。你的list.remove(s)让modCount变了,但迭代器里的expectedModCount还是旧值,一比对不一致,立刻抛异常。这个机制叫fail-fast——快速失败,宁可抛出异常,也不让你在不可预知的状态里继续跑下去。

必须声明一下:fail-fast不是绝对的线程安全保证。它是为了尽早暴露bug设计的,两个线程同时改集合,可能一个不抛异常但读到脏数据,也可能抛异常。真正多线程场景应该用CopyOnWriteArrayList或ConcurrentHashMap,它们的迭代器是弱一致的,不抛这个异常。

5.2 正确删除方式:用迭代器自己的remove

既然遍历时不能直接调集合的remove,那该怎么删?用迭代器自带的remove():

Iterator<String> it = list.iterator(); while (it.hasNext()) { String s = it.next(); if (s.equals("B")) { it.remove(); } }

关键在于:ArrayList.Itr的remove()方法在删除后,会把expectedModCount重新赋值为modCount,两者保持一致,所以不会抛异常。这是迭代器接口里remove()的真正用途——在遍历过程中安全地删除"刚被next()返回的元素"。

JDK官方文档对remove()的描述很有意思:"Removes from the underlying collection the last element returned by this iterator"。注意"last element returned"这个措辞,意思是你得先next()再remove(),想一次remove()两次next()返回的元素?不行,第二次remove()也会抛IllegalStateException——因为它记录的"上一次返回的元素"已经被删掉了。

实际开发中我更推荐直接用removeIf,Java 8之后这个东西太好用了:list.removeIf(s -> s.equals("B"));内部实现就是迭代器遍历加remove(),但代码简洁得多,也不容易写错。

5.3 增强for循环、迭代器与fori性能对比

经常有人问:三种遍历方式性能差别大吗?我直接给结论,不卖关子:

  • 对ArrayList,get(i)最快(数组随机访问O(1)),增强for循环(迭代器)略慢一点点,但差距在百万级元素内通常可以忽略。
  • 对LinkedList,get(i)是O(n),for i整体O(n²),是灾难;迭代器是O(n),必须用迭代器或增强for。
  • 对HashSet等非List集合,根本没有get(i),只能用迭代器/增强for/stream。

所以通用规则是:能用迭代器就用迭代器,别用下标循环。下标循环会让你下意识地依赖"'集合是List'这个事实",一旦换实现类就出事。写代码的时候把"面向接口"这四个字刻在脑子里,迭代器是它最好的练习场。

5.4 Stream和迭代器怎么选

Java 8之后,很多人用Stream替代了手动迭代:

list.stream().filter(s -> !s.equals("B")).collect(Collectors.toList()); list.forEach(System.out::println);

Stream底层其实也是基于迭代器(或Spliterator)实现的。选哪个?我的经验是:

  • 只做遍历、不玩链式操作:forEach或增强for,直观简单。
  • 需要过滤、映射、聚合:Stream更语义化,可读性远好于手写循环加if。
  • 需要中途停止或跳过元素:迭代器的hasNext()控制流更灵活。
  • 性能敏感且数据量大:迭代器通常开销更低,Stream的抽象层和lambda调用有一定成本。

但不管选哪种,理解迭代器模式本身依然是值得的——它是整个集合框架和Stream API的地基。地基不稳,上层再漂亮也是空中楼阁。

6. 迭代器模式在设计模式大家庭里的位置

6.1 和相关模式的区分

学设计模式最容易混淆的几个,我帮你理一遍:

  • 迭代器 vs 策略:策略模式是"封装算法族,让算法可以互相替换",迭代器封装的是"遍历算法",从这个角度算策略的一种特例。但迭代器有自己的角色划分和接口契约,所以单独列为一种模式。
  • 迭代器 vs 组合:组合模式(Composite)讲的是"树形结构中部分与整体的关系",它常和迭代器配合使用——对组合结构做遍历时,迭代器负责屏蔽树的层次差异。菜单树的例子就是两者结合。
  • 迭代器 vs 访问者:访问者模式关注"在不改变元素类的前提下,为元素增加新操作",它也需要遍历集合。迭代器管"怎么走到每个元素",访问者管"到了之后对元素做什么",两者天然互补。

理解这些区别后你会发现,设计模式不是孤立的教条,它们像乐高零件一样互相咬合。迭代器模式的关键词是"游标"和"解耦",抓住这两个词,它的定位就清晰了。

6.2 什么时候该用、什么时候别用

该用的场景:

  • 集合有多种遍历方式(顺序、倒序、深度优先等),且不想把这些逻辑塞进集合类里。
  • 需要对外隐藏集合的内部数据结构。
  • 想要统一遍历异构数据源。
  • 实现惰性加载,避免一次性把全部数据拉到内存。

不该硬用或者要斟酌的场景:

  • 集合本身就极其简单(比如就几个固定元素),外部直接访问字段更清晰,硬套模式是过度设计。
  • 集合类是内部私有使用,没有对外扩展和替换的需求——模式的价值很大程度在于"面向未来",如果确定没有未来,不搞。

判断标准就一句:有没有可能出现"第二种遍历方式"或者"第二种集合实现"?如果有,迭代器模式值得考虑;如果没有,写个普通方法遍历就行。

我在实际项目里见过把迭代器模式用在只有两种元素的配置对象上,硬写了三个类——那属于为了模式而模式,反而伤害了可读性。设计模式是工具,不是奖状,该用的时候用,不该用的时候别硬凑。

7. 最后分享一点我的使用体会

迭代器模式是我认为最适合作为"设计模式入门第一课"的模式之一,因为它足够简单——四个角色、两个方法,10分钟就能看懂结构;但它的影响足够深远——整个Java集合框架、Stream API、增强for循环的语法糖,全部建立在这一层抽象上。理解了它,你再看集合源码时会有一种"原来如此"的通透感。

我自己写代码时有个习惯:凡是涉及"遍历一组东西"的地方,先问一句"这组东西以后会不会换个存储方式?调用方是否需要感知存储细节?"只要答案是"会"或"不需要",我就让这个类实现Iterable,把遍历工作交给迭代器。这个习惯帮我挡掉了不少后期重构的麻烦。

如果你正在学设计模式,我建议别只背定义。拿一个自己项目里的真实集合类,按这篇文章的步骤手写一个迭代器,再把它换成二叉树、文件行、数据库分页各写一遍。写完之后你自然就懂迭代器模式的价值了——纸上得来终觉浅,这个模式尤其如此。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 1:10:10

Arm技术文档全集合:从Cortex到AMBA的底层开发速查指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:09:49

嵌入式Linux安卓驱动实战:从设备树到HAL全链路开发

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:09:03

ESMM如何解决推荐系统中的CVR预估偏差问题

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:08:52

ESP8285+MQTT电机控制器联网改造实战:从硬件到MQTTX调试

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:08:52

易语言远程线程注入原理与Windows内存操作实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/14 1:08:49

STM32C5迁移实测:CubeMX2配置、低功耗与性能全面评估

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华