Java 从入门到精通(八):集合框架(上)——List、Set、Queue 与迭代器机制

几乎每个 Java 程序员每天都在用 ArrayList 和 HashSet,但下面这些问题能答到源码层面的人并不多:为什么 foreach 循环里调用 list.remove() 会抛 ConcurrentModificationException,而 iterator.remove() 却不会?ArrayList 扩容到底是 1.5 倍还是 2 倍?Arrays.asList() 返回的 List 为什么不能 add?HashSet 里到底存的是什么?PriorityQueue 凭什么能 O(n log k) 求出 TopK?本文把 List、Set、Queue 三大族从接口契约一路拆到 JDK 源码,再把迭代器的 fail-fast 机制彻底讲透。

一、集合框架全景:两大体系与接口契约

1.1 Collection 与 Map:两条并行的线

java.util 包下的集合框架(Collections Framework)诞生于 JDK 1.2,用来取代 Vector、Hashtable、Enumeration 这些早期遗留类。它的第一层切分非常关键:Collection 是单列数据,Map 是双列键值映射,两者互不隶属。

也就是说,Map 并没有实现 Collection 接口,它是一条完全独立的线。很多人会下意识说出「Map 也是集合的一种」,这在概念上没错(它确实是集合框架的一部分),但在类型系统里 Map 与 Collection 没有任何继承关系。这也是为什么 Map 没有 iterator() 方法——你要遍历 Map,得先通过 keySet()、values() 或 entrySet() 拿到一个 Collection 视图。

本文覆盖 Collection 这条线,Map 家族留到下一篇展开。

1.2 接口继承关系(文本树)

把 Collection 支线的接口与常用实现画成一棵树,形状如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Iterable<T>                             (一切可 foreach 的根)
└── Collection<E> (add/remove/contains/size/toArray/clear)
├── List<E> (有序、可重复、可按索引访问)
│ ├── ArrayList (动态数组,随机访问 O(1))
│ ├── LinkedList (双向链表,同时实现 Deque)
│ └── Vector / Stack (遗留类,同步但已不推荐)
├── Set<E> (无序、不可重复)
│ ├── SortedSet -> NavigableSet
│ │ └── TreeSet (红黑树,有序)
│ ├── HashSet (哈希表,无序)
│ │ └── LinkedHashSet (哈希表 + 双向链表,保持插入序)
│ └── (JDK9+) ImmutableCollections.SetN / Set12
├── Queue<E> (队列,FIFO 或优先级)
│ ├── Deque<E> (双端队列,可当栈可当队列)
│ │ ├── ArrayDeque (循环数组)
│ │ └── LinkedList (链表)
│ ├── PriorityQueue (二叉堆,优先级队列)
│ └── BlockingQueue (JUC 阻塞队列,另一篇讲)
└── (JDK9+) 不可变集合 List.of / Set.of 的返回类型

Map<K,V> (与 Collection 平行,无继承关系)
├── HashMap / LinkedHashMap / TreeMap / Hashtable / ConcurrentHashMap

注意 Deque 继承自 Queue 而不是反过来:双端队列比普通队列能力更强,所以它是子接口。

1.3 六个核心接口的语义差异

接口 是否有序 是否允许重复 是否允许 null 核心语义 典型实现
Iterable — — — 可迭代,提供 iterator() 所有集合
Collection 取决于实现 取决于实现 取决于实现 一组对象的集合,定义通用操作 所有单列集合
List 有序(按插入序) 允许 允许(如 ArrayList) 有索引,可精确控制位置 ArrayList、LinkedList
Set 取决于实现 不允许 HashSet 允许 1 个;TreeSet 不允许 元素唯一,等价于数学上的集合 HashSet、TreeSet
Queue 按出队规则 允许 通常不建议(poll 返回 null 表空) 一端进一端出,FIFO 或优先级 ArrayDeque、PriorityQueue
Deque 按出队规则 允许 不建议 两端可进可出,兼容栈与队列 ArrayDeque、LinkedList

这张表里最容易被忽略的是 Queue 对 null 的态度:LinkedList 作为队列时允许放 null,但 ArrayDeque 直接禁止,原因很实在——poll() 用返回 null 表示「队列为空」,如果元素本身允许为 null,这两个语义就撞车了。

1.4 AbstractXXX 骨架类:模板方法模式的教科书

打开 ArrayList 的源码,你会看到它继承 AbstractList;HashSet 继承 AbstractSet。这些 AbstractXXX 就是集合框架提供的骨架实现(Skeletal Implementation),其设计目的是:让你实现一个新的集合时,只需要写最少的代码。

比如 AbstractCollection 里的 contains 就是靠 iterator 遍历实现的,toArray 也是;isEmpty() 干脆就是 size() == 0。至于 size() 和 iterator(),这两个必须由子类自己实现——因为只有子类知道数据存在哪里。

这就是典型的模板方法模式:父类把算法骨架写好,把真正变化的部分下沉为抽象方法交给子类。

1.5 接口与实现分离:以及迭代器、适配器两大模式

集合框架最值得学习的设计决策是接口与实现彻底分离。声明变量时写 List<String> list = new ArrayList<>(); 而不是 ArrayList<String> list = new ArrayList<>();,好处不只是「方便换实现」这么空泛:

  1. 换实现的成本趋近于零:把 ArrayList 换成 LinkedList,调用方代码一行不用改。
  2. 约束 API 面:接口只暴露契约方法,实现类的内部方法不会污染调用方。
  3. 便于包装增强:Collections.unmodifiableList()、synchronizedList() 返回的都是接口的另一种实现,调用方无感知。

框架里还藏着两个经典设计模式:

  • 迭代器模式:Iterable#iterator() 把「如何遍历」从集合本身剥离出去。数组、HashSet、红黑树、LinkedList 内部结构天差地别,但对使用者的遍历方式完全一致——这就是迭代器模式的价值:统一访问接口,隐藏内部结构。
  • 适配器模式:Arrays.asList() 把数组适配成 List;Collections.list(Enumeration) 把老式 Enumeration 适配成 ArrayList;下一篇要讲的 HashMap.keySet() 返回的 KeySet 是把 Map 适配成 Set 的视图。

判断一个 API 设计得好不好,就看它是否只依赖接口而不依赖实现。写方法签名时请坚持用 List/Set/Map,把 ArrayList 这类具体类型限制在 new 的那一刻。

二、Collection 通用操作与三个经典陷阱

2.1 通用操作速览

Collection 接口定义的方法不多,但涵盖了绝大多数日常操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.*;

public class CollectionBasicOps {
public static void main(String[] args) {
Collection<String> c = new ArrayList<>();
// 1. 添加:add 返回 boolean,Set 用它表示「是否真的加入了新元素」
boolean added = c.add("Java");
c.add("Kotlin");
System.out.println("add 返回: " + added);

// 2. 数量与判空
System.out.println("size = " + c.size() + ", isEmpty = " + c.isEmpty());

// 3. 包含判断:底层依赖元素的 equals 方法
System.out.println("contains Java? " + c.contains("Java"));

// 4. 删除:只删第一个匹配项,同样依赖 equals
c.remove("Kotlin");

// 5. 转数组:强烈建议传入正确类型与长度的数组,避免浪费一次反射创建
String[] arr = c.toArray(new String[0]);
System.out.println(Arrays.toString(arr));

// 6. 清空:只是把元素置 null 并 size=0,底层数组不会被回收(ArrayList 行为)
c.clear();
}
}

这里有个值得记住的细节:toArray(new String[0]) 在现代 JVM 上并不比 toArray(new String[c.size()]) 慢多少,反而更不容易写错——JDK 内部对零长度数组有优化。但在热路径循环里,预分配长度仍会略快。

2.2 陷阱一:Arrays.asList 的固定长度

Arrays.asList() 是个高频踩坑点。它返回的并不是 java.util.ArrayList,而是一个内部类 java.util.Arrays$ArrayList,直接持有你传入的那个数组的引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.util.*;

