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

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 | Iterable<T> (一切可 foreach 的根) |
注意 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<>();,好处不只是「方便换实现」这么空泛:
- 换实现的成本趋近于零:把
ArrayList换成LinkedList,调用方代码一行不用改。 - 约束 API 面:接口只暴露契约方法,实现类的内部方法不会污染调用方。
- 便于包装增强:
Collections.unmodifiableList()、synchronizedList()返回的都是接口的另一种实现,调用方无感知。
框架里还藏着两个经典设计模式:
- 迭代器模式:
Iterable#iterator()把「如何遍历」从集合本身剥离出去。数组、HashSet、红黑树、LinkedList内部结构天差地别,但对使用者的遍历方式完全一致——这就是迭代器模式的价值:统一访问接口,隐藏内部结构。 - 适配器模式:
Arrays.asList()把数组适配成List;Collections.list(Enumeration)把老式Enumeration适配成ArrayList;下一篇要讲的HashMap.keySet()返回的KeySet是把Map适配成Set的视图。
二、Collection 通用操作与三个经典陷阱
2.1 通用操作速览
Collection 接口定义的方法不多,但涵盖了绝大多数日常操作:
1 | import java.util.*; |
这里有个值得记住的细节: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 | import java.util.*; |
源码层面看,Arrays$ArrayList 继承自 AbstractList,但没有重写 add 和 remove,所以直接继承到了 AbstractList 的默认实现——那两行代码就是 throw new UnsupportedOperationException()。而 set、get、size 它都重写了,直接操作持有的数组。
要得到一个真正可增删的 List,有三种写法:
1 | String[] src = {"Java", "Go", "Rust"}; |
另一个隐藏坑:基本类型数组会被当成一个元素。
1 | int[] nums = {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 | import java.util.*; |
为什么连 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 | import java.util.*; |
原因在 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 | import java.util.*; |
三、迭代器机制与 ConcurrentModificationException
3.1 Iterator 的三件套
Iterator 接口只有四个方法,核心是前三个:hasNext() 判断还有没有,next() 取出下一个并把游标后移,remove() 删除上一次 next() 返回的元素。
1 | import java.util.*; |
注意 remove() 的两个限制:必须先调用过 next()(否则 lastRet == -1,抛 IllegalStateException),且不能连续调用两次(第二次时 lastRet 已被重置为 -1)。
3.2 foreach 到底编译成了什么
增强 for 循环是语法糖。如果遍历目标是数组,编译器把它翻译成普通的下标循环;如果遍历目标是 Iterable,编译器把它翻译成 Iterator 调用。
你可以用 javap -c 反编译验证,等价于下面这段代码:
1 | // 源码:for (String s : list) { 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 | private class Itr implements Iterator<E> { |
把这四点串起来,异常成因就一目了然了:
list.iterator()时,expectedModCount记录下当时的modCount,比如 5。- 循环中调用
list.remove(...),这是ArrayList自己的方法,它会modCount++变成 6,但它不知道有迭代器存在,不会去更新任何expectedModCount。 - 下一次循环执行
i.next()(foreach 里是隐式调用的),checkForComodification()发现 5 != 6,抛出ConcurrentModificationException。
而 iterator.remove() 之所以安全,全靠上面源码中标 ③ 的那一行:它调用完外部类的 remove 之后,主动把 modCount 的最新值同步给了 expectedModCount。这就是「Iterator.remove 的安全性来源」,没有半点魔法。
一个反直觉的细节:如果删除的是倒数第二个元素,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 | import java.util.*; |
补充一个容易漏的:正序 for-i 删除为什么错?因为删掉下标 i 的元素后,原本下标 i+1 的元素左移到 i,而循环变量继续 i++,于是跳过了一个元素,而且 size 缩水还可能导致越界。
3.6 ListIterator:可以双向走的迭代器
ListIterator 继承自 Iterator,是 List 独有的能力(listIterator() 方法定义在 List 接口上)。它多了 previous()、hasPrevious()、add()、set()、nextIndex()。
1 | import java.util.*; |
ArrayList.ListItr 同样维护 expectedModCount,所以它的 add/set/remove 也是 fail-fast 的——但每次都会同步 expectedModCount = modCount,因此不会误报。
四、List 家族:从 ArrayList 源码到 LinkedList
4.1 ArrayList 的延迟初始化
ArrayList 内部就两个核心字段:
1 | transient Object[] elementData; // 真正存元素的缓冲区 |
JDK 8 引入了延迟初始化优化。用无参构造器 new ArrayList<>() 时,elementData 并不会立刻分配 10 个槽位,而是指向一个共享的空数组常量:
1 | private static final Object[] 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 | public boolean add(E e) { |
四个关键结论:
- 扩容倍数是 1.5 倍(
old + (old >> 1)),不是 2 倍。取 1.5 是为了在「扩容次数」与「空间浪费」之间折中——2 倍会导致更严重的内存浪费,且老数组更容易触发 GC 大对象阈值。 >> 1是无符号右移除 2,比除法快,且对奇数向下取整(10 → 15,15 → 22)。Arrays.copyOf内部就是System.arraycopy,是一个 native 方法,走内存块级别的批量拷贝,比手写循环快得多。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 | private void writeObject(java.io.ObjectOutputStream s) throws java.io.IOException { |
注意 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 | public void add(int index, E element) { |
4.5 ensureCapacity:可观测的性能优化
如果你事先知道要往里塞 100 万条数据,直接 new ArrayList<>() 会经历大约 40 次扩容(10 → 15 → 22 → …),每次都是一次数组拷贝。提前一次性分配可以完全消除这些拷贝:
1 | import java.util.*; |
ensureCapacity(int) 是 ArrayList 独有的(不在 List 接口上),new ArrayList<>(initialCapacity) 是更通用的写法。trimToSize() 则把容量压缩到 size,适合「构建完之后只读」的场景。
4.6 LinkedList:双向链表与 Deque 身份
LinkedList 内部是标准的双向链表,节点定义如下:
1 | private static class Node<E> { |
它的 get(int index) 是个典型的反例:
1 | Node<E> node(int index) { |
虽然有「折半」优化,但复杂度仍是 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 | // 不推荐 |
需要线程安全时:
1 | List<String> syncList = Collections.synchronizedList(new ArrayList<>()); |
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 | import java.util.*; |
第二条尤其危险,因为异常可能发生在离 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 | public class HashSet<E> extends AbstractSet<E> implements Set<E>, Cloneable, java.io.Serializable { |
三个要点:
PRESENT是一个静态常量,所有元素共享同一个 value 对象,而不是每个元素 new 一个Object。这是纯粹的内存优化——一百万个元素也只占用一个Object实例。add的返回值靠map.put的返回值判断:HashMap.put在 key 已存在时返回旧 value(PRESENT),不存在时返回null。所以== null就等价于「这次真的加入了新元素」。- 去重能力完全来自
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 | import java.util.*; |
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 | // HashSet 中专门为 LinkedHashSet 预留的构造器 |
也就是说,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 | TreeSet<Integer> ts = new TreeSet<>(Arrays.asList(10, 20, 30, 40)); |
5.5 去重的正确姿势:equals 与 hashCode 必须成对重写
放进 HashSet 的对象,如果不重写 hashCode/equals,默认走 Object 的实现(比较地址),去重会完全失效。规则如下:
- 重写
equals必须重写hashCode,反之亦然。 - 相等的对象的
hashCode必须相等;hashCode相等的对象equals不一定相等(允许哈希冲突)。 equals必须满足自反、对称、传递、一致。- 用于计算
equals/hashCode的字段,在对象放入HashSet之后不要再修改——否则hashCode变了,contains和remove都会找不到它(变成「幽灵元素」)。
5.6 实战:日志去重统计
1 | import java.util.*; |
六、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 | import java.util.*; |
6.3 ArrayDeque 的环形数组
ArrayDeque 内部是一个循环数组(circular buffer):
1 | transient Object[] elements; // 长度恒为 2 的幂 |
判断空与满的方式很巧妙:head == tail 表示空;(tail + 1) & (elements.length - 1) == head 表示满。因为长度是 2 的幂,取模运算 x % length 可以直接写成位运算 x & (length - 1),这也是 HashMap 用的同一套技巧。
扩容时(JDK 8 的 doubleCapacity)容量翻倍,并把两段数据按顺序重新拼接:
1 | private void doubleCapacity() { |
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 | transient Object[] queue; // 堆数组 |
对于下标 k 的节点:父节点是 (k - 1) >>> 1,左孩子是 2k + 1,右孩子是 2k + 2。
插入(offer)走 siftUp 上浮:新元素先放数组末尾,然后不断与父节点比较,比父节点小就交换,直到满足堆性质。
1 | private static <T> void siftUpComparable(int k, T x, Object[] es) { |
出队(poll)走 siftDown 下沉:取出堆顶(下标 0,最小值),把末尾元素挪到堆顶,然后不断与较小的孩子比较并下沉。
1 | private static <T> void siftDownComparable(int k, T x, Object[] es, int n) { |
注意这两段源码里的优化技巧:不是每次比较都做真正的交换,而是单向赋值 + 最后一次性放入,把交换次数从 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 | import java.util.*; |
复杂度:时间 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 | import java.util.*; |
几个易错点:
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 | // java.util.List 的默认实现(JDK 8+) |
这个设计顺便解决了老版本 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 value4,对齐到 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 的两倍。
如果需要极致省内存:
- 用
int[]或第三方 primitive collection(如 fastutil、HPPC、Eclipse Collections)避免装箱,100 万个int只要 4 MB。 - 用
Integer.valueOf的缓存或IntBox复用小整数(-128~127 有缓存)。 - 构建完成后调用
trimToSize()释放冗余容量。
八、高频面试题
ArrayList 的扩容机制是怎样的?
默认容量 10(JDK 8 起延迟初始化,第一次add时才分配);容量不足时grow扩容为oldCapacity + (oldCapacity >> 1),即 1.5 倍;若 1.5 倍仍不够(addAll场景)则直接取所需最小容量;通过Arrays.copyOf(底层System.arraycopy)迁移数据。可通过ensureCapacity预分配避免多次扩容。为什么 foreach 循环里调用 list.remove() 会抛 ConcurrentModificationException,怎么解决?
foreach 编译后调用的是Iterator.next(),Iterator创建时用expectedModCount快照了modCount;list.remove()只自增modCount,不更新迭代器的expectedModCount,下次next()的checkForComodification()检测到不一致就抛异常。解决:用iterator.remove()(它会同步expectedModCount)、removeIf、倒序 for-i,或用 Stream 生成新集合。ArrayList 和 LinkedList 的区别?什么时候用 LinkedList?
ArrayList 基于数组,随机访问 O(1)、中间插入 O(n)、内存省、缓存友好;LinkedList 基于双向链表,随机访问 O(n)、已知节点增删 O(1)、每元素多约 24 字节。只有在频繁头尾增删、或需要 Deque 语义时才考虑 LinkedList,其余一律 ArrayList。ArrayList 是线程安全的吗?怎么让它安全?
不安全。方案:Collections.synchronizedList(new ArrayList<>())(迭代时需手动加锁)、CopyOnWriteArrayList(读多写少)、或用Vector(不推荐)。注意add方法本身就不是原子的(检查容量与写入元素之间可被打断)。HashSet 是如何保证元素不可重复的?底层存的是什么?
底层是HashMap,元素作为 key,value 是共享的静态常量PRESENT。add时调用map.put(e, PRESENT),返回null说明 key 不存在(加入成功),返回PRESENT说明已存在(加入失败)。判重依赖元素的hashCode与equals。HashSet、TreeSet、LinkedHashSet 有什么区别?
HashSet 无序、O(1)、允许一个 null;LinkedHashSet 在 HashSet 基础上用双向链表维护插入顺序,内存略高;TreeSet 基于 TreeMap(红黑树),有序、O(log n)、不允许 null,判重依据是compareTo/Comparator返回 0 而非equals。为什么重写 equals 必须重写 hashCode?
这是Object.hashCode的通用契约:相等的对象必须有相等的hashCode。如果只重写equals,两个「逻辑相等」的对象会有不同的哈希值,被放进HashMap/HashSet的不同桶里,contains、get、remove全部失效。Arrays.asList() 有什么坑?
返回的是Arrays$ArrayList(定长视图),不支持add/remove(抛UnsupportedOperationException),支持set;与原数组双向联动;基本类型数组会被当成单个元素(int[]→List<int[]>)。需要可变集合时包一层new ArrayList<>(...)。List.of 和 Arrays.asList 有什么区别?
List.of(JDK 9+)返回真正不可变的集合,连set都不支持,且不允许null元素(构造与contains(null)都抛 NPE),Set.of还不允许重复元素;Arrays.asList只是定长,允许set,允许null。PriorityQueue 的实现原理?它是有序的吗?
底层是二叉小顶堆(数组表示),offer走siftUp上浮、poll走siftDown下沉,都是 O(log n),peekO(1),建堆 O(n)。它的迭代顺序不保证有序,只有反复poll出的序列才有序。求 TopK 时维护容量 K 的小顶堆,复杂度 O(n log k)。fail-fast 和 fail-safe 的区别?
fail-fast 用modCount与expectedModCount比对,一旦发现并发修改立即抛ConcurrentModificationException(ArrayList、HashMap 的普通迭代器);fail-safe 遍历的是创建时的快照(如CopyOnWriteArrayList的COWIterator),不抛异常但可能读到旧数据,代价是写时复制带来的内存与写性能开销。ArrayDeque 为什么比 LinkedList 更适合当栈?
ArrayDeque 基于循环数组,内存连续、缓存友好、push时不产生新对象,容量不足时翻倍并用两次System.arraycopy重组;LinkedList 每次插入都要 new 一个Node(24 字节),GC 压力大、指针跳转 cache miss 多。且Stack继承Vector有方法级锁开销,官方 Javadoc 明确推荐用Deque替代。subList 返回的是新集合吗?
不是,是原集合的视图。SubList持有父ArrayList引用,对视图的set会写回原集合;一旦父集合发生结构性修改(add/remove/clear),视图的所有操作都会抛ConcurrentModificationException。需要独立子列表时请用new ArrayList<>(list.subList(a, b))。Queue 的 offer/poll/peek 与 add/remove/element 有什么区别?
前者在失败时返回特殊值(false/null),后者抛异常(IllegalStateException/NoSuchElementException)。设计两套 API 是为了适配容量受限的队列——队列满/空在业务上是正常状态,不该用异常表达。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);基本类型数组走双轴快排(不稳定但更快)。

















