Java 集合框架:从继承体系到数据结构,再到开发选型

0 阅读35分钟

拿到一批订单、景点、用户或任务之后,我们通常都会遇到几类问题:

  • 按接口返回的顺序展示,选什么?
  • 去掉重复 ID,还要保留第一次出现的位置,选什么?
  • 根据 ID 快速找到对象,选什么?
  • 每次取出优先级最高的任务,选什么?
  • 多个线程同时修改数据,还能直接使用 HashMap 吗?

集合的学习,最终要回答这些开发问题。只背“数组、链表、红黑树”还不够:还要知道接口约定了什么、实现类如何兑现约定,以及某个操作付出了什么成本。

本文以 Java 17 为主线;Java 21 引入的 Sequenced Collections 单独列图说明。 业务示例使用 Java 17 语法。标为“OpenJDK 17u 实现细节”的部分用于理解源码,不把扩容倍数、内部阈值当作所有 Java 版本必须遵守的 API 合同。

建议分三轮阅读:第一轮认识家族与选型;第二轮运行示例、理解数据结构;第三轮完成文末练习,再回到自己的业务代码做一次选择和解释。

一、为什么数组之外还需要集合?

数组可以存储一组同类型数据:

String[] names = new String[3];
names[0] = "湖边";
System.out.println(names.length); // 3:数组长度
System.out.println(names[1]);     // null:尚未赋值的引用元素

数组本身没有问题:长度固定、支持下标访问,适合已知长度的数据。数组也可以保存基本类型,例如 int[]。

但业务经常要求“长度不断变化”“按键查询”“自动去重”“维持排序”“从两端取数据”。如果只用数组,我们就得反复编写扩容、移动、查找、去重等代码。

Java 集合框架把常见需求分成三层:

  1. 接口:定义能力和规则,如 List、Set、Map。
  2. 实现类:选择具体数据结构,如 ArrayList、HashMap。
  3. 通用算法和工具:提供排序、查找、包装等能力,如 Collections、Arrays。

可以把接口理解为“我要什么能力”,把实现类理解为“如何提供这种能力”。不同实现可能有相同方法,却有不同顺序、性能与并发特性。Java 集合框架官方概览

1.1 集合框架是怎样发展到今天的?

阶段关键变化解决的问题
JDK 1.0已有 Vector、Hashtable、Stack 等类型提供早期容器能力,但还没有今天统一的集合框架
JDK 1.2引入统一的集合框架,核心包括 Collection、List、Set、Map 及常见实现让不同容器使用共同接口,算法可以复用
Java 5泛型、增强 for、Queue / PriorityQueue、并发工具库等进入开发日常减少类型强转,改善遍历和并发编程
Java 6增加 Deque / ArrayDeque、NavigableSet / NavigableMap 等完善双端操作与邻近、范围查询
Java 8增加 Stream,以及 removeIf、computeIfAbsent、merge 等操作方便批处理、条件删除和映射更新
Java 9 / 109 提供 List.of、Set.of、Map.of;10 提供对应 copyOf更方便地创建不可修改集合
Java 21引入 SequencedCollection、SequencedSet、SequencedMap统一具有确定遇见顺序的集合的首尾和反向视图操作

这些变化是叠加演进。Java 21 没有把 List、Set、Map 替换掉,而是补充了更准确的接口层次。旧类仍可能出现在遗留项目中,看到它们时要结合年代与约束理解。JDK 8 集合增强说明、不可修改集合官方指南、Java 21 顺序集合指南

二、先分清四组名字

名字是什么作用
Collection<E>接口以元素为单位组织一组对象的共同上层接口,定义 size、contains、iterator 等能力
Collections工具类提供 sort、reverse、unmodifiableList 等静态方法
Iterable<T>接口能提供一个 Iterator;实现它的对象可用于增强 for
Iterator<E>接口一次遍历过程中的游标,提供 hasNext、next,以及可选的 remove

Iterable 与 Iterator 没有相互继承关系:前者提供遍历器,后者执行一次遍历。数组也能用于增强 for,但这不表示数组实现了 Iterable。

另外,Map<K,V> 保存键值映射,不继承 Collection,也不继承 Iterable。它可以通过 keySet()、values()、entrySet() 暴露集合视图,后面会看到这种区别为什么重要。Collection API、Iterable API、Iterator API

三、Java 17 的顶层接口关系,一张图看清

01-java17-interfaces.png

读图规则:实线箭头由子接口指向直接父接口。 图中所有节点都是接口;没有用同一种线混画实现类。只展示本文主线接口,并非 JDK 的全部集合相关接口。

这张图可以还原成下面的关系:

Collection<E>   extends Iterable<E>
List<E>         extends Collection<E>
Set<E>          extends Collection<E>
SortedSet<E>    extends Set<E>
NavigableSet<E> extends SortedSet<E>
Queue<E>        extends Collection<E>
Deque<E>        extends Queue<E>

Map<K,V>               无父接口,独立于 Collection 分支
SortedMap<K,V>         extends Map<K,V>
NavigableMap<K,V>      extends SortedMap<K,V>

按照业务能力记忆更容易:

  • List:一串有位置的数据,可以按下标访问,通常允许重复。
  • Set:表达不重复元素;如何认定重复,需要看具体实现。
  • Queue:表达等待处理的数据,提供队头操作;不承诺所有队列都先进先出。
  • Deque:双端队列,两端都能进出,也可以当栈。
  • Map:通过 key 管理 value,一个 key 对应至多一个 value;value 可以重复。
  • SortedSet / SortedMap:增加排序语义;Map 按 key 排序。
  • NavigableSet / NavigableMap:再增加找邻近元素、范围和反向视图的能力,如 floor、ceiling。

注意“有序”至少有三种含义:

  1. 位置或遇见顺序:如 List 当前的排列。
  2. 插入或访问顺序:如 LinkedHashMap 的两种模式。
  3. 比较规则决定的排序顺序:如 TreeMap 的键顺序。

