☰
Java ListIterator接口详解:双向遍历与就地修改的利器
2026/10/21 6:53:45 网站建设 项目流程

写在前面的几句话。ListIterator 这个接口,在 JDK 的集合框架里存在感不算高,日常开发里大家用得更多的是 Iterator、for-each 或者 Stream。但只要跟 List 实现的源码打过照面,就一定会碰到它。我第一次认真读它是在排查一个“边遍历边删除却抛 ConcurrentModificationException”的问题时,翻 ArrayList 的源码才注意到这个接口的存在。后来在写链表相关的业务代码、做分页游标、处理列表元素替换时,才发现这接口比想象中好用得多。

这篇文章从“为什么需要 ListIterator”讲起,把它的全部 API、遍历游标语义、条件删除、边界情况以及源码层面的设计意图拆开来看,最后补一段实操建议。适合刚学完集合框架想深入一点的 Java 初学者,也适合写了几年业务代码但一直没认真看这个接口的人。

1. Iterator 的局限性:为什么在 List 场景下需要 ListIterator

1.1 JDK 迭代器家族的关系

在 Java 集合框架里,Iterator是最顶层的迭代接口,Collection下的所有容器都能通过它完成最基本的遍历和删除操作。List 体系下还有另一个接口,叫ListIterator,它继承自Iterator,是后者在 List 场景下的增强版。可以从 JDK 源码里直接看到它的定义:

public interface ListIterator<E> extends Iterator<E> { boolean hasNext(); E next(); boolean hasPrevious(); E previous(); int nextIndex(); int previousIndex(); void remove(); void set(E e); void add(E e); }

在实际实现上,ArrayList、LinkedList、Vector这些 List 实现类都针对这个接口提供了内部实现类,而List接口本身也定义了返回 ListIterator 的方法:listIterator()和listIterator(int index)。

1.2 只往后看不行的实际场景

先想一个问题:有 List 和 Iterator 就够了,为什么还要设计 ListIterator?

最直接的原因,是Iterator只能单向往后走,也就是“只能看下一个”,而且它只能做删除操作,没办法在遍历过程中替换元素、新增元素,也没办法倒着走。很多场景下这些能力恰恰是必需的。

举个例子,我在处理一个商品列表的 mock 数据时,需要按顺序把每个商品的价格翻倍,然后标记出打折标记。如果只用 for-each 或者 Iterator,代码会变得很别扭:要么先遍历收集索引,再在外面分批改,要么额外开一个辅助 List 存结果。而用 ListIterator,可以直接在遍历的同时调用set()替换当前元素,一个循环就把事情办完。

还有一个更常见的场景:在遍历一个有序列表时,需要从尾部开始找某个条件的元素。用Iterator做不到,因为游标只能往前;用普通 for 循环虽然可以从末尾往头走,但一旦涉及到删除,就得仔细算索引,非常容易越界。而listIterator(list.size())直接就把游标放在末尾,然后hasPrevious()和previous()反向遍历,代码读起来也很直观。

换句话说,ListIterator存在的意义,就是让 List 这种“有序、可索引、元素可改”的容器的所有操作,都能在遍历过程中一次完成。它的 API 设计不是炫技,而是补齐了普通迭代器在 List 场景下的三个明显短板:反向遍历、索引查询、遍历中修改。

2. 双向移动、索引定位、就地修改:ListIterator 的完整能力拆解

把 ListIterator 的 API 一次性列全,看起来是 11 个方法,但本质上可以归成三组:游标移动、索引查询、元素操作。下面分开细说。

2.1 第一组:游标移动——next/previous 与 hasNext/hasPrevious

这组方法最核心的理解点是“游标”到底指什么。很多人刚接触时容易把游标想成“指向某个元素”,但实际上 ListIterator 的游标是“元素和元素之间的间隙”。

假设 List 里有 A、B、C 三个元素,游标初始位置在第一个元素之前:

位置: [游标] A B C 索引: 0 1 2 3

调用一次next(),返回 A,游标移动到 A 和 B 之间。此时previousIndex()返回 0(A 的索引),nextIndex()返回 1(B 的索引)。再调用previous(),返回 A,游标移回初始位置。也就是说,next()和previous()是一对对称操作,它们返回的都是“越过游标”的那个元素。

这种设计意味着 ListIterator 天然支持双向遍历。比如判断一个列表是否为回文结构,可以用两个游标从两端逼近:

