概述
LinkedHashSet 是 Java 集合框架中一个独具匠心的设计——它继承自 HashSet,却借助 LinkedHashMap 实现了可预测的迭代顺序。其核心设计精妙之处在于:HashSet 预留了一个包级私有的钩子构造器,允许子类通过一个 dummy 标记参数替换底层 Map 实现,LinkedHashSet 正是利用这一机制,在完全不重写任何增删改方法的情况下,将底层存储从 HashMap 替换为 LinkedHashMap,从而获得了双向链表的顺序维护能力。 这种“子类化父类并替换底层依赖”的设计模式,堪称 Java 集合框架中代码复用的典范。本文将为您揭示从顺序保证的底层机制、构造器钩子的巧妙设计、核心操作的完整调用链路,到与 HashSet/TreeSet 的全方位对比、常见陷阱与面试高频考点的全链路认知体系。
- 继承 HashSet 但委托 LinkedHashMap:通过 HashSet 的包级私有构造器传入 dummy 标记,底层实际创建 LinkedHashMap,实现代码复用的同时扩展顺序能力。
- 双向链表维护插入顺序:每个节点通过 before/after 指针形成贯穿链,迭代时严格按照元素插入顺序输出。
- 可选的访问顺序模式:底层 LinkedHashMap 可通过构造参数 accessOrder=true 启用,元素被访问后被移至链表尾部,实现简单的 LRU 策略。
- 插入与查询效率:增删查时间复杂度 O(1)(平均),但略低于 HashSet(因需要额外维护链表指针)。
- 与 HashSet 和 TreeSet 的场景边界:需要可预测顺序时选 LinkedHashSet,需要排序时选 TreeSet,仅需快速去重时选 HashSet。
全文组织架构
flowchart TB
subgraph PART0["【全文概览】"]
A["LinkedHashSet 核心架构与定位"]
end
subgraph PART1["Part 1:基础认知篇"]
B1["模块 1:定义、核心特性与适用场景"]
B2["模块 2:接口与继承体系"]
end
subgraph PART2["Part 2:存储与构造篇"]
C1["模块 3:存储结构与底层依赖(源码剖析)"]
C2["模块 4:构造方法(源码剖析)"]
end
subgraph PART3["Part 3:核心原理篇"]
D1["模块 5:add 操作——顺序插入与去重"]
D2["模块 6:remove 与 contains 操作"]
end
subgraph PART4["Part 4:顺序维护与迭代篇"]
E1["模块 7:迭代顺序的保证机制"]
E2["模块 8:访问顺序模式与 LRU 实现"]
end
subgraph PART5["Part 5:对比与陷阱篇"]
F1["模块 9:LinkedHashSet vs HashSet vs TreeSet"]
F2["模块 10:常见陷阱与最佳实践"]
end
subgraph PART6["Part 6:总结与面试篇"]
G1["模块 11:注意事项与性能总结"]
G2["模块 12:面试高频专题"]
end
A --> PART1
A --> PART2
A --> PART3
A --> PART4
A --> PART5
A --> PART6
classDef highlight fill:#2c3e50,stroke:#1a252f,color:#ecf0f1
class A highlight
图注说明:全文以六大篇章递进式组织。
-
第一层——全文总览根节点:以 LinkedHashSet 核心架构与定位作为全文入口,引出六大篇章的递进式知识体系。
-
第二层——六大篇章的逻辑递进关系:
- Part 1 基础认知篇:建立对 LinkedHashSet 的定义、特性、适用场景和继承体系的基本认知,为后续深入源码分析奠定概念基础。
- Part 2 存储与构造篇:深入底层存储结构和构造方法的源码细节,重点揭示 HashSet 的包级私有构造器钩子设计,这是理解 LinkedHashSet 实现机制的关键切入点。
- Part 3 核心原理篇:详细剖析 add、remove、contains 三大核心操作的完整调用链路,展示 LinkedHashSet 如何通过父子类协作实现顺序维护。
- Part 4 顺序维护与迭代篇:聚焦迭代顺序的保证机制和访问顺序模式,揭示双向链表的遍历原理以及 LRU 的实现方案。
- Part 5 对比与陷阱篇:将 LinkedHashSet 与 HashSet、TreeSet 进行全方位对比,并总结开发中的常见陷阱与最佳实践。
- Part 6 总结与面试篇:汇总性能特征、注意事项,并集中呈现面试高频专题,覆盖十大必考知识点。
-
第三层——模块内部细分:每个 Part 包含两个模块,模块之间形成从理论到实践、从源码到应用的递进关系,确保每个主题都有充分的深度覆盖。
Part 1:基础认知篇
模块 1:定义、核心特性与适用场景
1.1 明确定义
LinkedHashSet 是继承自 HashSet 的、具有可预测迭代顺序(插入顺序或访问顺序)的、不允许重复元素的集合实现。 它在 Java 集合框架中位于 java.util 包下,实现了 Set、Cloneable 和 Serializable 接口。从其名称可以看出,LinkedHashSet = Linked(链表)+ HashSet(哈希集合),它融合了哈希表的高效查找能力和双向链表的顺序维护能力。
1.2 核心特性列表
| 特性 | 说明 |
|---|---|
| 可预测迭代顺序 | 默认按元素首次插入顺序输出,可选按访问顺序输出 |
| 不允许重复元素 | 基于元素的 hashCode() 和 equals() 方法判断唯一性 |
| 允许 null 元素 | 最多允许一个 null(与 Set 接口约定一致) |
| 底层数据结构 | 哈希表 + 双向链表(委托 LinkedHashMap 实现) |
| 平均性能 | 增删查 O(1),迭代 O(n),略低于 HashSet(需维护链表指针) |
| 非线程安全 | 无内建同步机制,多线程访问需外部同步 |
| fail-fast 迭代器 | 迭代过程中若结构被修改(非迭代器自身操作),抛出 ConcurrentModificationException |
1.3 适用场景与反例场景
适用场景:
- 需要保持原插入顺序的缓存:如缓存配置加载顺序,确保后续处理按配置文件中定义的次序执行。
- 去重后仍按原始顺序处理:如从日志流中提取唯一 IP,但需要保留首次出现的先后顺序。
- LRU 简单实现:利用底层 LinkedHashMap 的 accessOrder 功能,实现最近最少使用淘汰策略。
- JSON/XML 配置去重但需保持声明顺序:解析配置文件时去重但需按声明顺序处理。
反例场景:
- 需要元素自然排序:如字典序、数值排序 → 改用 TreeSet。
- 仅需快速去重且不关心顺序:如海量数据快速判重 → 改用 HashSet(性能略优)。
- 高并发场景:→ 改用 ConcurrentHashMap.newKeySet() 或 Collections.synchronizedSet()。
1.4 LinkedHashSet 特性与适用场景决策树
flowchart TD
A["需要集合存储唯一元素"] --> B{"是否关心元素顺序?"}
B -->|"不关心顺序"| C["使用 HashSet<br/>最快去重性能"]
B -->|"关心顺序"| D{"需要哪种顺序?"}
D -->|"插入顺序"| E["使用 LinkedHashSet<br/>默认构造"]
D -->|"访问顺序(LRU)"| F{"是否需要<br/>访问顺序模式?"}
F -->|"需要"| G["子类化 LinkedHashSet<br/>启用 accessOrder 或直接<br/>使用 LinkedHashMap"]
F -->|"不需要"| E
D -->|"自然排序/自定义排序"| H["使用 TreeSet"]
style E fill:#2c3e50,stroke:#1a252f,color:#ecf0f1
style A fill:#34495e,stroke:#1a252f,color:#ecf0f1
style H fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
style C fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
style G fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
图注说明:决策树展示了 Set 实现类的选型逻辑。
-
第一层——Set 选型的核心考量维度:判断“是否需要可预测的迭代顺序”是选择 HashSet、LinkedHashSet 或 TreeSet 的首要决策分支点。不关心顺序时直接选用 HashSet 获得最快性能,需要排序时转向 TreeSet,两者在需求明确时可直接决策。
-
第二层——顺序类型的二次区分:当用户需要可预测顺序但不确定具体顺序类型时,进入二级决策分支。
- 插入顺序(Insertion-Order) 是最常见的需求场景,直接通过 LinkedHashSet 默认构造即可满足,图中以高亮节点进行强调。
- 访问顺序(Access-Order) 是 LRU 缓存等特定场景的需求,LinkedHashSet 的公开 API 并不直接提供 accessOrder 参数入口,需要通过子类化并重写构造方法或直接使用 LinkedHashMap 来实现。
- 自然排序/自定义排序 始终由 TreeSet 处理,通过元素的 Comparable 接口或外部 Comparator 实现,时间复杂度为 O(log n)。
-
第三层——与本文定位的精确对应:图中 LinkedHashSet 对应节点采用高亮样式,突出其在“需要顺序但不需排序”场景中的独特价值。此外,内存开销因素在选型时也不容忽视——LinkedHashSet 比 HashSet 多出双向链表指针的额外开销,每个元素约增加 8-16 字节(64 位 JVM 上),在大规模数据场景下应纳入考量。
模块 2:接口与继承体系
2.1 类声明与继承链
LinkedHashSet 的类声明极为简洁:
public class LinkedHashSet<E>
extends HashSet<E>
implements Set<E>, Cloneable, java.io.Serializable
它继承 HashSet,而非直接继承 AbstractSet。这意味着 LinkedHashSet 获得了 HashSet 的所有行为——包括 add、remove、contains、size、iterator 等全部方法——而无需重写任何代码。它仅额外实现了 Cloneable 和 Serializable 接口,以支持克隆和序列化。
2.2 继承体系类图
classDiagram
class Iterable {
<<interface>>
}
class Collection {
<<interface>>
}
class Set {
<<interface>>
}
class AbstractCollection {
<<abstract>>
}
class AbstractSet {
<<abstract>>
}
class HashSet {
-HashMap map
-Object PRESENT
+HashSet()
#HashSet(int float boolean)
+add(E e)
+remove(Object o)
+contains(Object o)
+iterator()
}
class LinkedHashSet {
+LinkedHashSet()
+LinkedHashSet(int)
+LinkedHashSet(int float)
+LinkedHashSet(Collection)
+spliterator()
}
class HashMap {
+put(K V)
+remove(Object)
+containsKey(Object)
}
class LinkedHashMap {
-Entry head
-Entry tail
-boolean accessOrder
+LinkedHashMap(int float boolean)
+get(Object)
+newNode()
+afterNodeAccess()
+afterNodeInsertion()
+afterNodeRemoval()
}
class LinkedHashMapEntry {
-Entry before
-Entry after
}
class HashMapNode {
-int hash
-K key
-V value
-Node next
}
Iterable <|.. Collection
Collection <|.. Set
AbstractCollection <|-- AbstractSet
AbstractSet <|-- HashSet
HashSet <|-- LinkedHashSet
Set <|.. HashSet
Set <|.. LinkedHashSet
HashMap <|-- LinkedHashMap
LinkedHashMap --* HashSet
HashMapNode <|-- LinkedHashMapEntry
LinkedHashMapEntry --* LinkedHashMap
图注说明:类图展示了 LinkedHashSet 的完整继承和委托关系。
-
第一层——Set 规范继承链:从顶层接口
Iterable→Collection→Set,到抽象实现AbstractCollection→AbstractSet,再到具体实现HashSet→LinkedHashSet。LinkedHashSet 位于继承链的最末端,直接继承 HashSet,无需重写 AbstractSet 的任何方法。 -
第二层——核心委托关系(HashSet → HashMap/LinkedHashMap):
HashSet内部组合了一个HashMap实例(成员变量map),所有 Set 操作均委托给该 Map。- 关键设计:
HashSet提供了一个包级私有的特殊构造器HashSet(int, float, boolean),其中第三个参数dummy是纯标记参数,内部实际被忽略。该构造器创建的是LinkedHashMap而非HashMap,从而为 LinkedHashSet 提供了委托替换的钩子。图中以--*组合关系标注了LinkedHashMap --* HashSet,明确指出两者的连接关系。
-
第三层——链表节点继承体系:
HashMap.Node为基础节点,包含hash、key、value、next四个字段,用于解决哈希冲突。LinkedHashMap.Entry继承HashMap.Node,额外新增了before和after两个引用,用于维护双向链表的前后指针关系。- 红黑树节点
TreeNode继承LinkedHashMap.Entry,从而也具备了双向链表的能力(但非链表模式时不使用这些引用)。
-
第四层——关键设计结论:
- LinkedHashSet 无任何自定义字段,完全依赖继承的
map字段,仅通过构造器替换底层 Map 类型。 - HashMap.put 等方法内部调用
newNode()创建节点,而 LinkedHashMap 重写了newNode()方法返回LinkedHashMap.Entry实例,并在创建节点的同时调用linkNodeLast()维护双向链表。 afterNodeAccess、afterNodeInsertion、afterNodeRemoval三个钩子方法在 HashMap 中为空实现,由 LinkedHashMap 重写以维护对应的链表操作逻辑。这三个钩子是 LinkedHashMap 在不破坏 HashMap 原有逻辑的前提下实现顺序维护的模板方法模式核心。
- LinkedHashSet 无任何自定义字段,完全依赖继承的
Part 2:存储与构造篇
模块 3:存储结构与底层依赖(源码剖析)
3.1 LinkedHashSet 的无字段设计
一个经验丰富的 Java 开发者第一次阅读 LinkedHashSet 源码时,第一反应往往是惊讶——这个类竟然没有任何自己的成员变量。除了四个构造方法和一个 spliterator() 方法外,它完全是空白的:
// LinkedHashSet 源码概览(JDK 8)
public class LinkedHashSet<E>
extends HashSet<E>
implements Set<E>, Cloneable, java.io.Serializable {
private static final long serialVersionUID = -2851667679971038690L;
// 四个构造方法均调用 super(initialCapacity, loadFactor, true)
public LinkedHashSet(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor, true);
}
public LinkedHashSet(int initialCapacity) {
super(initialCapacity, .75f, true);
}
public LinkedHashSet() {
super(16, .75f, true);
}
public LinkedHashSet(Collection<? extends E> c) {
super(Math.max(2*c.size(), 11), .75f, true);
addAll(c);
}
@Override
public Spliterator<E> spliterator() {
return Spliterators.spliterator(this, Spliterator.DISTINCT | Spliterator.ORDERED);
}
}
与其它集合类的源码长度对比:HashSet 约 300+ 行源码(包含构造器、add、remove 等完整实现),LinkedHashMap 约 600+ 行(包含双向链表、LRU 相关逻辑),而 LinkedHashSet 总共仅约 50 行。这种极致的简洁,正是 JDK 设计者通过巧妙的重构手段实现零重复代码(Zero Boilerplate)的典范——既避免了在 LinkedHashSet 中重复实现一套“带顺序的 Set”逻辑,也避免了将 LinkedHashMap 的逻辑硬编码进 HashSet 造成耦合。
3.2 HashSet 的包级私有钩子构造器
这是整个设计中最巧妙的环节。HashSet 提供了一个包级私有(package-private)的构造器:
/**
* Constructs a new, empty linked hash set. (This package private
* constructor is only used by LinkedHashSet.) The backing
* HashMap instance is a LinkedHashMap with the specified initial
* capacity and the specified load factor.
*
* @param initialCapacity the initial capacity of the hash map
* @param loadFactor the load factor of the hash map
* @param dummy ignored (distinguishes this
* constructor from other int, float constructor.)
* @throws IllegalArgumentException if the initial capacity is less
* than zero, or if the load factor is nonpositive
*/
HashSet(int initialCapacity, float loadFactor, boolean dummy) {
map = new LinkedHashMap<>(initialCapacity, loadFactor);
}
关键设计点分析:
-
访问权限为默认(包级私有):该构造器没有
public修饰符,意味着只有java.util包内的类才能访问。LinkedHashSet 正好位于同一包内,可以直接调用。外部用户无法直接通过此构造器创建 HashSet 实例并替换底层 Map。 -
dummy 参数——纯签名区分器:第三个参数
dummy的值在方法体内完全未被使用。它的唯一作用是让编译器和 JVM 能够区分这个构造器签名与另一个公开构造器HashSet(int initialCapacity, float loadFactor)。若没有 dummy 参数,两者签名完全相同,将导致编译错误(Constructor ambiguity)。Javadoc 中明确写道:“@param dummy ignored (distinguishes this constructor from other int, float constructor.)”。 -
底层创建 LinkedHashMap:与 HashSet 默认构造器创建
new HashMap<>()不同,此构造器创建的是new LinkedHashMap<>(initialCapacity, loadFactor)。由于 HashSet 内部通过多态调用map的方法,而map的实际运行时类型是 LinkedHashMap,因此所有操作自动获得双向链表维护能力。 -
accessOrder 默认值为 false:此构造器创建的 LinkedHashMap 未传入第三个参数
accessOrder,因此默认使用插入顺序模式。这意味着标准 LinkedHashSet 只支持插入顺序,不直接支持访问顺序。这也是 LinkedHashSet 的一个明显设计限制。
3.3 LinkedHashMap.Entry 节点结构
LinkedHashMap 的条目节点是理解顺序维护的关键:
// 位于 LinkedHashMap.java
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
字段说明:
| 字段 | 来源 | 作用 |
|---|---|---|
hash | 继承自 HashMap.Node | 存储 key 的哈希值,用于哈希表的桶定位 |
key | 继承自 HashMap.Node | 存储的元素(Set 中为元素本身) |
value | 继承自 HashMap.Node | 在 Set 中始终为 PRESENT 常量 |
next | 继承自 HashMap.Node | 解决哈希冲突,指向同一个桶中的下一个节点 |
before | LinkedHashMap.Entry 新增 | 指向双向链表中当前节点的前驱节点 |
after | LinkedHashMap.Entry 新增 | 指向双向链表中当前节点的后继节点 |
3.4 LinkedHashMap 维护双向链表的关键字段与方法
classDiagram
class LinkedHashMap {
-transient Entry head
-transient Entry tail
-boolean accessOrder
+void linkNodeLast(Entry p)
+void afterNodeAccess(Node e)
+void afterNodeInsertion(boolean evict)
+void afterNodeRemoval(Node e)
}
class HashMap {
+Node newNode(int hash, K key, V value, Node next)
+void afterNodeAccess(Node p)
+void afterNodeInsertion(boolean evict)
+void afterNodeRemoval(Node p)
}
class Entry {
-Entry before
-Entry after
}
HashMap <|-- LinkedHashMap
Entry --* LinkedHashMap
图注说明:LinkedHashMap 双向链表维护的核心组件。
-
第一层——head 和 tail 引用:
head指向双向链表的头节点(最早插入),tail指向尾节点(最近插入或最近访问)。这两个引用是链表遍历的起始入口。注意:head 和 tail 在 LinkedHashSet 场景中始终指向实际元素节点,而非 Hash 桶的引用。 -
第二层——accessOrder 标志字段:控制链表的排序模式。
false(默认):插入顺序模式——新节点追加到链表尾部,重复 put 不改变现有节点位置。true:访问顺序模式——任何访问操作(get、put 更新)都会将节点移动到链表尾部。
-
第三层——钩子方法体系(模板方法模式的核心):
newNode()重写创建 LinkedHashMap.Entry 并调用linkNodeLast()插入链表尾部。afterNodeAccess(Node e):HashMap 中为空实现,LinkedHashMap 重写后在accessOrder=true时将访问节点移至链表尾部。afterNodeInsertion(boolean evict):HashMap 中为空实现,LinkedHashMap 重写后检查是否需要移除最老条目(支持 LRU 的 removeEldestEntry 机制)。afterNodeRemoval(Node e):HashMap 中为空实现,LinkedHashMap 重写后从双向链表中摘除被删除节点(更新 before/after 指针)。
模块 4:构造方法(源码剖析)
4.1 四个公开构造器的统一调用链
LinkedHashSet 的四个公开构造器,无一例外地调用了 HashSet 的包级私有构造器:
// 构造器 1:指定初始容量和负载因子
public LinkedHashSet(int initialCapacity, float loadFactor) {
super(initialCapacity, loadFactor, true); // dummy = true
}
// 构造器 2:指定初始容量,负载因子默认 0.75
public LinkedHashSet(int initialCapacity) {
super(initialCapacity, .75f, true); // dummy = true
}
// 构造器 3:默认构造器,容量 16,负载因子 0.75
public LinkedHashSet() {
super(16, .75f, true); // dummy = true
}
// 构造器 4:从指定集合构造
public LinkedHashSet(Collection<? extends E> c) {
super(Math.max(2*c.size(), 11), .75f, true);
addAll(c);
}
关键设计推论:
-
dummy 参数恒为 true:该参数在方法体内被忽略,但必须传入一个 boolean 值以匹配第一个构造器签名。JDK 设计者选择传入
true纯粹是惯例。 -
容量计算中的经验公式
Math.max(2*c.size(), 11):第三个构造器的容量计算使用了2*c.size()作为基准。这确保了在 addAll 循环插入过程中,HashMap 不会因为负载因子 0.75 的扩容触发阈值而被过早扩容(2 * size / 0.75 ≈ 2.67 * size,远大于实际元素数)。下限 11 是为极小的集合(如 size=1)保留合理的初始容量(HashMap 扩容触发阈值 threshold = capacity * loadFactor,若 capacity 过小则插入首个元素时即触发扩容)。 -
没有公开的 accessOrder 构造器:这是 LinkedHashSet 的一个显著局限性。标准 API 无法直接创建访问顺序模式的 LinkedHashSet。若需要此功能,必须通过反射或子类化。
4.2 构造演示 Demo
import java.util.*;
public class LinkedHashSetConstructionDemo {
public static void main(String[] args) {
// 1. 默认构造——插入顺序模式,容量 16,负载因子 0.75
LinkedHashSet<String> set1 = new LinkedHashSet<>();
set1.add("C");
set1.add("A");
set1.add("B");
System.out.println("默认构造,迭代顺序: " + set1); // [C, A, B]
// 2. 指定初始容量
LinkedHashSet<String> set2 = new LinkedHashSet<>(32);
set2.add("X");
set2.add("Y");
set2.add("Z");
System.out.println("指定容量,迭代顺序: " + set2); // [X, Y, Z]
// 3. 从集合构造——保留原集合的迭代顺序
List<String> list = Arrays.asList("One", "Two", "Three", "Two");
LinkedHashSet<String> set3 = new LinkedHashSet<>(list);
System.out.println("从集合构造,去重且保留顺序: " + set3); // [One, Two, Three]
// 4. 重复 put 不改变顺序(accessOrder=false)
set1.add("A"); // A 已存在
System.out.println("重复添加 A 后迭代顺序: " + set1); // [C, A, B],顺序不变
// 5. null 元素的处理
LinkedHashSet<String> set4 = new LinkedHashSet<>();
set4.add(null);
set4.add("Hello");
set4.add(null); // 重复 null,不会添加
System.out.println("包含 null 的集合: " + set4); // [null, Hello]
}
}
// ===== 运行结果 =====
// 默认构造,迭代顺序: [C, A, B]
// 指定容量,迭代顺序: [X, Y, Z]
// 从集合构造,去重且保留顺序: [One, Two, Three]
// 重复添加 A 后迭代顺序: [C, A, B]
// 包含 null 的集合: [null, Hello]
Part 3:核心原理篇
模块 5:add 操作——顺序插入与去重(源码剖析)
5.1 继承链中的方法调用
LinkedHashSet 本身没有重写 add 方法,完全继承自 HashSet:
// HashSet.java
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
由于构造器中已将 map 替换为 LinkedHashMap 实例,因此 map.put(e, PRESENT) 实际上调用的是 LinkedHashMap(继承自 HashMap)的 put 方法。PRESENT 是 HashSet 中定义的一个静态常量哑元对象:
// HashSet.java
private static final Object PRESENT = new Object();
Set 的元素作为 Map 的 Key 存储,所有 Key 共享同一个 Value(PRESENT 常量)。return 值的判断逻辑:Map.put 返回旧 value 对象(若 Key 此前存在),若 Key 此前不存在则返回 null。因此 map.put(e, PRESENT) == null 为 true 表示元素是新插入的,方法返回 true;为 false 表示元素已存在(即重复),方法返回 false。这一设计同时实现了 Set 的去重语义和插入成功/失败的返回值规则。
5.2 HashMap.putVal 内部的关键钩子点
HashMap 的 put 方法内部调用 putVal 方法,其中有三个关键位置调用了子类可重写的钩子方法:
// HashMap.putVal 简化代码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// ... 桶定位逻辑 ...
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null); // 钩子点 1:创建新节点
else {
// ... 冲突处理(链表遍历或红黑树操作)...
if (e != null) { // 找到了相同 key 的旧节点
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // 钩子点 2:访问节点后处理
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 钩子点 3:插入完成后处理
return null;
}
5.3 LinkedHashMap 对这些钩子的重写
钩子点 1——newNode() → linkNodeLast():
// LinkedHashMap.java
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
new LinkedHashMap.Entry<K,V>(hash, key, value, e);
linkNodeLast(p); // 将新节点追加到双向链表尾部
return p;
}
// linkNodeLast 方法——双向链表尾插法
private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
LinkedHashMap.Entry<K,V> last = tail; // 保存当前尾节点
tail = p; // 新节点成为尾节点
if (last == null) // 链表为空(首次插入)
head = p; // 头节点也指向新节点
else {
p.before = last; // 新节点的前驱指向旧尾节点
last.after = p; // 旧尾节点的后继指向新节点
}
}
钩子点 2——afterNodeAccess(): 在插入顺序模式(accessOrder=false)下为空操作;在访问顺序模式(accessOrder=true)下才会将节点移到链表尾部。
钩子点 3——afterNodeInsertion(): 检查 removeEldestEntry 条件(用于 LRU 淘汰策略,默认返回 false 不淘汰)。
5.4 add 操作完整流程图
flowchart TD
A["LinkedHashSet.add(E e)"] --> B["HashSet.add(e)<br/>return map.put(e, PRESENT)==null"]
B --> C["LinkedHashMap.put(e, PRESENT)<br/>(继承自HashMap.put)"]
C --> D["HashMap.putVal(hash, key, value, ...)"]
D --> E{"该 key 的 hash<br/>对应的桶位是否为空?"}
E -->|"空桶"| F["LinkedHashMap.newNode()"]
F --> G["创建 LinkedHashMap.Entry 节点"]
G --> H["linkNodeLast(新节点)<br/>追加到双向链表尾部"]
H --> I{"链表此前为空?<br/>(tail == null)"}
I -->|"是"| J["head = tail = 新节点"]
I -->|"否"| K["更新 tail 和新节点的<br/>before/after 指针"]
J --> L["afterNodeInsertion(evict)<br/>检查是否要淘汰最老节点"]
K --> L
L --> M["return null → add返回true<br/>✅ 插入成功"]
E -->|"非空桶"| N{"沿链表/红黑树<br/>查找是否已有<br/>相同 key?"}
N -->|"找到相同key"| O["更新 value 为 PRESENT<br/>afterNodeAccess(e)"]
O --> P["return 旧value ≠ null<br/>❌ add返回false(重复元素)"]
N -->|"未找到"| F
style A fill:#2c3e50,stroke:#1a252f,color:#ecf0f1
style H fill:#34495e,stroke:#1a252f,color:#ecf0f1
style M fill:#27ae60,stroke:#1a252f,color:#fff
style P fill:#e74c3c,stroke:#1a252f,color:#fff
图注说明:add 操作的六层调用链路与双向链表维护过程。
-
第一层——LinkedHashSet.add 入口:调用链起点为
LinkedHashSet.add(E e),该方法完全继承自 HashSet,内部仅一行代码return map.put(e, PRESENT) == null。背景机制:因为构造器已将map替换为 LinkedHashMap 实例,map.put()的实际运行时类型是 LinkedHashMap。 -
第二层——HashMap.putVal 核心逻辑分流:
LinkedHashMap.put继承自HashMap.put,内部调用putVal方法。这是 HashMap 存储逻辑的核心,根据哈希桶位是否为空分流到两种处理路径。注意:LinkedHashMap 没有重写putVal,零侵入式复用父类逻辑。 -
第三层——空桶路径(新节点创建):
- 步骤 A:调用
LinkedHashMap.newNode()—— 这是 LinkedHashMap 重写的钩子方法,创建的是LinkedHashMap.Entry类型节点(包含 before/after 指针)。 - 步骤 B:调用
linkNodeLast()—— 这是维护插入顺序的关键方法,将新节点追加到双向链表尾部。 - 步骤 C:判断链表是否为空(
tail == null),首次插入同时设置 head 和 tail;后续插入只更新 tail 和指针关系。
- 步骤 A:调用
-
第四层——非空桶路径(冲突处理):
- 步骤 A:遍历桶内的链表或红黑树结构,通过
hashCode()和equals()查找是否已有相同 Key 的元素。 - 步骤 B:若找到相同 Key,只更新 Value(将旧的 PRESENT 替换为新的 PRESENT),不调用 newNode,不修改双向链表顺序(accessOrder=false 时)。
- 步骤 C:若未找到,走空桶路径创建新节点。
- 步骤 A:遍历桶内的链表或红黑树结构,通过
-
第五层——结果返回:
- 新插入返回
null→ HashSet.add 判断null == null→ 返回true✅ 插入成功。 - 重复元素返回旧 PRESENT(非 null)→ HashSet.add 判断
非null == null→ 返回false❌ 元素重复,拒绝插入。
- 新插入返回
-
第六层——关键设计结论:
- LinkedHashSet 的重复判断完全依赖 HashMap 的
hashCode()和equals()机制,而顺序维护完全是 LinkedHashMap 通过newNode()和linkNodeLast()实现的附加行为,两者在 HashMap 的putVal方法中无缝衔接。 - 模板方法模式是理解这套机制的关键——HashMap 定义了算法骨架(putVal 流程),LinkedHashMap 通过重写
newNode、afterNodeAccess、afterNodeInsertion等方法在骨架的关键节点注入链表维护逻辑,实现“不修改框架即可扩展行为”的优雅设计。
- LinkedHashSet 的重复判断完全依赖 HashMap 的
模块 6:remove 与 contains 操作(源码剖析)
6.1 remove 操作——三层视角的调用链
LinkedHashSet 的 remove 同样继承自 HashSet:
// HashSet.java
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
第一层(Set 层):HashSet 委托给 Map 的 remove 方法,判断返回值是否为 PRESENT。
第二层(HashMap 层):HashMap.remove 查找并移除节点,关键钩子点在 afterNodeRemoval:
// HashMap.java removeNode 方法(简化)
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// ... 查找节点 ...
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
tab[index] = node.next;
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node); // 钩子点:从双向链表中摘除节点
return node;
}
return null;
}
第三层(LinkedHashMap 层):afterNodeRemoval 摘除双向链表中的节点引用:
// LinkedHashMap.java
void afterNodeRemoval(Node<K,V> e) {
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.before = p.after = null; // 清空被删除节点的指针,帮助 GC
if (b == null) // 被删除的是头节点
head = a; // 头节点后继成为新头节点
else
b.after = a; // 前驱节点的后继跳过当前节点
if (a == null) // 被删除的是尾节点
tail = b; // 尾节点的前驱成为新尾节点
else
a.before = b; // 后继节点的前驱跳过当前节点
}
6.2 contains 操作——纯查询,不改变链表
// HashSet.java
public boolean contains(Object o) {
return map.containsKey(o);
}
contains 直接调用 LinkedHashMap.containsKey(继承自 HashMap),利用哈希查找定位桶位后遍历链表/红黑树进行匹配。此操作不涉及 newNode、afterNodeAccess、afterNodeInsertion 等任何钩子方法,不会改变双向链表的顺序。这是 LinkedHashSet 与访问顺序模式的关键区别——在插入顺序模式下,contains 不改变元素位置。
6.3 remove 操作流程图
flowchart TD
A["LinkedHashSet.remove(Object o)"] --> B["HashSet.remove(o)<br/>return map.remove(o)==PRESENT"]
B --> C["LinkedHashMap.remove(o)<br/>(继承自HashMap.remove)"]
C --> D["HashMap.removeNode(hash, key, ...)"]
D --> E{"通过hash定位桶位<br/>查找目标节点"}
E -->|"未找到"| F["return null →<br/>remove返回false ❌"]
E -->|"找到节点 node"| G["从哈希桶中摘除 node<br/>(修改next指针或<br/>红黑树结构调整)"]
G --> H["afterNodeRemoval(node)<br/>🔑 LinkedHashMap重写的钩子"]
H --> I{"判断 node 在<br/>双向链表中的位置"}
I -->|"是 head(头节点)"| J["head = node.after<br/>(后继成为新头)"]
I -->|"是 tail(尾节点)"| K["tail = node.before<br/>(前驱成为新尾)"]
I -->|"中间节点"| L["node.before.after = node.after<br/>node.after.before = node.before"]
L --> M["node.before = node.after = null<br/>(清空指针帮助GC)"]
J --> M
K --> M
M --> N["return node →<br/>remove返回true ✅"]
style A fill:#2c3e50,stroke:#1a252f,color:#ecf0f1
style H fill:#34495e,stroke:#1a252f,color:#ecf0f1
style N fill:#27ae60,stroke:#1a252f,color:#fff
style F fill:#e74c3c,stroke:#1a252f,color:#fff
图注说明:remove 操作的核心在于双向链表的节点摘除。
-
第一层——方法委托链:
LinkedHashSet.remove -> HashSet.remove -> LinkedHashMap.remove -> HashMap.removeNode,与 add 操作相同的委托模式。 -
第二层——哈希桶层面摘除:HashMap.removeNode 首先通过哈希定位找到节点所在的桶,然后通过修改
next指针从哈希冲突链(或红黑树)中摘除节点。这一步是 HashMap 层面的通用逻辑,不涉及双向链表。 -
第三层——双向链表层面摘除(核心钩子):
afterNodeRemoval(node)是 LinkedHashMap 重写的钩子方法,负责从双向链表中摘除目标节点。- 三种位置的边界处理:
- 若被删除节点是头节点(b == null):将
head指向其后继节点a。 - 若被删除节点是尾节点(a == null):将
tail指向其前驱节点b。 - 若被删除节点在链表中间:通过
b.after = a和a.before = b将前后节点双向连接,跳过被删除节点。
- 若被删除节点是头节点(b == null):将
- 最后一步:将
node.before和node.after均置为 null,断开节点与链表的全部引用,帮助 GC 回收。
- 三种位置的边界处理:
-
第四层——返回值传递:HashMap.removeNode 返回被删除的节点 node → HashSet.remove 判断
node == PRESENT(此处返回的是 node 对象本身,equals 为真)→ 返回 true ✅ 删除成功。若未找到目标节点,removeNode 返回 null → HashSet.remove 判断null == PRESENT→ 返回 false ❌。
Part 4:顺序维护与迭代篇
模块 7:迭代顺序的保证机制(源码剖析)
7.1 迭代器获取链路
LinkedHashSet 的 iterator() 方法继承自 HashSet:
// HashSet.java
public Iterator<E> iterator() {
return map.keySet().iterator();
}
关键点在于 map.keySet().iterator() 的实际行为取决于 map 的运行时类型。 当 map 是 LinkedHashMap 时,keySet().iterator() 返回的是 LinkedKeyIterator;当 map 是 HashMap 时,返回的是 KeyIterator。两者的遍历策略完全不同。
7.2 HashMap KeyIterator vs LinkedHashMap LinkedKeyIterator
HashMap 的 KeyIterator(无序迭代)遍历的是哈希表数组,从 table[0] 到 table[length-1] 依次访问每个非空桶的链表节点,顺序由哈希分布决定,不可预测。
LinkedHashMap 的 LinkedKeyIterator(有序迭代)完全绕过哈希表,直接从 head 引用出发,沿 after 指针依次遍历双向链表中的每个节点:
// LinkedHashMap.java 内部类 LinkedKeyIterator
final class LinkedKeyIterator extends LinkedHashIterator
implements Iterator<K> {
public final K next() { return nextNode().getKey(); }
}
// LinkedHashIterator 核心逻辑
abstract class LinkedHashIterator {
LinkedHashMap.Entry<K,V> next; // 下一个要返回的节点
LinkedHashMap.Entry<K,V> current; // 当前节点
int expectedModCount;
LinkedHashIterator() {
next = head; // 从双向链表头节点开始
expectedModCount = modCount;
}
public final boolean hasNext() {
return next != null;
}
final LinkedHashMap.Entry<K,V> nextNode() {
LinkedHashMap.Entry<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException(); // fail-fast
if (e == null)
throw new NoSuchElementException();
current = e;
next = e.after; // 沿 after 指针移动到下一个节点
return e;
}
}
7.3 迭代顺序保证的时序图
sequenceDiagram
participant Client as 客户端代码
participant LHS as LinkedHashSet
participant LHM as LinkedHashMap
participant Iterator as LinkedKeyIterator
participant DLList as 双向链表(head→tail)
Client->>LHS: 1. add("C")
LHS->>LHM: put("C", PRESENT)
LHM->>DLList: linkNodeLast → head=tail=C
Client->>LHS: 2. add("A")
LHS->>LHM: put("A", PRESENT)
LHM->>DLList: linkNodeLast → C⇄A, tail=A
Client->>LHS: 3. add("B")
LHS->>LHM: put("B", PRESENT)
LHM->>DLList: linkNodeLast → C⇄A⇄B, tail=B
Client->>LHS: 4. add("A") [重复]
LHS->>LHM: put("A", PRESENT)
LHM-->>LHS: 返回旧值≠null (重复)
Note over DLList: 链表不变:C⇄A⇄B
Client->>LHS: 5. iterator()
LHS->>LHM: keySet().iterator()
LHM->>Iterator: new LinkedKeyIterator()
Note over Iterator: next = head (节点C)
Client->>Iterator: 6. hasNext() → true
Client->>Iterator: 7. next() → "C"
Note over Iterator: next = head.after (节点A)
Client->>Iterator: 8. next() → "A"
Note over Iterator: next = A.after (节点B)
Client->>Iterator: 9. next() → "B"
Note over Iterator: next = null (到达链表尾部)
Client->>Iterator: 10. hasNext() → false
Note over Client: 输出顺序:C → A → B<br/>严格等于插入顺序
图注说明:时序图分为两个阶段——元素插入阶段和迭代遍历阶段。
-
第一阶段——元素插入(步骤 1-4):
- 步骤 1:插入
"C",链表为空,linkNodeLast将 head 和 tail 都指向 C 节点。 - 步骤 2:插入
"A",链表已有节点,linkNodeLast将 A 追加到 C 之后,tail 指向 A。此时链表为 C ⇄ A(⇄表示双向连接)。 - 步骤 3:插入
"B",追加到尾部,链表变为 C ⇄ A ⇄ B,tail 指向 B。 - 步骤 4(关键):重复插入
"A"——HashMap.put 通过hashCode()和equals()判断已存在相同元素,putVal 返回旧值(非 null),不会触发 newNode() 和 linkNodeLast()。链表保持 C ⇄ A ⇄ B 不变,A 的位置没有改变。
- 步骤 1:插入
-
第二阶段——迭代遍历(步骤 5-10):
- 步骤 5:
LinkedKeyIterator构造时直接将next指向 head(C 节点),完全绕过哈希表桶索引(HashMap 的 KeyIterator 从table[0]开始扫描桶位,两者遍历策略截然不同)。 - 步骤 6-9:通过
e.after指针沿双向链表依次访问:C → A → B,每次next()都返回当前节点的 key 并将next更新为e.after。 - 步骤 10:B 的 after 为 null(B 是尾节点),
hasNext()返回 false。
- 步骤 5:
-
第三层——关键结论:
- 迭代顺序 = 首次插入顺序,不受 hashCode 分布影响,不受哈希表扩容影响,不被重复插入改变。
- LinkedHashMap 的迭代复杂度仅与元素数量 n 有关(O(n)),而 HashMap 的迭代需要遍历整张哈希表(包括空桶),复杂度与 capacity + n 有关。LinkedHashMap 的双向链表结构可以跳过所有空桶,直接访问有效元素,在稀疏哈希表场景中迭代性能实际优于 HashMap。
- fail-fast 机制:迭代器构造时记录
modCount,若检测到结构修改(非迭代器自身的 remove 操作),nextNode()立即抛出 ConcurrentModificationException。
模块 8:访问顺序模式与 LRU 实现(源码剖析)
8.1 LinkedHashSet 缺少公开 accessOrder 入口的设计限制
如前所述,LinkedHashSet 的四个公开构造器均调用 HashSet(int, float, boolean),该构造器创建的 LinkedHashMap 未传入 accessOrder 参数,因此默认为 false(插入顺序模式)。标准 LinkedHashSet 不支持访问顺序模式。
8.2 启用访问顺序模式的途径
途径一:子类化 LinkedHashSet 并调用父类构造器(行不通)
因为 HashSet 的包级私有构造器不对子包开放,LinkedHashSet 的子类无法直接调用。
途径二:使用反射(可行但不推荐)
// 不推荐的反射方式(仅为演示原理)
Constructor<HashSet> c = HashSet.class.getDeclaredConstructor(
int.class, float.class, boolean.class);
c.setAccessible(true);
HashSet<String> set = c.newInstance(16, 0.75f, true);
// set 实际上是 LinkedHashSet 的行为(底层是 LinkedHashMap)
途径三:直接使用 LinkedHashMap(推荐)
若需要访问顺序功能,最标准的方式是直接使用 LinkedHashMap 并传入 accessOrder=true:
// 推荐方式:直接使用 LinkedHashMap
LinkedHashMap<String, Object> map = new LinkedHashMap<>(16, 0.75f, true);
// 然后通过 map.keySet() 获得一个具有访问顺序的 Set 视图
Set<String> set = map.keySet();
或者使用 Collections.newSetFromMap 包装:
Set<String> accessOrderedSet = Collections.newSetFromMap(
new LinkedHashMap<String, Boolean>(16, 0.75f, true));
8.3 afterNodeAccess 的源码分析
在访问顺序模式下,当元素被 get 或重复 put 时,afterNodeAccess 方法将其移动到链表尾部:
// LinkedHashMap.java
void afterNodeAccess(Node<K,V> e) {
LinkedHashMap.Entry<K,V> last;
if (accessOrder && (last = tail) != e) { // 仅在访问顺序模式下,且不是尾节点
LinkedHashMap.Entry<K,V> p =
(LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
p.after = null;
if (b == null) // e 是头节点
head = a; // 后继成为新头
else
b.after = a; // 前驱跳过 e
if (a != null) // e 不是尾节点
a.before = b; // 后继的前驱指向前驱
else
last = b; // 理论上不会进入(前面已判断 tail != e)
if (last == null)
head = p; // 列表为空
else {
p.before = last; // p 的前驱指向旧尾节点
last.after = p; // 旧尾节点的后继指向 p
}
tail = p; // p 成为新尾节点
++modCount;
}
}
关键判断 (last = tail) != e:如果被访问的元素已经是尾节点,则不需要移动。这是一个性能优化——避免了链表已就绪时的无谓操作。
8.4 LRU 缓存实现 Demo
import java.util.*;
/**
* 基于 LinkedHashMap 的 LRU 缓存实现
* 利用 accessOrder=true 和 removeEldestEntry 钩子实现自动淘汰
*/
public class LRUCache<K, V> extends LinkedHashMap<K, V> {
private final int maxCapacity;
public LRUCache(int maxCapacity) {
// 关键:accessOrder=true 启用访问顺序模式
super(16, 0.75f, true);
this.maxCapacity = maxCapacity;
}
/**
* 重写 removeEldestEntry 方法
* 当 size() 超过 maxCapacity 时,自动移除最老的条目
*/
@Override
protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxCapacity;
}
// ===== Demo 测试 =====
public static void main(String[] args) {
LRUCache<String, Integer> cache = new LRUCache<>(3);
// 插入 3 个元素(达到容量上限)
cache.put("A", 1);
cache.put("B", 2);
cache.put("C", 3);
System.out.println("初始缓存: " + cache.keySet()); // [A, B, C]
// 访问 A——A 被移到链表尾部
cache.get("A");
System.out.println("访问 A 后: " + cache.keySet()); // [B, C, A]
// 访问 B——B 被移到链表尾部
cache.get("B");
System.out.println("访问 B 后: " + cache.keySet()); // [C, A, B]
// 插入新元素 D——触发淘汰最老节点 C
cache.put("D", 4);
System.out.println("插入 D 后(淘汰最老): " + cache.keySet()); // [A, B, D]
// 验证 C 已被淘汰
System.out.println("C 是否仍在缓存: " + cache.containsKey("C")); // false
}
}
// ===== 运行结果 =====
// 初始缓存: [A, B, C]
// 访问 A 后: [B, C, A]
// 访问 B 后: [C, A, B]
// 插入 D 后(淘汰最老): [A, B, D]
// C 是否仍在缓存: false
8.5 afterNodeAccess 触发移动的时序图
sequenceDiagram
participant Client as 客户端
participant LHM as LinkedHashMap<br/>(accessOrder=true)
participant HM as HashMap.putVal
participant Node as 双向链表节点
Note over LHM: 当前链表:A ⇄ B ⇄ C<br/>head=A, tail=C
Client->>LHM: get("B")
LHM->>HM: 哈希查找,找到节点 B
HM->>LHM: afterNodeAccess(B)
Note over LHM: 判断 accessOrder=true<br/>且 tail(C) ≠ B
LHM->>Node: 1. 从链表中间摘除 B
Note over Node: b=A, a=C<br/>A.after = C<br/>C.before = A
Note over Node: 链表变为:A → C
LHM->>Node: 2. 将 B 追加到链表尾部
Note over Node: last = tail = C<br/>B.before = C<br/>C.after = B
Note over Node: tail = B
Note over LHM: 最终链表:A ⇄ C ⇄ B<br/>head=A, tail=B
LHM-->>Client: 返回 B 对应的 value
Note over Client: B 从中间位置被移到了尾部<br/>迭代顺序变为 A → C → B
图注说明:访问顺序模式下 afterNodeAccess 的两阶段节点移动过程。
-
第一层——触发条件检查:
afterNodeAccess仅在accessOrder=true且 被访问节点不是当前尾节点(tail != e)时执行移动。双重检查避免了不必要的链表操作开销。在插入顺序模式(accessOrder=false)下,该方法直接返回,不做任何操作。 -
第二层——节点摘除阶段:将目标节点从双向链表的当前位置摘除。
- 更新前驱节点的
after指针,使其指向目标节点的后继节点。 - 更新后继节点的
before指针,使其指向目标节点的前驱节点。 - 三种边界情况处理:若 B 是头节点(
b == null),更新head = a;若 B 是尾节点(理论上不会进入此分支,因为前面已检查tail != e)。
- 更新前驱节点的
-
第三层——节点追加阶段:将摘除的节点追加到链表尾部。
- 将节点 B 的
before指向旧尾节点 C。 - 将旧尾节点 C 的
after指向节点 B。 - 更新
tail = B。
- 将节点 B 的
-
第四层——LRU 的重要设计推论:
- 链表头部 = 最久未访问的元素(即将被淘汰)。
- 链表尾部 = 最近访问的元素。
removeEldestEntry钩子配合afterNodeInsertion在每次新元素插入后检查是否需要淘汰head节点。完整的 LRU 淘汰时序为:插入新元素 → putVal 执行 → afterNodeInsertion(evict) → 判断 removeEldestEntry(eldest=head) 是否返回 true → 若 true,调用 HashMap.removeNode 移除 head 节点。
Part 5:对比与陷阱篇
模块 9:LinkedHashSet vs HashSet vs TreeSet——有序性的三种维度
9.1 全方位对比表
| 维度 | HashSet | LinkedHashSet | TreeSet |
|---|---|---|---|
| 底层数据结构 | 哈希表(HashMap) | 哈希表 + 双向链表(LinkedHashMap) | 红黑树(TreeMap) |
| 迭代顺序 | 无序(取决于哈希分布) | 插入顺序或访问顺序 | 自然排序或自定义排序 |
| null 元素 | 允许一个 null | 允许一个 null | 不允许(需要比较) |
| add 时间复杂度 | O(1) 平均 | O(1) 平均(略慢) | O(log n) |
| remove 时间复杂度 | O(1) 平均 | O(1) 平均(略慢) | O(log n) |
| contains 时间复杂度 | O(1) 平均 | O(1) 平均(略慢) | O(log n) |
| 迭代时间复杂度 | O(capacity + n) | O(n) | O(n) |
| 内存开销 | 最低 | 中等(多两个指针/节点) | 较高(红黑树节点) |
| 额外接口 | 无 | 无 | NavigableSet、SortedSet |
| 排序能力 | 无 | 无 | 有(Comparable/Comparator) |
| 典型场景 | 快速去重 | 去重+保留顺序 | 排序+去重 |
9.2 Set 选型决策树
flowchart TD
START["需要存储不重复的元素集合"] --> Q1{"是否需要<br/>可预测的迭代顺序?"}
Q1 -->|"不需要"| A1["HashSet<br/>✅ 最快去重性能<br/>✅ 最低内存开销<br/>⚠️ 迭代顺序不可预测"]
Q1 -->|"需要"| Q2{"需要哪种顺序?"}
Q2 -->|"排序顺序(字典序、数值序等)"| A2["TreeSet<br/>✅ 自动排序<br/>⚠️ O(log n) 增删查<br/>❌ 不允许 null"]
Q2 -->|"插入顺序"| Q3{"是否需要访问顺序<br/>(LRU)功能?"}
Q3 -->|"否(仅需插入顺序)"| A3["LinkedHashSet<br/>✅ O(1) 增删查<br/>✅ 插入顺序迭代<br/>⚠️ 内存开销略高"]
Q3 -->|"是(需要LRU)"| Q4{"能否接受使用 Map?"}
Q4 -->|"能"| A4["LinkedHashMap<br/>accessOrder=true<br/>✅ 完整 LRU 支持<br/>✅ 可子类化定制淘汰策略"]
Q4 -->|"必须使用 Set"| A5["Collections.newSetFromMap<br/>+LinkedHashMap(accessOrder=true)<br/>或反射创建"]
style A3 fill:#2c3e50,stroke:#1a252f,color:#ecf0f1
style A1 fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
style A2 fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
style A4 fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
style A5 fill:#7f8c8d,stroke:#1a252f,color:#ecf0f1
图注说明:扩展版决策树覆盖了访问顺序模式和必须使用 Set 接口的边界场景。
-
第一层——基础维度判断:是否需要可预测的迭代顺序是选型的首要分水岭。不需顺序则直接选 HashSet(最优性能);需要排序则选 TreeSet(但要接受 O(log n) 性能代价和 null 不支持的限制)。
-
第二层——顺序类型判断:这是模块 1 决策树的深化。需要插入顺序的场景进入第三层判断;需要排序的场景直接导向 TreeSet。
-
第三层——LRU 需求的边界处理:
- 无需 LRU:LinkedHashSet 是标准答案。
- 需要 LRU:
- 路径 A:直接用 LinkedHashMap(推荐,LinkedHashMap 本身就是为 LRU 设计的,提供了
removeEldestEntry等完整支持)。 - 路径 B:若必须使用 Set 接口类型,可通过
Collections.newSetFromMap(new LinkedHashMap<>(16, 0.75f, true))将一个配置了 accessOrder 的 LinkedHashMap 包装为 Set 视图。 - 路径 C:反射调用 HashSet 的包级私有构造器创建底层的 LinkedHashMap(accessOrder=true) 为未公开行为,生产环境慎用。
- 路径 A:直接用 LinkedHashMap(推荐,LinkedHashMap 本身就是为 LRU 设计的,提供了
-
第四层——与各条路径的关键结论映射:
- HashSet:无顺序开销,纯粹的哈希表去重工具,适合海量数据唯一性校验、黑名单/白名单判重等场景。
- LinkedHashSet:插入顺序 + O(1) 性能,适合配置去重但需保持声明顺序、日志溯源等场景。
- TreeSet:排序去重二合一,适合排行榜、范围查询、有序字典等场景,但需注意元素必须实现 Comparable 或提供 Comparator,否则在运行时抛出 ClassCastException。
模块 10:常见陷阱与最佳实践
陷阱 1:误认为 LinkedHashSet 会排序
错误认知:LinkedHashSet 的“有序”是指元素自动按自然顺序排列。
真相:LinkedHashSet 的“有序”是指元素的迭代顺序与插入顺序一致,而非排序。若需要排序,应使用 TreeSet。
// ❌ 错误:期望排序结果
LinkedHashSet<Integer> set = new LinkedHashSet<>();
set.add(3);
set.add(1);
set.add(2);
// 期望输出 [1, 2, 3]?错!
System.out.println(set); // 实际输出: [3, 1, 2] ← 按插入顺序
// ✅ 正确:需要排序用 TreeSet
TreeSet<Integer> sortedSet = new TreeSet<>();
sortedSet.add(3);
sortedSet.add(1);
sortedSet.add(2);
System.out.println(sortedSet); // [1, 2, 3] ← 自然排序
陷阱 2:修改已存入元素的字段导致 hashCode 变化
这是所有基于哈希的集合类共有的陷阱,也是实际开发中定位难度极高的 Bug 之源。
// ❌ 错误:存入可变对象后修改参与 hashCode 的字段
class Person {
String name;
int age;
public Person(String name, int age) { this.name = name; this.age = age; }
@Override public int hashCode() { return Objects.hash(name, age); }
@Override public boolean equals(Object o) { /* ... */ }
}
LinkedHashSet<Person> set = new LinkedHashSet<>();
Person p = new Person("Alice", 25);
set.add(p); // hashCode 基于 name="Alice", age=25
p.age = 30; // ⚠️ 修改字段,hashCode 变化!
set.contains(p); // ❌ false!元素已“丢失”在错误的桶里
set.remove(p); // ❌ false!无法删除
// ✅ 正确:使用不可变对象,或用不可变字段参与 hashCode/equals
// 推荐方案1:设计不可变类(final字段,无setter)
// 推荐方案2:若必须可变,存入集合后不要修改参与hashCode的字段
// 推荐方案3:仅用不可变字段(如UUID)参与hashCode/equals
原理深层解释:HashMap 在插入元素时根据当时的 hashCode() 计算桶位置并存放节点。元素被修改后,hashCode() 返回值变化,但节点仍停留在旧桶位置。后续的 contains 和 remove 操作根据新 hashCode() 计算桶位置,查找到的是另一个桶,自然找不到该元素。hashCode() 依赖的字段一旦在对象存入哈希集合后被修改,就会造成对象在集合中“隐式丢失”——既无法查到也无法删除,直到垃圾回收前一直占用内存。
陷阱 3:多线程并发修改导致 fail-fast
// ❌ 错误:多线程下直接使用 LinkedHashSet
LinkedHashSet<Integer> set = new LinkedHashSet<>();
// 线程 A:遍历
new Thread(() -> {
for (Integer i : set) { // 迭代器创建时记录 modCount
// ...
}
}).start();
// 线程 B:修改
new Thread(() -> {
set.add(100); // modCount++,触发 ConcurrentModificationException
}).start();
// ✅ 正确方案一:使用 Collections.synchronizedSet 包装
Set<Integer> syncSet = Collections.synchronizedSet(new LinkedHashSet<>());
// ✅ 正确方案二:使用 ConcurrentHashMap.newKeySet()(但无插入顺序保证)
Set<Integer> concurrentSet = ConcurrentHashMap.newKeySet();
// ✅ 正确方案三:迭代时使用 synchronized 块
synchronized (set) {
for (Integer i : set) {
// 整个迭代过程在同步块内执行
}
}
陷阱 4:启用访问顺序后迭代顺序反映访问频率
// ⚠️ 注意:accessOrder=true 时,迭代顺序会随访问而变化
LinkedHashMap<String, Integer> map = new LinkedHashMap<>(16, 0.75f, true);
map.put("A", 1);
map.put("B", 2);
map.put("C", 3);
System.out.println("初始: " + map.keySet()); // [A, B, C]
map.get("A"); // 访问 A
System.out.println("访问 A 后: " + map.keySet()); // [B, C, A]
map.put("B", 20); // 更新 B(也是访问)
System.out.println("更新 B 后: " + map.keySet()); // [C, A, B]
// 结论:在 accessOrder=true 模式下,迭代顺序不再是插入顺序
// 而是反映“最近访问”程度,越晚访问的元素越靠后
最佳实践总结
- 存入集合的元素优先使用不可变类(如 String、Integer、LocalDate 等),或确保存入后不修改参与 hashCode/equals 的字段。
- 多线程环境下使用 Collections.synchronizedSet 包装,并在迭代时使用 synchronized 代码块。
- 需要 LRU 功能时优先考虑 LinkedHashMap,LinkedHashSet 不是为 LRU 设计的。
- 大容量场景注意设置合适的初始容量,减少扩容带来的性能开销。
- 不要依赖 LinkedHashSet 的 equals 方法比较顺序——Set 接口规范明确声明 equals 不比较元素顺序。
Part 6:总结与面试篇
模块 11:注意事项与性能总结
11.1 时间复杂度总览
| 操作 | 平均时间复杂度 | 最坏时间复杂度 | 说明 |
|---|---|---|---|
| add(E e) | O(1) | O(n) | 最坏情况:哈希冲突严重导致桶内链表/红黑树遍历 |
| remove(Object o) | O(1) | O(n) | 同上 |
| contains(Object o) | O(1) | O(n) | 同上 |
| iterator() | O(1) | O(1) | 仅获取迭代器,返回 LinkedKeyIterator 实例 |
| iteration | O(n) | O(n) | 沿双向链表遍历,不受哈希表容量影响 |
| size() | O(1) | O(1) | 直接返回继承的 size 字段 |
11.2 内存开销分析(基于 64 位 JVM,压缩 OOP)
| 数据结构 | 每个元素的内存开销(估算) | 说明 |
|---|---|---|
| HashSet | ~32 字节(HashMap.Node) | hash + key + value + next 引用 |
| LinkedHashSet | ~48 字节(LinkedHashMap.Entry) | 额外多 before + after 两个引用(各 8 字节) |
| TreeSet | ~40 字节(TreeMap.Entry) | left + right + parent + color + key + value |
当集合包含 100 万个元素时,LinkedHashSet 比 HashSet 额外消耗约 16MB 内存(仅节点对象层面,不含哈希表桶空间等)。 在实际应用中,需要在顺序需求和内存开销之间做出权衡——对内存敏感的大规模数据处理场景,若顺序非必须,优先使用 HashSet;对中等规模(百万级以下)且需要顺序保证的场景,LinkedHashSet 的额外开销通常是可以接受的。
11.3 场景选择速查
- 🏃 【性能优先,不关心顺序】 → HashSet
- 📋 【需要保留插入/添加顺序】 → LinkedHashSet
- 🔢 【需要自然排序/范围查找】 → TreeSet
- 🧵 【多线程高并发去重】 → ConcurrentHashMap.newKeySet()
- 🗄️ 【LRU 缓存淘汰】 → LinkedHashMap (accessOrder=true) + removeEldestEntry
- 📝 【统计首次出现顺序】 → LinkedHashSet
模块 12:面试高频专题
考点 1:LinkedHashSet 和 HashSet 的区别?迭代顺序如何保证?
标准回答:
LinkedHashSet 是 HashSet 的子类,唯一区别在于 LinkedHashSet 内部使用 LinkedHashMap 存储元素,而 HashSet 使用 HashMap。LinkedHashMap 在 HashMap 的哈希表基础上维护了一条双向链表,所有 Entry 节点通过 before/after 指针串联。迭代时 LinkedKeyIterator 直接从链表头节点 head 出发沿 after 指针遍历,因此迭代顺序严格等于元素的首次插入顺序。HashSet 的 KeyIterator 遍历的是哈希表数组,顺序由哈希分布决定,不可预测。
追问模拟:
-
追问 1:重复 put 已存在的元素会改变它在链表中的位置吗? 答:在插入顺序模式(默认)下,不会。HashMap.putVal 发现 Key 已存在时,只更新 Value,调用 afterNodeAccess 钩子。而 LinkedHashMap.afterNodeAccess 仅在 accessOrder=true 时执行节点移动,默认 accessOrder=false 时为空操作。
-
追问 2:如果元素被 remove 后再重新 add,它在迭代顺序中处于什么位置? 答:remove 将元素从双向链表中摘除(afterNodeRemoval 钩子),重新 add 会创建新节点并追加到链表尾部(linkNodeLast),因此该元素会从原来的位置移动到链表末尾,变为最后迭代到的元素。
-
追问 3:HashMap 的迭代复杂度是 O(capacity+n),LinkedHashMap 呢? 答:LinkedHashMap 通过双向链表直接遍历有效元素,复杂度为 O(n),与哈希表容量无关。在容量远大于元素数量的稀疏哈希表场景中,LinkedHashMap 的迭代性能实际优于 HashMap——后者需要扫描大量空桶,而前者通过 tail 和 after 指针直接定位有效节点。
加分回答:
可以从模板方法设计模式角度分析:HashMap 在 putVal 中预留了 newNode()、afterNodeAccess()、afterNodeInsertion()、afterNodeRemoval() 四个钩子方法(自身为空实现),LinkedHashMap 通过重写这些方法在不修改 HashMap 源码的前提下实现了双向链表维护。这是典型的开闭原则——对扩展开放、对修改关闭。
考点 2:LinkedHashSet 的底层实现原理?和 LinkedHashMap 的关系是什么?
标准回答:
LinkedHashSet 继承自 HashSet,但它通过调用 HashSet 的包级私有构造器 HashSet(int initialCapacity, float loadFactor, boolean dummy) 将内部 map 字段初始化为 LinkedHashMap 实例(而非 HashMap)。LinkedHashSet 的所有操作(add、remove、contains、iterator)都直接继承自 HashSet,而 HashSet 的这些方法内部委托给 map 的对应方法。由于 map 的实际类型是 LinkedHashMap,因此所有操作自动获得了双向链表的顺序维护能力。LinkedHashSet 和 LinkedHashMap 的关系是适配器/装饰器模式——LinkedHashSet 将 LinkedHashMap 适配为 Set 接口,元素作为 Map 的 Key,共享同一个哑元 Value 对象 PRESENT。
追问模拟:
-
追问 1:LinkedHashSet 有没有自己重写的 add 方法? 答:没有。完全继承自 HashSet 的 add 方法,即
map.put(e, PRESENT) == null。创建新节点、维护链表的逻辑完全在 LinkedHashMap 内部完成。LinkedHashSet 的类体极其简洁(仅约 50 行源码)。 -
追问 2:LinkedHashSet 为什么能复用 HashSet 的所有方法而不需要任何修改? 答:多态性。HashSet 内部通过
map引用调用方法,编译时类型是 HashMap,运行时类型取决于构造器创建的实例类型。LinkedHashSet 通过构造器钩子将运行时类型替换为 LinkedHashMap,方法调用自动分派到子类实现。 -
追问 3:PRESENT 为什么设计为一个静态常量,而不是每次 new Object()? 答:两个原因:一是节省内存(所有 key 共享一个 value 对象,避免为每个元素创建一个 Object);二是 PRESENT 作为引用相等(==)判断的锚点——HashSet.remove 判断
map.remove(o) == PRESENT,利用引用相等比 equals 更高效。
加分回答:
可以引申到 JDK 中类似的适配器模式应用——TreeSet 和 TreeMap 的关系与之完全对称。整个 Java 集合框架中,Set 接口的三个主要实现(HashSet、LinkedHashSet、TreeSet)分别对应 Map 接口的三个实现(HashMap、LinkedHashMap、TreeMap),体现了“Set 是 Map 的 Key 视图”这一统一设计思想。
考点 3:HashSet 的构造器中的 dummy 参数是做什么的?
标准回答:
dummy 是 HashSet 包级私有构造器 HashSet(int initialCapacity, float loadFactor, boolean dummy) 的第三个参数。它在构造器方法体内完全不被使用,唯一作用是与另一个公开构造器 HashSet(int initialCapacity, float loadFactor) 做签名区分,因为 Java 不允许仅靠返回值区分重载方法。被调用的构造器内部创建的是 new LinkedHashMap<>(initialCapacity, loadFactor) 而非 new HashMap<>(),从而为 LinkedHashSet 提供了底层存储替换的钩子。
追问模拟:
-
追问 1:为什么不设计一个 protected 的、返回 LinkedHashMap 的工厂方法? 答:其一,构造器方案更简洁——LinkedHashSet 只需调用 super() 即可完成底层存储初始化,无需再覆盖工厂方法。其二,包级私有的访问控制确保了只有 java.util 包内的子类(即 LinkedHashSet)能使用此机制,避免了外部随意替换带来的安全风险。
-
追问 2:如果我自己写一个类继承 HashSet,能调用这个构造器吗? 答:如果自定义类不在
java.util包内,则无法调用此包级私有构造器。编译器会报错HashSet(int, float, boolean) is not public in HashSet。
加分回答:
可以提及类似的设计模式——标记参数(Flag/Tag Argument) 是一种 API 设计中的反模式,但此处 dummy 的使用属于构造器重载区分这一特殊场景的合理例外。同时指出 LinkedHashMap 自身也有类似设计:其构造器 LinkedHashMap(int, float, boolean accessOrder) 中的 accessOrder 参数是功能性参数而非纯标记参数,这体现了两个类在 API 设计上的差异。
考点 4:LinkedHashSet 如何实现 LRU 缓存?accessOrder 是什么?
标准回答:
LinkedHashSet 的公开 API 不支持直接创建访问顺序模式的实例,但底层 LinkedHashMap 支持。要实现 LRU 缓存,推荐直接使用 LinkedHashMap,通过构造器传入 accessOrder=true 启用访问顺序模式,并重写 removeEldestEntry 方法定义淘汰条件。当 accessOrder=true 时,每次 get 或 put(更新场景)操作都会通过 afterNodeAccess 钩子将访问的节点移动到双向链表尾部,链表头部即为最久未访问的元素。removeEldestEntry 由 afterNodeInsertion 在每次新元素插入后调用,返回 true 时自动移除头节点。
// LRU 缓存示例
class LRUCache<K,V> extends LinkedHashMap<K,V> {
private int capacity;
public LRUCache(int capacity) {
super(16, 0.75f, true); // accessOrder=true
this.capacity = capacity;
}
@Override
protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
return size() > capacity;
}
}
追问模拟:
-
追问 1:LinkedHashSet 本身能否直接实现 LRU? 答:不能。LinkedHashSet 的构造器都调用 HashSet 包级私有构造器,该构造器未传入 accessOrder 参数,默认为 false。若确实需要,可以通过反射调用包级私有构造器或使用
Collections.newSetFromMap包装访问顺序模式的 LinkedHashMap。 -
追问 2:afterNodeAccess 在 accessOrder=false 时会做什么? 答:什么都不做。方法首行判断
if (accessOrder && (last = tail) != e),accessOrder 为 false 直接返回,避免无谓的链表操作。
加分回答:
可以扩展讨论 LRU 的局限性——LRU 在突发流量下可能误淘汰热点数据(如某个热点数据短期未被命中,但即将被大量请求)。实际生产环境中,常见的改进方案包括:
- LRU-K:记录最近 K 次访问的时间,而非仅记录最近一次。
- W-TinyLFU:结合频率计数和 LRU 的混合策略(Caffeine Cache 的默认算法),解决 LRU 在扫描式访问下的“缓存污染”问题。
- 两级缓存架构(L1 + L2):L1 用 LRU 淘汰,L2 用频率统计,兼顾内存效率和命中率。
考点 5:LinkedHashSet 和 TreeSet 的区别?分别适用于什么场景?
标准回答:
| 维度 | LinkedHashSet | TreeSet |
|---|---|---|
| 底层实现 | LinkedHashMap(哈希表 + 双向链表) | TreeMap(红黑树) |
| 顺序保证 | 插入顺序(或访问顺序) | 自然排序或自定义 Comparator 排序 |
| 时间复杂度 | O(1)(增删查) | O(log n)(增删查) |
| null 元素 | 允许 | 不允许(需调用 compareTo 或 Comparator.compare) |
| 额外接口 | 无 | SortedSet、NavigableSet(支持范围查询) |
| 内存开销 | 中等(额外两个引用) | 较高(红黑树节点含 parent/left/right/color) |
| 适用场景 | 去重 + 保留原始顺序 | 排序去重、范围查找、排行榜 |
LinkedHashSet 适用于需要保留插入顺序的场景(如配置加载、日志溯源),TreeSet 适用于需要元素自动排序和范围查找的场景(如排行榜、数值范围查询、按字典序输出)。
追问模拟:
-
追问 1:TreeSet 的元素必须实现 Comparable 吗? 答:不一定。可以在构造 TreeSet 时传入 Comparator 实现。若两者都未提供,运行时会在第一次 add 时抛出 ClassCastException——因为 TreeMap.put 内部需要调用
compareTo或Comparator.compare来确定元素在红黑树中的位置。 -
追问 2:LinkedHashSet 和 TreeSet 哪个迭代更快? 答:LinkedHashSet 迭代复杂度为 O(n),TreeSet 为 O(n),理论上两者同一量级。但 LinkedHashSet 的迭代沿双向链表的 after 指针顺序前进,TreeSet 需要通过红黑树中序遍历(递归或栈模拟),实际测试中 LinkedHashSet 的迭代通常比 TreeSet 快约 20-40%(取决于元素数量和 JVM 优化),因为链表指针跳转比树的递归/栈操作有更好的缓存局部性。
加分回答:
可以延伸讨论二者在序列化行为上的差异:LinkedHashSet 的序列化会保留双向链表顺序(反序列化后顺序不变),TreeSet 的序列化保留元素的排序顺序(反序列化后自动重新构建红黑树,元素按排序顺序放置)。此外,LinkedHashMap 迭代不受 capacity 影响,而 TreeSet 的迭代性能与树结构深度相关。
考点 6:存入 LinkedHashSet 的元素需要重写 hashCode 和 equals 吗?
标准回答:
是的,且两者必须同时重写且保持一致性。LinkedHashSet 的去重机制依赖底层 HashMap 的 hashCode() 和 equals() 方法。hashCode() 决定元素存储在哪个哈希桶,equals() 判断两个哈希值相同的元素是否为同一对象。若只重写 equals() 不重写 hashCode(),则相等的两个对象可能落入不同哈希桶,无法去重。若只重写 hashCode() 不重写 equals(),则哈希冲突时无法准确判断相等性。
追问模拟:
-
追问 1:String 和 Integer 需要重写吗? 答:不需要。Java 标准库中的不可变类(String、Integer、LocalDate 等)都已正确重写了
hashCode()和equals(),可以直接安全地存入 LinkedHashSet。 -
追问 2:为什么 Object 的 hashCode 和 equals 不够用? 答:Object.hashCode 默认返回对象的内存地址(或 JVM 内部标识),Object.equals 默认比较引用相等(
==)。对于值对象(如 Person、Order),我们期望具有相同字段值的两个对象被视为“相等”,因此必须重写。
加分回答:
可以提及 hashCode() 和 equals() 的契约(Contract):
- 若
a.equals(b)为 true,则a.hashCode()必须等于b.hashCode()(一致性要求)。 - 若
a.hashCode()不等于b.hashCode(),则a.equals(b)必须为 false。 - 若
a.hashCode()等于b.hashCode(),a.equals(b)不一定为 true(哈希冲突允许存在)。
违反此契约会导致基于哈希的集合(HashSet、LinkedHashSet、HashMap)出现不可预测的行为——元素可能“丢失”、去重失效或查找失败。建议使用 java.util.Objects.hash() 工具方法或 IDE 自动生成来避免实现错误。
考点 7:LinkedHashSet 的迭代器是 fail-fast 吗?为什么?
标准回答:
是的。LinkedHashSet 的迭代器继承自 LinkedHashMap 的 LinkedKeyIterator,间接继承自 HashMap 的 HashIterator。HashIterator 在构造时记录当前集合的 modCount(修改计数器),每次 next() 调用时检查 modCount 是否与 expectedModCount 一致。若不一致(说明迭代过程中集合被外部修改),立即抛出 ConcurrentModificationException。这是 Java 集合框架的通用 fail-fast 设计,目的是尽早暴露并发修改问题,避免数据不一致导致的难以调试的错误。
追问模拟:
-
追问 1:fail-fast 是线程安全的保证吗? 答:不是。fail-fast 只是一种尽力而为的错误检测机制,不保证一定抛出异常。它不能替代真正的并发同步方案。
-
追问 2:如何在迭代中安全地删除元素? 答:使用迭代器自身的
remove()方法。HashIterator.remove()会在删除后同步更新expectedModCount = modCount,避免下次 next() 抛出异常。直接调用集合的 remove 方法则会触发 modCount 自增却不同步迭代器内的 expectedModCount,导致抛异常。 -
追问 3:ConcurrentHashMap 的迭代器是 fail-fast 吗? 答:不是。ConcurrentHashMap 的迭代器是弱一致性(weakly consistent) 的,允许在迭代过程中出现并发修改,不会抛出 ConcurrentModificationException,但可能不反映最新的修改。这是两种不同的并发策略:fail-fast 尽早暴露问题,弱一致性容忍并发但结果可能不实时。
加分回答:
可以提到 modCount 的本质是乐观锁版本号的简化版。HashMap 中任何结构性修改(put、remove、clear 等)都会使 modCount 自增,迭代器通过比较版本号检测并发修改。modCount 可能溢出(int 溢出回绕),但由于 expectedModCount 同步更新,不会导致错误的检测,这是一个微妙但正确的边界设计。
考点 8:LinkedHashSet 允许 null 元素吗?允许多个 null 吗?
标准回答:
LinkedHashSet 允许一个 null 元素,但不允许多个 null。这是 Set 接口的通用约定——不允许重复元素,null 也不例外。底层实现中,HashMap 的 put 方法对 null key 做了特殊处理(hash 值固定为 0,存储在 table[0] 桶中),因此支持存储一个 null。第二次 add(null) 时,putVal 发现桶位中已存在 null key(通过 key == null 判断相等),返回旧值,LinkedHashSet 返回 false 表示重复。
补充说明:TreeSet 不允许 null 元素(除非构造时提供可处理 null 的 Comparator),因为 null 无法调用 compareTo 方法进行排序比较。这是三个 Set 实现类在处理 null 方面的重要差异。
追问模拟:
-
追问 1:HashMap 的 null key 是如何存储的? 答:HashMap 对 null key 做了特殊处理,将其 hash 值固定为 0,存储在
table[0]索引的桶中。查找时也是先判断 key 是否为 null,若是则直接从table[0]桶中查找。 -
追问 2:ConcurrentHashMap 允许 null key 吗? 答:不允许。ConcurrentHashMap 的设计者认为 null 在并发环境下容易导致歧义——
get(key)返回 null 无法区分是 key 不存在还是 key 对应的 value 本身就是 null。因此直接禁止 null key 和 null value,传入 null 会抛出 NullPointerException。
加分回答:
可补充 Optional 的使用建议:在业务逻辑中,使用 Optional 作为 value(而非 null)可以更清晰地表达“值可能不存在”的语义,但需注意 Optional 不应作为 Map 的 key。
考点 9:如何让 LinkedHashSet 按访问顺序输出元素?
标准回答:
标准 LinkedHashSet API 不支持直接创建访问顺序模式的实例。要实现此功能,有以下三种途径:
- 使用 Collections.newSetFromMap 包装访问顺序模式的 LinkedHashMap(推荐):
Set<String> set = Collections.newSetFromMap(
new LinkedHashMap<String, Boolean>(16, 0.75f, true));
- 直接使用 LinkedHashMap 的 keySet 视图:
LinkedHashMap<String, Object> map = new LinkedHashMap<>(16, 0.75f, true);
Set<String> set = map.keySet();
此方案的唯一限制是 keySet() 返回的是视图 Set,不支持 add 操作(会抛出 UnsupportedOperationException),若仅需迭代访问顺序的结果则完全可行。
- 反射调用 HashSet 的包级私有构造器(不推荐,非标准 API)。
追问模拟:
- 追问 1:为什么 JDK 不直接提供一个 LinkedHashSet(int, float, boolean) 构造器? 答:设计权衡。LinkedHashSet 的定位是插入顺序的 Set 实现,访问顺序属于 LinkedHashMap 的高级特性。如果为 LinkedHashSet 添加 accessOrder 构造器,需要额外的构造器重载(原本已有四个构造器),且 Set 接口的访问语义不如 Map 直观。JDK 倾向于保持每个类的职责单一。
加分回答:
可以提到 JDK 21 中引入了 SequencedCollection 接口,LinkedHashSet 实现了该接口,提供了 addFirst、addLast、reversed() 等顺序操作方法。这些新接口进一步丰富了 LinkedHashSet 的顺序操作能力。
考点 10:解释 LinkedHashSet 中双向链表的作用和节点结构。
标准回答:
双向链表的作用是在哈希表提供 O(1) 快速查找的同时,记录元素的插入顺序(或访问顺序),保证可预测的迭代顺序。节点类型是 LinkedHashMap.Entry,继承自 HashMap.Node,新增了 before 和 after 两个引用。next(继承自 Node)用于解决哈希冲突(同桶内链表),before/after 用于维护贯穿全部元素的插入顺序双向链表。这两种不同用途的链表在同一个节点上正交共存——next 链解决桶内冲突,before/after 链维护全局顺序。
追问模拟:
-
追问 1:节点同时参与两条链表,删除操作如何保证一致性? 答:删除操作分两步处理。第一步:HashMap.removeNode 处理哈希桶链表摘除(修改
next指针),第二步:afterNodeRemoval 钩子处理双向链表摘除(修改before/after指针)。两者互不影响,保证了结构一致性。两条链表各自独立,操作互不干扰。 -
追问 2:LinkedHashMap 的 Entry 为什么不直接继承 HashMap.Node 并使用自身字段? 答:这正是 JDK 的巧妙设计——通过继承复用 Node 的 hash/key/value/next 字段,避免重复定义。
HashMap.Node提供了基础的哈希桶节点能力,LinkedHashMap.Entry在此基础上扩展双向链表能力,形成了递进式的节点类层次结构。红黑树节点TreeNode也继承自LinkedHashMap.Entry,在树化时自动具备双向链表能力,可平滑降级为普通链表节点。
加分回答:
可以对比 LinkedHashMap 与 C++ STL 中 std::unordered_map 的设计差异。C++ STL 的 unordered_map 基于开链法哈希表,不保证任何迭代顺序;而 LinkedHashMap 用双向链表解决了这个痛点。LinkedHashMap 的双向链表还带来了一个额外优势:迭代时间仅与元素数量成正比,与内部哈希表大小无关,非常适合需要对大型集合进行频繁迭代的场景。此外,LinkedHashMap 的双向链表默认不是循环链表——head.before 和 tail.after 均为 null,这与早期的 JDK 版本设计不同,JDK 7 之前曾使用循环链表(header 哑元节点),JDK 8 改为非循环设计以简化逻辑、减少边界判断。
结语
LinkedHashSet 是 Java 集合框架中“以小博大”的典范——仅约 50 行新增源码坐落在整个继承体系上,却通过三件法宝实现了顺序保证的完整语义:HashSet 的构造器钩子(类型替换)、LinkedHashMap 的双向链表(顺序维护)和 HashMap 的模板方法钩子(行为注入)。其本质可概括为:
LinkedHashSet = HashSet 的接口与去重语义 + LinkedHashMap 的存储与顺序能力
它的设计揭示了框架设计中一条核心原则——预留扩展点比预留功能更重要。HashSet 预留了构造器钩子和模板方法钩子,但并未定义“顺序”功能;LinkedHashSet 和 LinkedHashMap 仅填充了相关钩子,便获得了顺序能力。这种“框架定骨架,子类填血肉”的设计哲学,远比在 HashSet 中硬编码顺序逻辑更为灵活和可维护,也是理解 Spring、MyBatis 等主流框架扩展机制的通用思维模型。
参考源码版本:JDK 8(OpenJDK),涉及核心类 java.util.LinkedHashSet、java.util.HashSet、java.util.LinkedHashMap、java.util.HashMap。