后文会明确写出具体是哪一种。“不保证遍历顺序”也不等于“每次遍历都随机”。List API、Queue API、Map API

3.1 实现类、抽象类又是什么关系?

02-java17-class-inheritance.png

这张图只画类的 extends 关系,接口实现另看下表。 AbstractCollection、AbstractList 等是抽象类,它们提供骨架实现,减少实现一个新容器时的重复工作,不能直接 new。

类直接父类类声明中直接写出的集合主线接口
AbstractCollectionObjectCollection
AbstractListAbstractCollectionList
AbstractSequentialListAbstractList无,相关能力来自父类
AbstractSetAbstractCollectionSet
AbstractQueueAbstractCollectionQueue
AbstractMapObjectMap
ArrayListAbstractListList
LinkedListAbstractSequentialListList、Deque
HashSetAbstractSetSet
LinkedHashSetHashSetSet
TreeSetAbstractSetNavigableSet
ArrayDequeAbstractCollectionDeque
PriorityQueueAbstractQueue无,Queue 能力来自父类
HashMapAbstractMapMap
LinkedHashMapHashMapMap
TreeMapAbstractMapNavigableMap

此表基于 Java 17,省略 Serializable、Cloneable、RandomAccess 等非主线接口。因此,看到 ArrayList 只继承一个类、同时实现多个接口,不要困惑:Java 类是单继承,接口可以有多个。

另一个常见误会:HashSet 内部使用 HashMap,不代表 HashSet extends HashMap;TreeSet 基于 TreeMap 也是委托关系。类继承与内部组合,要分开理解。Java 17 官方包继承树

3.2 为什么经常写 List<String> names = new ArrayList<>()?

List<String> names = new ArrayList<>();
names.add("湖边");
names.add("山顶");
names.add("湖边");
System.out.println(names);        // [湖边, 山顶, 湖边]
System.out.println(names.get(1)); // 山顶:下标从 0 开始

左边用接口表达需求:需要一个列表。右边决定实现:用可扩容数组提供列表能力。以后实现变化时,依赖接口的代码更容易保持稳定;但不能随意替换,因为性能、空值规则等仍需匹配。

接口中的修改方法还可能是可选操作:某个对象虽然是 List,却可能不支持增删,调用时会抛出 UnsupportedOperationException。具体以实现和创建方式为准,后文会对照演示。

<String> 是泛型参数,约束元素类型。集合泛型使用引用类型,整数通常写 List<Integer>,不能写 List<int>;int 与 Integer 之间可以自动装箱和拆箱,拆箱 null 会抛出 NullPointerException。

泛型还不是“继承自动传递”:Integer 是 Number 的子类,List<Integer> 却不是 List<Number> 的子类。参数只需读取数字时可以考虑 List<? extends Number>;需要向其中写入整数时可以考虑 List<? super Integer>。? extends 也不能简单等同于绝对只读:它限制的是安全添加哪些类型,合法删除等操作仍可能存在。泛型与继承官方教程、通配符使用指南

3.3 常用操作先有一张地图

接口常用方法注意事项
Collectionsize、isEmpty、contains数量、判空、是否包含;具体查询成本看实现
Collectionadd、remove(Object)、clear修改可能不支持;单元素 add/remove 的布尔结果可表达是否改变
CollectionaddAll、removeAll、retainAll分别添加一批、删除属于另一批的元素、只保留另一批包含的元素;都会尝试修改接收者
CollectiontoArray无参数版本返回 Object[];需要 String[] 时可用 names.toArray(String[]::new)
Listget、set、add(index, e)、remove(index)位置从0开始;访问/修改已存在位置与插入新位置的边界不同
Mapget、containsKey、put、remove、getOrDefault对已映射到 null 的 key,getOrDefault 仍返回 null,不自动换成默认值
MapcomputeIfAbsent、merge按需计算映射、合并值;并发原子性要看具体实现合同

做集合运算时,先创建 new HashSet<>(left) 作为独立结果,再分别对它使用 addAll(right)、retainAll(right)、removeAll(right),可以得到并集、交集、差集。不要在没有意识到副作用的情况下直接修改业务原集合。

容器自身的相等也有规则:List 比较元素及其位置顺序;Set 比较成员,不因插入顺序不同就不等;Map 比较键值映射。不能把某个容器的遍历顺序,直接当作所有容器的 equals 规则。List 的 equals、Set 的 equals、Map 的 equals

四、先懂复杂度,再比较数据结构

这里的 n 表示元素数量,O(...) 描述数据量增长时主要成本怎样变化:

  • O(1):在所讨论的操作模型下,成本不随元素数量增长,具有常数上界,例如数组按下标取元素。
  • O(log n):数据增长很多倍,操作步骤只缓慢增长,例如平衡树查找。
  • O(n):可能需要遍历或移动一批元素。
  • O(n²):例如在链表上反复按下标遍历。

还要认识均摊:某一次数组扩容可能搬移 n 个元素,但把这一成本分摊到长期连续追加中,平均每次仍可达到 O(1)。

本文复杂度默认单次比较、equals、hashCode 的成本为常数。真实性能还受元素对象、内存分配、访问模式、GC 等影响,复杂度无法直接代替业务测量。

03-data-structures.png

五、List:从数组到动态数组、双向链表

5.1 ArrayList:日常列表的常见起点

ArrayList 把元素引用保存在内部数组中。按下标可以直接定位;空间不足时申请更大的数组,再复制原有内容。

操作成本及原因
get(index) / set(index, value)O(1),直接定位数组槽位
add(value) 尾部追加均摊 O(1);触发扩容的单次操作可能 O(n)
add(index, value)最坏 O(n),插入点后面的元素要移动
remove(index)最坏 O(n),后面的元素通常要向前移动
contains(value) / 按对象查找O(n),可能逐个比较

