HashSet推荐使用不可变对象的原因分析(学习心得)

12 阅读5分钟

HashSet概念

在 Java 中,HashSet 是一个基于哈希表实现的集合,它不保证元素的顺序,但能确保集合中没有重复的元素。为了保证 HashSet 正确的工作,存储在其中的对象需要满足一定的条件,尤其是当对象的字段影响 hashCode() 和 equals() 方法时。

为什么重写equals()方法一定要重写hashCode方法?

  1. 约定一致性:Java规范要求:如果两个对象通过equals()方法判断为相等,则它们的hashCode()返回值必须相同。这是为了保证逻辑一致性。
  2. 集合类依赖哈希机制:许多集合类(如 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的源码和编写了一些测试代码写了这一篇文章,也算是对我学习的一种记录。因为经验尚浅,其中可能有许多不太正确和总结不到位的地方,欢迎各位大佬来一起讨论和指教~