public class AsListTrap {
public static void main(String[] args) {
String[] src = {"Java", "Go", "Rust"};
List<String> list = Arrays.asList(src);

// 1. 长度固定:底层是数组,无法扩容
try {
list.add("Python"); // 抛 UnsupportedOperationException
} catch (UnsupportedOperationException e) {
System.out.println("add 失败: " + e.getClass().getSimpleName());
}
try {
list.remove(0); // 同样抛 UnsupportedOperationException
} catch (UnsupportedOperationException e) {
System.out.println("remove 失败: " + e.getClass().getSimpleName());
}

// 2. set 是允许的:修改的是底层数组对应位置
list.set(0, "JavaSE");
System.out.println("原数组被改了: " + Arrays.toString(src)); // [JavaSE, Go, Rust]

// 3. 反向联动:改原数组,List 内容也跟着变
src[1] = "Golang";
System.out.println("list 跟着变: " + list); // [JavaSE, Golang, Rust]
}
}

源码层面看,Arrays$ArrayList 继承自 AbstractList,但没有重写 add 和 remove,所以直接继承到了 AbstractList 的默认实现——那两行代码就是 throw new UnsupportedOperationException()。而 set、get、size 它都重写了,直接操作持有的数组。

要得到一个真正可增删的 List,有三种写法:

1
2
3
4
5
6
7
8
String[] src = {"Java", "Go", "Rust"};
// 方案 A:包一层 ArrayList(最常用,会发生一次数组拷贝)
List<String> a = new ArrayList<>(Arrays.asList(src));
// 方案 B:Collections.addAll,一步到位且是深拷贝
List<String> b = new ArrayList<>();
Collections.addAll(b, src);
// 方案 C:JDK 8+ Stream(元素为对象类型时简洁)
List<String> c = Arrays.stream(src).collect(Collectors.toList());

另一个隐藏坑:基本类型数组会被当成一个元素。

1
2
3
4
5
6
int[] nums = {1, 2, 3};
// 注意这里泛型被推断为 int[],而不是 Integer
List<int[]> wrong = Arrays.asList(nums);
System.out.println(wrong.size()); // 1,不是 3
// 正确做法:用 Integer 数组或 Stream 装箱
List<Integer> right = Arrays.asList(1, 2, 3);

2.3 陷阱二:List.of / Set.of 的不可变集合(JDK 9+)

JDK 9 引入了 List.of、Set.of、Map.of 一族工厂方法,返回的是 不可变(immutable)集合,实现类是 java.util.ImmutableCollections 下的 ListN、List12、SetN、Set12。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import java.util.*;

public class ImmutableListDemo {
public static void main(String[] args) {
List<String> list = List.of("Java", "Go", "Rust");

try {
list.add("C++"); // UnsupportedOperationException
} catch (UnsupportedOperationException e) {
System.out.println("不可变集合不支持 add");
}
try {
list.set(0, "JavaSE"); // 连 set 也不支持
} catch (UnsupportedOperationException e) {
System.out.println("不可变集合不支持 set");
}

// 1. 不允许 null 元素:构造时直接 NPE
try {
List<String> withNull = List.of("a", null);
} catch (NullPointerException e) {
System.out.println("List.of 不允许 null");
}
// 2. 连 contains(null) 都是 NPE,而不是返回 false
try {
list.contains(null);
} catch (NullPointerException e) {
System.out.println("contains(null) 也抛 NPE");
}

// 3. Set.of 不允许重复元素,重复直接 IllegalArgumentException
try {
Set<String> dup = Set.of("a", "a");
} catch (IllegalArgumentException e) {
System.out.println("Set.of 不允许重复元素");
}
}
}

为什么连 contains(null) 都要抛 NullPointerException?源码里 ListN#indexOf 第一行就是 Objects.requireNonNull(o)。设计者的理由是:既然集合里不可能有 null,那么查询 null 就是一个逻辑错误,而不是一个应该返回 false 的合法查询。这比 HashSet 那种「允许存一个 null」的设计更严格、更不容易产生歧义。

List.of 相比 Collections.unmodifiableList() 还有两个优势:一是真正的不可变(unmodifiableXXX 只是包装了一层,底层集合被改了,包装层内容也会变);二是内存更省——List12 只有两个字段,ListN 只有一个数组,没有 modCount、size 之外的冗余字段。

2.4 陷阱三:批量操作的性能悬崖

addAll、removeAll、retainAll 看起来是 O(n),实际复杂度取决于另一侧集合的类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import java.util.*;

public class BulkOperationTrap {
public static void main(String[] args) {
List<Integer> big = new ArrayList<>();
for (int i = 0; i < 100_000; i++) big.add(i);

List<Integer> filter = new ArrayList<>();
for (int i = 0; i < 10_000; i++) filter.add(i);

// 慢:ArrayList 的 removeAll 对每个元素都遍历 filter,O(n*m) = 10 亿次比较
long t0 = System.nanoTime();
List<Integer> copy1 = new ArrayList<>(big);
copy1.removeAll(filter);
long t1 = System.nanoTime();
System.out.printf("List 版 removeAll: %d ms, 剩余 %d%n",
(t1 - t0) / 1_000_000, copy1.size());

// 快:先把过滤集合转成 HashSet,contains 变成 O(1),整体 O(n)
long t2 = System.nanoTime();
List<Integer> copy2 = new ArrayList<>(big);
copy2.removeAll(new HashSet<>(filter));
long t3 = System.nanoTime();
System.out.printf("Set 版 removeAll: %d ms, 剩余 %d%n",
(t3 - t2) / 1_000_000, copy2.size());
}
}

原因在 ArrayList#batchRemove:它对每个元素调用 c.contains(elementData[r])。如果 c 是 ArrayList,contains 是线性扫描,总复杂度 O(n×m);如果 c 是 HashSet,contains 是 O(1),总复杂度 O(n)。10 万 ×1 万 vs 10 万,差距可以到几百倍。

同理,retainAll(求交集)、containsAll 都应该先转 HashSet。

2.5 集合与数组的互转

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.util.*;
import java.util.stream.Collectors;

public class ArrayCollectionConvert {
public static void main(String[] args) {
// 数组 -> List
String[] arr = {"a", "b", "c"};
List<String> fixed = Arrays.asList(arr); // 定长视图
List<String> mutable = new ArrayList<>(Arrays.asList(arr)); // 可变副本

// List -> 数组
String[] back1 = mutable.toArray(new String[0]); // 推荐:JDK 内部会优化
String[] back2 = mutable.toArray(new String[mutable.size()]);

// 基本类型数组 -> List<Integer>:必须装箱
int[] ints = {1, 2, 3};
List<Integer> boxed = Arrays.stream(ints).boxed().collect(Collectors.toList());

// List<Integer> -> int[]
int[] unboxed = boxed.stream().mapToInt(Integer::intValue).toArray();
System.out.println(Arrays.toString(unboxed));
}
}

三、迭代器机制与 ConcurrentModificationException

3.1 Iterator 的三件套

Iterator 接口只有四个方法,核心是前三个:hasNext() 判断还有没有,next() 取出下一个并把游标后移,remove() 删除上一次 next() 返回的元素。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.*;

public class IteratorBasic {
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("Java", "Go", "Rust"));
Iterator<String> it = list.iterator();
while (it.hasNext()) {
String s = it.next(); // 每次 next 只能调用一次 remove
if (s.startsWith("G")) {
it.remove(); // 删除刚刚返回的 "Go"
}
}
System.out.println(list); // [Java, Rust]
}
}

注意 remove() 的两个限制:必须先调用过 next()(否则 lastRet == -1,抛 IllegalStateException),且不能连续调用两次(第二次时 lastRet 已被重置为 -1)。

3.2 foreach 到底编译成了什么

增强 for 循环是语法糖。如果遍历目标是数组,编译器把它翻译成普通的下标循环;如果遍历目标是 Iterable,编译器把它翻译成 Iterator 调用。

你可以用 javap -c 反编译验证,等价于下面这段代码:

1
2
3
4
5
6
// 源码:for (String s : list) { System.out.println(s); }
// 编译后等价于:
for (Iterator<String> i = list.iterator(); i.hasNext(); ) {
String s = i.next();
System.out.println(s);
}

这个翻译结果直接解释了下一节的异常:foreach 循环里没有显式的 Iterator 变量,你无法调用 iterator.remove(),只能调用 list.remove()——而后者正是异常来源。

3.3 modCount 与 expectedModCount:fail-fast 的源码真相

ArrayList 继承自 AbstractList,后者有一个字段:

1
protected transient int modCount = 0;   // 结构性修改次数(增删、扩容、clear 等)

结构性修改(structural modification) 指改变集合大小或内部结构的操作。add、remove、clear、ensureCapacity 会使 modCount++;而 set 只改值不改结构,modCount 不变。

再看 ArrayList#iterator() 返回的 Itr 内部类(JDK 8 源码节选):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private class Itr implements Iterator<E> {
int cursor; // 下一个要返回元素的下标
int lastRet = -1; // 上一次返回元素的下标,-1 表示无
int expectedModCount = modCount; // 创建迭代器时,把 modCount 快照下来

public boolean hasNext() {
return cursor != size;
}

@SuppressWarnings("unchecked")
public E next() {
checkForComodification(); // ① 每次 next 前先校验
int i = cursor;
if (i >= size) throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length) throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}

public void remove() {
if (lastRet < 0) throw new IllegalStateException();
checkForComodification(); // ② 删除前也校验
try {
ArrayList.this.remove(lastRet); // 调用外部类 remove,modCount++
cursor = lastRet; // 游标回退,因为元素左移了
lastRet = -1;
expectedModCount = modCount; // ③ 关键:同步更新快照!
} catch (IndexOutOfBoundsException ex) {
throw new ConcurrentModificationException();
}
}

final void checkForComodification() {
if (modCount != expectedModCount) // ④ 快照与当前值不一致 -> 抛异常
throw new ConcurrentModificationException();
}
}

把这四点串起来,异常成因就一目了然了:

  1. list.iterator() 时,expectedModCount 记录下当时的 modCount,比如 5。
  2. 循环中调用 list.remove(...),这是 ArrayList 自己的方法,它会 modCount++ 变成 6,但它不知道有迭代器存在,不会去更新任何 expectedModCount。
  3. 下一次循环执行 i.next()(foreach 里是隐式调用的),checkForComodification() 发现 5 != 6,抛出 ConcurrentModificationException。

而 iterator.remove() 之所以安全,全靠上面源码中标 ③ 的那一行:它调用完外部类的 remove 之后,主动把 modCount 的最新值同步给了 expectedModCount。这就是「Iterator.remove 的安全性来源」,没有半点魔法。

modCount 只是一个 int,做不了真正的同步,所以它只能「尽力检测」,这就是 fail-fast(快速失败)的含义:不保证一定能发现并发修改,但一旦发现就立刻抛异常,而不是带着脏数据继续跑出更诡异的结果。它不是并发保护机制。

一个反直觉的细节:如果删除的是倒数第二个元素,foreach 循环反而不会抛异常。因为删完后 size 减 1,下一次 hasNext() 判断 cursor != size 时恰好相等,循环直接结束,next() 根本没机会执行 checkForComodification()。这种「有时报错有时不报错」的特性,正是这类 bug 难以排查的原因。

3.4 fail-fast 与 fail-safe

维度 fail-fast fail-safe(弱一致)
代表类 ArrayList、HashMap、LinkedList 的普通迭代器 CopyOnWriteArrayList、ConcurrentHashMap
检测机制 modCount vs expectedModCount 无检测,遍历的是快照
并发修改时的行为 抛 ConcurrentModificationException 正常遍历,不抛异常
数据一致性 强一致(发现问题就报错) 弱一致:可能读到旧数据
底层实现 直接遍历底层数组 写时复制(修改时复制新数组,读仍走旧数组)
适用场景 单线程 / 明确加锁 读多写少的高并发场景
内存开销 低 高(每次写都复制整个数组)

CopyOnWriteArrayList 的 iterator() 返回的是 COWIterator,它持有创建时的数组快照,并且它的 remove() 直接抛 UnsupportedOperationException——因为在一个快照上删除毫无意义。

3.5 正确删除元素的四种姿势

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import java.util.*;
import java.util.stream.Collectors;

public class RemoveElementWays {
public static void main(String[] args) {
// 姿势一(最推荐):Iterator.remove
List<String> l1 = new ArrayList<>(Arrays.asList("Java", "Go", "Rust", "Go"));
for (Iterator<String> it = l1.iterator(); it.hasNext(); ) {
if ("Go".equals(it.next())) it.remove();
}
System.out.println("姿势一: " + l1);

// 姿势二:倒序 for-i 删除(正序会漏元素,倒序不会)
List<String> l2 = new ArrayList<>(Arrays.asList("Java", "Go", "Rust", "Go"));
for (int i = l2.size() - 1; i >= 0; i--) {
if ("Go".equals(l2.get(i))) l2.remove(i);
}
System.out.println("姿势二: " + l2);

// 姿势三:JDK 8+ removeIf(内部就是迭代器实现,最简洁)
List<String> l3 = new ArrayList<>(Arrays.asList("Java", "Go", "Rust", "Go"));
l3.removeIf("Go"::equals);
System.out.println("姿势三: " + l3);

// 姿势四:Stream 过滤生成新集合(不修改原集合)
List<String> l4 = new ArrayList<>(Arrays.asList("Java", "Go", "Rust", "Go"));
List<String> filtered = l4.stream()
.filter(s -> !"Go".equals(s))
.collect(Collectors.toList());
System.out.println("姿势四: " + filtered);
}
}

补充一个容易漏的:正序 for-i 删除为什么错?因为删掉下标 i 的元素后,原本下标 i+1 的元素左移到 i,而循环变量继续 i++,于是跳过了一个元素,而且 size 缩水还可能导致越界。

3.6 ListIterator:可以双向走的迭代器

ListIterator 继承自 Iterator,是 List 独有的能力(listIterator() 方法定义在 List 接口上)。它多了 previous()、hasPrevious()、add()、set()、nextIndex()。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import java.util.*;

public class ListIteratorDemo {
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("Java", "Go", "Rust"));
ListIterator<String> it = list.listIterator();

// 正向走,顺手替换元素(set 修改的是 next() 刚返回的位置)
while (it.hasNext()) {
String s = it.next();
it.set(s.toUpperCase());
}
System.out.println("转大写后: " + list); // [JAVA, GO, RUST]

// 反向遍历:这是普通 Iterator 做不到的
System.out.print("反向遍历:");
while (it.hasPrevious()) {
System.out.print(" " + it.previous());
}
System.out.println();

// add 是插入到「下一次 next() 会返回的元素之前」
ListIterator<String> it2 = list.listIterator();
it2.next(); // 游标停在 JAVA 之后
it2.add("Scala"); // 插到 JAVA 与 GO 之间
System.out.println("插入后: " + list);
}
}

ArrayList.ListItr 同样维护 expectedModCount,所以它的 add/set/remove 也是 fail-fast 的——但每次都会同步 expectedModCount = modCount,因此不会误报。

四、List 家族:从 ArrayList 源码到 LinkedList

4.1 ArrayList 的延迟初始化

ArrayList 内部就两个核心字段:

1
2
transient Object[] elementData;   // 真正存元素的缓冲区
private int size; // 元素个数(注意:不是数组长度)

JDK 8 引入了延迟初始化优化。用无参构造器 new ArrayList<>() 时,elementData 并不会立刻分配 10 个槽位,而是指向一个共享的空数组常量:

1
2
3
4
5
6
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};

public ArrayList() {
// JDK 8+:先给空数组,第一次 add 时才扩容到 10
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}

真正的「默认容量 10」在第一次 add 时才生效(ensureCapacityInternal 里 Math.max(DEFAULT_CAPACITY, minCapacity))。这样做的好处是:new ArrayList<>() 但从不使用的场景(比如一个空的结果集容器)就完全不会分配数组。JDK 8 之前是构造器里直接 this(10),白白浪费。

elementData 声明为 Object[] 而不是 E[],是因为 Java 泛型擦除后无法直接创建泛型数组;所有读取处都要 (E) 强转,这也是为什么 ArrayList 源码里到处是 @SuppressWarnings("unchecked")。

4.2 grow:1.5 倍扩容的完整逻辑

这是面试必考、也是本文最硬核的一段(JDK 8 源码):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public boolean add(E e) {
ensureCapacityInternal(size + 1); // ① 确保能装下 size+1 个
elementData[size++] = e; // ② 直接尾部写入,O(1) 摊还
return true;
}

