HashSet概念
在 Java 中,HashSet 是一个基于哈希表实现的集合,它不保证元素的顺序,但能确保集合中没有重复的元素。为了保证 HashSet 正确的工作,存储在其中的对象需要满足一定的条件,尤其是当对象的字段影响 hashCode() 和 equals() 方法时。
为什么重写equals()方法一定要重写hashCode方法?
- 约定一致性:Java规范要求:如果两个对象通过equals()方法判断为相等,则它们的hashCode()返回值必须相同。这是为了保证逻辑一致性。
- 集合类依赖哈希机制:许多集合类(如 HashMap、HashSet)依赖hashCode()来确定对象的存储位置。如果不重写 hashCode(),可能导致相等的对象被分配到不同的哈希桶中,导致无法正确查找或去重。例如,在HashSet中,即使两个对象通过equals()判断相等,但如果hashCode()不一致,它们会被认为是不同对象。
部分HashSet源码
这是我抽取了JDK25的HashSet的add方法的源码,HashSet底层其实是通过HashMap实现的,只不过对于HashSet来说key也是value,可能是为了节省内存等操作,底层是传了一个Object对象PRESENT作为所有key的value。
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
进入put函数之后他会调用putVal方法,并且计算key对应的哈希值传入
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
putVal函数其实就是处理的就是hashmap的放入键-值对的主要逻辑了,他首先会去对里面的Node类型数组进行判空决定是否进行初始化。紧接着就是计算键-值对对应的数组下标位置,可以看到他是通过哈希值进行计算的。确定数组下标位置之后需要对数组所在位置的Node节点进行判空,是否是链表,是否是红黑树,再执行相应的插入逻辑。可以看到,他插入之前会首先寻找有没有相同的key的节点,防止重复key的出现,他首先进行哈希值的判断在调用equal函数进行判断(这样做的原因是因为hash值的判断通常要比equal更加高效,而且相同hash值的元素不一定是相等的,可能存在哈希冲突)。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
如果HashSet存入的对象发生改变会发生什么?
先说结论:如果没有重写equals方法和hashcode方法,那么对象的里的属性发生改变不会对HashSet造成影响。但是如果重写了equals方法和hashcode方法,那么对象的属性发生改变会使HashSet找不到原来对象的存储位置产生一些错误。
测试代码
Set<User> users=new HashSet<>();
User user1=new User("张三","张三email","password",1L);
User user2=new User("李四","李四email","password",2L);
users.add(user1);
users.add(user2);
System.out.println("******************************");
if(users.contains(user1)){
System.out.println("contains user1");
}else{
System.out.println("not contains user1");
}
if(users.contains(user2)){
System.out.println("contains user2");
}else{
System.out.println("not contains user2");
}
System.out.println("修改属性之后------------------------");
user1.setEmail("张三newEmail");
user2.setEmail("李四newEmail");
if(users.contains(user1)){
System.out.println("contains user1");
}else{
System.out.println("not contains user1");
}
if(users.contains(user2)){
System.out.println("contains user2");
}else{
System.out.println("not contains user2");
}
System.out.println("******************************");
没有重写equals方法和hashcode方法的情况
/******************************/
contains user1
contains user2
修改属性之后------------------------
contains user1
contains user2
/******************************/
重写了equals方法和hashcode方法的情况
/******************************/
contains user1
contains user2
修改属性之后------------------------
not contains user1
not contains user2
/******************************/
原因
由上面的源码可以知道hashSet首先通过哈希值判断对象是否相等,如果相等了在调用equals进行判断。原生的hashcode方法是native方法,调用它产生的哈希值和属性无关,并且原生的equals方法通过对象地址去比较,对象属性的改变依然不影响它查找对象。但是重写了hashcode方法和equals方法会让哈希值和equals的执行结果和对象属性产生依赖,这样子再修改存入HashSet的对象的属性时就会让你下一次再找他的时候找不到它,从而引起一些不可预见的报错。
如何避免产生这种错误?
为了避免这种错误的产生,推荐使用不可变对象让HashSet存储,因为这样子可以很好的避免这类错误。可以采用构造器的方式对对象进行初始化,并且将对象属性都设置为不可更改的final。
public class UserSafe {
private final String name;
private final String email;
private final String password;
private final Long id;
// 私有构造函数,只能通过 Builder 创建对象
private UserSafe(Builder builder) {
this.name = builder.name;
this.email = builder.email;
this.password = builder.password;
this.id = builder.id;
}
@Override
public String toString() {
return "User{" +
"name='" + name + ''' +
", email='" + email + ''' +
", password='" + password + ''' +
", id=" + id +
'}';
}
// Getter 方法保持不变
public String getName() {
return name;
}
public String getEmail() {
return email;
}
public String getPassword() {
return password;
}
public Long getId() {
return id;
}
// 静态内部类 Builder
public static class Builder {
private String name;
private String email;
private String password;
private Long id;
public Builder setName(String name) {
this.name = name;
return this;
}
public Builder setEmail(String email) {
this.email = email;
return this;
}
public Builder setPassword(String password) {
this.password = password;
return this;
}
public Builder setId(Long id) {
this.id = id;
return this;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Builder builder = (Builder) o;
return Objects.equals(name, builder.name) && Objects.equals(email, builder.email) && Objects.equals(password, builder.password) && Objects.equals(id, builder.id);
}
@Override
public int hashCode() {
return Objects.hash(name, email, password, id);
}
// 构建 User 对象的方法
public UserSafe build() {
return new UserSafe(this);
}
}
}
小结
这个问题是我在《Effective Java》这本书看到的,书中虽然有提为什么不行以及可能产生的一些后果。但是毕竟实践才能出真知,我阅读了一些HashSet的源码和编写了一些测试代码写了这一篇文章,也算是对我学习的一种记录。因为经验尚浅,其中可能有许多不太正确和总结不到位的地方,欢迎各位大佬来一起讨论和指教~