List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "b", "a")); ListIterator<String> start = list.listIterator(); ListIterator<String> end = list.listIterator(list.size()); boolean isPalindrome = true; while (start.nextIndex() < end.previousIndex()) { if (!start.next().equals(end.previous())) { isPalindrome = false; break; } }

注意,结束条件用的是两个索引的关系,而不是“元素是否相等”。因为当列表长度为奇数时,两个游标会交汇在同一个元素上,如果继续调用 next/previous 就可能重复比较同一个元素,虽然不会报错,但逻辑上不够严谨。用索引关系判断,可以精确控制循环何时停止。

2.2 第二组:索引查询——nextIndex 与 previousIndex

这两个方法一看名字就知道是返回索引,但有一个细节很多人会搞错:nextIndex()返回的是“如果接下来调用next()会返回的那个元素的索引”,而不是“当前游标所在的索引”。游标本身是间隙,没有索引,只有间隙前后的元素才有索引。

每次调用next()之后,previousIndex()就会变成刚才那个元素的索引,而nextIndex()则指向下一个。所以这两个方法常常被用在“获取当前操作位置”的场景里。

比如在遍历时想删除一个元素,同时希望删完就把游标停在正确位置,可以这样:

List<Integer> numbers = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5)); ListIterator<Integer> it = numbers.listIterator(); while (it.hasNext()) { int current = it.next(); if (current % 2 == 0) { it.remove(); } }

这段代码里,remove()删除的是刚才next()返回的元素,删除后游标仍然停留在原位置,而hasNext()继续判断剩余元素。整个过程不需要手动处理索引,也就不存在“删完要不要补 1”的问题。

从源码角度讲,ArrayList.Itr.listIterator类的这两个方法返回的是cursor - 1和cursor,分别代表上一个元素的索引和下一个元素的索引。这也是为什么listIterator(size)之后调用nextIndex()会返回size——因为游标在末尾间隙,往前看是最后一个元素,往后看已经没有元素了。

2.3 第三组:元素操作——remove/set/add

这三个方法是 ListIterator 最值钱的扩展能力,因为Iterator只提供remove,而且语义限制很多。

remove()和Iterator.remove()语义一致:删除“最近一次 next/previous 返回的元素”。它在 ListIterator 里还有一个规定,就是删除操作和set操作都会把“最近一次返回的元素”这个状态清空。也就是说,每次只允许对最近一次访问的元素做一次修改。

set(E e)是替换元素。它替换的也是“最近一次调用 next/previous 返回的元素”。如果你在一开始什么都没有调用时就调用set,会抛出IllegalStateException。这是很多初学者踩的典型坑。

add(E e)是在游标当前位置插入元素。它的语义比较特殊:元素插入在当前游标间隙的位置,插入后,next()仍然会返回原来的下一个元素,而如果接着调用previous(),会返回刚插入的新元素。这个设计更符合“插入游标前”的直觉。

看官方文档的表述,add之后,下一次调用next()不受影响,但下一次调用previous()会返回元素。乍一看有点绕,记住一句话就够了:add 是把新元素放在当前游标前面,游标自己不动,新元素前面的老元素被往后挤了一位。

3. 正确使用姿势:通读一遍各个方法的调用约束

光知道方法签名还不够,用好 ListIterator 的另一个关键是理解它的调用约束。这一节把每个方法的前提条件、抛异常情况和状态变化梳理出来,方便对照。

3.1 所有状态的起点:游标位置与“最近访问元素”标记

ListIterator 内部维护两个核心状态:游标位置cursor,以及一个表示“最近是否有通过 next/previous 访问过元素”的标记,在源码里通过lastRet字段体现。

lastRet的初始值是 -1,表示没有最近访问的元素。next()或previous()成功执行后,lastRet会被更新为该元素的索引。任何对remove、set、add的调用,都会改动这个状态,从而影响下一次调用。

这里的潜在风险是,remove()之后lastRet会被重置为 -1。如果你在同一个迭代器上连续两次调用remove(),第二次就会抛出IllegalStateException,因为“最近访问的元素”已经被删掉了,没有可删的第二次。

再看set(),它不改变cursor位置,只替换当前元素。add()则是插入新元素,插入后cursor会向后移一位,lastRet会被重置为 -1。

我把这些状态变化整理成一个表格,方便对照:

操作前置条件cursor 变化lastRet 变化返回内容
next()hasNext 为 true后移一位更新为当前元素索引原最后一个元素
previous()hasPrevious 为 true前移一位更新为当前元素索引游标前一个元素
remove()曾调用 next/previous,且期间未调用 remove/add不变重置为 -1无
set(e)曾调用 next/previous,且期间未调用 remove/add不变重置为 -1无
add(e)无特殊要求后移一位重置为 -1无

