深入理解 Java HashSet

在 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 最核心、最容易踩坑的知识点。

完整去重逻辑(添加元素时):

  1. 当调用 hashSet.add(Object e) 时,底层会调用 hashMap.put(e, PRESENT),将元素 e 作为 HashMap 的 key,固定空对象作为 value。

  2. HashMap 会先计算 key(即 e)的哈希值(通过 hashCode() 方法),根据哈希值计算数组下标,定位到对应的桶。

  3. 判断该桶中是否存在元素:

    1. 若桶为空:直接将当前 key 存入,添加成功。

    2. 若桶不为空:遍历桶中的元素(链表/红黑树),对比两个元素的哈希值和内容:

      • 哈希值不同:说明是不同元素,直接添加到桶中(链表尾插/红黑树插入)。

      • 哈希值相同 :再调用 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 一致)

  1. 条件一:元素个数(size)> 扩容阈值(threshold = capacity × loadFactor),触发扩容。

  2. 条件二:JDK 1.8 独有,当某个链表长度 ≥ 8,但数组长度 < 64 时,优先扩容而非树化(底层 HashMap 的逻辑)。

4.2 扩容流程(简化版)

  1. 创建一个容量为原来 2 倍的新数组(保持 2 的 n 次方)。

  2. 将旧数组中的所有 key(即 HashSet 的元素)重新计算下标,迁移到新数组中。

  3. 更新扩容阈值(新阈值 = 新容量 × 加载因子)。

注意: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(),多线程场景需用线程安全的集合。

相关推荐
Ralph_Y2 小时前
C++:static
开发语言·c++
摇滚侠3 小时前
Java 项目教程《黑马商城-ElasticSearch 篇》,分布式架构项目,从开发到部署
java·分布式·elasticsearch
佩奇大王3 小时前
P2408 特殊日期
java·开发语言
YMH.3 小时前
Day3.14c++
开发语言·c++
于先生吖3 小时前
JAVA国际版图文短视频交友系统源码:多语言适配,短视频+图文双形态可商用
java·音视频·交友
花间相见3 小时前
【JAVA基础11】—— 吃透原码、反码、补码:计算机数值表示的底层逻辑
java·开发语言·笔记
阿蒙Amon3 小时前
C#常用类库-详解Playwright
开发语言·c#
特种加菲猫3 小时前
C++ std::list 完全指南:从入门到精通所有接口
开发语言·c++