它允许重复元素与 null,维护列表位置顺序,但不提供并发修改安全保证。对于接口返回列表、表格行、批量处理等“追加、读取、遍历较多”的情况,通常先考虑它。ArrayList API

size 与 capacity 不同:

List<String> names = new ArrayList<>(100);
System.out.println(names.size()); // 0:没有存入任何元素
names.add("湖边");
System.out.println(names.size()); // 1

100 是预留容量,不是自动创建 100 个有效元素,也不能据此调用 get(99)。合理预估容量可以减少扩容,但不应盲目预留巨量空间。

源码补充:OpenJDK 17u 中,无参构造先使用共享空数组,第一次普通追加时通常分配容量 10;常规扩容优先增长原容量的一半,但会受最小所需容量等约束。“每次严格 1.5 倍”并不准确,API 也没有承诺固定增长倍数。OpenJDK 17u ArrayList 源码

5.2 LinkedList:双向链表,成本主要在“找到位置”

每个链表节点保存元素以及前后节点的引用。首尾位置已知,所以首尾插入、删除为 O(1);按下标查找节点则要遍历,最坏为 O(n)。

“链表插入只需要改几个指针”只描述节点定位之后的操作。linkedList.add(index, value) 需要先找到位置,不能因此统一说它是 O(1)。

如果遍历一个链表:

for 循环每次 get(i)      → 反复定位,整体可能 O(n²)
增强 for / Iterator     → 沿节点移动,完整遍历 O(n)

LinkedList 同时实现 List 和 Deque,允许 null。但“频繁增删就选 LinkedList”过于粗糙:还要看增删位置、是否已持有迭代器、遍历比例、节点分配成本。单线程首尾操作也应比较 ArrayDeque。LinkedList API

5.3 List<Integer>.remove 的重载陷阱

List<Integer> numbers = new ArrayList<>(List.of(10, 20, 30));
numbers.remove(1);                  // remove(int index):删除下标 1
System.out.println(numbers);        // [10, 30]
numbers.remove(Integer.valueOf(10)); // remove(Object value):删除值 10
System.out.println(numbers);        // [30]

这里不是“整数集合统一按值删除”,而是编译器根据参数类型选择了不同重载。

六、Map:从逐个查找,到哈希定位和有序映射

6.1 HashMap:为什么通常能更快地按 key 查找?

假设要通过景点 ID 查询名称,用 List 通常要逐个比较;HashMap<Long, String> 则把 key 映射到桶位置,再在相应桶里匹配。

可以把一次查找理解成:

key → hashCode 与哈希扰动 → 计算桶位置 → 在桶内匹配 key → 找到 value

不同 key 可能具有相同哈希值;即使哈希值不同,也可能落入同一个桶。因此,定位桶之后仍要进一步匹配 key,不能仅凭桶位置或哈希值认定两个 key 相等。对于正常键的使用,遵守 equals / hashCode 契约是前提。

Map<Long, String> poiNames = new HashMap<>();
poiNames.put(101L, "湖边");
poiNames.put(102L, "山顶");
String old = poiNames.put(101L, "湖畔");
System.out.println(old);                // 湖边:已有 key 的旧值
System.out.println(poiNames.get(101L)); // 湖畔
System.out.println(poiNames.size());    // 2:同一个 key 不会增加一条映射
System.out.println(poiNames.get(999L)); // null:这里表示没有这个 key

HashMap 允许一个 null key,也允许多个 key 对应 null value。因此,get(key) == null 可能表示不存在,也可能表示映射值就是 null;需要区分时使用 containsKey。

合理哈希分布下,基本查找和修改通常为 O(1),插入的扩容成本按均摊理解。遍历成本与容量和元素数之和有关,并非只看 size。它不保证遍历顺序,也不提供并发修改安全保证。HashMap API

哈希桶为什么会有链表和红黑树?

碰撞很少时,桶里只有少量节点,链表已足够简单。碰撞严重时,链表查询变长,OpenJDK 的实现会在符合条件时把桶改为红黑树,改善这类情况的性能。

红黑树是一种自平衡二叉搜索树。它通过颜色和结构约束,控制树高,避免普通搜索树在极端插入顺序下退化成长链。

下面区分公开默认配置与 OpenJDK 17u 的内部实现规则:

细节准确理解
默认负载因子 0.75(公开默认配置)平衡空间与碰撞成本;不是“数组 75% 的槽位都占用了才扩容”
表容量为 2 的幂实现用它配合位运算定位与扩容拆分;不代表所有哈希表都如此
TREEIFY_THRESHOLD = 8树化阈值,但还受插入路径和表容量等条件影响
MIN_TREEIFY_CAPACITY = 64表容量不足时优先扩容,而不是立即树化
UNTREEIFY_THRESHOLD = 6用于扩容拆分树桶等路径,不能概括成“普通删除到 6 个必然退化”

如果追到普通 put 的链表插入路径:桶里已有 8 个节点,加入第 9 个不同 key 后会调用树化逻辑;还要检查表容量。compute、merge 等路径不应机械套用这句计数口诀。

同样,不要说“树化以后任何情况下查找都严格 O(log n)”。在同哈希且无法提供有效比较方向等情况下,树桶查找可能探索多个分支。对初学者,更有价值的认识是:好的 key 设计、正确的哈希契约与合适容量,比背阈值更重要。 OpenJDK 17u HashMap 源码:putVal、treeifyBin、TreeNode.find、split

6.2 LinkedHashMap:在哈希查询上增加确定顺序

LinkedHashMap 在 HashMap 基础上额外维护贯穿条目的双向链表。默认按首次插入 key 的顺序遍历;重新 put 已有 key 只改值,不改变它的插入位置。

Map<String, Integer> prices = new LinkedHashMap<>();
prices.put("B", 20);
prices.put("A", 10);
prices.put("B", 21);
System.out.println(prices.keySet()); // [B, A]

