Java 基础体系 · 第 56/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。

Java List 深入:ArrayList、LinkedList、迭代器、复杂度和选型

List 是 Java 集合框架中表示“有序、可重复、按位置访问”的接口。它解决的不是“如何存一组对象”这么简单,而是同时定义了几类语义:

  • 元素是否有顺序;
  • 是否允许重复;
  • 是否允许 null
  • 如何按索引读取、替换、插入和删除;
  • 迭代期间修改集合时应如何表现;
  • 返回的集合是可变、不可变,还是某个集合的视图。

ArrayListLinkedList 都实现了 List,但它们对这些操作的成本模型完全不同。理解这种差异,需要先区分 接口语义数据结构实现算法复杂度


1. List 到底保证什么

1.1 有序不等于排序

List 的“有序”表示元素具有稳定的遍历顺序,并且可以通过从 0 开始的整数索引访问:

List<String> languages = new ArrayList<>();
languages.add("Java");
languages.add("Go");
languages.add("Rust");

System.out.println(languages.get(0)); // Java
System.out.println(languages);        // [Java, Go, Rust]

这个顺序是插入顺序,不代表集合会自动按字典序或数值排序。除非显式调用:

languages.sort(Comparator.naturalOrder());

排序会改变列表中元素的排列;它不是 List 的默认行为。

1.2 List 通常允许重复元素

List<String> values = new ArrayList<>();
values.add("A");
values.add("A");

System.out.println(values.size()); // 2

列表中两个相等的元素仍然占据两个位置。List 通过位置区分它们,而不是像 Set 那样试图去重。

“相等”通常依据元素的 equals 方法。列表的 equals 需要同时满足:

  1. 两个对象都是列表;
  2. 长度相同;
  3. 对应位置的元素彼此相等。

因此:

List<String> a = List.of("A", "B");
List<String> b = new LinkedList<>(List.of("A", "B"));

System.out.println(a.equals(b)); // true

列表的具体实现不同,但只要元素顺序和内容相同,列表可以相等。对应的 hashCode 也依赖元素顺序,因此改变元素顺序通常会改变列表的哈希值。

1.3 null 是否允许取决于实现和创建方式

ArrayListLinkedList 都允许存放 null

List<String> list = new ArrayList<>();
list.add(null);
System.out.println(list.get(0)); // null

List.of 创建的是不可变列表,并且不允许 null

List<String> list = List.of("A", "B");
// List.of("A", null); // NullPointerException

因此,“List 是否允许 null”不是接口层面的统一答案,需要查看具体实现或工厂方法的约束。

1.4 size() 是元素数量,不是内部容量

ArrayList 来说,需要区分:

  • size:当前实际元素数量;
  • capacity:内部数组能够容纳的元素数量。

容量大于等于大小,但容量不是 List 接口的一部分,也不能通过 List 接口直接读取。

ArrayList<Integer> list = new ArrayList<>(1000);
System.out.println(list.size()); // 0

这里预留了较大的内部空间,但列表仍然没有元素。预分配容量可以减少后续扩容次数,却不会改变列表的逻辑大小。


2. ArrayList:连续数组带来的访问优势

2.1 核心结构

ArrayList 的核心可以抽象为一个对象引用数组:

elementData
+------+------+------+------+
|  E0  |  E1  |  E2  |  E3  | ...
+------+------+------+------+
   0      1      2      3

当列表有 n 个元素时,逻辑索引 i 对应数组中的 elementData[i]。因此,读取任意位置不需要从头遍历:

E value = list.get(i);

只要索引合法,访问路径与 i 的大小无关,所以 ArrayList.get(i) 的复杂度是 O(1)

set(i, value) 也只是替换数组槽位,同样是 O(1),但它不改变列表长度。

2.2 尾部追加为什么通常是 O(1)

假设当前数组有容量 c,实际元素数量为 n

容量:5
元素:[A, B, C, D, _]
size = 4

执行:

list.add("E");

只需要把 "E" 放入下标 4 的位置,随后将 size 增加到 5。这次操作是 O(1)。

如果数组已满:

容量:5
元素:[A, B, C, D, E]
size = 5

