要让java的hashset正确避免元素重复,核心在于必须正确重写hashcode()和equals()方法。1. 自定义类必须同时重写hashcode()和equals()方法,否则hashset无法识别逻辑上相同的对象为重复;2. equals()方法需满足自反性、对称性、传递性、一致性和与null比较返回false的契约;3. hashcode()必须保证:如果两个对象equals为true,则它们的hashcode必须相等;4. 应使用相同的字段参与hashcode()和equals()的计算;5. 用于计算hashcode()和equals()的字段应尽量保持不可变,或在对象加入hashset后不再修改,否则会导致哈希码变化,使对象“丢失”在原桶中,造成查找失败、幽灵元素或重复添加等问题;6. 正确的做法是:若需修改关键字段,应先从hashset中移除对象,修改后再重新添加。只有遵循这些原则,hashset才能正确维护元素唯一性并保证性能。
要让Java的
HashSet
正确避免元素重复,核心在于你自定义的类必须正确地重写
hashCode()
和
equals()
方法。没有这两个方法的正确实现,
HashSet
就无法识别两个逻辑上相同的对象是重复的,因为它的唯一性判断机制正是基于它们。
解决方案
HashSet
内部依赖于对象的哈希码(hash code)和相等性(equality)来判断元素是否重复。当你尝试向
HashSet
中添加一个元素时,它会先调用该元素的
hashCode()
方法来确定它在内部数组中的存储位置(即“桶”)。如果这个桶中已经有元素,它会进一步调用
equals()
方法来比较新元素与桶中现有元素是否相等。只有当
hashCode()
和
equals()
都表明两个对象“相同”时,
HashSet
才会认为它们是重复的,从而拒绝添加。
这意味着,如果你想让
HashSet
正确处理你自定义的对象,比如一个
Person
类,你就必须为
Person
类提供一套符合契约的
hashCode()
和
equals()
实现。这个契约非常重要:
立即学习“Java免费学习笔记(深入)”;
- 自反性(Reflexive): 任何非null的
x
,
x.equals(x)
必须返回
true
。
- 对称性(Symmetric): 任何非null的
x
和
y
,如果
x.equals(y)
返回
true
,那么
y.equals(x)
也必须返回
true
。
- 传递性(Transitive): 任何非null的
x
、
y
和
z
,如果
x.equals(y)
返回
true
,并且
y.equals(z)
返回
true
,那么
x.equals(z)
也必须返回
true
。
- 一致性(Consistent): 任何非null的
x
和
y
,只要它们用于
equals
比较的信息没有被修改,那么多次调用
x.equals(y)
都应该返回相同的结果。
- 与null的比较: 任何非null的
x
,
x.equals(null)
必须返回
false
。
-
hashCode
一致性:
如果两个对象通过equals
方法比较是相等的,那么它们的
hashCode()
方法必须产生相同的整数结果。反之不一定成立,即不同的对象可以有相同的哈希码(哈希冲突),但此时
equals
方法会负责区分它们。
通常,IDE(如IntelliJ IDEA或Eclipse)都提供了自动生成
hashCode()
和
equals()
的功能,它们通常基于类的成员变量来生成,这大大简化了我们的工作。但理解背后的原理仍然至关重要。
import java.util.HashSet; import java.util.Objects; // Java 7+ 提供了Objects.hash()和Objects.equals()简化实现 public class MyObject { private int id; private String name; public MyObject(int id, String name) { this.id = id; this.name = name; } public int getId() { return id; } public String getName() { return name; } @Override public boolean equals(Object o) { // 检查是否是同一个对象引用 if (this == o) return true; // 检查是否为null或类型不匹配 if (o == null || getClass() != o.getClass()) return false; // 类型转换 MyObject myObject = (MyObject) o; // 比较关键字段 return id == myObject.id && Objects.equals(name, myObject.name); // 使用Objects.equals处理null安全 } @Override public int hashCode() { // 使用Objects.hash()生成哈希码,它能处理null字段 return Objects.hash(id, name); } @Override public String toString() { return "MyObject{" + "id=" + id + ", name='" + name + ''' + '}'; } public static void main(String[] args) { HashSet<MyObject> set = new HashSet<>(); MyObject obj1 = new MyObject(1, "Alice"); MyObject obj2 = new MyObject(2, "Bob"); MyObject obj3 = new MyObject(1, "Alice"); // 逻辑上与obj1相同 set.add(obj1); System.out.println("添加obj1: " + set.size()); // 1 set.add(obj2); System.out.println("添加obj2: " + set.size()); // 2 set.add(obj3); // 尝试添加逻辑上重复的obj3 System.out.println("添加obj3: " + set.size()); // 仍然是2,因为obj3被识别为重复 System.out.println("Set内容: " + set); System.out.println("Set是否包含obj1: " + set.contains(obj1)); // true System.out.println("Set是否包含obj3: " + set.contains(obj3)); // true } }
深入理解
hashCode()
hashCode()
与
equals()
方法在
HashSet
中的作用
说起来,
HashSet
能做到这一点,背后其实藏着一套精妙的机制。它并不是直接把所有元素都拿出来两两比较,那效率也太低了。它利用了哈希表的原理。
当你调用
add()
方法时,
HashSet
会先计算你传入对象的
hashCode()
。这个哈希码会告诉
HashSet
大概要去哪个“桶”(bucket)里找。想象一下,你有一个大抽屉柜,每个抽屉上都有一个编号,哈希码就是这个编号。
HashSet
会根据哈希码把对象放到对应的抽屉里。如果两个对象的哈希码不同,那它们肯定不在同一个抽屉里,也就肯定不相等,
HashSet
压根就不会去比较它们的
equals()
方法,直接就认为它们是不同的对象。这大大提高了查找效率。
但是,不同的对象可能会计算出相同的哈希码,这叫做“哈希冲突”。就像你可能有两个不同的文件,它们的校验和(哈希值)碰巧一样。当发生哈希冲突时,
HashSet
会在同一个桶里存储多个对象。这时,
equals()
方法就派上用场了。
HashSet
会遍历这个桶里的所有对象,逐一调用它们的
equals()
方法与新对象进行比较。如果发现有任何一个对象与新对象相等(
equals()
返回
true
),那么
HashSet
就认为新对象是重复的,不会添加到集合中。
所以,
hashCode()
是第一道关卡,它负责快速定位和初步筛选;而
equals()
则是第二道、也是最终的关卡,它负责精确判断两个对象是否真的相等。如果
hashCode()
写得不好,导致大量冲突,那么
HashSet
的性能会急剧下降,因为它不得不频繁地进行
equals()
比较,甚至退化成接近
ArrayList
的线性查找。我记得有一次,调试一个线上问题,就卡在
HashSet
里明明放了重复数据,却怎么也找不到原因,最后才发现是
equals()
方法写错了,或者干脆没重写,导致
HashSet
无法正确识别对象相等性,它只是在比较内存地址了。
自定义类作为
HashSet
HashSet
元素时需要注意什么?
当你把自定义类的实例放进
HashSet
时,除了前面提到的正确重写
hashCode()
和
equals()
,还有一些非常实际的坑需要注意,否则你的
HashSet
行为可能会变得非常诡异。
一个常见的误区是,只重写了
equals()
而忘记重写
hashCode()
。在这种情况下,你的
equals()
方法可能正确地判断了两个对象逻辑上相等,但由于没有重写
hashCode()
,它们会使用
Object
类默认的
hashCode()
方法,这通常是基于对象的内存地址生成的。结果就是,即使两个逻辑上相等的对象,它们的哈希码也可能不同,
HashSet
会把它们放到不同的桶里,从而认为它们是两个独立的、不重复的元素。这样一来,
HashSet
的唯一性保证就彻底失效了。
另一个重要的点是,用于计算
hashCode()
和
equals()
的字段应该是不可变的。或者说,一旦一个对象被添加到
HashSet
中,你就不应该再修改那些参与
hashCode()
和
equals()
计算的字段。如果修改了,这个对象的哈希码可能就变了。那么,当你想从
HashSet
中查找或删除这个对象时,
HashSet
会根据它新的哈希码去寻找,而对象本身还在它旧的哈希码对应的桶里“待着”,这就导致你找不到了,也删不掉,对象就像“失踪”了一样。这其实是一个非常隐蔽且难以调试的问题。
举个例子,如果你的
Person
类里有
age
字段,并且
hashCode()
和
equals()
都依赖于
age
。你把一个
Person
对象加到
HashSet
里,然后修改了这个
Person
对象的
age
。此时,这个
Person
对象在
HashSet
内部的位置(桶)是基于它旧的
age
计算出来的哈希码决定的。当你再次尝试用这个修改后的
Person
对象去
contains()
或者
remove()
时,
HashSet
会根据新的
age
计算出新的哈希码,去另一个桶里找,自然就找不到了。
所以,最佳实践是,如果你要将对象放入
HashSet
,确保那些决定其唯一性的字段是不可变的,或者至少保证这些字段在对象被添加到集合后不再改变。
修改
HashSet
HashSet
中已存在元素的哈希值会发生什么?
这绝对是一个会让你头疼的问题,因为它会导致
HashSet
的内部结构混乱,进而产生各种意想不到的错误行为。
正如前面提到的,
HashSet
在添加元素时,会根据元素的
hashCode()
值将其放置到内部哈希表的特定“桶”中。一旦元素被放置,它的位置就固定了,至少在理论上是这样。如果你在元素被添加到
HashSet
之后,修改了该元素中用于计算
hashCode()
的字段,那么该元素的
hashCode()
值就会发生变化。
想象一下这个场景:你有一个
Person
对象
p
,它的
id
是1,
name
是”Alice”。你把它
add
到了
HashSet
中。
HashSet
根据
p
当前的哈希码,把它放到了哈希表的某个桶里(比如桶A)。现在,你直接修改了
p
的
id
,变成了2。此时,
p
的
hashCode()
值也随之改变了。
问题来了:
- 查找失败: 当你尝试用这个修改后的
p
去
contains(p)
或者
remove(p)
时,
HashSet
会根据
p
新的
hashCode()
值去查找,它会去新的桶(比如桶B)里找。但是,
p
这个对象实例本身还在旧的桶A里待着呢。结果就是,
HashSet
找不到它,
contains()
会返回
false
,
remove()
会失败。
- “幽灵”元素: 那个被修改的
p
对象实际上还存在于旧的桶A中,但你已经无法通过正常方式访问或移除它了。它变成了
HashSet
中的一个“幽灵”元素,占据着空间,却无法被正确管理。
- 重复添加: 如果你创建了一个新的
Person
对象,其
id
是2,
name
是”Alice”(与修改后的
p
逻辑相等),并尝试将其添加到
HashSet
中。
HashSet
会根据这个新对象的哈希码将其放置到桶B中。由于桶B中并没有原始的
p
对象,
HashSet
会认为这个新对象是唯一的,并将其添加进去。这样,你的
HashSet
中就可能存在逻辑上重复但物理上不同的对象,彻底破坏了
HashSet
的唯一性保证。
所以,这是一个非常重要的规则:不要在对象被添加到
HashSet
之后,修改任何会影响其
hashCode()
或
equals()
结果的字段。 如果你确实需要修改对象的状态,并且这个状态会影响其在
HashSet
中的唯一性判断,那么正确的做法是:先将旧对象从
HashSet
中移除,修改对象状态,然后将修改后的对象重新添加到
HashSet
中。当然,更好的设计是让作为
HashSet
元素的类是不可变的,这样从根本上避免了这类问题。
评论(已关闭)
评论已关闭