环境
- 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));
代码逻辑简单直观:
- 创建了一个Set
set
- 创建了另一个Set
subset
,并添加元素1
- 把
subset
添加到set
里 - 在
subset
里再添加一个元素2
- 遍历
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