3.2 remove() 配合 next()/previous() 的对称性

remove()支持删除“最近访问”的元素,无论这个元素是通过next()访问的还是通过previous()访问的。这是 ListIterator 和普通 Iterator 的一个重要差异:Iterator 只能删除 next 过的元素,而 ListIterator 两边都能删。

举个例子:

List<Integer> list = new LinkedList<>(Arrays.asList(1, 2, 3, 4)); ListIterator<Integer> it = list.listIterator(list.size()); // 游标在末尾 if (it.hasPrevious()) { int last = it.previous(); // 返回 4 it.remove(); // 删掉 4 }

这段代码会删掉最后一个元素。虽然过程是“先往前取,再删除”,但效果和从前往后删是一致的,而且不用手动计算索引。

3.3 set() 与 add() 的实际作用点

set()和add()都很容易在错误的时候调用。核心规则是:

  • set()只能替换“最近访问”的元素,所以它之前必须有一次成功执行的next()或previous(),并且期间没有执行过remove()或add()。
  • add()没有任何前置条件,它把元素插入到游标当前位置。

这带来一个很有意思的用法:在遍历的同时,每隔一个元素插入一个新值。这是普通 Iterator 做不到的。

List<String> names = new ArrayList<>(Arrays.asList("A", "B", "C")); ListIterator<String> it = names.listIterator(); while (it.hasNext()) { String name = it.next(); if ("B".equals(name)) { it.add("B+"); // 在 B 之后插入 } } System.out.println(names); // [A, B, B+, C]

因为add()之后游标位置不变,循环继续读下一个元素时会读到“C”,正好跳过了刚插入的“B+”。

3.4 索引方法与游标的关系

nextIndex()和previousIndex()可以直接用于计算当前游标前后元素的位置。特别要注意的是,previousIndex()的返回值不能直接当成get(previousIndex())用,因为游标位置可能已经改变。同理,在双向遍历时要小心对同一个迭代器交替调用 next 和 previous 时索引的反复变化。

下面的代码演示了一个实用场景:打印列表每个元素的前后位置。这是调试时常见的操作。

List<String> list = Arrays.asList("x", "y", "z"); ListIterator<String> it = list.listIterator(); while (it.hasNext()) { int idx = it.nextIndex(); String s = it.next(); System.out.printf("索引 %d -> 元素 %s,上一个索引 %d%n", idx, s, it.previousIndex()); }

输出结果会显示每个元素的索引,以及访问该元素之后游标的状态。这样就不容易弄混游标和索引的关系。

4. 与 Iterator 的核心差异对比:不只是多几个方法

很多教程把 ListIterator 描述成“Iterator 的增强版”,这句话没错,但说得太笼统。两者之间不只是方法数量的差异,而是设计目标和适用场景的差异。这一节做一次系统的对比。

4.1 遍历方向的差异:单向 vs 双向

Iterator 的游标设计成只能向前,hasNext()和next()两个方法就代表了一次性遍历的整个过程。ListIterator 在继承这两个方法的同时,增加了hasPrevious()和previous(),从而实现了双向遍历。

双向遍历的意义不只是“能倒着走”,而是在某些算法中必须同时从两端逼近。比如求两个有序列表的交集、链表反转、回文判断,这些场景里如果只能用单向迭代,就得先把列表反转或额外拷贝一份,既浪费空间又降低效率。

4.2 修改能力的差异:remove vs set/add

Iterator 唯一拿得出手的修改操作是remove(),而且它的语义比较严格:只能删除“最近一次 next 返回”的元素。如果上一次操作是hasNext()或remove()本身,再调用remove()就会抛异常。

ListIterator 在 remove 之外还能set()替换和add()新增,这让遍历过程中的“写操作”完整了。更关键的是,ListIterator 的set()可以替换通过previous()访问过的元素,这在倒序遍历时非常有用。

4.3 索引与定位能力的差异

普通 Iterator 没有索引概念,你问它“现在到哪了”,它答不上来。ListIterator 的nextIndex()和previousIndex()直接暴露了游标位置,而且在构造时可以指定起始索引:list.listIterator(2)表示游标从索引 2 的位置开始。

这个能力特别适合做“断点续传”式的遍历。比如有一个分页需求,第一页处理完 10 条数据,第二页从第 10 条之后继续,就可以用一个 ListIterator 记住当前位置,下一次直接用这个迭代器,而不必重新遍历。

4.4 适用的容器范围不同

最本质的差异在于适用范围。Iterator 是通用迭代器,任何Collection都能用;ListIterator 只适用于List及其子类。Set、Queue 这类容器都没有索引概念,自然无法支持双向和索引定位。

在具体实现层面,ArrayList的 ListIterator 和LinkedList的 ListIterator 虽然都实现了同一个接口,但内部机制完全不同。ArrayList 的基于数组下标,移动游标是简单的整数加减;LinkedList 的基于节点指针,中式链表从头开始找第 index 个节点的复杂度是 O(n)。选型时如果明确知道要频繁使用listIterator(index)定位,LinkedList 的随机访问成本要考虑在内。

对比维度IteratorListIterator
继承关系根接口继承 Iterator
遍历方向单向向前双向
索引支持无有 nextIndex/previousIndex
修改能力仅 removeremove/set/add
适用容器Collection 所有实现仅 List 实现类
起始位置只能从头开始可指定任意索引

5. 源码层面的一些设计细节:快速失败机制与游标语义

读源码是理解这个接口最好的方式。这里拿ArrayList的内部类ListItr来拆解,因为它结构简单,逻辑清晰,比较有代表性。

5.1 ModCount 与并发修改检查

ListItr继承自Itr,构造时会把ArrayList的modCount保存到一个字段expectedModCount里。每次调用next()、previous()、remove()、set()、add()时,都会先调用checkForComodification(),检查当前modCount是否等于expectedModCount,不等就抛出ConcurrentModificationException。

这就是“快速失败”机制。它不能保证并发修改一定被检测到,但能在大多数情况下尽早暴露问题,而不是等到数据错乱才报错。

有一个容易被忽略的细节:add()和remove()成功后,expectedModCount会被重新赋值成当前的modCount,所以这两个操作本身不会触发下次的快速失败。但如果是直接通过 list 的remove()方法删除元素,而不是通过迭代器删除,那么modCount就会变化,迭代器里的expectedModCount没变,下一次调用就会抛异常。

这解释了为什么“使用迭代器 remove 没问题,但使用 list.remove(index) 会报 ConcurrentModificationException”这个经典问题。

5.2 游标字段的移动规律

看ListItr的next()方法:

public E next() { checkForComodification(); int i = cursor; ... cursor = i + 1; lastRet = i; return (E) elementData[lastRet]; }

cursor永远指向“下一个要访问的元素”,lastRet记录“最近访问的元素索引”。previous()方法类似,只是把cursor减一,再把移动前的位置赋给lastRet。

add()方法的实现更有意思。插入元素后,游标会指向新插入元素的下一个位置,也就是让新元素成为“前一个访问的元素”:

public void add(E e) { checkForComodification(); ... cursor = i + 1; // 游标移到新元素后面 lastRet = -1; // 清除最近访问状态 expectedModCount = modCount; }

这印证了之前说过的行为:add()后如果调用previous(),返回的是刚插入的元素;如果调用next(),返回的是原来的下一个元素。

5.3 set() 的合法区间

set()在ArrayList里的实现很简单,就是ArrayList.set(lastRet, e)。而调用它之前必须确认lastRet >= 0。

如果你刚构造迭代器就调用set(),或者在上一次remove()之后没有再next()就调用set(),都会抛出IllegalStateException。

我们平时代码里出现这种问题的原因,多半是“过滤删除和过滤替换搞混了”,在一个循环里同时想删又想换,中间状态没理顺。处理这类问题的标准做法是:删除元素之后,通过next()重新获取元素,再进行set()。

5.4 ArrayList 与 LinkedList 实现上的差异

LinkedList的 ListIterator 底层是链表指针移动,previous()和next()的复杂度都是 O(1),这一点跟数组实现一致。但listIterator(int index)的初始化方法,在 LinkedList 里要遍历链表找到指定节点,复杂度是 O(n)。所以如果你用的是 LinkedList,又频繁用listIterator(size-1)做尾部遍历,性能会很差。

ArrayList 定位到任意索引只需要 O(1),所以它的listIterator(index)非常快。选型时这算一个隐含因素,可以优先考虑用 ArrayList 做需要随机访问的容器,哪怕它删除头部元素的复杂度不如 LinkedList,但在 ListIterator 使用的常见场景里,ArrayList 的表现其实更好。

6. 实战经验分享:写业务代码时怎么用得顺手

最后这部分是长期使用 ListIterator 后的一些实践沉淀,不涉及源码,纯经验。

6.1 当查看条件删除/替换时,优先考虑 ListIterator 而不是临时拷贝

这是我在代码 review 时见得最多的反模式:遍历一个 List,要把满足条件的元素删除,于是新开一个 List 存储要保留的元素,最后再赋回去。代码又没有错,但增加了内存占用和拷贝成本。

用 ListIterator 之后,代码更短更清晰:

List<Order> orders = ...; ListIterator<Order> it = orders.listIterator(); while (it.hasNext()) { Order o = it.next(); if (o.isExpired()) { it.remove(); } else if (o.isOverdue()) { it.set(o.withLateFeeApplied()); } }

一次性完成过滤和字段更新,不需要开临时数组。对于数据量稍大的场景,性能差异就很明显。

6.2 双向游标做窗口或滑动处理

有一次做一个时间轴渲染,数据是一个按时间排序的事件列表,需要把每个事件前后 5 分钟的事件也一起拉出来做聚合展示。最直观的实现是用两个索引来夹逼,但边界处理非常烦。

后来换了一个思路:用两个 ListIterator,一个从事件所在位置往前,一个从事件所在位置往后,分别取邻近事件,再合并。代码立刻变得清晰,而且previousIndex()和nextIndex()天然提供了边界判断。

List<Event> events = ...; Event target = ...; int targetIndex = events.indexOf(target); ListIterator<Event> backIt = events.listIterator(targetIndex + 1); ListIterator<Event> forwardIt = events.listIterator(targetIndex); List<Event> window = new ArrayList<>(); int backStep = 0; while (backIt.hasPrevious() && backStep < 5) { window.add(backIt.previous()); backStep++; } // 再通过 forwardIt 收集后面的 5 条...

这种做法比两个 for 循环直接用下标清爽很多,核心原因是游标状态由迭代器自己维护,不容易算漏。

6.3 注意 subList 与 ListIterator 的联动问题

List.subList返回的是原列表的视图,它的 ListIterator 在操作时同样受modCount限制。如果你先拿了一个 subList 的 ListIterator,再往原列表里加元素,就会导致 subList 视图的迭代器快速失败。

实际踩过一次坑:分页更新时,先用 subList 取出某一页数据,然后边遍历边把满足条件的数据从原列表剔除。迭代器是通过 subList 拿的,但删除用的是原 List 的 remove,结果迭代器内部立刻判定并发修改,抛了异常。

正确的做法是:直接从原 List 拿 ListIterator,遍历时根据游标位置判断是否在当前页范围内,再做删除或替换。

6.4 不要在 for-each 里操作元素的 remove/add

for-each 编译后本质上是使用 Iterator,但它不允许你获取这个 Iterator,所以你没法调用 remove、set、add。很多人误以为 for-each 里可以用 list.remove,结果就是 ConcurrentModificationException。

这类操作统一改成 ListIterator 后,问题就消失了。如果确实不需要修改操作,只是普通遍历,for-each 或 Stream 仍然是最简洁的选择。ListIterator 代表了“在遍历过程中要对这个 List 本身做手脚”的需求,判断标准很直接。

6.5 性能实测的一个小结论

用 ArrayList 各跑一百万次“遍历并删除偶数”的操作,listIterator.remove 比先遍历收集索引再批量删除要慢一点点,因为前者是逐个删除、每次删除都涉及数组元素前移;后者可以一次性清理。但 ListIterator 的代码更简单、更安全,可读性收益远超这点性能差距。

如果对性能极敏感,且批量删除比例高,可以考虑用 Java 8 的removeIf,它内部做了优化,逻辑接近“集中删除”,比逐条 remove 快。ListIterator 更适合需要同时做条件移除和条件替换、或者需要双向访问的场景。

6.6 给新手的三个建议

第一,先从 ArrayList 的 ListIterator 练手,看它打印每个元素的前后索引,体会游标间隙的概念。第二,试着用 ListIterator 写一个双队列遍历合并的程序,练完基本就不会忘。第三,遇到 ConcurrentModificationException 时,先想想这个异常是从哪个迭代器抛出来的,是迭代器本身的修改方式不对,还是另一个视图在“捣乱”。能把这几件事想明白,这个接口的理解就算过关了。

用这个接口的时候,我也曾经出过错,比如在set()前忘记调用 next,或者add()之后误以为游标停在新增元素上,结果下次 next 跳过了元素。如果你也在用 ListIterator,顺着这几个代码场景过一遍,就能少踩不少坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询