在Java的集合体系中,HashSet是基于HashMap实现的,它不允许存储重复元素,而这个去重能力的核心就依赖于对象的equals方法和hashCode方法的正确实现。当我们把自定义对象放入HashSet时,如果只重写其中一个方法或者重写逻辑不符合规范,就可能出现明明是相同内容的对象却被HashSet判定为不同元素的情况。

HashSet的去重底层逻辑
HashSet判断两个元素是否重复时,会先比较两者的hashCode值:
- 如果hashCode不同,直接判定两个对象不相等,不会调用equals方法
- 如果hashCode相同,才会进一步调用equals方法判断内容是否一致
只有当hashCode相同且equals返回true时,HashSet才会认为两个对象是重复的,不会重复存储。因此要保证对象在HashSet中的唯一性,必须同时正确重载equals和hashCode方法,并且两者的逻辑要满足一致性约束。
equals和hashCode的约束规则
Java规范中对equals和hashCode的重载有明确的要求,核心规则有三条:
- 如果两个对象通过equals方法判断相等,那么它们的hashCode必须返回相同的值
- 如果两个对象的hashCode相同,equals方法不一定返回true,因为可能存在哈希冲突
- 如果对象的内容没有发生改变,那么多次调用hashCode方法应该返回相同的值
如果违反了第一条规则,就会出现两个equals相等的对象hashCode不同,导致HashSet无法正确去重,这是最常见的错误场景。
正确的重载实现示例
假设我们有一个自定义的用户类User,包含id和name两个属性,我们希望id和name都相同的User对象被视为同一个对象,存入HashSet时自动去重。
错误的实现方式
很多开发者会只重写equals方法,忽略hashCode:
public class User {
private Integer id;
private String name;
public User(Integer id, String name) {
this.id = id;
this.name = name;
}
// 只重写equals,没有重写hashCode
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
User user = (User) obj;
return id != null ? id.equals(user.id) : user.id == null &&
(name != null ? name.equals(user.name) : user.name == null);
}
}
这种情况下,两个id和name都相同的User对象,equals返回true,但hashCode不同,HashSet会认为它们是不同元素,导致重复存储。
正确的实现方式
需要同时重写equals和hashCode,并且保证equals相等的对象hashCode一定相同:
public class User {
private Integer id;
private String name;
public User(Integer id, String name) {
this.id = id;
this.name = name;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
User user = (User) obj;
// 比较id和name是否都相等
return id != null ? id.equals(user.id) : user.id == null &&
(name != null ? name.equals(user.name) : user.name == null);
}
@Override
public int hashCode() {
int result = id != null ? id.hashCode() : 0;
// 结合name的哈希值计算最终hashCode,保证equals相等的对象hashCode相同
result = 31 * result + (name != null ? name.hashCode() : 0);
return result;
}
}
这里选择31作为乘数是因为31是奇素数,并且31 * i可以被优化为(i << 5) - i,计算效率高,同时能减少哈希冲突的概率。
验证去重效果
我们可以编写测试代码验证重载后的效果:
import java.util.HashSet;
public class Test {
public static void main(String[] args) {
HashSet<User> userSet = new HashSet<>();
User user1 = new User(1, "张三");
User user2 = new User(1, "张三");
userSet.add(user1);
userSet.add(user2);
// 输出结果为1,说明重复元素被正确去重
System.out.println(userSet.size());
}
}
如果使用的是没有正确重载equals和hashCode的User类,这里输出的结果会是2,说明两个相同内容的对象被重复存储了。
常见注意事项
- 不要在equals方法中使用不确定的属性,比如如果对象的某个属性是可变的,修改后equals的结果会变化,导致HashSet无法正确找到已经存储的对象
- 如果类继承了父类,重写equals时要考虑父类的属性,避免遗漏比较字段
- 可以使用IDE自动生成equals和hashCode方法,避免手动编写出现逻辑错误,生成后检查是否符合业务上的相等判断规则