再次追加时,需要:

  1. 分配更大的数组;
  2. 将旧数组中的 5 个引用复制到新数组;
  3. 在新数组末尾写入新元素;
  4. 更新内部引用和大小。

这一次是 O(n),其中 n 是扩容前的元素数量。

但连续追加 n 个元素时,扩容不会每次都发生。扩容后的容量通常按某种增长策略增加,具体增长比例属于实现细节,不应依赖固定数值。由于大多数追加只需一次数组写入,少量扩容复制的总成本可以摊销到所有追加操作上,因此 ArrayList.add(E) 的规范复杂度通常描述为 摊销 O(1)

“摊销 O(1)”不是说每一次调用都严格 O(1),而是说进行一长串操作后,平均到每次操作的成本为常数级。

2.3 中间插入为什么是 O(n)

考虑:

List<String> list = new ArrayList<>(List.of("A", "B", "C", "D"));
list.add(1, "X");

插入前:

索引: 0    1    2    3
元素: A    B    C    D

为了给 "X" 腾出索引 1,需要把后面的元素整体向右移动:

D:3 -> 4
C:2 -> 3
B:1 -> 2
X:写入 1

插入后:

索引: 0    1    2    3    4
元素: A    X    B    C    D

如果插入位置后面有 n - i 个元素,就需要移动这些引用,因此 add(i, e) 的移动成本为:

O(ni)O(n-i)

在最坏情况下,向头部插入,i = 0,成本为 O(n)。所以通常将 ArrayList.add(index, element) 记为 O(n)

即使数组有足够容量、不需要扩容,中间插入仍然需要移动元素;扩容只是可能额外增加一次 O(n) 的复制。

2.4 删除中间元素也需要移动

执行:

List<String> list = new ArrayList<>(List.of("A", "B", "C", "D"));
list.remove(1);

删除索引 1"B" 后,需要把后面的元素左移:

C:2 -> 1
D:3 -> 2

结果为:

[A, C, D]

如果删除位置为 i,后续元素数量为 n - i - 1,因此删除的移动成本为:

O(ni1)O(n-i-1)

最坏情况仍然是 O(n)。

2.5 ArrayList 的内存和缓存局部性

ArrayList 保存的是对象引用数组,而不是把对象本身内嵌到数组中。对于对象列表,数组中的元素通常是引用:

引用数组: [ref0, ref1, ref2, ...]
               |     |     |
               v     v     v
             Object Object Object

它仍然具有良好的数组连续访问特征:

for (int i = 0; i < list.size(); i++) {
    consume(list.get(i));
}

这类顺序遍历通常适合 CPU 缓存和 JVM 的优化。实际性能还会受对象分配、垃圾回收、元素类型和访问模式影响,因此不能把 Big-O 直接等同于固定运行时间。


3. LinkedList:节点链接带来的插入特征

3.1 核心结构

LinkedList 通常使用双向链表节点。每个节点包含:

+---------+---------+---------+
|  prev   |  item   |  next   |
+---------+---------+---------+

整体结构类似:

null <- [A] <-> [B] <-> [C] <-> [D] -> null

节点不要求在内存中连续排列。插入一个节点时,只需要修改相邻节点的链接:

插入 X 前:

[A] <-> [B]

插入 X 后:

[A] <-> [X] <-> [B]

如果已经持有 [A][B] 的节点引用,链接修改本身是 O(1)。

但是,Listadd(index, element) 接收的是索引,不是节点引用。为了找到索引对应的节点,仍然需要遍历。因此不能简单地说“链表插入是 O(1)”;准确说法是:

  • 已知节点位置后插入:O(1);
  • 按索引定位并插入:通常 O(n);
  • 通过已有 ListIterator 在当前位置插入:插入动作本身为 O(1)。

3.2 按索引访问为什么是 O(n)

执行:

linkedList.get(i);

必须从某个端点沿着 nextprev 指针移动到第 i 个节点。双向链表可以选择较近的一端:

  • 如果 i 接近 0,从头部走;
  • 如果 i 接近 size - 1,从尾部走。

因此其遍历步数大约为:

min(i, ni)\min(i,\ n-i)