private void ensureCapacityInternal(int minCapacity) {
// 若还是「延迟初始化」的空数组,则首次至少扩到 DEFAULT_CAPACITY(10)
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}

private void ensureExplicitCapacity(int minCapacity) {
modCount++; // 结构性修改计数
if (minCapacity - elementData.length > 0) // 容量不够才扩容
grow(minCapacity);
}

private void grow(int minCapacity) {
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // ③ 1.5 倍:右移一位 = 除以 2
if (newCapacity - minCapacity < 0) // 1.5 倍还不够(比如 addAll 大批量)
newCapacity = minCapacity; // 直接用所需最小容量
if (newCapacity - MAX_ARRAY_SIZE > 0) // 超过上限特殊处理
newCapacity = hugeCapacity(minCapacity);
elementData = Arrays.copyOf(elementData, newCapacity); // ④ 拷贝迁移
}

四个关键结论:

  1. 扩容倍数是 1.5 倍(old + (old >> 1)),不是 2 倍。取 1.5 是为了在「扩容次数」与「空间浪费」之间折中——2 倍会导致更严重的内存浪费,且老数组更容易触发 GC 大对象阈值。
  2. >> 1 是无符号右移除 2,比除法快,且对奇数向下取整(10 → 15,15 → 22)。
  3. Arrays.copyOf 内部就是 System.arraycopy,是一个 native 方法,走内存块级别的批量拷贝,比手写循环快得多。
  4. addAll 时 1.5 倍可能不够,此时直接采用 minCapacity,避免连续多次扩容。

扩容的完整流程是:新建更大的数组 → System.arraycopy 拷贝 → 替换 elementData 引用 → 老数组等待 GC。这意味着每次扩容都有 O(n) 的数组拷贝成本,但因为摊还到 n 次 add 上,均摊仍是 O(1)。

4.3 为什么 elementData 是 transient

elementData 被声明为 transient,意味着默认的序列化机制会跳过它。原因很简单:数组长度通常大于 size,直接序列化会把那些 null 槽位也写进流里,白白浪费空间。

ArrayList 因此自定义了 writeObject / readObject:

1
2
3
4
5
6
7
8
9
10
11
private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException {
int expectedModCount = modCount; // 先快照,防止序列化过程中被改
s.defaultWriteObject(); // 先写 size 等非 transient 字段
s.writeInt(size); // 显式写元素个数
for (int i = 0; i < size; i++) { // 只写真实存在的元素
s.writeObject(elementData[i]);
}
if (modCount != expectedModCount) { // 序列化期间被改了 -> 同样 fail-fast
throw new ConcurrentModificationException();
}
}

注意 s.writeInt(size) 这行:size 不是 transient,defaultWriteObject() 已经写过一次了,这里又写一遍是为了兼容早期版本(JDK 1.2 的 ArrayList 没有 size 字段参与默认序列化)。反序列化时 readObject 会按 size 重新分配刚好够用的数组——反序列化后的 ArrayList 容量等于 size,没有冗余空间。

4.4 随机访问与中间插入的代价

操作 ArrayList 原因分析
get(i) / set(i, e) O(1) 数组连续内存,直接 elementData[i] 寻址
尾部 add(e) O(1) 摊还 除非触发扩容
指定位置 add(i, e) O(n) 需要 System.arraycopy 把 i 之后的元素整体右移
指定位置 remove(i) O(n) 同样需要整体左移
contains(e) O(n) 线性扫描调用 equals
内存开销 低(数组 + 少量冗余容量) —

中间插入的那次移动,源码就是一行:

1
2
3
4
5
6
7
8
public void add(int index, E element) {
rangeCheckForAdd(index);
ensureCapacityInternal(size + 1);
// 把 index 及其后的元素整体右移一位,给新元素腾位置
System.arraycopy(elementData, index, elementData, index + 1, size - index);
elementData[index] = element;
size++;
}

4.5 ensureCapacity:可观测的性能优化

如果你事先知道要往里塞 100 万条数据,直接 new ArrayList<>() 会经历大约 40 次扩容(10 → 15 → 22 → …),每次都是一次数组拷贝。提前一次性分配可以完全消除这些拷贝:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.*;

public class EnsureCapacityBench {
public static void main(String[] args) {
int n = 2_000_000;

long t0 = System.currentTimeMillis();
List<Integer> a = new ArrayList<>();
for (int i = 0; i < n; i++) a.add(i);
long t1 = System.currentTimeMillis();

long t2 = System.currentTimeMillis();
List<Integer> b = new ArrayList<>();
b.ensureCapacity(n); // 或者 new ArrayList<>(n)
for (int i = 0; i < n; i++) b.add(i);
long t3 = System.currentTimeMillis();

System.out.printf("默认: %d ms, 预分配: %d ms%n", t1 - t0, t3 - t2);
// 用完后如果不再新增,可以缩容释放冗余空间
((ArrayList<Integer>) b).trimToSize();
}
}

ensureCapacity(int) 是 ArrayList 独有的(不在 List 接口上),new ArrayList<>(initialCapacity) 是更通用的写法。trimToSize() 则把容量压缩到 size,适合「构建完之后只读」的场景。

4.6 LinkedList:双向链表与 Deque 身份

LinkedList 内部是标准的双向链表,节点定义如下:

1
2
3
4
5
6
7
8
9
10
11
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element; this.next = next; this.prev = prev;
}
}
transient int size = 0;
transient Node<E> first; // 头指针
transient Node<E> last; // 尾指针

它的 get(int index) 是个典型的反例:

1
2
3
4
5
6
7
8
9
10
11
12
Node<E> node(int index) {
// 折半查找:前半段从头遍历,后半段从尾遍历
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++) x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--) x = x.prev;
return x;
}
}

虽然有「折半」优化,但复杂度仍是 O(n),而且链表节点的内存不连续,缓存局部性极差——每访问一个节点都是一次几乎必然 cache miss 的指针跳转。这才是 LinkedList 随机访问慢的根本原因,比时间复杂度数字本身影响更大。

LinkedList 真正的优势在于:已知节点位置时的插入删除是 O(1)(只改指针),以及它同时实现了 Deque,可以作为栈、队列、双端队列使用。

4.7 Vector 与 Stack:两个不该再用的遗留类

  • Vector:JDK 1.0 就有,所有方法都加 synchronized,扩容是 2 倍(可通过 capacityIncrement 自定义)。它的同步是方法级粗粒度锁,在绝大多数单线程场景下纯属浪费,多线程场景下又不如 Collections.synchronizedList 或 CopyOnWriteArrayList 灵活。
  • Stack:继承自 Vector,是「用继承表达复用」的坏例子——栈只需要 push/pop/peek,但因为继承了 Vector,它顺手获得了 add(int, E)、remove(int)、get(int) 这些破坏栈语义的方法。

官方给出的替代方案非常明确:用 Deque 当栈(ArrayDeque)。

1
2
3
4
5
6
// 不推荐
Stack<String> bad = new Stack<>();
// 推荐:官方 Javadoc 明确建议
Deque<String> good = new ArrayDeque<>();
good.push("a"); good.push("b");
System.out.println(good.pop()); // b

需要线程安全时:

1
2
3
4
5
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
// 注意:迭代时仍必须手动加锁
synchronized (syncList) {
for (String s : syncList) System.out.println(s);
}

4.8 ArrayList vs LinkedList vs Vector 选型表

维度 ArrayList LinkedList Vector
底层结构 动态数组 双向链表 动态数组
默认初始容量 10(延迟初始化) 无(空链表) 10(立即分配)
扩容策略 1.5 倍 无需扩容 2 倍(可设 capacityIncrement)
随机访问 get(i) O(1) O(n) O(1)
头部插入 O(n) O(1) O(n)
尾部插入 O(1) 摊还 O(1) O(1) 摊还
内存开销 低(约 4 字节/引用 + 冗余容量) 高(每元素约 24 字节节点开销) 同 ArrayList
缓存友好性 极好(连续内存) 差(指针跳转) 极好
线程安全 否 否 是(方法级 synchronized)
迭代删除 支持 Iterator.remove 支持 支持(Enumeration 另有实现)
推荐度 ★★★★★ ★★★(仅当频繁头尾增删/当 Deque) ★(遗留)

实测参考(JDK 17,100 万元素,仅看相对量级,绝对值随硬件变化):

