在 Java 集合框架中,HashSet 是最常用的"去重容器"之一,它基于 HashMap 实现,核心作用是存储不重复的元素,支持快速的添加、删除和查找操作。很多开发者在使用 HashSet 时,只知道它能去重,却不清楚其底层实现逻辑、去重原理以及使用时的注意事项,甚至会混淆它与 HashMap、TreeSet 的区别。
本文将从「基础认知、底层原理、核心流程、源码细节、常见误区、面试考点」六个维度,详细拆解 HashSet,让你不仅会用,更能吃透其底层逻辑,轻松应对开发与面试。
一、HashSet 基础认知:是什么?核心特性有哪些?
1.1 什么是 HashSet?
HashSet 是 Java 中实现了 Set 接口的集合类,它继承自 AbstractSet,底层依托 HashMap 实现,本质上是一个"包装了 HashMap 的集合"。它的核心设计目标是保证元素唯一,同时提供 O(1) 级别的平均存取效率。
简单来说:HashSet = 去重 + 无序 + 高效,适合用于"需要存储不重复元素,且不关心元素顺序"的场景(比如存储用户 ID、去重数据等)。
1.2 HashSet 核心特性(必记)
-
元素唯一 :这是 HashSet 最核心的特性,不允许存储重复元素。判断元素是否重复,依赖元素的
hashCode()和equals()方法(这是重点,后面详细讲)。 -
无序性:存储顺序与插入顺序无关,且随着元素的添加、删除或扩容,元素的存储位置可能发生变化(底层依赖 HashMap 的无序性)。
-
允许存储 null 值:但只能存储 1 个 null 值(因为元素唯一,重复的 null 会被过滤)。
-
非线程安全 :多线程环境下,同时添加、删除元素可能出现数据不一致、并发修改异常(
ConcurrentModificationException),并发场景需使用Collections.synchronizedSet(new HashSet<>())或CopyOnWriteArraySet。 -
高效存取:平均情况下,添加(add)、删除(remove)、查找(contains)操作的时间复杂度都是 O(1),极端情况下(哈希冲突严重)会退化到 O(n) 或 O(log n)(依赖底层 HashMap 的结构)。
-
无索引:HashSet 没有像 ArrayList 那样的索引,无法通过下标获取元素,只能通过迭代器(Iterator)或增强 for 循环遍历。
1.3 HashSet 与 HashMap 的直观关联
很多人会疑惑:HashSet 为什么能去重?为什么效率和 HashMap 一样?答案很简单:HashSet 的底层就是 HashMap,它把 HashSet 的元素作为 HashMap 的 key,用一个固定的"空对象"作为 HashMap 的 value,借助 HashMap 的 key 唯一性,实现 HashSet 的元素去重。
举个通俗的例子:HashMap 是"key-value 键值对",而 HashSet 相当于"只存 key,不关心 value",value 被默认填充为一个固定的空对象(private static final Object PRESENT = new Object();),这样一来,HashMap 的 key 唯一特性,就直接变成了 HashSet 的元素唯一特性。
二、HashSet 底层原理:依托 HashMap 实现的核心逻辑
HashSet 的底层实现非常简单,核心就是"复用 HashMap 的功能",我们从「底层结构、核心参数、去重原理」三个方面拆解。
2.1 底层结构(JDK 1.8)
HashSet 没有自己独立的底层结构,完全依赖 HashMap 的底层结构:数组 + 链表 + 红黑树。
-
数组(哈希桶):作为主干,用于快速定位元素,每个下标位置称为一个"桶"。
-
链表:用于解决哈希冲突,当多个元素的哈希值计算出相同的数组下标时,以链表形式挂载在对应桶下。
-
红黑树:当链表长度达到 8 且数组长度 ≥ 64 时,链表转为红黑树,将查找效率从 O(n) 提升到 O(log n)(与 HashMap 完全一致)。
简单总结:HashSet 的底层结构 = HashMap 的底层结构,HashSet 的元素 = HashMap 的 key,HashSet 的 value = 固定空对象 PRESENT。
2.2 核心参数(与 HashMap 完全一致)
HashSet 的核心参数,本质上就是其底层 HashMap 的参数,因为 HashSet 本身没有额外的参数,所有参数都直接复用 HashMap 的:
| 参数名称 | 含义 | 默认值 | 作用 |
|---|---|---|---|
| initialCapacity(初始容量) | 底层 HashMap 的初始数组长度 | 16 | 必须是 2 的 n 次方,保证哈希下标计算均匀 |
| loadFactor(加载因子) | 数组的填充阈值 | 0.75 | 平衡空间占用和哈希冲突概率,达到阈值触发扩容 |
| threshold(扩容阈值) | 触发扩容的元素数量 | initialCapacity × loadFactor(默认 12) | 元素数量超过阈值,底层 HashMap 扩容,HashSet 同步扩容 |
| TREEIFY_THRESHOLD(树化阈值) | 链表转红黑树的长度 | 8 | 链表长度 ≥ 8 且数组长度 ≥ 64 时,转为红黑树 |
| UNTREEIFY_THRESHOLD(退化阈值) | 红黑树退化为链表的节点数 | 6 | 红黑树节点数 ≤ 6 时,退化为链表,节省内存 |
2.3 核心:HashSet 去重原理(重中之重)
HashSet 能去重,本质是借助 HashMap 的 key 唯一性 ,而 HashMap 的 key 唯一性,又依赖于元素的 hashCode() 和 equals() 方法------这也是 HashSet 最核心、最容易踩坑的知识点。
完整去重逻辑(添加元素时):
-
当调用
hashSet.add(Object e)时,底层会调用hashMap.put(e, PRESENT),将元素 e 作为 HashMap 的 key,固定空对象作为 value。 -
HashMap 会先计算 key(即 e)的哈希值(通过
hashCode()方法),根据哈希值计算数组下标,定位到对应的桶。 -
判断该桶中是否存在元素:
-
若桶为空:直接将当前 key 存入,添加成功。
-
若桶不为空:遍历桶中的元素(链表/红黑树),对比两个元素的哈希值和内容:
-
若哈希值不同:说明是不同元素,直接添加到桶中(链表尾插/红黑树插入)。
-
若哈希值相同 :再调用
equals()方法对比内容:-
equals() 返回 true:说明元素重复,不添加,直接返回 false。
-
equals() 返回 false:说明是"哈希冲突"(不同元素哈希值相同),添加到桶中,返回 true。
-
-
-
关键结论:HashSet 判断元素是否重复,必须同时满足两个条件:① 哈希值相同(hashCode() 结果一致);② equals() 方法返回 true。缺一不可。
举个反例:如果两个对象的 hashCode() 不同,即使 equals() 返回 true,HashSet 也会认为是不同元素,会同时存储;如果两个对象的 hashCode() 相同,但 equals() 返回 false,HashSet 会认为是不同元素,会存储在同一个桶的链表/红黑树中(哈希冲突)。
三、HashSet 核心方法:源码级解析(JDK 1.8)
HashSet 的所有方法,本质上都是调用底层 HashMap 的对应方法,没有自己独立的业务逻辑。我们重点解析最常用的 4 个方法:add()、remove()、contains()、size()。
3.1 构造方法:初始化底层 HashMap
HashSet 有 4 个构造方法,核心都是初始化底层的 HashMap,我们看最常用的 3 个:
java
// 1. 无参构造:初始化默认容量(16)、默认加载因子(0.75)的 HashMap
public HashSet() {
map = new HashMap<>();
}
// 2. 指定初始容量的构造方法
public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}
// 3. 指定初始容量和加载因子的构造方法
public HashSet(int initialCapacity, float loadFactor) {
map = new HashMap<>(initialCapacity, loadFactor);
}
// 4. 传入一个集合,将集合中的元素添加到 HashSet(去重)
public HashSet(Collection<? extends E> c) {
map = new HashMap<>(Math.max((int) (c.size()/.75f) + 1, 16));
addAll(c);
}
注意:HashSet 没有"指定底层 HashMap 为链表/红黑树"的构造方法,所有结构相关的逻辑,都和底层 HashMap 完全一致。
3.2 add() 方法:添加元素(核心去重逻辑)
add() 是 HashSet 最核心的方法,作用是添加元素并去重,底层直接调用 HashMap 的 put() 方法。
java
// HashSet 的 add() 方法
public boolean add(E e) {
// 调用 HashMap 的 put() 方法,key = e,value = 固定空对象 PRESENT
return map.put(e, PRESENT) == null;
}
// 底层 HashMap 的 put() 方法核心逻辑(简化)
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
返回值说明:
-
如果添加的元素是新元素(不重复):HashMap 的 put() 方法会返回 null,因此 HashSet 的 add() 方法返回 true。
-
如果添加的元素是重复元素:HashMap 的 put() 方法会返回旧的 value(即 PRESENT),因此 HashSet 的 add() 方法返回 false。
3.3 remove() 方法:删除元素
删除元素,本质是删除底层 HashMap 中对应的 key,返回值表示"是否删除成功"。
java
// HashSet 的 remove() 方法
public boolean remove(Object o) {
// 调用 HashMap 的 remove() 方法,删除 key = o,返回删除的 value
return map.remove(o) == PRESENT;
}
返回值说明:
-
如果元素存在:HashMap 的 remove() 方法会返回对应的 value(PRESENT),因此 HashSet 的 remove() 方法返回 true。
-
如果元素不存在:HashMap 的 remove() 方法会返回 null,因此 HashSet 的 remove() 方法返回 false。
3.4 contains() 方法:判断元素是否存在
判断元素是否存在,本质是判断底层 HashMap 中是否存在对应的 key,效率极高(平均 O(1))。
java
// HashSet 的 contains() 方法
public boolean contains(Object o) {
// 直接调用 HashMap 的 containsKey() 方法
return map.containsKey(o);
}
3.5 size() 方法:获取元素个数
直接返回底层 HashMap 的元素个数(key 的个数),因为 HashMap 的 size 就是 HashSet 的元素个数。
java
public int size() {
return map.size();
}
四、HashSet 扩容机制:完全复用 HashMap 的扩容逻辑
HashSet 本身没有扩容逻辑,它的扩容完全依赖底层 HashMap 的扩容机制------当 HashSet 的元素个数(即 HashMap 的 key 个数)超过扩容阈值(capacity × loadFactor)时,底层 HashMap 会触发扩容,HashSet 同步完成扩容。
4.1 扩容触发条件(与 HashMap 一致)
-
条件一:元素个数(size)> 扩容阈值(threshold = capacity × loadFactor),触发扩容。
-
条件二:JDK 1.8 独有,当某个链表长度 ≥ 8,但数组长度 < 64 时,优先扩容而非树化(底层 HashMap 的逻辑)。
4.2 扩容流程(简化版)
-
创建一个容量为原来 2 倍的新数组(保持 2 的 n 次方)。
-
将旧数组中的所有 key(即 HashSet 的元素)重新计算下标,迁移到新数组中。
-
更新扩容阈值(新阈值 = 新容量 × 加载因子)。
注意:JDK 1.8 扩容时,会通过位运算快速拆分元素(无需重新计算哈希),效率比 JDK 1.7 高很多,且不会出现死循环问题(与 HashMap 一致)。
五、HashSet 常见使用误区(避坑重点)
很多开发者使用 HashSet 时,会因为不了解其底层原理,踩一些常见的坑,这里总结 4 个最容易出错的场景:
误区 1:忘记重写 hashCode() 和 equals() 方法,导致去重失败
这是最常见的坑!如果存储的是自定义对象(比如 User、Student),没有重写 hashCode() 和 equals() 方法,HashSet 会使用 Object 类的默认方法,而 Object 类的 hashCode() 是根据对象的内存地址计算的,equals() 是判断内存地址是否相同------这会导致"内容相同但内存地址不同"的对象被认为是不同元素,无法去重。
java
// 反例:自定义 User 类,未重写 hashCode() 和 equals()
class User {
private String id;
private String name;
public User(String id, String name) {
this.id = id;
this.name = name;
}
}
public class Test {
public static void main(String[] args) {
HashSet<User> set = new HashSet<>();
set.add(new User("1", "张三"));
set.add(new User("1", "张三")); // 两个对象内容相同,但内存地址不同
System.out.println(set.size()); // 输出 2,去重失败!
}
}
解决方案:重写 hashCode() 和 equals() 方法,根据对象的核心属性(比如 id)来计算哈希值和判断相等。
java
class User {
private String id;
private String name;
public User(String id, String name) {
this.id = id;
this.name = name;
}
// 重写 equals():根据 id 判断是否相等
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
User user = (User) o;
return Objects.equals(id, user.id);
}
// 重写 hashCode():根据 id 计算哈希值
@Override
public int hashCode() {
return Objects.hash(id);
}
}
此时再添加两个 id 相同的 User 对象,HashSet 会认为是重复元素,只存储一个,去重成功。
误区 2:认为 HashSet 是有序的
很多初学者会误以为"添加顺序就是遍历顺序",但实际上,HashSet 是无序的------因为底层 HashMap 的 key 是无序的,且扩容时元素位置会发生变化,因此遍历 HashSet 时,顺序可能与插入顺序不一致。
如果需要"有序且去重"的集合,应该使用 TreeSet(自然排序)或 LinkedHashSet(插入顺序)。
误区 3:认为 HashSet 可以存储多个 null 值
HashSet 允许存储 null 值,但只能存储 1 个------因为 null 的 hashCode() 是 0,equals() 方法判断 null 与 null 相等,因此重复的 null 会被去重。
误区 4:多线程环境下直接使用 HashSet
HashSet 是非线程安全的,多线程同时添加、删除元素时,可能出现:
-
数据不一致:比如重复添加元素、元素丢失。
-
并发修改异常(
ConcurrentModificationException):遍历过程中修改集合。
解决方案:
-
使用
Collections.synchronizedSet(new HashSet<>()):对所有方法加锁,线程安全,但效率较低。 -
使用
CopyOnWriteArraySet:采用"写时复制"机制,线程安全,适合读多写少的场景。
六、HashSet 与相关集合的对比(面试高频)
面试中经常会问:HashSet、LinkedHashSet、TreeSet 的区别?HashSet 与 HashMap 的区别?这里整理成表格,一目了然。
6.1 HashSet、LinkedHashSet、TreeSet 对比
| 对比项 | HashSet | LinkedHashSet | TreeSet |
|---|---|---|---|
| 底层结构 | 数组 + 链表 + 红黑树(依赖 HashMap) | 链表 + 哈希表(依赖 LinkedHashMap) | 红黑树(依赖 TreeMap) |
| 有序性 | 无序 | 插入顺序 | 自然排序(或自定义排序) |
| 去重原理 | hashCode() + equals() | hashCode() + equals() | compareTo()(或 Comparator) |
| 时间复杂度 | 平均 O(1),极端 O(log n) | 平均 O(1),略低于 HashSet(多维护链表) | O(log n)(红黑树操作) |
| 是否允许 null | 允许 1 个 null | 允许 1 个 null | 不允许 null(会抛异常) |
| 适用场景 | 无需有序,仅需去重,追求高效 | 需要保持插入顺序,且去重 | 需要排序,且去重 |
6.2 HashSet 与 HashMap 对比
| 对比项 | HashSet | HashMap |
|---|---|---|
| 存储结构 | 仅存储元素(key) | 存储 key-value 键值对 |
| 核心功能 | 去重、快速查找 | 键值对映射、快速存取 |
| 底层依赖 | 依赖 HashMap 实现 | 独立底层结构(数组 + 链表 + 红黑树) |
| 元素/key 唯一性 | 元素唯一(依赖 HashMap 的 key 唯一) | key 唯一,value 可重复 |
| 是否允许 null | 允许 1 个 null 元素 | 允许 1 个 null key,多个 null value |
| 常用方法 | add()、remove()、contains()、size() | put()、get()、remove()、containsKey()、size() |
七、HashSet 高频面试题(含答案)
HashSet 是 Java 面试中的高频考点,尤其是与 HashMap 结合的问题,这里整理 5 道最常考的题目,附带详细答案。
1. HashSet 的底层实现原理是什么?
答:HashSet 底层依托 HashMap 实现,本质是一个"包装了 HashMap 的集合"。它将 HashSet 的元素作为 HashMap 的 key,用一个固定的空对象(PRESENT)作为 HashMap 的 value,借助 HashMap 的 key 唯一性,实现 HashSet 的元素去重。底层结构与 HashMap 一致,是数组 + 链表 + 红黑树(JDK 1.8)。
2. HashSet 如何判断元素是否重复?
答:判断元素是否重复,必须同时满足两个条件:① 两个元素的 hashCode() 方法返回值相同;② 两个元素的 equals() 方法返回 true。缺一不可。如果哈希值不同,即使内容相同,也会被认为是不同元素;如果哈希值相同,但内容不同(哈希冲突),会被存储在同一个桶的链表/红黑树中。
3. 为什么存储自定义对象时,要重写 hashCode() 和 equals() 方法?
答:因为默认的 hashCode() 方法是根据对象的内存地址计算的,equals() 方法是判断内存地址是否相同。如果不重写,"内容相同但内存地址不同"的自定义对象,会被 HashSet 认为是不同元素,无法实现去重。重写后,根据对象的核心属性(比如 id)计算哈希值和判断相等,才能正确去重。
4. HashSet、LinkedHashSet、TreeSet 的区别是什么?
答:核心区别在底层结构、有序性和适用场景:
-
HashSet:底层 HashMap,无序,平均 O(1) 效率,适合仅需去重、无需有序的场景。
-
LinkedHashSet:底层 LinkedHashMap,保持插入顺序,效率略低于 HashSet,适合需要插入顺序的去重场景。
-
TreeSet:底层 TreeMap(红黑树),自然排序/自定义排序,O(log n) 效率,适合需要排序的去重场景。
5. HashSet 是线程安全的吗?如何保证线程安全?
答:HashSet 是非线程安全的,多线程环境下会出现数据不一致、并发修改异常。保证线程安全的方式有两种:① 使用 Collections.synchronizedSet(new HashSet<>()),对所有方法加锁;② 使用 CopyOnWriteArraySet,采用写时复制机制,适合读多写少的场景。
八、总结
HashSet 看似简单,实则是 HashMap 的"简化版",核心逻辑完全依赖 HashMap,但它的"去重"特性在日常开发中非常实用。掌握 HashSet 的关键,在于理解其底层 HashMap 的实现,尤其是 hashCode() 和 equals() 方法的作用------这不仅是使用 HashSet 的基础,也是 Java 面试的高频考点。
最后总结核心要点:
-
HashSet 底层 = HashMap,元素 = HashMap 的 key,value = 固定空对象。
-
去重原理:hashCode() + equals() 双重判断。
-
特性:无序、唯一、非线程安全、允许 1 个 null,平均 O(1) 存取效率。
-
避坑重点:自定义对象必须重写 hashCode() 和 equals(),多线程场景需用线程安全的集合。