Map<String, Integer> recent = new LinkedHashMap<>(16, 0.75f, true);
recent.put("A", 1);
recent.put("B", 2);
recent.get("A");
System.out.println(recent.keySet()); // [B, A]:启用了访问顺序

适合需要稳定输出顺序、按首次出现顺序聚合的情况。访问顺序模式配合 removeEldestEntry 可做简单 LRU 演示,但不自动提供并发、过期、容量治理等生产级缓存能力;此模式下 get 也可能改变顺序。LinkedHashMap API

6.3 TreeMap:需要按 key 排序、找范围与邻居

TreeMap 使用红黑树,按照 key 的自然顺序,或者构造时给出的 Comparator 排序。get、put、remove 等基本操作保证 O(log n)。

NavigableMap<Integer, String> milestones = new TreeMap<>();
milestones.put(30, "上线");
milestones.put(10, "设计");
milestones.put(20, "开发");
System.out.println(milestones.keySet()); // [10, 20, 30]
System.out.println(milestones.floorKey(25));   // 20:不大于目标的最大 key
System.out.println(milestones.ceilingKey(25)); // 30:不小于目标的最小 key
System.out.println(milestones.subMap(10, true, 30, false).keySet()); // [10, 20]

自然排序依赖 Comparable,自定义排序依赖 Comparator。TreeMap 是按 key 排序,不会自动按 value 排序。自然排序不接受 null key;能处理 null 的比较器可以改变这一规则,value 则可以为 null。TreeMap API

七、Set:不重复,到底按什么标准?

7.1 HashSet、LinkedHashSet、TreeSet 的来龙去脉

实现内部基础重复判断与顺序常见用途
HashSetHashMap依赖正确的 hashCode / equals;不保证遍历顺序快速判断是否存在、普通去重
LinkedHashSet使用 LinkedHashMap 维持顺序同上,保留首次插入顺序ID 去重且保留输入顺序
TreeSetTreeMap比较结果为 0 视为同一元素;按比较规则排序排序去重、范围与邻近查找

哈希型 Set 把元素放在内部 Map 的 key 位置,value 用占位对象即可。这解释了为什么它既能去重,又能复用 Map 的查找能力。HashSet、LinkedHashSet 允许一个 null;TreeSet 自然排序不接受 null,能处理 null 的比较器可允许它。HashSet API、LinkedHashSet API、TreeSet API

List<Long> input = List.of(103L, 101L, 103L, 102L);
Set<Long> distinct = new LinkedHashSet<>(input);
System.out.println(distinct); // [103, 101, 102]
List<Long> result = new ArrayList<>(distinct);
System.out.println(result);   // [103, 101, 102]

7.2 自定义对象如何正确去重?

如果两个对象在业务上表示同一件事,需要明确相等标准。哈希集合要求:

  1. equals 为 true 的对象,hashCode 必须相同。
  2. hashCode 相同,equals 可以为 false,这就是可能的碰撞。
  3. 重写 equals 时通常需要同时重写 hashCode。
  4. 对象作为 key 或 Set 元素期间,不能随意修改参与判等和哈希计算的字段。

Java 17 可以用 record 表达简单值对象:

record PoiKey(long tenantId, long poiId) {}
Set<PoiKey> keys = new HashSet<>();
keys.add(new PoiKey(1, 101));
keys.add(new PoiKey(1, 101));
keys.add(new PoiKey(2, 101));
System.out.println(keys.size()); // 2:按两个组成字段一起判断

record 会生成相应的 equals、hashCode 等方法,但引用字段仍可能指向可变对象,不能据此认定对象深不可变。实际 ID key 更适合由稳定的数字、字符串等值组成。Object.equals/hashCode 契约、Record API

7.3 TreeSet 的“重复”可能出乎意料

Set<String> byLength = new TreeSet<>(Comparator.comparingInt(String::length));
byLength.add("ab");
byLength.add("cd");
System.out.println(byLength.size()); // 1:比较长度相等,比较结果就是 0

Comparator<String> order = Comparator.comparingInt(String::length)
        .thenComparing(Comparator.naturalOrder());
Set<String> complete = new TreeSet<>(order);
complete.add("ab");
complete.add("cd");
System.out.println(complete); // [ab, cd]

按长度比较的规则与 String.equals 不一致,因此不适合“保留所有不同字符串”的需求。排序集合的比较规则应与 equals 一致,才能完整遵守一般 Set / Map 契约。

对 TreeSet 元素、TreeMap 的 key,也不要在存入后修改比较规则依赖的字段,否则既有树结构与新的比较结果可能不一致。

对于整数比较,优先 Integer.compare(a, b),别用 a - b:减法可能溢出,导致排序关系错误。Comparator API

八、Queue 与 Deque:任务、栈和优先级

8.1 Queue 的两套方法为什么同时存在?

目的失败时抛异常的一组用特殊值表示正常失败的一组
入队add(e)offer(e):容量受限而放不下时返回 false
取出队头remove()poll():空时返回 null
查看队头element()peek():空时返回 null

这不是“右侧永远不会抛异常”:非法元素、null 或其他约束仍可能导致异常。它们区分的是容量不足、队列为空等常见失败如何表达。

队列经常是 FIFO,但优先队列是例外。Queue 本身也没有定义阻塞等待,那属于 BlockingQueue 的能力。Queue API

8.2 ArrayDeque:用同一种结构实现队列和栈

ArrayDeque 使用可扩容的循环数组。头尾索引沿数组循环移动,不需要每次取出队头后搬移全部剩余元素。常见首尾操作均摊 O(1),按元素搜索或删除通常 O(n);它不允许 null,也不提供并发修改安全保证。

Deque<String> queue = new ArrayDeque<>();
queue.offerLast("任务A");
queue.offerLast("任务B");
System.out.println(queue.pollFirst()); // 任务A:队尾进、队头出
System.out.println(queue.pollFirst()); // 任务B
System.out.println(queue.pollFirst()); // null

