要让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 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()
与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时,除了前面提到的正确重写
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在添加元素时,会根据元素的
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元素的类是不可变的,这样从根本上避免了这类问题。










