HashSet
HashSet
HashSet 是 Java 集合框架中的一个类,它实现了 Set 接口,基于哈希表(Hash Table)的数据结构。HashSet 是一个 无序集合,不允许存储重复元素,并且允许存储 null 值
HashSet特点
- 基于哈希表实现
- HashSet 内部使用哈希表来存储元素,哈希表是一种高效的数据结构,支持快速的插入、删除和查询操作
- 不允许重复元素
- HashSet 中的元素是唯一的,如果尝试添加重复元素,添加操作会被忽略。
- 元素的唯一性是通过 equals() 和 hashCode() 方法来判断的
- 无序性
- HashSet 不保证元素的顺序,元素的存储顺序可能与插入顺序不同
- 允许 null 元素
- HashSet 允许存储 null 值,但只能存储一个 null
- 非线程安全
- HashSet 不是线程安全的
HashSet常用方法
HashSet 实现了 Set 接口,因此支持 Set 的所有方法
HashSet底层实现
HashSet 的底层是一个 哈希表,具体来说,它内部使用了一个 HashMap 来存储元素。HashSet 的元素是 HashMap 的键(Key),而值(Value)是一个固定的 PRESENT 对象(private static final Object PRESENT = new Object())
-
核心字段
- HashMap<E, Object> map
- HashSet 内部使用 HashMap 来存储元素
- 元素的键是 HashSet 的元素,值是一个固定的 PRESENT 对象
-
构造函数,HashSet 提供了多个构造函数,用于初始化内部的 HashMap
-
默认构造函数
-
创建一个默认初始容量为 16,负载因子为 0.75 的 HashMap
-
HashMap 和 HashSet 在第一次创建时不会立即分配 16 的内存空间,它们的初始化是延迟加载的
-
在第一次添加元素时(调用 put() 或 add() 方法),HashMap 会初始化一个默认容量为 16 的哈希表
public HashSet() { map = new HashMap<>(); }
-
-
指定初始容量的构造函数
public HashSet(int initialCapacity) { map = new HashMap<>(initialCapacity); } 创建一个指定初始容量的 HashMap
-
指定初始容量和负载因子的构造函数
public HashSet(int initialCapacity, float loadFactor) { map = new HashMap<>(initialCapacity, loadFactor); } 创建一个指定初始容量和负载因子的 HashMap
-
基于集合的构造函数
- 用于通过已有集合初始化新的 HashSet 实例。该构造函数会将传入集合 c 的所有元素添加到新 HashSet 中,并自动去重
- 根据集合 c 的容量初始化 HashMap 的大小,具体如下
public HashSet(Collection<? extends E> c) { map = new HashMap<>(Math.max((int) (c.size() / .75f) + 1, 16)); addAll(c); }
-
-
哈希函数
- HashMap 使用键的 hashCode() 方法计算哈希值
- 哈希值通过扰动函数((h = key.hashCode()) ^ (h >>> 16))进一步处理,以减少哈希冲突
-
哈希冲突解决
- 当两个键的哈希值映射到同一个桶(Bucket)时,会发生哈希冲突
- HashMap 使用链表或红黑树来解决哈希冲突
- 如果桶中的元素数量较少(小于 8),使用链表存储。
- 如果桶中的元素数量较多(大于等于 8),如果还满足 哈希表总容量 ≥ 64,则将链表转换为红黑树
- 当红黑树中的节点数 减少至 6 或以下 时,树会退化为链表以节省内存。此阈值与树化阈值(8)之间设置 2 的差值,避免频繁转换
- 退化操作 仅由节点数触发,与哈希表的当前容量无关
HashSet扩容机制
-
扩容的触发条件
- 当哈希表中的元素数量超过 容量 × 负载因子 时,HashSet 会自动扩容
- 是元素数量不是Node数组中的数量,即使是全部在一条链表上也会进行扩容
- 容量:哈希表的当前大小(桶的数量)
- 负载因子:哈希表在扩容之前可以达到的填充比例,默认值为 0.75
-
扩容的核心逻辑
- 新容量通常是旧容量的 2 倍
- 新阈值是新容量 × 负载因子
- 创建一个新的哈希表,容量为新容量
- 扩容需要遍历旧表中的所有元素,并将其重新分配到新表中
- 扩容会创建一个新的哈希表,容量为旧表的 2 倍
迁移数据
- 遍历旧表中的每个桶,将元素重新分配到新表中。
- 如果桶中只有一个元素,则直接放入新表。
- 如果桶中是链表,则将链表拆分为低位链表和高位链表,分别放入新表。
- 如果桶中是红黑树,需要将红黑树拆分为两棵红黑树,分别放入新表中的不同位置
HashSet判断元素是否重复
-
调用元素的 hashCode() 方法,生成初始哈希值,HashMap内部对哈希值进行二次计算(如异或高位),减少哈希冲突概率
-
根据扰动后的哈希值,通过 (n-1) & hash 公式确定元素应存入的哈希桶
-
若目标桶已有元素(哈希冲突),遍历该桶内的链表或红黑树节点
-
进一步通过 ==(引用地址相同)或 equals()(内容相同)判断是否重复
-
满足以下任一条件即判定为重复元素
-
哈希值相同,且引用地址相同(== 返回 true)
-
哈希值相同,且 equals() 方法返回 true
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break;
-
-
若未找到重复元素,将新元素插入链表尾部或红黑树对应位置
如何正确的重写equals()和hashCode()方法
-
equals() 方法的重写规则
- 自反性:x.equals(x) 必须返回 true。
- 对称性:若 x.equals(y) 为 true,则 y.equals(x) 也必须为 true。
- 传递性:若 x.equals(y) 和 y.equals(z) 为 true,则 x.equals(z) 必须为 true。
- 一致性:多次调用 equals() 结果应一致(对象未被修改时)。
- 非空性:x.equals(null) 必须返回 false
实现步骤
- 类型检查:使用 if (o == null || getClass() != o.getClass()) 判断对象类型是否一致。
- 字段比较:逐一比较所有关键字段(影响对象逻辑相等的字段)。
- 空安全处理:使用 Objects.equals(field, other.field) 替代 field.equals() 以避免空指针
@Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; MyClass obj = (MyClass) o; return Objects.equals(field1, obj.field1) && Objects.equals(field2, obj.field2); }
-
hashCode() 方法的重写规则
核心原则
- 一致性:若 equals() 结果为 true,则两个对象的 hashCode() 必须相同。
- 高效性:哈希码应尽量分布均匀以减少哈希碰撞。
- 包含所有关键字段:哈希码需基于 equals() 中比较的所有字段生成
生成哈希码的方法
- Java 7+:使用 Objects.hash(field1, field2, ...)
@Override public int hashCode() { return Objects.hash(field1, field2); }
- 数组字段处理:使用 Arrays.hashCode() 生成数组的哈希码
-
注意事项
-
同时重写 equals() 和 hashCode(),若只重写 equals() 而忽略 hashCode(),可能导致哈希集合(如 HashMap)行为异常,例如无法正确检索对象
-
继承场景处理,若父类已重写 equals()/hashCode(),子类需调用 super.equals() 和 super.hashCode() ,并追加子类字段的比较
-
如果两个子类的字段相等,但是父类不同,如果不调用父类的两个方法则会错误的认为两个子类对象相等
-
避免可变字段参与哈希码,若字段可变且参与哈希计算,对象存入集合后修改字段会导致哈希码变化,破坏集合的稳定性
-
当向 HashSet 中添加元素时,会先计算对象的哈希值(通过 hashCode() 方法),然后根据哈希值将对象存储到对应的位置,调用 equals() 方法确认对象是否存在
-
当从 HashSet 中删除元素时,会先计算对象的哈希值,然后根据哈希值找到对象的位置,最后调用 equals() 方法确认对象是否匹配
-
对象的哈希码是基于字段的值计算的。如果字段的值被修改,哈希码也会随之变化
class Person { int id; // 可变字段 String name; @Override public int hashCode() { return id; // 哈希码基于 id 计算 } } HashSet<Person> set = new HashSet<>(); Person p = new Person(); p.id = 1; set.add(p); // 哈希码为 1,存储在位置 1 p.id = 2; // 哈希码变为 2 set.contains(p); // 集合会去位置 2 查找,但对象实际存储在位置 1,导致查找失败
-
其中添加删除HashSet中的对象也类似,导致集合的稳定性被破坏
- 无法删除对象:调用 remove() 方法时,集合无法找到对象的位置,导致删除失败
- 重复存储对象:如果修改字段后再次添加对象,集合会将其视为一个新对象,导致重复存储
- 数据不一致:集合的内部状态被破坏,可能导致不可预知的错误
-
解决方法
- 避免可变字段参与哈希码计算:确保参与哈希码计算的字段是不可变的,以维护集合的稳定性
- 先删除再修改:如果需要修改对象的字段,可以先从集合中删除对象,修改后再重新添加
-
-
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· 三行代码完成国际化适配,妙~啊~
· .NET Core 中如何实现缓存的预热?