场景 ArrayList LinkedList
顺序遍历(for-i) 约 3 ms 约 6 ms
顺序遍历(foreach) 约 5 ms 约 7 ms
尾部追加 100 万次 约 25 ms(含扩容) 约 60 ms
头部插入 10 万次 约 1200 ms(O(n²) 移动) 约 5 ms
随机 get 10 万次 约 2 ms 约 9000 ms
内存占用(100 万 Integer) 约 20 MB 约 40 MB

结论非常清晰:除非你需要频繁在头部/中间(已知节点)增删,或者需要把它当 Deque 用,否则一律选 ArrayList。LinkedList 在实际工程中胜出的场景远比教科书描述的要少。

4.9 subList 的视图陷阱

subList(from, to) 返回的不是新集合,而是原集合的一个视图(view),内部实现类 SubList 持有父 ArrayList 的引用,并把 expectedModCount 绑定到父的 modCount。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import java.util.*;

public class SubListTrap {
public static void main(String[] args) {
List<String> list = new ArrayList<>(Arrays.asList("a", "b", "c", "d", "e"));
List<String> sub = list.subList(1, 3); // [b, c]
System.out.println("sub = " + sub);

// 1. 改视图 = 改原集合
sub.set(0, "B");
System.out.println("原集合被改: " + list); // [a, B, c, d, e]

// 2. 反向也成立:改原集合后,视图的任何操作都会 CME
list.add("f");
try {
System.out.println(sub); // 这里就会抛 ConcurrentModificationException
} catch (ConcurrentModificationException e) {
System.out.println("父集合被结构性修改后,子视图失效");
}
}
}

第二条尤其危险,因为异常可能发生在离 add 很远的地方(比如某个方法返回了 subList 结果,调用方早忘了它是视图)。安全做法:需要独立子列表时,显式包一层 new ArrayList<>(list.subList(1, 3))。

另外,subList 的一个妙用是区间删除:list.subList(1, 3).clear(); 会精确删除原集合下标 [1,3) 的元素,比循环 remove 高效得多(只需一次 arraycopy)。

List 选型的三句话口诀:默认 ArrayList;已知规模就 new ArrayList<>(n) 预分配;要当栈或双端队列用 ArrayDeque,别用 Stack 和 Vector。

五、Set 家族:HashMap 的马甲与排序规则

5.1 HashSet 的底层就是一个 HashMap

打开 HashSet 源码,全文只有三百多行,核心字段就两个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable {
private transient HashMap<E, Object> map; // 真正干活的
private static final Object PRESENT = new Object(); // 所有 value 共用的占位对象

public HashSet() {
map = new HashMap<>(); // 默认容量 16,负载因子 0.75
}
public boolean add(E e) {
return map.put(e, PRESENT) == null; // put 返回 null 说明 key 原来不存在
}
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
public boolean contains(Object o) {
return map.containsKey(o);
}
public Iterator<E> iterator() {
return map.keySet().iterator(); // 直接复用 keySet 的迭代器
}
}

三个要点:

  1. PRESENT 是一个静态常量,所有元素共享同一个 value 对象,而不是每个元素 new 一个 Object。这是纯粹的内存优化——一百万个元素也只占用一个 Object 实例。
  2. add 的返回值靠 map.put 的返回值判断:HashMap.put 在 key 已存在时返回旧 value(PRESENT),不存在时返回 null。所以 == null 就等价于「这次真的加入了新元素」。
  3. 去重能力完全来自 HashMap 的 key 唯一性,也就是 hashCode + equals。

HashSet 的无参构造器创建的 HashMap 默认容量 16、负载因子 0.75,意味着第 13 个元素加入时触发扩容到 32。如果你知道大概规模,用 new HashSet<>(expectedSize / 0.75f + 1) 可以避免中途扩容(Guava 的 Maps.newHashMapWithExpectedSize 就是这个公式)。

5.2 TreeSet:TreeMap 的包装与两种排序规则

TreeSet 底层是 NavigableMap(实际是 TreeMap),元素作为 key,value 同样是 PRESENT。它的特殊能力是有序,依赖两种排序规则之一:

  • 自然排序(Comparable):元素类实现 Comparable<T> 接口,重写 compareTo。
  • 定制排序(Comparator):构造 TreeSet 时传入一个 Comparator。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.util.*;

public class TreeSetOrdering {
static class Student implements Comparable<Student> {
String name;
int score;
Student(String name, int score) { this.name = name; this.score = score; }

// 自然排序:分数降序,分数相同按姓名升序
@Override
public int compareTo(Student o) {
int r = Integer.compare(o.score, this.score); // 降序:反过来比
return r != 0 ? r : this.name.compareTo(o.name);
}
@Override
public String toString() { return name + "(" + score + ")"; }
}

public static void main(String[] args) {
// 方式一:自然排序(Comparable)
Set<Student> byComparable = new TreeSet<>();
byComparable.add(new Student("Alice", 90));
byComparable.add(new Student("Bob", 95));
byComparable.add(new Student("Cindy", 90));
System.out.println("自然排序: " + byComparable);
// 输出 [Bob(95), Alice(90), Cindy(90)]:分数降序,同分按名字

// 方式二:定制排序(Comparator),按名字长度升序
Set<Student> byComparator = new TreeSet<>(
Comparator.comparingInt((Student s) -> s.name.length())
.thenComparing(s -> s.name));
byComparator.addAll(byComparable);
System.out.println("定制排序: " + byComparator);
}
}

TreeSet 判断「重复」用的是 compareTo 返回 0,而不是 equals。这是一个极其重要的区别:如果你的 compareTo 与 equals 逻辑不一致(比如 compareTo 只比分数,equals 比姓名+分数),那么两个 equals 不相等但 compareTo 为 0 的对象,在 TreeSet 里会被当成同一个元素而丢掉一个。这违反了 Set 接口的通用契约,属于典型的隐藏 bug。

另外 TreeSet 不允许 null 元素(compareTo 时会 NPE),而 HashSet 允许存一个 null。

5.3 LinkedHashSet:用双向链表记住插入顺序

LinkedHashSet 继承自 HashSet,但它的构造器调用的是 HashSet 那个包私有的特殊构造器:

1
2
3
4
5
6
7
8
9
// HashSet 中专门为 LinkedHashSet 预留的构造器
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}

// LinkedHashSet
public LinkedHashSet() {
super(16, .75f, true); // 第三个参数只是用来区分重载,无实际意义
}

也就是说,LinkedHashSet 的全部魔法就是把底层容器从 HashMap 换成 LinkedHashMap。而 LinkedHashMap 在每个 Entry 上额外维护了 before/after 两个指针,把所有节点串成一条双向链表,从而在哈希表之外额外记录了插入顺序(或访问顺序)。

代价是:每个节点多两个引用(约 8 字节),以及插入时多几次指针操作。收益是迭代顺序可预测——这在需要「去重同时保持原顺序」的场景里无可替代。

5.4 三种 Set 的选型对比

维度 HashSet LinkedHashSet TreeSet
底层实现 HashMap(数组+链表/红黑树) LinkedHashMap(+ 双向链表) TreeMap(红黑树)
迭代顺序 无序(取决于 hash) 插入顺序 排序顺序(自然序或定制序)
时间复杂度 add/remove/contains 均 O(1) 平均 同 HashSet,略慢 均 O(log n)
null 元素 允许 1 个 允许 1 个 不允许(NPE)
判重依据 hashCode + equals hashCode + equals compareTo / Comparator 返回 0
元素要求 正确实现 hashCode/equals 同 HashSet 实现 Comparable 或提供 Comparator
额外内存 低 每节点约 +8 字节 每节点约 +16 字节(树节点指针)
典型场景 纯去重、快速查找 去重且保序(如日志去重) 需要排序/范围查询(如排行榜)

TreeSet 还额外实现了 NavigableSet,提供 lower/floor/ceiling/higher 这些「找最接近元素」的方法,这是 HashSet 完全做不到的:

1
2
3
4
5
6
TreeSet<Integer> ts = new TreeSet<>(Arrays.asList(10, 20, 30, 40));
System.out.println(ts.lower(25)); // 20:小于 25 的最大值
System.out.println(ts.floor(30)); // 30:小于等于 30 的最大值
System.out.println(ts.ceiling(25)); // 30:大于等于 25 的最小值
System.out.println(ts.higher(40)); // null
System.out.println(ts.subSet(10, false, 40, true)); // (10, 40] = [20, 30, 40]