最坏仍然是 O(n)。所以 LinkedList.get(i) 不适合放在按索引递增的循环中作为主要访问方式:

for (int i = 0; i < linkedList.size(); i++) {
    consume(linkedList.get(i)); // 可能导致 O(n²)
}

假设有 n 个元素,从 get(0)get(n - 1) 的总步数近似为:

i=0n1min(i,ni)\sum_{i=0}^{n-1}\min(i,n-i)

这个和是 Θ(n²),所以整个循环可能从预期的线性遍历退化为二次复杂度。

应改用迭代器或增强 for

for (String value : linkedList) {
    consume(value);
}

迭代器沿着节点的 next 引用前进,每个节点只访问一次,总复杂度为 O(n)。

3.3 LinkedList 的两端操作

LinkedList 同时实现了 Deque,可以作为双端队列:

Deque<String> queue = new LinkedList<>();

queue.addFirst("A");
queue.addLast("B");

System.out.println(queue.removeFirst()); // A
System.out.println(queue.removeLast());  // B

由于链表对象通常保存头节点和尾节点引用,两端插入和删除不需要遍历,通常是 O(1)。

不过,LinkedList 不是所有队列场景的默认选择。若只需要队列或双端队列,ArrayDeque 通常具有更紧凑的内存布局和更好的局部性;选型应基于所需接口和访问模式,而不是仅因为“链表两端操作是 O(1)”就使用 LinkedList


4. 复杂度不能只看单个操作

4.1 常见操作比较

下表描述的是典型复杂度。n 表示列表元素数量。

操作 ArrayList LinkedList 说明
get(i) O(1) O(n) 链表需要定位节点
set(i, e) O(1) O(n) 链表需要先定位节点
尾部 add(e) 摊销 O(1) O(1) ArrayList 偶尔扩容
头部插入 O(n) O(1) 数组需移动全部元素
中间按索引插入 O(n) O(n) 链表定位占主要成本
已知迭代器位置插入 需要移动,通常 O(n) O(1) 链表已有节点位置
尾部删除 O(1) O(1) 不考虑缩容等实现细节
头部删除 O(n) O(1) 数组需整体左移
contains O(n) O(n) 需要线性比较
顺序迭代 O(n) O(n) 但常数因素可能不同
sort 依赖排序算法,通常 O(n log n) 通常也需先转为数组或使用链表排序策略 API 复杂度不应假设具体实现细节

这里有三个重要限制。

第一,复杂度描述的是增长趋势,不是实际耗时。一次 ArrayList.get(i) 虽然是 O(1),但一次数组引用读取和一次链表节点跳转的常数成本可能不同。

第二,操作组合会改变结论。对 LinkedList 连续调用 get(i),可能形成 O(n²);使用迭代器则是 O(n)。

第三,LinkedList 在“已知节点”条件下的 O(1) 插入,不能直接推导出“按索引插入比 ArrayList 快”。按索引调用时,定位节点本身已经可能是 O(n)。

4.2 一个完整的反例:链表为什么会被错误使用

下面的代码逻辑上没有错误:

LinkedList<Integer> list = new LinkedList<>();

for (int i = 0; i < 100_000; i++) {
    list.add(i);
}

long sum = 0;
for (int i = 0; i < list.size(); i++) {
    sum += list.get(i);
}

但复杂度分析如下:

  1. list.size() 通常是 O(1);
  2. 第一次 get(0) 需要很少移动;
  3. 中间位置的 get(i) 需要从较近端遍历;
  4. 循环调用了 nget
  5. 总定位成本为 Θ(n²)。

改成:

long sum = 0;
for (int value : list) {
    sum += value;
}

迭代器沿着链表逐节点前进,每个节点只处理一次,总复杂度为 O(n)。

ArrayList 上,这两种写法通常都保持 O(n),但增强 for 仍然更清晰,也避免把代码绑定到具体实现的索引访问模型。


5. 迭代器:不是“更短的循环”,而是位置状态对象

5.1 IterableIterator 与增强 for

List 实现了 Iterable,因此可以用于增强 for

for (String value : list) {
    System.out.println(value);
}

编译器会把它转换为语义近似如下的代码:

Iterator<String> iterator = list.iterator();

while (iterator.hasNext()) {
    String value = iterator.next();
    System.out.println(value);
}

Iterator<E> 至少提供:

  • hasNext():是否还有下一个元素;
  • next():返回下一个元素;
  • remove():删除最近一次 next() 返回的元素,是否支持由具体迭代器决定。

迭代器保存了“当前遍历到哪里”的状态。对于 ArrayList,这个状态通常表现为数组索引;对于 LinkedList,通常表现为当前节点及其邻接位置。接口不要求具体状态布局,但要求迭代行为符合接口约定。

5.2 Iterator.remove() 的状态规则

正确删除方式:

List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4));

Iterator<Integer> iterator = numbers.iterator();
while (iterator.hasNext()) {
    int value = iterator.next();
    if (value % 2 == 0) {
        iterator.remove();
    }
}

System.out.println(numbers); // [1, 3]

每一次 remove() 都对应最近一次成功的 next()。以下调用顺序不合法:

Iterator<Integer> iterator = numbers.iterator();
iterator.remove(); // IllegalStateException

因为还没有调用 next(),迭代器没有“最近返回的元素”。

同一个元素被删除后,不能再次直接 remove()

Iterator<Integer> iterator = numbers.iterator();
iterator.next();
iterator.remove();
iterator.remove(); // IllegalStateException

删除后必须再次调用 next(),才能建立下一次可删除状态。

5.3 为什么不能直接在增强 for 中调用列表的 remove

下面的写法存在问题:

List<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4));

for (Integer value : numbers) {
    if (value % 2 == 0) {
        numbers.remove(value);
    }
}

增强 for 使用的是列表迭代器,而 numbers.remove(value) 是直接修改列表。迭代器并不知道这次外部修改,后续调用 next() 时可能抛出 ConcurrentModificationException

正确写法是使用迭代器自身的删除操作:

Iterator<Integer> iterator = numbers.iterator();

while (iterator.hasNext()) {
    Integer value = iterator.next();
    if (value % 2 == 0) {
        iterator.remove();
    }
}

或者使用集合提供的批量条件删除:

numbers.removeIf(value -> value % 2 == 0);

removeIf 的语义是删除满足条件的元素。它比手写循环更直接,但谓词本身仍然不应通过另一个路径修改同一个列表,否则会产生未定义的组合行为或并发修改异常。

5.4 ListIterator:可以双向移动和插入

ListIterator<E>Iterator 的扩展,提供:

  • hasPrevious()
  • previous()
  • nextIndex()
  • previousIndex()
  • add(E)
  • set(E)

示例:

List<String> list = new ArrayList<>(List.of("A", "C"));

ListIterator<String> iterator = list.listIterator();

while (iterator.hasNext()) {
    String value = iterator.next();
    if (value.equals("A")) {
        iterator.add("B");
    }
}

System.out.println(list); // [A, B, C]

iterator.add("B") 把元素插入到“迭代器当前位置”,也就是最近一次 next() 返回的元素之后、下一次 next() 将返回的元素之前。

set 可以替换最近一次通过 next()previous() 返回的元素:

ListIterator<String> iterator = list.listIterator();
String value = iterator.next();
iterator.set(value.toLowerCase());

set 不改变列表大小,因此通常不属于结构性修改;addremove 会改变大小,属于结构性修改。

5.5 通过迭代器插入时,ArrayListLinkedList 的差异

假设迭代器已经定位到插入位置:

ListIterator<String> iterator = list.listIterator(position);
iterator.add("X");

LinkedList,插入节点只需调整相邻节点链接,因此插入动作本身是 O(1)。但创建这个迭代器并定位到 position 可能已经是 O(n)。

ArrayList,迭代器插入仍然需要把后续数组元素向右移动,因此通常是 O(n)。

这说明“使用迭代器”主要解决的是:

  • 避免 LinkedList.get(i) 的重复定位;
  • 允许安全地通过当前迭代状态修改集合;
  • 为链表提供已知位置上的高效插入。

它不会让 ArrayList 的中间插入变成 O(1)。


6. 结构性修改与 fail-fast 行为

6.1 什么是结构性修改