Deque<String> stack = new ArrayDeque<>();
stack.push("A"); // 等价于 addFirst
stack.push("B");
System.out.println(stack.pop()); // B:同一端进出,后进先出
System.out.println(stack.pop()); // A

单线程栈和双端队列通常可以先考虑它。官方也建议优先用 Deque 表达栈,而不是继续使用早期的 Stack。不过 Deque 接口不等于一律禁止 null:LinkedList 实现允许,但队列场景不建议放入 null,否则无法清晰区分“空队列”与“元素就是 null”。Deque API、ArrayDeque API

版本提醒:Java 17 的 ArrayDeque 内部数组长度不要求为 2 的幂,扩容也不能概括成固定翻倍。不要把旧版源码文章里的位运算与容量细节直接搬过来。OpenJDK 17u ArrayDeque 源码

8.3 PriorityQueue:堆保证“下一位”,不保证全体已经排好

PriorityQueue 使用数组表示二叉堆,队头是比较规则下最小的元素。入队需要上浮,出队需要下沉,堆调整成本为 O(log n);peek 为 O(1)。数组扩容的某次入队还可能发生 O(n) 复制成本。contains、remove(Object) 通常需要线性查找。

Queue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(5);
minHeap.offer(1);
minHeap.offer(3);
while (!minHeap.isEmpty()) {
    System.out.println(minHeap.poll()); // 依次输出 1、3、5
}

Queue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
maxHeap.addAll(List.of(5, 1, 3));
System.out.println(maxHeap.poll()); // 5

堆只保证相应的父子比较关系。forEach、增强 for、toString() 输出不保证完整排序顺序;需要按优先级取出时连续 poll()。相同优先级的元素也不保证按入队先后取出,需要时应把序号加入比较规则。

它适合 Top K、待处理优先级、调度算法等问题;不允许 null,多线程场景不能直接当作安全队列使用。PriorityQueue API、OpenJDK 17u PriorityQueue 源码

九、常见实现的开发对照表

以下均指常规实现,不包含额外同步包装。表中“通常常数”对哈希结构要求哈希分布合理,对可扩容数组追加要按均摊理解。

实现主要结构顺序规则常见操作成本空值
ArrayList动态数组列表位置顺序下标访问 O(1),尾增均摊 O(1),中间增删最坏 O(n)允许
LinkedList双向链表列表/双端顺序首尾操作 O(1),按下标定位 O(n)允许
HashSet哈希表无遍历顺序保证基本增删查通常 O(1)允许一个
LinkedHashSet哈希表与链表首次插入顺序基本增删查通常 O(1)允许一个
TreeSet红黑树比较规则顺序基本增删查 O(log n)自然排序不允许;依比较器而定
HashMap哈希表,桶可树化无遍历顺序保证基本操作通常 O(1),插入按均摊理解一个 null key,value 可为 null
LinkedHashMap哈希表与链表默认插入,可启用访问顺序基本操作通常 O(1),遍历 O(n)同 HashMap
TreeMap红黑树key 的比较规则顺序基本增删查 O(log n)key 依比较器;value 可为 null
ArrayDeque循环数组由首尾操作决定首尾操作均摊 O(1)不允许
PriorityQueue二叉堆队头优先;遍历不保证有序堆调整 O(log n),查看队头 O(1)不允许

记忆顺序可以是:先判断语义,再看顺序,再看操作成本,最后看并发和边界。 例如“需要唯一 ID”并不足以决定 HashSet;如果还要保持输入顺序,更适合 LinkedHashSet。

十、遍历、删除和视图:代码能跑,不代表语义正确

10.1 普通遍历与 Map 遍历

增强 for 适合只读遍历;需要 key 和 value 时,Map 通过 entrySet() 遍历:

Map<String, Integer> counts = new LinkedHashMap<>();
counts.put("已支付", 2);
counts.put("待支付", 1);
for (Map.Entry<String, Integer> entry : counts.entrySet()) {
    System.out.println(entry.getKey() + "=" + entry.getValue());
}
// 已支付=2
// 待支付=1

entrySet() 是 Map 提供的集合视图,不是 Map 自己变成了 Collection。Map 集合视图 API

10.2 迭代中删除,要使用对应机制

List<Integer> numbers = new ArrayList<>(List.of(1, -1, 2, -2));
for (Iterator<Integer> it = numbers.iterator(); it.hasNext();) {
    if (it.next() < 0) {
        it.remove();
    }
}
System.out.println(numbers); // [1, 2]

numbers.removeIf(n -> n % 2 == 0);
System.out.println(numbers); // [1]

对支持修改的集合,可以使用它支持的 Iterator.remove() 或 removeIf。Iterator.remove() 是可选操作,且一次 next() 后最多调用一次。

对于普通 ArrayList 等集合,遍历时从迭代器之外增删元素,可能触发 ConcurrentModificationException。单线程也可能出现;fail-fast 只是尽力发现错误,不保证每次违规都会抛异常,更不提供并发安全。不能以“这次没抛异常”为理由保留这种写法。ConcurrentModificationException API

10.3 视图会关联到原数据

List<String> all = new ArrayList<>(List.of("A", "B", "C"));
List<String> part = all.subList(0, 2); // 前闭后开,选中 A、B
part.set(0, "X");
System.out.println(all); // [X, B, C]

Map<String, Integer> map = new HashMap<>();
map.put("A", 1);
map.keySet().remove("A");
System.out.println(map.isEmpty()); // true

subList、Map 的 keySet / values / entrySet 等通常表达对底层容器的视图。支持的修改会反映到原容器;但并非所有方向的修改都支持,例如不能随意通过 keySet().add() 创建映射,因为它没有提供 value。

原列表绕过 subList 发生结构修改后,该子列表的语义不能继续依赖。需要独立列表时,使用 new ArrayList<>(all.subList(from, to));注意这仍是元素引用的浅复制。List.subList API