5.5 去重的正确姿势:equals 与 hashCode 必须成对重写

放进 HashSet 的对象,如果不重写 hashCode/equals,默认走 Object 的实现(比较地址),去重会完全失效。规则如下:

  1. 重写 equals 必须重写 hashCode,反之亦然。
  2. 相等的对象的 hashCode 必须相等;hashCode 相等的对象 equals 不一定相等(允许哈希冲突)。
  3. equals 必须满足自反、对称、传递、一致。
  4. 用于计算 equals/hashCode 的字段,在对象放入 HashSet 之后不要再修改——否则 hashCode 变了,contains 和 remove 都会找不到它(变成「幽灵元素」)。

第 4 条是最难排查的一类 bug:对象放进去时 hash 是 100,改了字段后 hash 变成 200,set.remove(obj) 会去 200 号桶找,而对象还躺在 100 号桶里,永远删不掉,直到 GC 前一直占着内存。

5.6 实战:日志去重统计

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import java.util.*;

public class DedupDemo {
static class User {
private final String id;
private final String name;

User(String id, String name) { this.id = id; this.name = name; }

// 约定:id 相同即视为同一人
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User u = (User) o;
return Objects.equals(id, u.id);
}

@Override
public int hashCode() {
// Objects.hash 会把实参装进数组,热点路径上不如手写 31 法
return id != null ? id.hashCode() : 0;
}

@Override
public String toString() { return id + ":" + name; }
}

public static void main(String[] args) {
List<User> raw = Arrays.asList(
new User("1", "张三"), new User("2", "李四"),
new User("1", "张三丰"), new User("3", "王五"),
new User("2", "李四光"));

// 1. 去重但保持首次出现顺序
Set<User> ordered = new LinkedHashSet<>(raw);
System.out.println("保持顺序去重: " + ordered);

// 2. 去重且按 id 排序
Set<User> sorted = new TreeSet<>(Comparator.comparing(u -> u.id));
sorted.addAll(raw);
System.out.println("排序去重: " + sorted);

// 3. 统计每个 id 出现次数(Map 思路,下一篇展开)
Map<String, Integer> freq = new HashMap<>();
for (User u : raw) freq.merge(u.id, 1, Integer::sum);
System.out.println("出现次数: " + freq);
}
}

六、Queue 与 Deque:两套 API 与堆的应用

6.1 Queue 的两套 API

Queue 为每个操作都提供了两个版本,区别只在于失败时的表现:

操作 抛异常版 返回特殊值版 失败时的表现
插入(队尾) add(e) offer(e) add 抛 IllegalStateException(容量受限队列满时);offer 返回 false
移除(队头) remove() poll() remove 抛 NoSuchElementException;poll 返回 null
检查(队头) element() peek() element 抛 NoSuchElementException;peek 返回 null

设计两套 API 是为了适配「容量受限」的队列(如 ArrayBlockingQueue):这类队列满时插入是正常业务情况,不该抛异常,所以用 offer。而 add 走的是 Collection 的通用契约,用异常表达失败。

日常编码建议:优先使用 offer/poll/peek,语义更温和,不会打断正常流程。

6.2 Deque:既能当栈又能当队列

Deque(double-ended queue,读作 “deck”)在两端都提供了完整的进出操作,于是它可以同时表达两种数据结构:

数据结构 操作 Deque 方法 对应的遗留类方法
栈(LIFO) 入栈 push(e) = addFirst(e) Stack.push
栈 出栈 pop() = removeFirst() Stack.pop
栈 查看栈顶 peek() = peekFirst() Stack.peek
队列(FIFO) 入队 addLast(e) / offerLast(e) Queue.add
队列 出队 removeFirst() / pollFirst() Queue.remove
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.*;

public class DequeAsStackAndQueue {
public static void main(String[] args) {
// 当栈用
Deque<String> stack = new ArrayDeque<>();
stack.push("A"); stack.push("B"); stack.push("C");
System.out.println("栈: " + stack.peek() + " -> " + stack.pop()); // C -> C

// 当队列用
Deque<String> queue = new ArrayDeque<>();
queue.offerLast("A"); queue.offerLast("B"); queue.offerLast("C");
System.out.println("队列: " + queue.peekFirst() + " -> " + queue.pollFirst()); // A -> A
}
}

6.3 ArrayDeque 的环形数组

ArrayDeque 内部是一个循环数组(circular buffer):

1
2
3
4
transient Object[] elements;   // 长度恒为 2 的幂
transient int head; // 队头元素下标
transient int tail; // 队尾元素「下一个待插入」位置
private static final int MIN_INITIAL_CAPACITY = 8;

判断空与满的方式很巧妙:head == tail 表示空;(tail + 1) & (elements.length - 1) == head 表示满。因为长度是 2 的幂,取模运算 x % length 可以直接写成位运算 x & (length - 1),这也是 HashMap 用的同一套技巧。

扩容时(JDK 8 的 doubleCapacity)容量翻倍,并把两段数据按顺序重新拼接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private void doubleCapacity() {
assert head == tail; // 只在「满」时调用
int p = head;
int n = elements.length;
int r = n - p; // head 右侧(含 head)到数组末尾的元素个数
int newCapacity = n << 1; // 翻倍
if (newCapacity < 0) throw new IllegalStateException("Sorry, deque too big");
Object[] a = new Object[newCapacity];
// 分两段拷贝:先拷 head 到末尾的部分,再拷开头到 tail 的部分
System.arraycopy(elements, p, a, 0, r);
System.arraycopy(elements, 0, a, r, p);
elements = a;
head = 0;
tail = n;
}

6.4 栈的性能对比:ArrayDeque vs LinkedList vs Stack

实现 push/pop 1000 万次耗时(相对) 内存占用(相对) 线程安全 是否允许 null
ArrayDeque 1.0×(最快) 最低(连续数组) 否 不允许(NPE)
LinkedList 约 1.8× 高(每节点 24 字节) 否 允许
Stack 约 3.5×(synchronized 开销) 中 是 允许

ArrayDeque 胜出的原因除了连续内存的缓存友好性,还有一点:它不需要像 LinkedList 那样每次 push 都 new 一个 Node 对象,也就不会产生大量短命对象触发 GC。

6.5 PriorityQueue:二叉堆与上浮下沉

PriorityQueue 是优先级队列,出队顺序由优先级(自然序或 Comparator)决定,而不是插入顺序。它的底层是一个二叉小顶堆,用数组表示:

1
2
3
4
transient Object[] queue;                  // 堆数组
private int size = 0;
private final Comparator<? super E> comparator;
private static final int DEFAULT_INITIAL_CAPACITY = 11;

对于下标 k 的节点:父节点是 (k - 1) >>> 1,左孩子是 2k + 1,右孩子是 2k + 2。

插入(offer)走 siftUp 上浮:新元素先放数组末尾,然后不断与父节点比较,比父节点小就交换,直到满足堆性质。

1
2
3
4
5
6
7
8
9
10
11
12
private static <T> void siftUpComparable(int k, T x, Object[] es) {
Comparable<? super T> key = (Comparable<? super T>) x;
while (k > 0) {
int parent = (k - 1) >>> 1; // 父节点下标
Object e = es[parent];
if (key.compareTo((T) e) >= 0) // 已经 >= 父节点,堆性质满足,停
break;
es[k] = e; // 父节点往下沉(不是真交换,只单向赋值)
k = parent;
}
es[k] = key; // 最后一次性放入,省掉多次交换
}

出队(poll)走 siftDown 下沉:取出堆顶(下标 0,最小值),把末尾元素挪到堆顶,然后不断与较小的孩子比较并下沉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private static <T> void siftDownComparable(int k, T x, Object[] es, int n) {
Comparable<? super T> key = (Comparable<? super T>) x;
int half = n >>> 1; // 只需要下沉到最后一个非叶子节点
while (k < half) {
int child = (k << 1) + 1; // 左孩子
Object c = es[child];
int right = child + 1;
// 选出左右孩子中较小的那个
if (right < n && ((Comparable<? super T>) c).compareTo((T) es[right]) > 0)
c = es[child = right];
if (key.compareTo((T) c) <= 0) // 已经比孩子都小,停
break;
es[k] = c; // 孩子上浮
k = child;
}
es[k] = key;
}