结构性修改通常指会改变列表大小,或以某种方式破坏已有迭代位置假设的修改,例如:

add(e)
add(index, e)
remove(index)
clear()
removeIf(...)

而替换已有位置的元素:

list.set(index, value);

不改变列表大小,通常不属于结构性修改。

具体方法是否改变迭代器可观察结构,要以对应 API 契约为准;不能仅凭“是否调用了 set”推导所有实现的内部行为。

6.2 fail-fast 的含义

ArrayListLinkedList 的迭代器通常会记录集合修改状态。检测到迭代器创建后发生了未通过该迭代器进行的结构性修改时,可能抛出:

java.util.ConcurrentModificationException

这称为 fail-fast。它的用途是尽早暴露典型编程错误,而不是提供并发安全。

关键点是:fail-fast 通常只是“尽力检测”,不是规范保证的并发控制机制。多线程下是否及时抛异常不应作为程序正确性的依据。

因此,下面的推理是错误的:

如果没有抛出 ConcurrentModificationException,就说明并发修改是安全的。

正确的结论是:

普通 ArrayListLinkedList 都不是线程安全容器;并发读写需要额外同步或选择并发集合。

6.3 多线程访问的正确边界

若使用 Collections.synchronizedList

List<String> list =
        Collections.synchronizedList(new ArrayList<>());

synchronized (list) {
    for (String value : list) {
        consume(value);
    }
}

对同步包装列表进行迭代时,遍历整个过程需要在同一个锁上同步。只同步单次 add 或单次 get,并不能自动保护“检查后再操作”或完整遍历这样的复合动作。

如果场景是读多写少,并且希望迭代器看到创建时的快照,可以考虑 CopyOnWriteArrayList

List<String> listeners = new CopyOnWriteArrayList<>();

它的写操作需要复制底层数组,写入成本和内存分配明显更高;因此它适合元素数量有限、读取和遍历远多于修改的场景,不适合高频写入的大列表。


7. ArrayListLinkedList 的 API 细节

7.1 remove(int)remove(Object) 的重载陷阱

List 有两个容易混淆的删除方法:

E remove(int index)
boolean remove(Object object)

当列表类型为 List<Integer> 时:

List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30));

numbers.remove(1);
System.out.println(numbers); // [10, 30]

参数 1int,匹配 remove(int),删除索引 1 的元素 20,而不是删除值为 1 的元素。

要按值删除整数 1,需要显式装箱:

numbers.remove(Integer.valueOf(1));

如果使用 List<String>,通常没有这个歧义,因为字符串不能转换成 int

list.remove("A"); // 按值删除 "A"

这是编译期重载选择问题,不是 ArrayListLinkedList 的特殊行为。

7.2 subList 是视图,不是独立副本

List<String> original =
        new ArrayList<>(List.of("A", "B", "C", "D"));

List<String> part = original.subList(1, 3);
part.set(0, "X");

System.out.println(original); // [A, X, C, D]

subList(from, to) 的区间是左闭右开:

[from, to)[from,\ to)

这里包含索引 1,不包含索引 3,所以视图内容是 [B, C]

subList 通常是原列表的视图:

  • 对子列表进行 set,会影响原列表;
  • 对原列表进行结构性修改,可能使已有子列表的后续操作抛出 ConcurrentModificationException
  • 如果需要独立副本,应显式复制:
List<String> copy = new ArrayList<>(original.subList(1, 3));

7.3 不可变列表、固定大小列表和可变列表不是一回事

不可变列表

List<String> immutable = List.of("A", "B");
immutable.set(0, "X"); // UnsupportedOperationException

List.of 返回的列表不能添加、删除或替换元素。它也不允许 null

固定大小视图

String[] array = {"A", "B"};
List<String> fixed = Arrays.asList(array);

fixed.set(0, "X");     // 允许
// fixed.add("C");     // UnsupportedOperationException

Arrays.asList 返回的是数组的固定大小列表视图。set 会修改数组内容:

System.out.println(array[0]); // X

因此它既不是普通可变列表,也不是完全不可变列表。

可变副本

List<String> mutable = new ArrayList<>(List.of("A", "B"));
mutable.add("C"); // 允许