十一、固定长度、不可修改、不可变,区别很大

11.1 五种写法放在一起比较

创建方式增删/替换元素与原容器的关系深复制元素?
Arrays.asList(array)固定长度,支持 set,不支持改变长度与原数组相互反映元素替换否
new ArrayList<>(source)支持增删改容器结构独立,仍共享元素对象否
Collections.unmodifiableList(source)不能通过返回列表修改原列表后续变化仍可见,是包装视图否
List.copyOf(source)不可修改原集合后续增删、替换元素不反映到结果否
List.of(...)不可修改不作为输入数组的修改视图否

copyOf 不保证每次都分配新实例:合适的不可修改输入可以复用。理解它与原容器的隔离保证,比依赖对象身份更重要;元素对象本身仍可能被共享。

List<String> source = new ArrayList<>(List.of("A"));
List<String> view = Collections.unmodifiableList(source);
List<String> snapshot = List.copyOf(source);
source.add("B");
System.out.println(view);     // [A, B]
System.out.println(snapshot); // [A]

String[] array = {"A", "B"};
List<String> fixed = Arrays.asList(array);
fixed.set(0, "X");
System.out.println(array[0]); // X:修改反映到原数组
// fixed.add("C");           // 抛 UnsupportedOperationException

Arrays.asList 返回的也不是 java.util.ArrayList。若需要可增删列表,显式写 new ArrayList<>(Arrays.asList(array))。Arrays.asList API、Collections.unmodifiableList API

11.2 不可修改容器,不等于元素对象也不可变

StringBuilder name = new StringBuilder("A");
List<StringBuilder> names = List.of(name);
name.append("B");
System.out.println(names.get(0)); // AB:容器没变,元素对象变了

final List<?> 只限制变量重新赋值,也不会冻结列表内容。要获得更强的不可变语义,还需要设计不可变元素、控制引用暴露,必要时做防御性复制。

工厂方法还有这些边界:

  • List.of 允许重复元素,但拒绝 null。
  • Set.of 拒绝重复元素,也拒绝 null;Set.copyOf 可以对含重复元素的输入去重。
  • Map.of 拒绝重复 key,拒绝 null key/value;不同 key 可以对应相同 value。
  • Set.of、Map.of、Set.copyOf、Map.copyOf 不应被当作保留输入顺序的工具。

这些规则属于具体工厂方法,不能推广成“所有 List 都不允许 null”。List 工厂方法、Set 工厂方法、Map 工厂方法

11.3 基本类型数组不会在这里自动变成多个包装元素

int[] values = {1, 2};
System.out.println(Arrays.asList(values).size()); // 1:整个 int[] 是一个元素
List<Integer> boxed = Arrays.stream(values).boxed().toList();
System.out.println(boxed); // [1, 2]

这也是为什么不能只凭方法名字判断转换结果,要同时看泛型与参数类型。

十二、Collections、Arrays 和 Stream 如何配合?

Collections 操作集合,Arrays 操作数组;Stream 表达一条数据处理流水线,不是 Collection 子接口,也不是另一个用于保存数据的 List。

List<Integer> numbers = new ArrayList<>(List.of(3, 1, 2));
numbers.sort(Comparator.naturalOrder());
System.out.println(numbers); // [1, 2, 3]
System.out.println(Collections.binarySearch(numbers, 2)); // 1
Collections.reverse(numbers);
System.out.println(numbers); // [3, 2, 1]

List<Integer> filtered = numbers.stream()
        .filter(n -> n >= 2)
        .sorted()
        .toList();
System.out.println(filtered); // [2, 3]

binarySearch 的前提是输入已按相应规则排序,否则结果不可依赖。sort、reverse 等会修改输入,输入必须支持相应修改操作。

Java 16 起的 Stream.toList() 返回不可修改列表。Collectors.toList() 则没有承诺具体实现类、可修改性或线程安全性。明确需要可修改 ArrayList 时,使用:

List<Integer> mutable = List.of(1, 2, 3).stream()
        .filter(n -> n > 1)
        .collect(java.util.stream.Collectors.toCollection(ArrayList::new));
mutable.add(4);
System.out.println(mutable); // [2, 3, 4]

不要因为本机观察到 Collectors.toList() 返回 ArrayList,就把它当作 API 保证。Collections API、Stream.toList API、Collectors.toList API

十三、多线程场景:选对容器,只是第一步

13.1 常用并发容器怎样选?

需求可考虑的类型要了解的边界
多线程共享 key/valueConcurrentHashMap不允许 null key/value,复合业务不自动原子化
遍历很多、修改很少的列表CopyOnWriteArrayList写入通常复制数组;迭代器看创建时的数组快照
线程间传递任务,需要容量与等待控制ArrayBlockingQueue / 配置容量的 LinkedBlockingQueue显式考虑容量、阻塞、超时与消费速度
并发 FIFO 队列,无需阻塞等待接口ConcurrentLinkedQueue需要自行管理业务积压,不提供 BlockingQueue 的等待接口
需要并发安全的排序映射/集合ConcurrentSkipListMap / ConcurrentSkipListSet跳表结构,适合还需要排序、范围操作的情况

这里是选型入口,并不等于并发编程的完整知识。无界积压会带来内存压力,线程安全也不代表业务流程正确。java.util.concurrent 官方包说明、BlockingQueue API

13.2 线程安全的方法,不自动组成线程安全的业务

下面的逻辑即使使用 ConcurrentHashMap,也可能丢失更新:

读取旧计数 → 加 1 → 写回

两个线程可能读到同一个旧值,再覆盖彼此的结果。单键累加应使用表达相应原子更新的方法:

java.util.concurrent.ConcurrentHashMap<String, Integer> counts =
        new java.util.concurrent.ConcurrentHashMap<>();
counts.merge("已支付", 1, Integer::sum);
counts.merge("已支付", 1, Integer::sum);
System.out.println(counts.get("已支付")); // 2

