拿到一批订单、景点、用户或任务之后,我们通常都会遇到几类问题:
- 按接口返回的顺序展示,选什么?
- 去掉重复 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 集合框架把常见需求分成三层:
- 接口:定义能力和规则,如
List、Set、Map。 - 实现类:选择具体数据结构,如
ArrayList、HashMap。 - 通用算法和工具:提供排序、查找、包装等能力,如
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 / 10 | 9 提供 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 的顶层接口关系,一张图看清
读图规则:实线箭头由子接口指向直接父接口。 图中所有节点都是接口;没有用同一种线混画实现类。只展示本文主线接口,并非 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。
注意“有序”至少有三种含义:
- 位置或遇见顺序:如
List当前的排列。 - 插入或访问顺序:如
LinkedHashMap的两种模式。 - 比较规则决定的排序顺序:如
TreeMap的键顺序。
后文会明确写出具体是哪一种。“不保证遍历顺序”也不等于“每次遍历都随机”。List API、Queue API、Map API
3.1 实现类、抽象类又是什么关系?
这张图只画类的 extends 关系,接口实现另看下表。 AbstractCollection、AbstractList 等是抽象类,它们提供骨架实现,减少实现一个新容器时的重复工作,不能直接 new。
| 类 | 直接父类 | 类声明中直接写出的集合主线接口 |
|---|---|---|
AbstractCollection | Object | Collection |
AbstractList | AbstractCollection | List |
AbstractSequentialList | AbstractList | 无,相关能力来自父类 |
AbstractSet | AbstractCollection | Set |
AbstractQueue | AbstractCollection | Queue |
AbstractMap | Object | Map |
ArrayList | AbstractList | List |
LinkedList | AbstractSequentialList | List、Deque |
HashSet | AbstractSet | Set |
LinkedHashSet | HashSet | Set |
TreeSet | AbstractSet | NavigableSet |
ArrayDeque | AbstractCollection | Deque |
PriorityQueue | AbstractQueue | 无,Queue 能力来自父类 |
HashMap | AbstractMap | Map |
LinkedHashMap | HashMap | Map |
TreeMap | AbstractMap | NavigableMap |
此表基于 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 常用操作先有一张地图
| 接口 | 常用方法 | 注意事项 |
|---|---|---|
| Collection | size、isEmpty、contains | 数量、判空、是否包含;具体查询成本看实现 |
| Collection | add、remove(Object)、clear | 修改可能不支持;单元素 add/remove 的布尔结果可表达是否改变 |
| Collection | addAll、removeAll、retainAll | 分别添加一批、删除属于另一批的元素、只保留另一批包含的元素;都会尝试修改接收者 |
| Collection | toArray | 无参数版本返回 Object[];需要 String[] 时可用 names.toArray(String[]::new) |
| List | get、set、add(index, e)、remove(index) | 位置从0开始;访问/修改已存在位置与插入新位置的边界不同 |
| Map | get、containsKey、put、remove、getOrDefault | 对已映射到 null 的 key,getOrDefault 仍返回 null,不自动换成默认值 |
| Map | computeIfAbsent、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 等影响,复杂度无法直接代替业务测量。
五、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 的来龙去脉
| 实现 | 内部基础 | 重复判断与顺序 | 常见用途 |
|---|---|---|---|
HashSet | HashMap | 依赖正确的 hashCode / equals;不保证遍历顺序 | 快速判断是否存在、普通去重 |
LinkedHashSet | 使用 LinkedHashMap 维持顺序 | 同上,保留首次插入顺序 | ID 去重且保留输入顺序 |
TreeSet | TreeMap | 比较结果为 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 自定义对象如何正确去重?
如果两个对象在业务上表示同一件事,需要明确相等标准。哈希集合要求:
equals为true的对象,hashCode必须相同。hashCode相同,equals可以为false,这就是可能的碰撞。- 重写
equals时通常需要同时重写hashCode。 - 对象作为 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/value | ConcurrentHashMap | 不允许 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:有确定遇见顺序的键值映射。
图按分区重复展示少数同名接口,便于读图,省略未变化的 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 吗?允许重复吗?比较规则是什么?结果会不会通过视图被别人改掉?
十八、自测:能够解释并运行这些,才算把知识落到了代码里
下面不要求背源码阈值,要求说出业务语义、运行结果和选择理由。
- 把
[3, 1, 3, 2]去重,结果必须为[3, 1, 2],选什么? - 一个查询列表已经完成加载,接下来读多写少、频繁按下标访问,ArrayList 与 LinkedList 怎样选?
new ArrayList<>(100).size()是多少?能否立即get(99)?- 为什么自定义对象重写 equals 时通常也要重写 hashCode?hashCode 相同就一定相等吗?
- TreeSet 只按年龄比较,两个不同用户年龄相同,会发生什么?怎样表达正确的业务相等关系?
- PriorityQueue 的增强 for 输出是否一定按优先级排好?怎样得到按优先级取出的序列?
- Arrays.asList、unmodifiableList、copyOf,哪一个与原容器后续变化有关?
- 普通列表在单线程迭代时外部删除,为什么也可能抛 ConcurrentModificationException?
- 使用 ConcurrentHashMap 后,“先 get 再 put 累加”为什么仍可能错误?
- 在 Java 17 与 Java 21 中,List 的直接父接口分别是什么?Map 是否属于 Collection?
参考答案与继续实践
- LinkedHashSet 去重,再按需转 ArrayList。
- 通常先选 ArrayList;还应结合数据量和实际操作测量,而不是只凭结构名称判断。
- size 为 0;立即访问下标 99 会越界。
- 哈希容器依赖相等对象具有相同哈希值;反方向不成立,碰撞不等于相等。
- 比较为 0 就视为同一元素。应先定义“同一个用户”的规则,再选择 Set 或 List,并设计与 equals 一致的排序/判等;不能只因加了 ID 就假定所有用户模型都已一致。
- 不保证;反复 poll,若还要保留原队列可以先复制成另一个 PriorityQueue 再取出。
- asList 与数组共享槽位;unmodifiableList 是关联原集合的视图;copyOf 隔离原集合后续结构变化,但都不深复制元素。
- 迭代器维护遍历预期,外部结构修改会破坏这一预期;错误并不要求存在多个线程,检测也只是尽力而为。
- 多个线程可能读到同一旧值;对应的单键更新可以使用 merge / compute,跨多个步骤的业务仍需一致性设计。
- 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、编译和运行分别记录。
学习集合时,最好能够对每一次选择说出一句完整的话:我需要什么数据语义,因此选择哪个接口;主要执行哪些操作,因此选择哪个实现;又有哪些顺序、空值、修改与并发约束需要遵守。