如果代码需要独立且可增删的列表,显式创建 ArrayList 往往能消除视图和不可变性方面的歧义。

7.4 List.copyOf 也会创建不可变结果

List<String> source = new ArrayList<>(List.of("A", "B"));
List<String> copy = List.copyOf(source);

source.set(0, "X");

System.out.println(copy); // [A, B]

List.copyOf 创建的是不可变列表结果,而不是持续反映 source 变化的视图;它也拒绝 null 元素。

需要注意:不可变列表中的元素对象本身不一定不可变。集合不能被修改,不代表元素对象的内部状态也不能被修改。


8. Java 25 中的顺序集合能力

从 Java 21 开始,集合框架引入了 SequencedCollection 体系。在 Java 25 中,List 属于有序集合体系,可以使用按首尾语义表达的操作,例如:

List<String> list = new ArrayList<>(List.of("B", "C"));

list.addFirst("A");
list.addLast("D");

System.out.println(list.getFirst()); // A
System.out.println(list.getLast());  // D
System.out.println(list);            // [A, B, C, D]

还可以获得逆序视图:

List<String> reversed = list.reversed();

System.out.println(reversed); // [D, C, B, A]

reversed() 返回的是反向顺序视图,而不是必须复制所有元素的新列表。对原列表和反向视图的修改关系应按照 SequencedCollection 和具体实现的 API 契约理解;如果业务需要独立快照,应显式复制:

List<String> reversedCopy = new ArrayList<>(list.reversed());

这些 API 在 Java 25 中属于标准集合 API,但代码若需要兼容 Java 20 或更早版本,就不能使用这些方法。版本兼容性不能只看编译器,也要看部署运行时。


9. 选型:先描述访问模式,再选择实现

9.1 优先选择 ArrayList 的情况

以下访问模式通常适合 ArrayList

List<Record> records = new ArrayList<>();
  • 需要频繁按索引读取;
  • 主要是尾部追加;
  • 主要是顺序遍历;
  • 很少在列表头部或中间插入、删除;
  • 希望数据结构更紧凑、遍历局部性更好;
  • 需要把列表当作普通可变序列使用。

一个常见的数据流是:

追加元素 -> 顺序读取 -> 偶尔按索引修改

ArrayList 对这个模式的成本模型较自然:追加摊销 O(1),索引访问 O(1),遍历 O(n)。

9.2 LinkedList 适合的情况

LinkedList 只有在其节点结构确实匹配访问模式时才有优势,例如:

  • 需要频繁从两端插入和删除;
  • 已经通过迭代器定位到修改位置;
  • 需要同时使用 ListDeque 的语义;
  • 不依赖随机索引访问。

例如,使用迭代器在已定位位置反复插入:

LinkedList<String> list =
        new LinkedList<>(List.of("A", "D"));

ListIterator<String> iterator = list.listIterator(1);
iterator.add("B");
iterator.add("C");

System.out.println(list); // [A, B, C, D]

这里迭代器已经位于 "D" 之前。每次插入只需调整链表链接;如果改为不断调用 add(index, value),每次都可能重新定位索引,分析就不同了。

9.3 只需要队列时不要被 LinkedList 的名字限制

如果需求是先进先出队列:

Queue<Task> queue = new ArrayDeque<>();

如果需求是双端队列:

Deque<Task> deque = new ArrayDeque<>();

ArrayDeque 使用循环数组实现两端操作,通常避免了链表节点的额外对象和指针开销。它不允许 null,并且不是线程安全容器。

这不是说 LinkedList 不能做队列,而是应该按抽象需求选择接口,再比较实现的数据布局和约束。


10. 常见误解和诊断方法

10.1 “链表插入永远是 O(1)”

错误原因是忽略了“找到插入位置”的成本。

linkedList.add(500_000, value);

这个 API 只给出了索引。链表必须先走到该索引对应的节点,因此整体操作通常是 O(n),只有节点位置已经通过迭代器或内部链接确定时,插入动作才是 O(1)。

10.2 “ArrayList 扩容意味着每次追加都是 O(n)”

错误原因是把偶发扩容和所有追加混为一谈。