注意这两段源码里的优化技巧:不是每次比较都做真正的交换,而是单向赋值 + 最后一次性放入,把交换次数从 O(log n) 降为 1 次赋值。这个技巧叫「hole(空洞)法」。

复杂度:offer 与 poll 都是 O(log n),peek 是 O(1),建堆 new PriorityQueue<>(collection) 是 O(n)(不是 n×log n,用的是自底向上的 heapify)。

另外要记住:PriorityQueue 的迭代顺序不保证有序,只有反复 poll 出来的序列才是有序的。

6.6 实战:用 PriorityQueue 求 TopK

求「N 个数里最大的 K 个」,经典解法是维护一个大小为 K 的小顶堆:遍历所有元素,堆不满就加入,堆满了就比较堆顶——比堆顶大就替换堆顶(堆顶是堆中最小的,也就是「K 个候选里最弱的那个」)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import java.util.*;

public class TopKDemo {
/** 返回数组中最大的 k 个数(结果按升序排列) */
public static List<Integer> topK(int[] nums, int k) {
if (k <= 0) return List.of();
// 小顶堆:堆顶是当前候选中最小的一个,一旦来了更大的就把它踢掉
PriorityQueue<Integer> minHeap = new PriorityQueue<>(k);
for (int n : nums) {
if (minHeap.size() < k) {
minHeap.offer(n); // 先填满 k 个
} else if (n > minHeap.peek()) {
minHeap.poll(); // 淘汰当前最小的候选
minHeap.offer(n);
}
}
List<Integer> result = new ArrayList<>(minHeap);
Collections.sort(result); // 堆内无序,排序后输出更友好
return result;
}

public static void main(String[] args) {
int[] nums = {3, 1, 5, 12, 2, 11, 7, 9, 4, 8, 10, 6};
System.out.println("Top5: " + topK(nums, 5)); // [8, 9, 10, 11, 12]

// 求最小的 k 个:反过来用大顶堆
PriorityQueue<Integer> maxHeap =
new PriorityQueue<>((a, b) -> Integer.compare(b, a));
for (int n : nums) {
if (maxHeap.size() < 4) maxHeap.offer(n);
else if (n < maxHeap.peek()) { maxHeap.poll(); maxHeap.offer(n); }
}
System.out.println("Bottom4: " + new ArrayList<>(maxHeap)); // [4, 3, 2, 1]
}
}

复杂度:时间 O(n log k)(k 远小于 n 时远优于排序的 O(n log n)),空间 O(k)。这个思路在海量数据 TopK 场景下是标准解法。

6.7 阻塞队列:留给 JUC 篇

BlockingQueue 在 Queue 基础上增加了两个关键能力:队列为空时取元素的线程阻塞等待,队列满时放元素的线程阻塞等待。它的方法分为四类:抛异常(add/remove)、返回特殊值(offer/poll)、一直阻塞(put/take)、超时退出(offer(e, timeout, unit)/poll(timeout, unit))。

常见实现有 ArrayBlockingQueue(有界数组)、LinkedBlockingQueue(可选有界链表)、PriorityBlockingQueue、SynchronousQueue(不存储元素,直接移交)、DelayQueue(延迟队列)。它们是线程池与生产者-消费者模型的基石,细节放到本系列的 JUC 篇展开。

七、集合工具类、排序与性能

7.1 Collections 常用方法速查

方法 作用 备注
sort(list[, cmp]) 排序 JDK 8 起委托给 list.sort()
binarySearch(list, key) 二分查找 必须先排序,未找到返回 -(插入点) - 1
reverse(list) 反转 O(n)
shuffle(list[, rnd]) 随机打乱 Fisher-Yates,用于洗牌、抽奖
swap(list, i, j) 交换两个元素 ArrayList 上 O(1),LinkedList 上 O(n)
fill(list, obj) 全部替换 O(n)
copy(dest, src) 复制 dest 的 size 必须 >= src
min / max(list[, cmp]) 极值 O(n)
frequency(c, o) 统计出现次数 O(n)
disjoint(c1, c2) 判断无交集 内部会把较小的一方转 HashSet
unmodifiableList/Set/Map 只读包装 是视图,底层改动会反映出来
synchronizedList/Set/Map 同步包装 迭代时仍需手动 synchronized
emptyList() / singletonList(e) 空集合 / 单元素集合 不可变,省内存
nCopies(n, obj) n 个相同元素的不可变 List 只存一份引用

7.2 Comparator 的链式比较(JDK 8+)

Comparator 在 JDK 8 之后变成了一个函数式接口,并新增了一组默认方法,可以像搭积木一样组合排序规则:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import java.util.*;

public class ComparatorChaining {
static class Order {
String customer;
int amount;
LocalDate date;
Order(String c, int a, LocalDate d) { customer = c; amount = a; date = d; }
@Override public String toString() {
return customer + " " + amount + " " + date;
}
}

public static void main(String[] args) {
List<Order> orders = new ArrayList<>(Arrays.asList(
new Order("Bob", 300, LocalDate.of(2026, 1, 3)),
new Order("Alice", 200, LocalDate.of(2026, 1, 2)),
new Order("Bob", 100, LocalDate.of(2026, 1, 1)),
new Order(null, 500, LocalDate.of(2026, 1, 5))));

// 链式比较:金额降序 -> 日期升序 -> 客户名升序(null 排最前)
Comparator<Order> cmp = Comparator
.comparing(Order::getAmount, Comparator.reverseOrder())
.thenComparing(Order::getDate)
.thenComparing(Order::getCustomer, Comparator.nullsFirst(String::compareTo));

orders.sort(cmp);
orders.forEach(System.out::println);
}
}

几个易错点:

  • reversed() 是反转整个比较器,而 Comparator.reverseOrder() 通常配合 comparing(keyExtractor, keyComparator) 只反转某一个键。
  • thenComparing 只有前一个键相等时才会生效。
  • nullsFirst/nullsLast 必须显式声明,否则键为 null 时直接 NPE。
  • comparingInt/comparingLong/comparingDouble 避免了 Integer 装箱,热路径上更快。

7.3 Collections.sort、List.sort 与 TimSort

JDK 8 之后 Collections.sort(list, c) 的实现就一行:list.sort(c)。所以两者功能等价,只是 List.sort 是实例方法、更自然。真正的差异在实现侧:

1
2
3
4
5
6
7
8
9
10
// java.util.List 的默认实现(JDK 8+)
default void sort(Comparator<? super E> c) {
Object[] a = this.toArray(); // ① 先转成数组,避免链表 O(n²) 访问
Arrays.sort(a, (Comparator) c); // ② 数组排序(对象走 TimSort)
ListIterator<E> i = this.listIterator(); // ③ 通过 ListIterator 写回
for (Object e : a) {
i.next();
i.set((E) e);
}
}

这个设计顺便解决了老版本 Collections.sort 在 LinkedList 上性能灾难的问题(JDK 7 的 Collections.sort 也是先转数组,但 List.sort 把它做成了标准能力)。ArrayList 和 LinkedList 都重写了 sort 以走更快的路径。

Arrays.sort(Object[]) 使用的是 TimSort——一种源自 Python 的稳定归并排序变体。要点:

  • 稳定排序:相等元素的相对顺序保持不变(这是它取代旧版归并排序的主要原因之一)。
  • 最坏 O(n log n),最好 O(n):输入已基本有序时接近线性。
  • 利用已有单调段(run):扫描出天然有序的片段,再按规则合并,MIN_MERGE 阈值为 32。
  • 若 Comparator 违反传递性或对称性,可能抛出 IllegalArgumentException: Comparison method violates its general contract!——这在 thenComparing 组合不当或用浮点差值比较时很常见。
  • 基本类型数组(如 int[])不走 TimSort,用的是双轴快排(Dual-Pivot Quicksort),不稳定但更快。

7.4 遍历方式的性能对比

