什么?从Set里取出的元素竟然不包含在这个Set里!——记Java HashSet的一个坑

环境

  • Ubuntu 22.04.1
  • IntelliJ IDEA 2024.1
  • Java 21.0.4

问题

看下面的代码:

        Set<Set<Integer>> set = new HashSet<>();

        Set<Integer> subset = new HashSet<>();
        subset.add(1);
        set.add(subset);

        subset.add(2);

        for (Set<Integer> e : set)
            System.out.println(set.contains(e));

代码逻辑简单直观:

  1. 创建了一个Set set
  2. 创建了另一个Set subset ,并添加元素 1
  3. subset 添加到 set
  4. subset 里再添加一个元素 2
  5. 遍历 set 里的元素,检查其是否包含在 set

第5步看起来完全没有意义:从一个集合里取出的元素,怎么可能不包含在这个集合里呢?所以显然应该输出 true

然而,代码的运行结果为:

false

这就尴尬了……究竟是怎么回事呢?

分析

hashcode

先说原因:因为subset的hashcode变了。

subset 添加元素 2 的前后,把其hashcode打印出来看一下:

        ......
        System.out.println(subset.hashCode());
        subset.add(2);
        System.out.println(subset.hashCode());
        ......

运行结果如下:

1
3

简单讲,HashSet类并没有自己单独的hashcode计算逻辑,而是依赖其内部存储元素的hashcode值。HashSet的hashcode是其包含元素的hashcode值的组合结果。所以当 subset 所包含的元素有变化时,其hashcode也会改变。

contains()方法、add()方法和遍历

好吧,就算是hashcode变化了,那为什么会影响 contains() 方法呢?

这是因为,HashSet是根据元素的哈希码来存储和查找元素。在向 set 中添加 subset 时, subset 的哈希码被计算并用于存储位置的确定。之后向 subset 中添加元素 2 ,改变了其哈希码。当进行 set.contains() 检查时,由于 subset 的哈希码已经改变, set 无法正确定位到原来添加的那个 subset 实例。

那么问题又来了:既然定位不到 subset ,那怎么在遍历 set 时还能找到 subset 呢?

HashSet存储元素时,会根据元素的hashcode值确定元素在内部哈希表中的存储位置。当调用 add() 方法添加元素时,它会计算元素的hashcode,并根据这个值找到对应的存储桶(bucket)。如果该存储桶为空,就直接将元素放入;如果不为空,会再调用 equals() 方法来判断桶内已有的元素是否与要添加的元素相等,以决定是否添加( equals() 方法比较的是内存地址)。

当调用 contains() 方法时,同样先计算传入元素的hashcode,找到对应的存储桶,然后在该桶内通过 equals() 方法查找是否存在相等的元素,所以hashcode变化后,就找不到对象了。

而遍历时能找到对象,是因为HashSet的遍历是基于其内部存储结构进行的,它会按照存储的顺序依次访问每个元素,不会重新计算hashcode来定位元素。

注:上面这几段话,是来自豆包,略有修改。

另外,由于 subset 的hashcode有变化,就可以通过 add() 方法,再次把 subset 添加到 set 里,这就违反了“Set不包含重复元素”的特性。

hashCode()和equals()方法

延展一下,现有一个 HashSet<Person> 集合的实例 set ,其中 Person 是自定义类。

假如 Person 类没有自定义 hashCode() 方法和 equals() 方法,默认情况下, equals() 方法比较对象的内存地址, hashCode() 方法基于对象的内存地址生成哈希码。这样,当 person1 对象被添加到 set 后,就算修改其年龄、体重等信息,hashcode和 equals() 方法不会受影响,这个人还是这个人。

如果 Person 类重写了 hashCode()equals() 方法,并且这两个方法的实现依赖于年龄、体重等属性,则 person1 对象的年龄变化后, hashCode()equals() 方法就会受影响,比如:

  • contains() 方法会返回false,这是前面我们提到的类似问题
  • 可以把 person1 对象再次添加到 set 里,这就违反了“Set不包含重复元素”的特性

因此,对 hashCode()equals() 方法的改写,要小心谨慎,根据实际情况而定。

对象内存地址

刚才提到, hashCode()equals() 方法默认是根据对象的内存地址来计算的,那么在垃圾回收中,如果对象的内存地址发生变化了怎么办?

实际上垃圾回收过程并不会改变对象的内存地址。

常见的垃圾回收算法:

  • 标记 - 清除算法:首先标记所有可达对象,然后清除未标记的对象(即垃圾对象)所占用的内存空间,不会移动存活对象,因此不会改变它们的内存地址。
  • 标记 - 整理算法:在标记出所有可达对象后,将存活对象向一端移动,然后清理边界以外的内存。这种算法虽然移动了对象,但它是在垃圾回收完成后,对所有存活对象进行整体的内存整理,而不是在对象存活期间随意改变其地址。
  • 复制算法:将堆内存分为两块,每次只使用其中一块。当这块内存满时,将存活对象复制到另一块空闲内存,然后清理原来的内存块。同样,在对象存活期间,其内存地址是稳定的。

需要注意的是,当存活对象被复制到另一块内存区域时,其内存地址确实会发生变化 。不过,JVM通过句柄池等机制来确保程序对对象的引用不受影响。简单讲有两种方式:

  • 句柄访问方式:JVM使用句柄访问对象。垃圾回收后,虽然对象的实际内存地址改变了,但只是句柄指向对象在新内存地址的实例数据,程序无需感知对象内存地址的变化。
  • 直接指针访问方式:即程序中的引用直接指向对象的实际内存地址。在这种情况下,垃圾回收器在移动对象时,会同时更新所有指向该对象的引用。这一过程对程序员是透明的,并且所有相关引用的更新是原子性和一致性的,确保程序的正确性。

总而言之, JVM通过特定的机制保证了程序对对象的引用不受影响,使得程序员无需关心对象在内存中的实际物理地址变化。

参考

  • www.doubao.com
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值