单次扩容复制确实是 O(n),但追加序列的总复制成本可以摊销。若能够预估元素数量,可以使用构造器预留容量:

List<String> list = new ArrayList<>(expectedSize);

这可以减少扩容和数组复制,但不能保证完全不扩容;实际数量超过预留容量时仍会增长。

10.3 “ConcurrentModificationException 说明有多个线程”

不一定。单线程中也会出现:

for (String value : list) {
    list.add("new");
}

异常的根本原因是迭代器检测到不符合其遍历约定的结构性修改。它可以来自同一线程,也可以来自其他线程。

诊断时应检查:

  1. 是否在遍历期间直接调用了列表的 addremoveclear
  2. 是否通过 subList 修改了父列表;
  3. 是否存在其他线程修改普通列表;
  4. 是否错误地把 fail-fast 当成同步机制。

10.4 “用 synchronizedList 就能安全遍历”

同步包装只负责把单个方法调用放到锁保护下。遍历是多个调用组成的复合过程,必须显式锁住整个迭代周期:

List<String> list =
        Collections.synchronizedList(new ArrayList<>());

synchronized (list) {
    Iterator<String> iterator = list.iterator();
    while (iterator.hasNext()) {
        process(iterator.next());
    }
}

如果遍历期间必须允许其他线程修改,则应重新评估需求:使用快照、并发集合或消息传递,不能只依赖 fail-fast 行为。


11. 一个可运行的综合示例

下面的程序同时展示顺序访问、迭代器删除、remove(int) 重载和 subList 视图:

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class ListDemo {
    public static void main(String[] args) {
        List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30, 40));

        // 1. 按索引读取:ArrayList 的典型优势
        System.out.println(numbers.get(2)); // 30

        // 2. 使用迭代器安全删除偶数
        Iterator<Integer> iterator = numbers.iterator();
        while (iterator.hasNext()) {
            int value = iterator.next();
            if (value == 20) {
                iterator.remove();
            }
        }
        System.out.println(numbers); // [10, 30, 40]

        // 3. remove(int) 删除索引,而不是删除值
        numbers.remove(1);
        System.out.println(numbers); // [10, 40]

        // 4. subList 是视图,修改它会影响原列表
        List<Integer> view = numbers.subList(0, 1);
        view.set(0, 99);

        System.out.println(view);    // [99]
        System.out.println(numbers); // [99, 40]

        // 5. 复制后才是独立的可变列表
        List<Integer> copy = new ArrayList<>(view);
        copy.add(100);

        System.out.println(view);    // [99]
        System.out.println(copy);    // [99, 100]
    }
}

执行过程的关键状态如下:

  1. 初始列表是 [10, 20, 30, 40]
  2. 迭代器返回 20,通过 iterator.remove() 删除它,得到 [10, 30, 40]
  3. numbers.remove(1) 删除索引 130,得到 [10, 40]
  4. subList(0, 1) 覆盖原列表索引 0,视图内容为 [10]
  5. view.set(0, 99) 同时改变视图和原列表;
  6. new ArrayList<>(view) 创建独立副本,之后向副本添加元素不再影响原列表。

这个例子还说明:列表 API 的正确使用不能只记住方法名,还必须理解方法的索引语义、视图关系和迭代器状态。


12. 最终判断原则

选择 ArrayList 还是 LinkedList,可以先回答四个问题:

  1. 是否需要频繁 get(index)
  2. 修改位置是否已经由迭代器或节点定位?
  3. 是否主要在两端操作?
  4. 是否需要顺序遍历而不是随机访问?

若需要随机访问、顺序遍历和尾部追加,通常选择 ArrayList。若需要已定位位置上的链式修改,或确实需要双端链表语义,才考虑 LinkedList。若需求本质上是队列或双端队列,应同时比较 ArrayDeque

List 的接口决定了“能做什么”,ArrayListLinkedList 的结构决定了“做这些事要付出什么代价”,迭代器则把遍历位置和修改规则显式化。只有把三者结合起来,复杂度分析和容器选型才不会停留在“数组快、链表插入快”这类过于粗略的口诀上。


系列导航与关联阅读

官方资料

本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。