概述
HashSet 是 Java 集合框架中最常用的 Set 实现,它以“无序、不可重复”的语义为开发者提供了极致简洁的元素去重能力。然而,揭开这层表象,你会发现 HashSet 并非一个独立的数据结构,而是一个完全委托给 HashMap 的轻量级封装——它只是巧妙借用了 HashMap 键的唯一性来保证集合元素的唯一性。本文将带领你从 JDK 8 源码出发,深入剖析 HashSet 的委托机制、哈希冲突解决、序列化黑魔法以及 fail‑fast 行为,并通过大量可直接运行的 Demo 和 Mermaid 流程图,呈现一条从底层原理到最佳实践的完整认知链路。
核心知识点一览
- 基于 HashMap 的委托实现:HashSet 内部组合一个
HashMap,元素作为 Key,所有 Value 共享同一个静态哑元对象PRESENT,从而复用 HashMap 的哈希能力与去重逻辑。 - add 方法的去重语义:
add(E e)直接调用map.put(e, PRESENT) == null,利用 HashMap 对相同 Key 的更新行为判断元素是否为新插入,返回值精确反映是否添加成功。 - 散列表的访问性能:增删查平均时间复杂度为 O(1),但高度依赖
hashCode()的离散度和负载因子,极端哈希冲突下链表退化为红黑树前查询可能恶化到 O(n)。 - equals 与 hashCode 的契约绑定:自定义对象必须同时正确覆写
equals()与hashCode(),否则相同业务语义的对象会产生不同哈希值或被误判为不相等的 Key,导致去重失效。 - 无序性与非线程安全:遍历顺序由底层 HashMap 的桶位置和链表顺序决定,完全无保证;多线程并发修改可能造成数据覆盖丢失或触发
ConcurrentModificationException。
全文组织架构图
flowchart TB
subgraph Part1["Part 1: 基础认知篇"]
A1["定义 核心特性与适用场景"]
A2["接口与继承体系"]
end
subgraph Part2["Part 2: 存储与构造篇"]
B1["存储结构与底层依赖"]
B2["构造方法剖析"]
end
subgraph Part3["Part 3: 核心原理篇"]
C1["add 操作与去重核心"]
C2["remove 与 contains 操作"]
C3["其他重要方法"]
end
subgraph Part4["Part 4: 迭代与序列化篇"]
D1["迭代器与 fail-fast 机制"]
D2["序列化与克隆机制"]
end
subgraph Part5["Part 5: 对比与陷阱篇"]
E1["Set 三大实现类对比"]
E2["常见陷阱与最佳实践"]
end
subgraph Part6["Part 6: 总结与面试篇"]
F1["注意事项与性能总结"]
F2["面试高频专题"]
end
Part1 --> Part2 --> Part3 --> Part4 --> Part5 --> Part6
A1 --> A2 --> B1 --> B2 --> C1 --> C2 --> C3 --> D1 --> D2 --> E1 --> E2 --> F1 --> F2
架构图说明
一句话概括:全文按“认知→存储→原理→迭代/序列化→对比陷阱→总结面试”六大递进模块组织,形成从基础到深挖、从源码到实战的闭环认知路径。
逐层分解:
- 第一层(Part 1·基础认知篇):先明确 HashSet 是什么、有何特性、何时使用,再梳理其继承体系,建立全局观。
- 第二层(Part 2·存储与构造篇):深入字段与构造器源码,揭示其完全依赖 HashMap 的委托本质和哑元对象设计。
- 第三层(Part 3·核心原理篇):围绕 add / remove / contains 三个核心操作的完整调用链路,详解哈希扰动、桶定位、链表/红黑树判断等细节。
- 第四层(Part 4·迭代与序列化篇):解释迭代器的 fail‑fast 保护机制以及 HashSet 如何通过定制序列化协议安全处理底层 HashMap。
- 第五层(Part 5·对比与陷阱篇):通过选型决策树帮助读者在 HashSet / TreeSet / LinkedHashSet 中做出正确选择,并汇总 4 大典型陷阱及正确示例。
- 第六层(Part 6·总结与面试篇):提炼性能调优要点,将全部面试考点集中整理,提供高分回答模板。
HashSet 是 HashMap 的 Key 视图封装,其全部行为均通过转发实现,掌握 HashMap 就等于掌握了 HashSet。
Part 1:基础认知篇
模块 1:定义、核心特性与适用场景
定义
HashSet 是基于哈希表(实际为 HashMap)实现的 Set 接口,它不保证元素的迭代顺序,不允许包含重复元素,并且允许包含最多一个 null 元素。该类非线程安全,若多线程并发访问,必须通过外部同步或 Collections.synchronizedSet 包装。
核心特性列表
- 去重机制:利用
equals()与hashCode()双重判定,逻辑相等的对象只能存储一次。 - 常数时间性能:在良好哈希分布下,
add、remove、contains均提供 O(1) 平均时间。 - 允许 null:由于 HashMap 支持一个 null 键,HashSet 也允许存入一个 null 元素。
- 非线程安全:未使用任何同步手段,迭代器的 fail‑fast 只能尽力检测并发修改。
- 无序性:由 HashMap 的哈希桶排列决定,不可预测。
- 基于 HashMap:本质是 HashMap 的一个视图层,所有操作直接转发。
适用场景与反例
- 适用:需要快速去重、不关心顺序的场景,例如用户标签集合、URL 黑名单、每日访问 IP 统计等。
- 不适用:需要排序的场景(应改用
TreeSet);需要维持插入顺序的场景(应改用LinkedHashSet);对内存极度敏感、又需极小开销判断存在性(可考虑布隆过滤器);需要线程安全且高性能的并发集合(应改用ConcurrentHashMap.newKeySet()或CopyOnWriteArraySet)。
底层本质一句话
HashSet 是 HashMap 的 Key 集合视图,所有 Value 被一个不可变哑元对象 PRESENT 统一填充。
特性与适用场景决策树
flowchart TD
Start((需要存放不重复元素?)) -->|是| Order{需要排序?}
Start -->|否| Other[考虑 List 或其他结构]
Order -->|自然/比较排序| TreeSet[TreeSet]
Order -->|插入顺序| LinkedHashSet[LinkedHashSet]
Order -->|不关心| Nullable{允许 null?}
Nullable -->|是| HashSet[HashSet]
Nullable -->|否| NoNull[仍可用 HashSet, 但不存 null]
HashSet --> ThreadSafe{多线程环境?}
ThreadSafe -->|是| Wrap[使用 Collections.synchronizedSet 或 ConcurrentHashMap.newKeySet]
ThreadSafe -->|否| Use[直接使用 HashSet]
决策树说明
一句话概括:该决策树帮助开发者从“是否需要去重”开始,逐步根据排序需求、null 容忍度、线程安全性选择正确的 Set 实现或包装器。
逐层分解:
- 第一层:确认是否需要元素唯一性,若否,应选用 List 等其它集合。
- 第二层:在 Set 体系内,根据是否需要排序分流:需要大小排序则选择 TreeSet,需要维持插入顺序则选择 LinkedHashSet,其余默认走 HashSet。
- 第三层:针对 HashSet 进一步判断是否允许 null,虽然 HashSet 允许 null,但若业务不允许,调用时须做前置校验。
- 第四层:判断运行环境是否多线程,若是,必须外挂同步包装或替换为并发安全版本。
数据结构映射:决策树中每个叶子节点对应一个具体集合类,箭头上的条件对应需求特性。
源码方法对应:HashSet的无序性由HashMap的数组+链表/红黑树结构实现;TreeSet源于TreeMap的红黑树排序;LinkedHashSet源于LinkedHashMap的双向链表维护插入顺序。
关键结论强调:当你不关心顺序且无需线程安全时,HashSet 是去重需求的最佳默认选择。
模块 2:接口与继承体系
HashSet 继承自 AbstractSet,实现了 Set、Cloneable 和 Serializable 接口。
AbstractSet 提供了 equals()、hashCode() 和 removeAll() 的模板实现,基于迭代器进行元素逐个比较,因此作为基类可以减少子类代码量。但 HashSet 本身并未重写 equals() 和 hashCode(),直接沿用了 AbstractSet 的实现,这也意味着两个 HashSet 是否相等取决于各自包含的元素是否完全相同。
完整继承链类图
classDiagram
class Iterable {
<<interface>>
+iterator(): Iterator
}
class Collection {
<<interface>>
+add(e: E): boolean
+remove(o: Object): boolean
+contains(o: Object): boolean
+size(): int
+isEmpty(): boolean
+iterator(): Iterator
}
class Set {
<<interface>>
+add(e: E): boolean
+remove(o: Object): boolean
+contains(o: Object): boolean
+size(): int
+isEmpty(): boolean
+iterator(): Iterator
}
class AbstractCollection {
<<abstract>>
+isEmpty(): boolean
+contains(o: Object): boolean
+add(e: E): boolean
+remove(o: Object): boolean
+iterator(): Iterator
}
class AbstractSet {
<<abstract>>
+equals(o: Object): boolean
+hashCode(): int
+removeAll(c: Collection): boolean
}
class HashSet {
-map: HashMap<E, Object>
-PRESENT: Object
+HashSet()
+HashSet(c: Collection)
+HashSet(initialCapacity: int, loadFactor: float)
+add(e: E): boolean
+remove(o: Object): boolean
+contains(o: Object): boolean
+size(): int
+isEmpty(): boolean
+clear(): void
+iterator(): Iterator
+clone(): Object
}
class HashMap {
+put(key: K, value: V): V
+remove(key: Object): V
+containsKey(key: Object): boolean
+size(): int
+keySet(): Set
}
class Serializable {
<<interface>>
}
class Cloneable {
<<interface>>
}
Iterable <|-- Collection
Collection <|-- Set
Collection <|.. AbstractCollection
Set <|-- AbstractSet
AbstractCollection <|-- AbstractSet
AbstractSet <|-- HashSet
Set <|.. HashSet
Serializable <|.. HashSet
Cloneable <|.. HashSet
HashSet *-- HashMap : map
HashSet ..> HashMap : 完全委托
类图说明
一句话概括:HashSet 在继承链上仅仅实现了 Set 接口,但其核心能力来自内部组合的 HashMap 实例,这是典型的委托模式。
逐层分解:
- 继承层:
HashSet→AbstractSet→AbstractCollection,实现了Set、Cloneable、Serializable。 - 组合层:
HashSet拥有一个HashMap字段map,所有方法都通过调用map的相应方法完成,HashSet本身不维护任何元素存储结构。 - 契约层:
Set接口定义了不允许重复元素的约定,HashMap的 Key 唯一性天然满足该契约。
数据结构映射:HashSet无独立数据结构,其逻辑元素散列表就是HashMap的数组Node<K,V>[] table,元素被当成 Key 放入Node。
源码方法对应:AbstractSet.equals()利用迭代器逐项比较,HashSet直接继承,故两个 set 相等判断完全基于所含元素,与底层 HashMap 桶顺序无关。
关键结论强调:HashSet 不是 HashMap 的子类,而是它的客户;通过组合而非继承,HashSet 完美封装了 HashMap 的 Key 唯一性,同时屏蔽了 Value 的复杂性。
Part 2:存储与构造篇
模块 3:存储结构与底层依赖(源码剖析)
HashSet 源码中最核心的两个字段:
private transient HashMap<E,Object> map;
private static final Object PRESENT = new Object();
map:负责实际存储的 HashMap 实例。所有元素都作为map的 Key 存入,Value 统一使用静态常量PRESENT。PRESENT:一个哑元对象,所有条目共享同一个引用。选择 new Object() 而非 null,是因为如果使用 null,HashMap.put返回 null 时无法区分是“原本没有该 Key”还是“原先 Key 对应的值即为 null”,使用非 null 哑元对象可保证add方法能准确通过put 返回值 == null判断是否新增成功。
这种设计表明 HashSet 完全是一个视图层,它对 HashMap 进行了极简封装,使得用户只能操作 Key,而无法直接操作 Value。这也意味着所有对 HashSet 的操作,本质上都是在操作底层 HashMap,因此 HashMap 的容量、负载因子、树化阈值等参数直接决定 HashSet 的行为与性能。
HashSet 委托结构类图
classDiagram
class HashSet {
-map: HashMap
-PRESENT: Object
+add(e: E): boolean
+remove(o: Object): boolean
+contains(o: Object): boolean
}
class HashMap~K,V~ {
-table: Node[]
+put(key: K, value: V): V
+remove(key: Object): V
+containsKey(key: Object): boolean
}
class Node~K,V~ {
-hash: int
-key: K
-value: V
-next: Node
}
HashSet *-- HashMap : "map (组合)"
HashMap *-- Node : "table 数组"
HashSet ..> PRESENT : "所有 Value 指向 PRESENT"
note for HashSet "map.put(e, PRESENT)"
类图说明
一句话概括:该图展示了 HashSet 内部“组合 HashMap + 哑元常量”的存储模型,强调所有元素被映射为 HashMap 的 Key。
逐层分解:
- 第一层(HashSet):提供面向集合的公开 API,如
add、remove,内部持有map引用。 - 第二层(HashMap):真实存储容器,底层为
Node数组table,每个 Node 包含哈希值、键、值及下一个节点指针。 - 第三层(Node):所有存入 HashSet 的元素
e最终被封入Node,key = e,value = PRESENT。
数据结构映射:逻辑上的集合元素被映射到 HashMap 的 Key 空间;PRESENT 作为一个单例被所有 Node 共享,节省内存。
源码方法对应:HashSet.add(e)→map.put(e, PRESENT);HashSet.contains(o)→map.containsKey(o)。
关键结论强调:理解 HashSet 的关键就在于理解“它只使用 HashMap 键空间,值空间被一个永恒不变的哑元对象占据”,这种委托模式使得 HashSet 行为完全等价于 HashMap 键集的容器层面语义。
模块 4:构造方法(源码剖析)
HashSet 提供四个构造器,均围绕底层 HashMap 的实例化展开,源码如下:
-
无参构造
public HashSet() { map = new HashMap<>(); }底层 HashMap 默认初始容量 16,负载因子 0.75。
-
基于集合的构造
public HashSet(Collection<? extends E> c) { map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16)); addAll(c); }根据
c.size()预先计算一个足够容纳所有元素且避免立即扩容的容量,最小容量仍为 16,然后批量添加。 -
指定容量与负载因子
public HashSet(int initialCapacity, float loadFactor) { map = new HashMap<>(initialCapacity, loadFactor); }允许精确控制底层 HashMap 的初始大小和负载因子,便于性能调优。
-
包私有构造(供 LinkedHashSet 使用)
HashSet(int initialCapacity, float loadFactor, boolean dummy) { map = new LinkedHashMap<>(initialCapacity, loadFactor); }dummy形参仅用于区分签名,实际无意义。该构造器创建的是 LinkedHashMap 实例,因此LinkedHashSet继承HashSet时只需调用此构造器,即可自然获得插入顺序维护能力。
延迟分配时机
HashMap 的 table 数组首次在 put 操作发生时才进行分配(JDK 8 中 resize() 触发),因此 new HashSet() 仅仅初始化了 map 引用,真正的存储空间在第一次 add 时才分配。
Demo 代码:构造与观察容量(反射)
public class HashSetConstructionDemo {
public static void main(String[] args) throws Exception {
HashSet<String> set = new HashSet<>();
System.out.println("After new HashSet, size=" + set.size()); // 0
// 通过反射窥视底层 HashMap 的 table 长度
Field mapField = HashSet.class.getDeclaredField("map");
mapField.setAccessible(true);
HashMap<?,?> map = (HashMap<?,?>) mapField.get(set);
Field tableField = HashMap.class.getDeclaredField("table");
tableField.setAccessible(true);
Object[] table = (Object[]) tableField.get(map);
System.out.println("Before add, table=" + table); // null
set.add("Java");
table = (Object[]) tableField.get(map);
System.out.println("After add, table length=" + (table == null ? 0 : table.length));
// 输出类似 16,验证首次 put 触发扩容
}
}
输出显示添加元素后 table 长度变为 16,印证了延迟分配机制。
Part 3:核心原理篇
模块 5:add 操作——去重的核心(源码剖析)
HashSet.add(E e) 源码只有一行:
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
语义解析:
- 调用
HashMap.put(e, PRESENT),如果e先前不作为 Key 存在,则put返回null,add返回true,表示添加成功。 - 如果
e已经存在,put会覆盖 Value 为PRESENT(实际上还是同一个对象),并返回旧值(即之前的PRESENT),add返回false。 - 因此,去重完全依赖于 HashMap 内部 Key 的
equals与hashCode双重仲裁。
让我们追踪一条完整的调用链:
HashSet.add(e)
→ HashMap.put(e, PRESENT)
→ hash(key) // 扰动函数,混合高低位
→ putVal(hash, key, value, ...)
→ (n-1) & hash 定位桶下标
→ 遍历桶内链表/红黑树,逐个判断 “p.hash == hash && (k == key || key.equals(k))”
→ 如果找到匹配 Node,更新 value,返回旧值;否则插入新 Node,返回 null。
add 调用链流程图
flowchart TD
Start(["HashSet.add e"]) --> Put["map.put(e, PRESENT)"]
Put --> Hash["计算 hash: h = key.hashCode h ^= h>>>16"]
Hash --> Index["计算桶下标: (n-1) & hash"]
Index --> Check{"桶首节点为空?"}
Check -->|"是"| Insert["插入新 Node, size++"]
Check -->|"否"| Loop{"遍历链表/红黑树"}
Loop --> Match{"hash 相等 且 key==e 或 e.equals(key)?"}
Match -->|"是"| Update["更新 value 为 PRESENT 返回旧值"]
Match -->|"否"| Next["继续下一个节点"]
Next --> Loop
Insert --> AfterInsert["afterNodeInsertion 预留钩子"]
AfterInsert --> ReturnNull["返回 null"]
Update --> ReturnOld["返回旧 PRESENT"]
ReturnNull --> AddTrue["add 返回 true"]
ReturnOld --> AddFalse["add 返回 false"]
流程图说明
一句话概括:该图完整描述了从 HashSet.add 出发,经 HashMap.put 的哈希扰动、桶定位、链/树遍历判重,最终根据是否首次插入返回布尔的全部流程。
逐层分解:
- 第一层(HashSet 转发):add 直接委托
map.put(e, PRESENT),未做任何附加逻辑。 - 第二层(哈希扰动):
hash(Object key)将 Key 哈希值的高 16 位与低 16 位异或,增加随机性,降低低位相同的碰撞几率。 - 第三层(桶定位):利用
(n-1) & hash代替取模,要求容量 n 为 2 的幂,快速映射到 0~n-1 的桶索引。 - 第四层(冲突处理):当桶非空时,顺着链表或红黑树逐一对比,判定条件为
hash 相等 && (引用相等 || equals 相等)。若命中,则替换 value 并返回旧值;否则追加节点。 - 第五层(返回值映射):HashMap 返回 null 意味着插入新 Key,HashSet.add 返回 true;否则表示 Key 已存在,add 返回 false。
数据结构映射:HashMap 的Node<K,V>链表/红黑树节点实际承载 HashSet 元素;hash 字段来自 Key 的 hashCode() 再经扰动函数处理。
源码方法对应:HashMap.put→putVal→ 内部循环遍历Node.next,红黑树分支调用putTreeVal。
关键结论强调:HashSet 的去重秘密就是 HashMap 的 Key 唯一性,而这一机制严格依赖 equals 与 hashCode 的正确重写;如果两个对象 equals 为 true 但 hash 值不同,就可能分别落在不同桶,导致重复存储。
模块 6:remove 与 contains 操作(源码剖析)
这两个操作的源码同样极简:
public boolean remove(Object o) {
return map.remove(o) == PRESENT;
}
public boolean contains(Object o) {
return map.containsKey(o);
}
- remove:
HashMap.remove(o)当 Key 存在时返回对应的 Value(即PRESENT),因此判断== PRESENT即可确认是否删除成功。注意,如果元素不存在返回null,null == PRESENT为 false,正确表示删除失败。 - contains:直接委托
containsKey,利用 HashMap 的查找逻辑,时间复杂度 O(1) 平均。
remove 操作调用链路
flowchart LR
A["HashSet.remove(o)"] --> B["map.remove(o)"]
B --> C{"hash 扰动 + 定位桶"}
C --> D["遍历链表/树查找"]
D --> E{"匹配?"}
E -->|"是"| F["删除节点 Node 返回其 value: PRESENT"]
E -->|"否"| G["返回 null"]
F --> H["== PRESENT -> true"]
G --> I["== PRESENT -> false"]
流程图说明
一句话概括:remove 流程图展示了 HashSet 如何将元素的删除完全转交给 HashMap,并借助 PRESENT 对象精确转换返回值为布尔语义。
逐层分解:
- 第一步:HashSet.remove 传入元素对象,无类型限制。
- 第二步:进入 HashMap.remove,经过相同的哈希扰动和桶定位,在链表/红黑树中进行查找匹配(条件同 put 时的查找)。
- 第三步:若找到匹配节点,
removeNode方法解除其链/树连接,并返回节点的value,该值恒为PRESENT。 - 第四步:HashSet 据此与静态常量
PRESENT比对,确认删除成功。 - 第五步:若未找到,返回
null,判断为 false。
数据结构映射:删除动作直接操作 HashMap 的 Node 节点,可能导致桶内链表断开或树转为链表。
源码方法对应:HashMap.remove→removeNode(hash(key), key, null, false, true)。
关键结论强调:contains 和 remove 同样完全依赖 Key 的 hashCode/equals 契约;若重写不正确,contains 可能返回 false 即使元素存在,remove 可能无法正确删除。
模块 7:其他重要方法源码简析
-
size() / isEmpty() / clear()
public int size() { return map.size(); } public boolean isEmpty() { return map.isEmpty(); } public void clear() { map.clear(); }全部是单纯转发。
-
iterator()
public Iterator<E> iterator() { return map.keySet().iterator(); }返回的是
HashMap内部KeySet的迭代器,实际类型为KeyIterator,它继承自HashIterator,具有 fail‑fast 检测机制(详见模块 8)。需要注意的是keySet().iterator()返回的迭代器并不直接支持add操作(虽然 KeySet 不提供 add),但迭代器本身的remove是支持且安全的。
Part 4:迭代与序列化篇
模块 8:迭代器与 fail‑fast 机制
HashSet.iterator() 返回的迭代器实际上是 HashMap 的 KeyIterator 实例。该迭代器在创建时捕获底层 HashMap 的 modCount(结构修改计数器)。每次调用 next() 或 remove() 时,都会检查当前 modCount 与预期值是否一致,若不一致立即抛出 ConcurrentModificationException。这种尽力而为的快速失败机制主要用于及早暴露并发修改错误,但不保证能够捕获所有并发修改。
迭代器 next() 与 modCount 交互
sequenceDiagram
participant Client
participant HashSet
participant HashMap
participant KeyIterator as KeyIterator(fail-fast)
Note over KeyIterator: expectedModCount = modCount
Client->>HashSet: iterator()
HashSet->>HashMap: keySet().iterator()
HashMap->>KeyIterator: new KeyIterator()
KeyIterator-->>HashSet: 迭代器实例
HashSet-->>Client: 迭代器
Client->>KeyIterator: next()
KeyIterator->>KeyIterator: checkForComodification()
alt modCount != expectedModCount
KeyIterator-->>Client: throw ConcurrentModificationException
else 一致
KeyIterator->>HashMap: nextNode() 遍历 table
HashMap-->>KeyIterator: Node
KeyIterator-->>Client: element
end
交互图说明
一句话概括:该时序图展现了 fail‑fast 迭代器在每次获取元素前都会比对 modCount,一旦发现异步结构修改就抛出 CME。
逐层分解:
- 第一步(获取迭代器):HashSet 调用
HashMap.keySet().iterator(),HashIterator 构造时记录expectedModCount = modCount。 - 第二步(next 调用):执行
checkForComodification(),若modCount != expectedModCount抛出 CME,否则从当前桶位置继续遍历链表/红黑树获取下一元素。 - 第三步(安全删除):使用迭代器自身的
remove()方法会在删除后同步更新expectedModCount,因此不会抛出 CME;但若在迭代期间使用集合本身的remove方法,则modCount增加导致下次next失败。
数据结构映射:迭代器内部维护指向当前Node的引用和下一个节点的引用,直接依赖 HashMap 的数组+链表结构进行遍历。
源码方法对应:HashIterator.nextNode()在 HashMap 非静态内部类中实现,会跳过空桶,按照table数组索引递增方向移动。
关键结论强调:在 HashSet 遍历期间,任何导致底层 HashMap 结构变更(增/删)的操作都会触发 CME,除非使用迭代器自身的 remove 或使用 removeIf 等安全方法。
模块 9:序列化与克隆机制
HashSet 实现了 Serializable,但内部 map 字段被声明为 transient,表明它并不希望采用默认的序列化方式。原因在于哈希表本身与 JVM 的哈希种子、内存布局强相关,默认序列化不仅浪费空间,还可能导在跨 JVM 时重建的哈希分布不同。因此,HashSet 自定义了 writeObject 和 readObject 方法:
private void writeObject(java.io.ObjectOutputStream s) throws IOException {
s.defaultWriteObject();
// 写出 HashMap 的容量与负载因子
s.writeInt(map.capacity());
s.writeFloat(map.loadFactor());
// 写出元素个数
s.writeInt(map.size());
// 逐个写出元素
for (E e : map.keySet())
s.writeObject(e);
}
private void readObject(java.io.ObjectInputStream s) throws IOException, ClassNotFoundException {
s.defaultReadObject();
int capacity = s.readInt();
float loadFactor = s.readFloat();
int size = s.readInt();
// 根据容量和负载因子重建 HashMap,并逐个放入元素
map = new HashMap<>(capacity, loadFactor);
for (int i=0; i<size; i++) {
E e = (E) s.readObject();
map.put(e, PRESENT);
}
}
这种按元素流式序列化的策略不仅恢复了集合内容,还允许反序列化时以最适合当前 JVM 的哈希表布局重新构建,保证了反序列化后 HashSet 的哈希性能。
克隆机制
HashSet.clone() 调用 super.clone()(浅拷贝),然后对 map 字段调用 HashMap.clone():
public Object clone() {
try {
HashSet<E> newSet = (HashSet<E>) super.clone();
newSet.map = (HashMap<E, Object>) map.clone();
return newSet;
} catch (CloneNotSupportedException e) {
throw new InternalError(e);
}
}
这是浅克隆,HashSet 本身被复制,但内部的元素对象并没有被深拷贝,新旧集合共享相同的元素引用。因此,如果修改元素的可变状态,可能影响到双方。
Part 5:对比与陷阱篇
模块 10:HashSet vs TreeSet vs LinkedHashSet——三大实现类对比
| 特性 | HashSet | TreeSet | LinkedHashSet |
|---|---|---|---|
| 底层数据结构 | HashMap | TreeMap(红黑树) | LinkedHashMap |
| 元素顺序 | 无序(桶序) | 自然排序或 Comparator 排序 | 保持插入顺序 |
| 时间复杂度 | add/remove/contains 平均 O(1) | O(log n) | O(1)(稍高常数) |
| null 支持 | 允许一个 null | 不允许(需比较) | 允许一个 null |
| 内存开销 | 中等 | 较大(维护红黑树) | 较大(额外链表) |
| 适用场景 | 无排序需求,追求极致性能 | 需要排序的场景,如范围查找 | 需要记住插入顺序且无需排序 |
Set 选型决策树
flowchart TD
Start(["选择 Set 实现"]) --> Order{"需要特定的遍历顺序?"}
Order -->|"需要自然/比较排序"| TreeSet["TreeSet 红黑树 O(log n)"]
Order -->|"需要插入顺序"| LinkedHashSet["LinkedHashSet 双向链表 O(1)"]
Order -->|"不关心顺序"| Perf{"对性能极度敏感?"}
Perf -->|"是 数据量大"| HashSet["HashSet O(1) 快速操作"]
Perf -->|"否 需要导航"| TreeSet
HashSet --> Null{"允许 null?"}
Null -->|"允许"| HS["HashSet 允许 null"]
Null -->|"不允许"| HSCheck["代码校验 null"]
LinkedHashSet --> NullL{"允许 null?"}
NullL -->|"允许"| LHS["LinkedHashSet 允许 null"]
NullL -->|"不允许"| LHSCheck["代码校验 null"]
TreeSet --> NPE["TreeSet 禁止 null Comparable/Comparator 抛出 NPE"]
决策树说明
一句话概括:该图提炼了选择 Set 实现的三步决策:是否需顺序 → 何种顺序 → 性能与 null 约束,最终定位最佳实现。
逐层分解:
- 第一层:顺序需求。TreeSet 适用于自然/定制排序;LinkedHashSet 适用于仅维持插入顺序;HashSet 则完全无序。
- 第二层:性能考量。HashSet 的 O(1) 极快,但若需要范围查找(如找出大于某值的所有元素),TreeSet 的 subSet 等方法是 HashSet 不具备的,此时即便慢些也值得。
- 第三层:null 容忍度。TreeSet 因依赖比较器,插入 null 几乎必抛 NullPointerException;HashSet 和 LinkedHashSet 都允许一个 null,若业务禁止 null,必须自行前置校验。
数据结构映射:TreeSet 内部树节点Entry<K,V>有 left/right/parent 指针;LinkedHashSet 节点在 HashMap 节点基础上增加 before/after 双向链表指针。
源码方法对应:TreeSet.add→m.put(e, PRESENT),其中m为TreeMap;LinkedHashSet构造器通过模块 4 的包私有构造器注入LinkedHashMap。
关键结论强调:无特殊顺序需求时,HashSet 始终是最高效的选择;一旦业务逻辑涉及排序或插入顺序,应果断转向 TreeSet 或 LinkedHashSet。
模块 11:常见陷阱与最佳实践
陷阱 1:自定义对象未重写 hashCode/equals 导致去重失效
错误示例
class Person {
String name;
int age;
Person(String name, int age) { this.name = name; this.age = age; }
}
// 未覆写 hashCode/equals
HashSet<Person> set = new HashSet<>();
set.add(new Person("Alice", 30));
set.add(new Person("Alice", 30));
System.out.println(set.size()); // 2,期望 1
根本原因:Object 的 equals 比较引用地址,hashCode 返回不同整数,两个业务相同对象被放入不同桶。
正确示例
class Person {
String name;
int age;
// constructor...
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Person)) return false;
Person p = (Person) o;
return age == p.age && Objects.equals(name, p.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
此时 set.size() 输出 1。
陷阱 2:遍历中删除抛出 ConcurrentModificationException
错误示例(增强 for 循环本质是迭代器)
for (String s : set) {
if (s.startsWith("A")) {
set.remove(s); // 抛出 CME
}
}
正确做法
- 使用
Iterator.remove():Iterator<String> it = set.iterator(); while (it.hasNext()) { String s = it.next(); if (s.startsWith("A")) it.remove(); } - 或 Java 8+
removeIf:set.removeIf(s -> s.startsWith("A"));
陷阱 3:多线程下数据丢失或无限循环
HashSet 非线程安全,并发 add 可能导致 HashMap 内部链表成环或元素丢失。
解决方案
Set<String> syncSet = Collections.synchronizedSet(new HashSet<>());- 或使用并发集合:
ConcurrentHashMap.newKeySet()返回线程安全的 Set 视图。
陷阱 4:修改元素导致 hashCode 变化,元素“丢失”
典型场景
class MutablePerson { String name; /* 可变,参与了 hashCode */ }
HashSet<MutablePerson> set = new HashSet<>();
MutablePerson p = new MutablePerson("Alice");
set.add(p);
p.name = "Bob"; // 改变属性导致 hashCode 变化
System.out.println(set.contains(p)); // 很可能 false
set.remove(p); // 可能失败,导致内存泄漏
最佳实践:
存入 HashSet 的对象最好是不可变对象(如 String、包装类),或至少保证参与 hashCode/equals 计算的字段不可变。如果确实需要修改,应先移除,修改后重新加入。
Part 6:总结与面试篇
模块 12:注意事项与性能总结
时间复杂度表(假设良好哈希分布)
add:平均 O(1),最坏 O(log n)(桶内为树)或 O(n)(链表)remove/contains:同上size:O(1)- 迭代:与容量 + 元素数成正比,O(capacity + size)
负载因子调优建议
- 默认 0.75 是时间与空间折衷。若内存紧张可增大(如 1.0),但会增加冲突概率;若追求极致查询性能可适当减小(如 0.5),但会消耗更多内存。
- 预估元素数量初始化容量,可避免频繁的扩容 rehash 开销:
new HashSet<>(expectedSize / 0.75 + 1)。
null 元素注意事项
- HashSet 允许 null,但许多操作(如
contains(null))能正确返回 true。但大量使用 null 通常暗示设计不佳,应当用更有意义的默认值或 Optional。
模块 13:面试高频专题(独立详细)
1. HashSet 的底层数据结构是什么?如何保证元素不重复?
标准回答:底层完全依赖 HashMap。元素被当作 HashMap 的 Key 存入,Value 固定为静态常量 PRESENT。HashMap 内部使用数组+链表+红黑树存储,通过 Key 的 hashCode() 与 equals() 两个方法联合保证唯一性:当 put 时发现哈希值相同且 equals 为真,则认为 Key 已存在,只更新 Value 但不新增实体。
追问模拟:如果两个对象 equals 相等但 hashCode 不同会怎样?
回答:这两个对象会落入不同的哈希桶,HashSet 将它们视为不同元素,出现“业务重复但集合中存在两份”的错误,违背 Set 契约。
加分回答:可提及 JDK 8 中哈希冲突严重时链表会树化为红黑树,避免 O(n) 查询,同时指出 hashCode 的设计必须满足“相等对象必同哈希”的原则,并且要尽可能分散。
2. HashSet 的 add 方法返回值的含义?如何判断插入成功?
标准回答:add(E e) 返回 boolean 值,true 表示此元素之前不在集合中,添加成功;false 表示集合中已存在与 e 相等(equals)的元素,添加失败。底层通过 map.put(e, PRESENT) == null 判断,如果 put 返回 null 表明之前不存在此 Key。
追问模拟:如果 add 传入 null 会怎样?
回答:HashSet 允许单个 null,首次 add(null) 返回 true,再次返回 false。HashMap 的 put 同样支持一个 null 键。
3. 为什么存入 HashSet 的对象要重写 hashCode 和 equals?如果不重写会怎样?
标准回答:HashSet 依赖 hashCode 决定元素落在哪个桶,依赖 equals 在桶内判定是否相等。如果不重写,会继承 Object 的默认实现——equals 比较引用地址,hashCode 通常返回内存地址相关整数。这导致内容相同的不同对象被视为不等,无法正确去重,出现重复元素。
加分回答:不仅要去重,还需要遵循 equals 与 hashCode 的契约:若两个对象 equals 相等,hashCode 必须相等;反之则不然。此外,建议使用 Objects.hash 生成哈希,并保证参与计算的字段不可变以避免“丢失”元素。
4. HashSet 与 HashMap 的关系?PRESENT 对象的作用?
标准回答:HashSet 内部聚合一个 HashMap 实例 map,所有方法均由 HashMap 的对应方法实现。PRESENT 是一个 static final Object 实例,作为共享的虚拟 Value 填充到每个条目中。它最主要的作用有两点:① 避免使用 null 使 put 返回值无法区分“旧值为 null”还是“Key 不存在”;② 所有条目共用同一个引用,节省内存。
追问模拟:能否用 null 代替 PRESENT?
回答:技术上可以,但会导致 add 始终返回 true,失去判断新增的能力,因为 HashMap 对不存在键的 put 也返回 null,无法区分。
5. HashSet 如何遍历?迭代器原理是什么?
标准回答:通过 iterator() 方法返回一个迭代器,底层是 HashMap.keySet().iterator(),实际类型为 KeyIterator。该迭代器按哈希桶索引和链表/树顺序遍历所有非空桶里的每个节点。迭代器是 fail‑fast 的,创建时记录 modCount,遍历过程中若发现结构性修改(增加/删除元素,非迭代器自身操作),立即抛出 ConcurrentModificationException。
加分回答:可使用 for‑each 语法糖,编译后仍为迭代器遍历。当需要边遍历边删除时,应使用 Iterator.remove() 或 Java 8 的 removeIf 方法。
6. HashSet 与 TreeSet 的区别?如何选择?
标准回答:① 底层实现:HashSet 基于 HashMap(数组+链表+红黑树),TreeSet 基于 TreeMap(红黑树)。② 顺序:HashSet 无序,TreeSet 按自然顺序或比较器排序。③ 性能:HashSet 增删查 O(1),TreeSet O(log n)。④ null 支持:HashSet 允许一个 null,TreeSet 通常不允许(比较器可能需要比较 null)。⑤ 功能:TreeSet 提供额外导航方法如 first()、last()、subSet()。
选择依据:需要排序或范围查找时用 TreeSet;仅需去重且追求性能时用 HashSet。
7. HashSet 线程安全吗?如何在并发中使用安全的 Set?
标准回答:HashSet 完全非线程安全。并发添加可能引起 HashMap 链表成环、数据丢失等问题。常见安全化手段:① Collections.synchronizedSet(new HashSet<>()) 返回一个所有方法加锁的包装器,但迭代时仍需手动同步;② 使用 ConcurrentHashMap.newKeySet() 获得并发 Set,它利用分段锁和高并发算法,适合高并发场景;若元素数稳定、读多写少,也可考虑 CopyOnWriteArraySet(底层 CopyOnWriteArrayList)。
加分回答:提及 ConcurrentHashMap.newKeySet() 内部实际是 ConcurrentHashMap.KeySetView,支持原子添加,并可直接获得并发的 Map 视图。
8. HashSet 的默认容量和负载因子是多少?如何影响性能?
标准回答:默认初始容量 16,负载因子 0.75。当元素数超过容量 × 负载因子(即 12)时,HashMap 会进行 resize 扩容为原来的两倍,并重新哈希所有元素。负载因子越大,空间利用率越高,但冲突概率增加,查询效率下降;反之,负载因子越小,查询越快,但内存浪费越多。可以在构造时指定,根据预估元素量调优避免频繁扩容。
9. 如果修改了 HashSet 中元素的 hashCode,会发生什么?
标准回答:元素的哈希值改变后,它在底层 HashMap 中的桶位置可能不再与新哈希值匹配。此时 contains 或 remove 会在新桶位置查找,几乎找不到该元素,导致“元素丢失”、内存泄漏。迭代器遍历仍可能访问到旧桶中的元素(因为它未移动),但后果不可预测。这正是推荐使用不可变对象作为集合元素的原因。
加分回答:如果必须修改,正确的做法是先 remove,修改后再 add;或者在设计上避免将可变字段放入 hashCode 计算。
10. 为什么推荐使用不可变对象作为 HashSet 的元素?
标准回答:不可变对象创建后状态恒定,哈希值固定。这保证了在集合生命周期内,哈希值不变,桶位置稳定,contains、remove 等操作始终能正确定位。此外,不可变对象天然线程安全,能被安全地共享在多线程环境中。JDK 中的 String、Integer 等包装类就是最佳范例。如果确实需要可变对象,务必确保参与 hashCode 计算的字段在存入集合后不会发生改变。
加分回答:可举例 Guava 的 ImmutableSet 或 Java 9+ Set.of 创建的不可变集合,它们进一步从集合层面防止修改,提供更强的安全保障。
总结
全文从 HashSet 的委托本质出发,纵贯源码、架构、陷阱与面试,揭示了 Set 去重的核心奥秘。当你在代码中写下 Set<String> set = new HashSet<>() 时,脑中映出的不应仅仅是那个菱形符号,而应是背后 HashMap 的数组、链表和红黑树正在为你的元素唯一性保驾护航。*