Java 基础体系 · 第 56/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java List 深入:ArrayList、LinkedList、迭代器、复杂度和选型
List 是 Java 集合框架中表示“有序、可重复、按位置访问”的接口。它解决的不是“如何存一组对象”这么简单,而是同时定义了几类语义:
- 元素是否有顺序;
- 是否允许重复;
- 是否允许
null; - 如何按索引读取、替换、插入和删除;
- 迭代期间修改集合时应如何表现;
- 返回的集合是可变、不可变,还是某个集合的视图。
ArrayList 和 LinkedList 都实现了 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 需要同时满足:
- 两个对象都是列表;
- 长度相同;
- 对应位置的元素彼此相等。
因此:
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 是否允许取决于实现和创建方式
ArrayList 和 LinkedList 都允许存放 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
再次追加时,需要:
- 分配更大的数组;
- 将旧数组中的 5 个引用复制到新数组;
- 在新数组末尾写入新元素;
- 更新内部引用和大小。
这一次是 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) 的移动成本为:
在最坏情况下,向头部插入,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(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)。
但是,List 的 add(index, element) 接收的是索引,不是节点引用。为了找到索引对应的节点,仍然需要遍历。因此不能简单地说“链表插入是 O(1)”;准确说法是:
- 已知节点位置后插入:O(1);
- 按索引定位并插入:通常 O(n);
- 通过已有
ListIterator在当前位置插入:插入动作本身为 O(1)。
3.2 按索引访问为什么是 O(n)
执行:
linkedList.get(i);
必须从某个端点沿着 next 或 prev 指针移动到第 i 个节点。双向链表可以选择较近的一端:
- 如果
i接近0,从头部走; - 如果
i接近size - 1,从尾部走。
因此其遍历步数大约为:
最坏仍然是 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) 的总步数近似为:
这个和是 Θ(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);
}
但复杂度分析如下:
list.size()通常是 O(1);- 第一次
get(0)需要很少移动; - 中间位置的
get(i)需要从较近端遍历; - 循环调用了
n次get; - 总定位成本为 Θ(n²)。
改成:
long sum = 0;
for (int value : list) {
sum += value;
}
迭代器沿着链表逐节点前进,每个节点只处理一次,总复杂度为 O(n)。
在 ArrayList 上,这两种写法通常都保持 O(n),但增强 for 仍然更清晰,也避免把代码绑定到具体实现的索引访问模型。
5. 迭代器:不是“更短的循环”,而是位置状态对象
5.1 Iterable、Iterator 与增强 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 不改变列表大小,因此通常不属于结构性修改;add 和 remove 会改变大小,属于结构性修改。
5.5 通过迭代器插入时,ArrayList 与 LinkedList 的差异
假设迭代器已经定位到插入位置:
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 的含义
ArrayList 和 LinkedList 的迭代器通常会记录集合修改状态。检测到迭代器创建后发生了未通过该迭代器进行的结构性修改时,可能抛出:
java.util.ConcurrentModificationException
这称为 fail-fast。它的用途是尽早暴露典型编程错误,而不是提供并发安全。
关键点是:fail-fast 通常只是“尽力检测”,不是规范保证的并发控制机制。多线程下是否及时抛异常不应作为程序正确性的依据。
因此,下面的推理是错误的:
如果没有抛出
ConcurrentModificationException,就说明并发修改是安全的。
正确的结论是:
普通
ArrayList和LinkedList都不是线程安全容器;并发读写需要额外同步或选择并发集合。
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. ArrayList 和 LinkedList 的 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]
参数 1 是 int,匹配 remove(int),删除索引 1 的元素 20,而不是删除值为 1 的元素。
要按值删除整数 1,需要显式装箱:
numbers.remove(Integer.valueOf(1));
如果使用 List<String>,通常没有这个歧义,因为字符串不能转换成 int:
list.remove("A"); // 按值删除 "A"
这是编译期重载选择问题,不是 ArrayList 或 LinkedList 的特殊行为。
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) 的区间是左闭右开:
这里包含索引 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 只有在其节点结构确实匹配访问模式时才有优势,例如:
- 需要频繁从两端插入和删除;
- 已经通过迭代器定位到修改位置;
- 需要同时使用
List和Deque的语义; - 不依赖随机索引访问。
例如,使用迭代器在已定位位置反复插入:
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");
}
异常的根本原因是迭代器检测到不符合其遍历约定的结构性修改。它可以来自同一线程,也可以来自其他线程。
诊断时应检查:
- 是否在遍历期间直接调用了列表的
add、remove或clear; - 是否通过
subList修改了父列表; - 是否存在其他线程修改普通列表;
- 是否错误地把 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]
}
}
执行过程的关键状态如下:
- 初始列表是
[10, 20, 30, 40]; - 迭代器返回
20,通过iterator.remove()删除它,得到[10, 30, 40]; numbers.remove(1)删除索引1的30,得到[10, 40];subList(0, 1)覆盖原列表索引0,视图内容为[10];view.set(0, 99)同时改变视图和原列表;new ArrayList<>(view)创建独立副本,之后向副本添加元素不再影响原列表。
这个例子还说明:列表 API 的正确使用不能只记住方法名,还必须理解方法的索引语义、视图关系和迭代器状态。
12. 最终判断原则
选择 ArrayList 还是 LinkedList,可以先回答四个问题:
- 是否需要频繁
get(index)? - 修改位置是否已经由迭代器或节点定位?
- 是否主要在两端操作?
- 是否需要顺序遍历而不是随机访问?
若需要随机访问、顺序遍历和尾部追加,通常选择 ArrayList。若需要已定位位置上的链式修改,或确实需要双端链表语义,才考虑 LinkedList。若需求本质上是队列或双端队列,应同时比较 ArrayDeque。
List 的接口决定了“能做什么”,ArrayList 和 LinkedList 的结构决定了“做这些事要付出什么代价”,迭代器则把遍历位置和修改规则显式化。只有把三者结合起来,复杂度分析和容器选型才不会停留在“数组快、链表插入快”这类过于粗略的口诀上。
系列导航与关联阅读
- 系列入口:Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
- 上一篇:Java 注解处理器:编译期模型、代码生成、增量构建和调试
- 下一篇:Java HashMap 深入:哈希、桶、树化、扩容、迭代和键约束
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论