putIfAbsent、computeIfAbsent、compute、merge 解决不同的单键操作,不应把它们理解成跨多个 key 或数据库事务。

对于 ConcurrentHashMap.computeIfAbsent,计算函数应短小,不应在其中修改这个 Map;也不能说“这个函数整个生命周期只执行一次”:计算返回 null、抛异常、映射被删除等情况都可能导致以后重新计算。网络请求去重、缓存过期、重试等还需要额外设计。

另外,ConcurrentHashMap<String, ArrayList<Order>> 只保护容器自身的并发操作,里面的 ArrayList 不会自动获得线程安全能力。ConcurrentHashMap 的迭代器是弱一致的,也不是整个 Map 在同一时刻的完整快照。ConcurrentHashMap API

13.3 快照迭代与同步包装

CopyOnWriteArrayList 的迭代器使用创建时的数组快照,后续容器修改不会反映到这个迭代器;迭代器不支持 remove,快照也没有深复制元素对象。CopyOnWriteArrayList API

如果使用同步包装,遍历仍需要按官方要求手工同步,并且统一通过包装对象访问:

List<String> safe = Collections.synchronizedList(new ArrayList<>());
safe.add("A");
synchronized (safe) {
    for (String value : safe) {
        System.out.println(value); // A
    }
}

“单个方法有同步”与“从检查到执行整个业务步骤都受到同一把锁保护”,是两回事。Collections.synchronizedList API

十四、Java 21 的变化:为什么新增 Sequenced 接口?

在 Java 17 中,List、Deque、LinkedHashSet、LinkedHashMap 都有各自确定的遇见顺序,但缺少一套覆盖它们的统一首尾/反序 API。

Java 21 引入了三个接口:

  • SequencedCollection:有确定遇见顺序的元素集合。
  • SequencedSet:同时具备 Set 与 SequencedCollection 语义。
  • SequencedMap:有确定遇见顺序的键值映射。

04-java21-sequenced.png

图按分区重复展示少数同名接口,便于读图,省略未变化的 Iterable 上层;实线箭头仍表示子接口指向直接父接口。紫色节点是新增接口。 为与实际声明一致,保留了 SortedSet → Set 这条冗余直接边。

几个准确的变化:

List            extends SequencedCollection
Deque           extends Queue, SequencedCollection
SequencedSet    extends SequencedCollection, Set
SortedSet       extends Set, SequencedSet

SequencedMap    extends Map
SortedMap       extends SequencedMap

Set、Queue 本身没有被统一改成 SequencedCollection 的子接口;普通 HashSet / HashMap 也不会因此突然获得顺序保证。LinkedHashSet、LinkedHashMap 则分别接入 SequencedSet、SequencedMap。

以下片段需要 Java 21+,不能放进 Java 17 项目编译:

List<String> names = new ArrayList<>(List.of("A", "B"));
System.out.println(names.getFirst()); // A
System.out.println(names.getLast());  // B
List<String> reversed = names.reversed();
reversed.set(0, "X");
System.out.println(names); // [A, X]:反序视图关联原列表

reversed() 是反序视图,不等于复制出一份完全独立的数据。首尾修改也可能是可选操作:例如 SortedSet 的顺序由比较规则决定,它的 addFirst / addLast 会抛出 UnsupportedOperationException,不能强行绕过排序规则插入到指定端。

图中的继承关系是 Java 21 开始形成的接口体系;维护其他 JDK 版本时,应查对应版本的 API,不把本文图当成未来所有版本的完整继承树。Java 21 官方指南、SequencedCollection API、SequencedSet API、SequencedMap API、Java 21 SortedSet API

十五、几个不常作为首选、但值得认识的类型

15.1 EnumSet / EnumMap:当值域就是一个枚举

枚举取值范围固定时,不必机械地使用 HashSet / HashMap。EnumSet 内部使用位向量,EnumMap 使用数组;它们按照枚举声明顺序遍历,类型也更明确。EnumSet 拒绝 null;EnumMap 拒绝 null key,但允许 null value。它们本身不是通用并发容器。

enum Permission { READ, WRITE, DELETE }
Set<Permission> permissions = EnumSet.of(Permission.READ, Permission.WRITE);
Map<Permission, String> labels = new EnumMap<>(Permission.class);
labels.put(Permission.WRITE, "写入");
labels.put(Permission.READ, "读取");
System.out.println(permissions);     // [READ, WRITE]
System.out.println(labels.keySet()); // [READ, WRITE]

例如权限标记、固定业务状态等,可以先判断枚举是否符合领域含义。EnumSet API、EnumMap API

15.2 Vector / Stack / Hashtable:认识历史,不机械套用

它们是早期类。Vector、Hashtable 有同步方法,但这不自动保护所有多步业务;Stack 还继承了 Vector 的列表 API。新代码通常根据需要选择 ArrayList、Deque、ConcurrentHashMap 或其他明确的同步方案,而不是只看到“带同步”就使用旧类。Stack 官方建议、Hashtable API

15.3 WeakHashMap / IdentityHashMap:特殊语义优先于“更高级”

WeakHashMap 使用弱键语义,键不再被其他强引用持有时,相应条目可能因 GC 而消失,不等于一个有可靠过期时间和容量策略的缓存。IdentityHashMap 用引用身份 == 判断 key 相同,而不是通常的对象相等语义。只有需求明确依赖这些规则时才使用。WeakHashMap API、IdentityHashMap API

十六、把集合放回真实业务:线路中的景点处理

需求:接口给出景点列表,要保留页面显示顺序,按 ID 查找,还要把用户重复选择的景点 ID 去重。

先分别写出三种需求,而不是用一个容器硬撑全部用途:

  • 展示:List<Poi>。
  • 按 ID 查询:Map<Long, Poi>。
  • 去重并保留选择顺序:LinkedHashSet<Long>。