遍历方式 ArrayList LinkedList 说明
for (int i = 0; i < size; i++) 最快 极慢(O(n²)) 只适合随机访问型的 List
增强 for(foreach) 快 快 通用最优,编译为 Iterator
显式 Iterator 快(与 foreach 等价) 快 需要边遍历边删除时用它
forEach 方法(list.forEach(...)) 快,可略优于 foreach 快 ArrayList 重写过,少了迭代器分配
并行 Stream(parallelStream) 大数据量才有收益 视场景 有拆分与合并开销,小集合反而更慢
ListIterator 快 快 需要双向遍历或修改元素时用

实测参考(JDK 17,100 万 Integer,单位毫秒,量级参考):

方式 ArrayList LinkedList
for-i 约 3 ms 极慢(不可用)
foreach 约 5 ms 约 7 ms
forEach 方法 约 4 ms 约 7 ms
顺序 Stream 约 10 ms 约 12 ms
并行 Stream(8 核) 约 6 ms 约 10 ms

结论:日常用 foreach 就够;需要删除用 Iterator 或 removeIf;只有对 ArrayList 且确实处于热点循环时,for-i 才有必要;不要为了遍历用 parallelStream,除非数据量上十万且单次处理耗时明显。

7.5 内存占用估算:一个 ArrayList<Integer> 到底多大

以 64 位 JVM、开启压缩指针(默认开启)为例:

  • 数组对象头:16 字节(Mark Word 8 + Klass 指针 4 + 数组长度 4,对齐后 16)。
  • 每个引用槽位:4 字节(压缩指针)。
  • 每个 Integer 对象:16 字节(对象头 12 + int value 4,对齐到 16)。

所以一个装了 100 万个 Integer 的 ArrayList:

组成部分 计算 大小
数组本体 16 + 4 × 容量(约 120 万,1.5 倍扩容后) 约 4.8 MB
100 万个 Integer 对象 16 × 100 万 约 16 MB
引用槽位(按 100 万计) 4 × 100 万 约 4 MB
合计 — 约 20 MB

换成 LinkedList:每个 Node 是 24 字节(对象头 16 + prev 4 + next 4 + item 4,对齐 24),加上 Integer 的 16 字节,每个元素 40 字节,100 万个就是 约 40 MB,是 ArrayList 的两倍。

如果需要极致省内存:

  1. 用 int[] 或第三方 primitive collection(如 fastutil、HPPC、Eclipse Collections)避免装箱,100 万个 int 只要 4 MB。
  2. 用 Integer.valueOf 的缓存或 IntBox 复用小整数(-128~127 有缓存)。
  3. 构建完成后调用 trimToSize() 释放冗余容量。

八、高频面试题

  1. ArrayList 的扩容机制是怎样的?
    默认容量 10(JDK 8 起延迟初始化,第一次 add 时才分配);容量不足时 grow 扩容为 oldCapacity + (oldCapacity >> 1),即 1.5 倍;若 1.5 倍仍不够(addAll 场景)则直接取所需最小容量;通过 Arrays.copyOf(底层 System.arraycopy)迁移数据。可通过 ensureCapacity 预分配避免多次扩容。

  2. 为什么 foreach 循环里调用 list.remove() 会抛 ConcurrentModificationException,怎么解决?
    foreach 编译后调用的是 Iterator.next(),Iterator 创建时用 expectedModCount 快照了 modCount;list.remove() 只自增 modCount,不更新迭代器的 expectedModCount,下次 next() 的 checkForComodification() 检测到不一致就抛异常。解决:用 iterator.remove()(它会同步 expectedModCount)、removeIf、倒序 for-i,或用 Stream 生成新集合。

  3. ArrayList 和 LinkedList 的区别?什么时候用 LinkedList?
    ArrayList 基于数组,随机访问 O(1)、中间插入 O(n)、内存省、缓存友好;LinkedList 基于双向链表,随机访问 O(n)、已知节点增删 O(1)、每元素多约 24 字节。只有在频繁头尾增删、或需要 Deque 语义时才考虑 LinkedList,其余一律 ArrayList。

  4. ArrayList 是线程安全的吗?怎么让它安全?
    不安全。方案:Collections.synchronizedList(new ArrayList<>())(迭代时需手动加锁)、CopyOnWriteArrayList(读多写少)、或用 Vector(不推荐)。注意 add 方法本身就不是原子的(检查容量与写入元素之间可被打断)。

  5. HashSet 是如何保证元素不可重复的?底层存的是什么?
    底层是 HashMap,元素作为 key,value 是共享的静态常量 PRESENT。add 时调用 map.put(e, PRESENT),返回 null 说明 key 不存在(加入成功),返回 PRESENT 说明已存在(加入失败)。判重依赖元素的 hashCode 与 equals。

  6. HashSet、TreeSet、LinkedHashSet 有什么区别?
    HashSet 无序、O(1)、允许一个 null;LinkedHashSet 在 HashSet 基础上用双向链表维护插入顺序,内存略高;TreeSet 基于 TreeMap(红黑树),有序、O(log n)、不允许 null,判重依据是 compareTo/Comparator 返回 0 而非 equals。

  7. 为什么重写 equals 必须重写 hashCode?
    这是 Object.hashCode 的通用契约:相等的对象必须有相等的 hashCode。如果只重写 equals,两个「逻辑相等」的对象会有不同的哈希值,被放进 HashMap/HashSet 的不同桶里,contains、get、remove 全部失效。

  8. Arrays.asList() 有什么坑?
    返回的是 Arrays$ArrayList(定长视图),不支持 add/remove(抛 UnsupportedOperationException),支持 set;与原数组双向联动;基本类型数组会被当成单个元素(int[] → List<int[]>)。需要可变集合时包一层 new ArrayList<>(...)。

  9. List.of 和 Arrays.asList 有什么区别?
    List.of(JDK 9+)返回真正不可变的集合,连 set 都不支持,且不允许 null 元素(构造与 contains(null) 都抛 NPE),Set.of 还不允许重复元素;Arrays.asList 只是定长,允许 set,允许 null。

  10. PriorityQueue 的实现原理?它是有序的吗?
    底层是二叉小顶堆(数组表示),offer 走 siftUp 上浮、poll 走 siftDown 下沉,都是 O(log n),peek O(1),建堆 O(n)。它的迭代顺序不保证有序,只有反复 poll 出的序列才有序。求 TopK 时维护容量 K 的小顶堆,复杂度 O(n log k)。

  11. fail-fast 和 fail-safe 的区别?
    fail-fast 用 modCount 与 expectedModCount 比对,一旦发现并发修改立即抛 ConcurrentModificationException(ArrayList、HashMap 的普通迭代器);fail-safe 遍历的是创建时的快照(如 CopyOnWriteArrayList 的 COWIterator),不抛异常但可能读到旧数据,代价是写时复制带来的内存与写性能开销。

  12. ArrayDeque 为什么比 LinkedList 更适合当栈?
    ArrayDeque 基于循环数组,内存连续、缓存友好、push 时不产生新对象,容量不足时翻倍并用两次 System.arraycopy 重组;LinkedList 每次插入都要 new 一个 Node(24 字节),GC 压力大、指针跳转 cache miss 多。且 Stack 继承 Vector 有方法级锁开销,官方 Javadoc 明确推荐用 Deque 替代。

  13. subList 返回的是新集合吗?
    不是,是原集合的视图。SubList 持有父 ArrayList 引用,对视图的 set 会写回原集合;一旦父集合发生结构性修改(add/remove/clear),视图的所有操作都会抛 ConcurrentModificationException。需要独立子列表时请用 new ArrayList<>(list.subList(a, b))。

  14. Queue 的 offer/poll/peek 与 add/remove/element 有什么区别?
    前者在失败时返回特殊值(false/null),后者抛异常(IllegalStateException/NoSuchElementException)。设计两套 API 是为了适配容量受限的队列——队列满/空在业务上是正常状态,不该用异常表达。

  15. Collections.sort 和 List.sort 有什么关系?排序算法是什么?
    JDK 8 起 Collections.sort(list, c) 直接委托给 list.sort(c),两者等价。List.sort 的默认实现先把集合转成数组,再调用 Arrays.sort,最后用 ListIterator 写回。对象数组走 TimSort(稳定、最坏 O(n log n)、基本有序时接近 O(n)、MIN_MERGE 为 32);基本类型数组走双轴快排(不稳定但更快)。