record Poi(long id, String name) {}
List<Poi> pois = new ArrayList<>(List.of(
        new Poi(101, "湖边"),
        new Poi(102, "山顶"),
        new Poi(103, "古街")
));

Map<Long, Poi> byId = new HashMap<>();
for (Poi poi : pois) {
    Poi previous = byId.putIfAbsent(poi.id(), poi);
    if (previous != null) {
        throw new IllegalArgumentException("重复景点 ID:" + poi.id());
    }
}

List<Long> selectedIds = List.of(103L, 101L, 103L);
Set<Long> orderedIds = new LinkedHashSet<>(selectedIds);
List<Poi> selected = new ArrayList<>();
for (Long id : orderedIds) {
    Poi poi = byId.get(id);
    if (poi == null) {
        throw new IllegalArgumentException("景点不存在:" + id);
    }
    selected.add(poi);
}

System.out.println(selected.stream().map(Poi::name).toList()); // [古街, 湖边]

这里主动约定了“重复 ID 报错”,没有让后出现的数据悄悄覆盖前一条。选型完成后,还需要说明业务规则:重复怎么处理,空值是否允许,结果顺序是什么。

如果数据来自数据库,也不能认为 WHERE id IN (...) 会自动保留传入 ID 的顺序;集合只是应用内处理的一部分,还需要显式维护和还原业务顺序。

同样,Java Set 去重不能代替数据库唯一约束,也不能代替服务端权限校验;多个请求、多个实例和持久化层是不同的边界。

十七、开发选型,可以按这六个问题走

问题选择方向
需要一串有位置的数据、按下标访问吗?List,常先看 ArrayList
需要唯一元素吗?Set;普通去重 HashSet,保留插入顺序 LinkedHashSet
需要通过 key 查对象吗?Map;常规 HashMap,稳定输出顺序 LinkedHashMap
需要始终按比较规则排序、范围或邻近查询吗?TreeSet / TreeMap,并发时另看跳表容器
需要首尾操作、栈、队列或每次取优先元素吗?ArrayDeque / PriorityQueue;线程间等待另看 BlockingQueue
数据是否共享、是否允许修改、是否要求稳定快照?再选择并发容器、同步、不可修改副本或其他一致性方案

额外核对四个边界:允许 null 吗?允许重复吗?比较规则是什么?结果会不会通过视图被别人改掉?

十八、自测:能够解释并运行这些,才算把知识落到了代码里

下面不要求背源码阈值,要求说出业务语义、运行结果和选择理由。

  1. 把 [3, 1, 3, 2] 去重,结果必须为 [3, 1, 2],选什么?
  2. 一个查询列表已经完成加载,接下来读多写少、频繁按下标访问,ArrayList 与 LinkedList 怎样选?
  3. new ArrayList<>(100).size() 是多少?能否立即 get(99)?
  4. 为什么自定义对象重写 equals 时通常也要重写 hashCode?hashCode 相同就一定相等吗?
  5. TreeSet 只按年龄比较,两个不同用户年龄相同,会发生什么?怎样表达正确的业务相等关系?
  6. PriorityQueue 的增强 for 输出是否一定按优先级排好?怎样得到按优先级取出的序列?
  7. Arrays.asList、unmodifiableList、copyOf,哪一个与原容器后续变化有关?
  8. 普通列表在单线程迭代时外部删除,为什么也可能抛 ConcurrentModificationException?
  9. 使用 ConcurrentHashMap 后,“先 get 再 put 累加”为什么仍可能错误?
  10. 在 Java 17 与 Java 21 中,List 的直接父接口分别是什么?Map 是否属于 Collection?

参考答案与继续实践

  1. LinkedHashSet 去重,再按需转 ArrayList。
  2. 通常先选 ArrayList;还应结合数据量和实际操作测量,而不是只凭结构名称判断。
  3. size 为 0;立即访问下标 99 会越界。
  4. 哈希容器依赖相等对象具有相同哈希值;反方向不成立,碰撞不等于相等。
  5. 比较为 0 就视为同一元素。应先定义“同一个用户”的规则,再选择 Set 或 List,并设计与 equals 一致的排序/判等;不能只因加了 ID 就假定所有用户模型都已一致。
  6. 不保证;反复 poll,若还要保留原队列可以先复制成另一个 PriorityQueue 再取出。
  7. asList 与数组共享槽位;unmodifiableList 是关联原集合的视图;copyOf 隔离原集合后续结构变化,但都不深复制元素。
  8. 迭代器维护遍历预期,外部结构修改会破坏这一预期;错误并不要求存在多个线程,检测也只是尽力而为。
  9. 多个线程可能读到同一旧值;对应的单键更新可以使用 merge / compute,跨多个步骤的业务仍需一致性设计。
  10. Java 17 是 Collection;Java 21 是 SequencedCollection。Map 在两者中都不是 Collection 的子接口。

继续做三个小练习:用 Deque 完成括号匹配;用 Map 完成单词频次统计;用 PriorityQueue 维护一个 Top K。每题都补上空输入、重复值、边界值,并解释所选结构的时间和空间成本。

附:本文的版本与核查方式

  • 接口关系以 Java 17 / Java 21 官方 API 声明为准,两套图分开呈现。
  • OpenJDK 内部结构、扩容与树化规则单独标为实现细节;官方 API 未承诺的行为不作为通用保证。
  • Java 17 的 24 组业务片段提供可运行代码和输出核对;另覆盖固定长度视图、浅复制、比较器去重、可变 key、迭代器与并发累加等边界检查。
  • Java 21 的新增 API 示例使用 JDK 22 的 javac --release 21 编译,并在 Java 22 运行核对输出;这不是 Java 21 运行时测试。阅读源码、核对 API、编译和运行分别记录。

学习集合时,最好能够对每一次选择说出一句完整的话:我需要什么数据语义,因此选择哪个接口;主要执行哪些操作,因此选择哪个实现;又有哪些顺序、空值、修改与并发约束需要遵守。