一、总述
在 HashSet 中它是没有什么新的方法的,因此我们只需要研究它的底层原理就行了。
HashSet集合 底层采取 哈希表 存储数据的。
哈希表 是一种对于增删改查数据性能都比较好的数据结构
在Java的不同版本中,哈希表的组成是不一样的。
JDK8以前:数组 + 链表
JDK8开始:数组 + 链表 + 红黑树
在哈希表中有一个非常非常重要的值:哈希值。
二、哈希值
1)介绍
哈希值:对象的整数表现形式。
哈希表在底层是有数组存在的,如果你要添加一个数据,它不是从 0索引 挨个往后存储的,而是根据 int index = (数组长度 - 1) & 哈希值; 这个公式算出元素在哈希表中应存入的位置。

如果现在要拿一个对象进行计算,就需要先将对象变成整数才能计算,此时这个整数有个专业名词:哈希值。
在Java中,是根据 hashCode() 计算出来的 int类型 的整数。
hashCode() 是定义在 Object 中的,所有对象都可以调用它,方法底层默认使用地址值进行计算。
但是一般情况下,用地址值去计算哈希值,这个意义并不是很大。
一般情况下,会重写 hashCode(),利用对象内部的属性值计算哈希值。
2)对象的哈希值特点
1、如果没有重写 hashCode(),此时用的是 Object 中的 hashCode(),它使用地址值计算的,而每个对象的地址值又是不一样的,因此不同对象计算出的哈希值是不同的。
2、如果已经重写了 hashCode(),不同的对象只要属性值相同,计算出的哈希值就是一样的。
3、在小部分情况下,不同的属性值或者不同的地址值计算出来的哈希值也有可能一样,一旦发生了这种情况,我们就会把它叫做:哈希碰撞。
3)代码示例
首先创建一个 Student类,此时先不重写 hashCode()
public class Student implements Comparable<Student>{
private String name;
private int age;
// 无参构造、有参构造、get、set方法
}
然后在测试类中创建对象
Student s1 = new Student("zhangsan",23);
Student s2 = new Student("zhangsan",23);
如果没有重写hashCode方法,不同对象计算出的哈希值是不同的。
hashCode() 返回值类型是 int。
1、如果没有重写 hashCode(),此时用的是 Object 中的 hashCode(),它使用地址值计算的,而每个对象的地址值又是不一样的,因此不同对象计算出的哈希值是不同的
System.out.println(s1.hashCode());// 990368553
System.out.println(s2.hashCode());// 1096979270
2、如果已经重写了 hashCode(),不同的对象只要属性值相同,计算出的哈希值就是一样的。
重写也不需要我们自己写,快捷键 alt + Insert ,然后选择 equlas() and hashCode()。
这里的模版默认采用 JKD7 以上的版本,直接点击 Next 即可。
这里会默认把所有的属性全部写上,这里我们也不用去选择,直接也点击 Next 就好
然后 Create 即可
此时就会发现,IDEA已经帮你重写好了 equals方法
此时就会发现,不同的对象只要属性值相同,计算出的哈希值就是一样的。
System.out.println(s1.hashCode());//-1461067292
System.out.println(s2.hashCode());//-1461067292
3、在小部分情况下,不同的属性值或者不同的地址值计算出来的哈希值也有可能一样,一旦发生了这种情况,我们就会把它叫做:哈希碰撞
例如字符串,字符串底层其实也重写好了 hashCode(),它也是根据里面的 "abc" 来进行计算的。
我们可以来看一下,ctrl + N 搜索 java.lang包 下的 String
然后 ctrl + F12 搜索 hashCode()。
可以发现它已经重写了,并且在重写的时候它是按照字符串内部的属性来计算的哈希值。
回到测试类中,分别使用 "abc"、"abD" 来打印一下它们的哈希值,可以发现是一样的,此时就发生了哈希碰撞。
System.out.println("abc".hashCode()); // 96354
System.out.println("acD".hashCode());//96354
三、底层原理
JDK8以前:数组 + 链表
当我们创建了一个 HashSet集合,在底层第一步:创建一个默认长度为16,默认加载因子为0.75的数组,数组名为tablet。
此时数组中什么也没存,因此默认初始化值为 null。
0.75 先在这里留个印象,待会在添加数据的时候就清除了。

1)情况1
在添加数据的时候,它不是从 0索引 开始一个一个往后添加的,而是根据 元素的哈希值 跟 数组的长度 计算出当前元素应存入的位置。
公式为:int index = (数组长度 - 1) & 哈希值;,得到的整数就是这个整数应存入的位置。
假设此时添加的元素计算出来应存入的位置(索引)为 4,此时它就会判断,判断 4索引 的位置是不是 null,如果是 null,就会将元素直接添加进去

接下来添加第二个元素,其实就是重复刚刚的过程。
首先获取这个元素的哈希值,拿着哈希值跟数组的长度进行计算,算出应存入的位置。
假设它计算的位置是 1索引,就会进行判断 1索引 上是不是 null,如果是 null 就会直接存进去。

但是就会有一个问题:如果不是 null 呢?
2)情况2
如果当前计算的值不是 null,表示已经有元素,此时会去调用 equals() 比较对象内部的属性值是否相同。
例如现在添加的第三个元素,拿着属性值跟数组的长度计算了一下,计算出应存入的位置也是 4,此时就会判断 4索引 上是不是 null,如果是 null 直接添加。

但是现在它不是 null,表示有元素,则调用 equals() 比较对象内部的属性值。
如果属性值一样,那么当前的元素会进行舍弃;如果不一样,就会添加新的元素形成链表。
前面不同JKD版本的操作方式都是一样的,但是到这里,此时添加的时候,不同的JDK版本就会有分歧了。
JDK8以前:新元素存入数组,老元素挂在新元素下面
JDK8及JDK8以后:新元素直接挂在老元素下面
首先我们来看JDK8以前的操作:新元素存数组中,老元素往下移,挂载新元素的后面形成链表

JDK8以后,新元素直接挂载老元素下面形成链表。

继续往下,添加第四个元素,同样的,会利用第四个元素的哈希值跟数组的长度进行计算,算出当前的这个元素在数组中应存入的位置。
该位置如果为 null,则直接添加。
假设它现在计算出来的位置还是 4,但是 4索引 不是 null,此时它就会去调用对象内部的 equals() 比较对象内部的属性值。
4索引 下挂了一条链表,此时它会从链表的第一个元素开始调用 equals(),依次跟链表上的每一个元素进行比较,如果跟链表中所有元素都不一样,就会添加新的元素。
JDK8以前:新元素存数组中,老元素往下移,挂载新元素的后面。
但是现在我们使用的都是 8 以后的版本,所以直接将新元素挂载下面就行了
3)情况3
此时再来添加第五个元素,根据刚刚的过程,首先还是需要计算出应存入的位置,可以发现这个位置里面已经有元素了,此时它就会通过 equals() 依次跟链表上的每一个元素进行比较,如果跟所有的元素都不一样,才会添加新的元素。
但是在比较到第三个的时候它发现了:这两个元素是一样的。
如果一样,当前的元素就会舍弃不存,不会到集合中进行添加。
通过这种方式,HashSet() 就能保证元素的唯一。
4)情况4:0.75加载因子
现在我们再回过头来看这里的 0.75 加载因子。
当我们按照刚刚的方式,不断的去添加元素,上面数组中的元素会越来越多,这个时候就会用到加载因子了。
这个加载因子其实就是 HashSet 的扩容时机,当数组中存了 16 × 0.75 = 12 个元素的时候,此时数组就会扩容成原先的两倍,即从 16 扩容到 32。
5)情况5
还有种情况,当链表的长度大于8,并且数组长度大于等于64,当前的链表就会自动转为红黑树,从而提高操作效率。

所以说在JDK8以后,HashSet 的底层它是由 数组、挂载下面的链表、红黑树 这三种结构组成的。

四、总结
1、组成结构
JDK8以前:数组 + 链表
JDK8及JDK8以后:数组 + 链表 + 红黑树
2、添加元素的过程
其中加载因子是用来扩容的。
3、注意点
① JDK8以后,当链表的长度大于8,并且数组长度大于等于64,当前的链表就会自动转为红黑树,从而提高操作效率。
② HashSet集合存储自定义类型元素,要想实现元素的唯一,要求必须重写 hashCode方法 和 equals方法
如果没有重写,那么不管是 hashCode() 还是 equals(),在底层都是使用地址值进行 计算 / 比较,但是地址值对我们来讲意义并不是很大,而且每一个对象的地址值还不是一样的,因此我们需要重写它们两个。
重写 hashCode() 的目的是根据属性值去计算哈希值,重写 equals() 的目的:在比较的时候比的也是对象内部的属性值。
③ 如果在以后,我们存储的不是 Studnet,而是 String、Integer ,此时就不需要我们自己重写 equals() 了,因为这两个类都是Java提供的,它已经重写好了。
五、HashSet 的三个问题
问题1:HashSet 为什么存和取的顺序不一样?
问题2:HashSet 为什么没有索引?
问题3:HashSet 是利用什么机制保证数据去重的?
问题1:HashSet 为什么存和取的顺序不一样?
以下面的哈希表为例,HashSet 在遍历的时候,是从数组的 0索引 然后遍历每个索引的链表。
此时 0索引 位置是 null,因此它会跳过。
1索引 下面挂了一条链表,所以它会把链表里面所有的元素遍历完毕。
然后再去看 2索引、3索引,由于都是 null,因此跳过。
再去看 4索引,4索引 也是链表,所以它会继续讲这个链表遍历完毕。
如果数组里面存的不是链表,而是红黑树,那么也会使用我们以前的讲解方式将这棵树遍历完毕。

我们分别给这个哈希表里面的每一个元素编上号
第一个黄色的元素,就一定是第一个添加到数组当中的元素吗?不一定,因此它的存和取的顺序是有可能不一样的。
问题2:HashSet 为什么没有索引?
其实就是因为 HashSet 不够纯粹,在底层是由 数组 + 链表 + 红黑树 这三个组合形成的。
虽然数组中是有索引的,但是数组中有可能还挂着链表、红黑树,难道下面挂着的所有元素都是在1索引吗?这是不合适的,因此就直接取消了 HashSet 的索引机制。
问题3:HashSet 是利用什么机制保证数据去重的?
其实就是利用 HashCode() 和 equals()。
利用 HashCode() 可以得到哈希值,而 哈希值 就可以确定这个元素是添加在数组的哪个位置。
然后再去调用第二个方法 equals(),去比较对象内部的属性值是不是相同。
因此,如果HashSet集合存储自定义类型元素,要想实现元素的唯一,要求必须重写 hashCode方法 和 equals方法
六、练习:利用 HashSet集合 去除重复的元素
案例需求:创建一个存储学生对象的集合,存储多个学生对象,使用程序实现在控制台遍历该集合
要求:学生对象的成员变量值相同,我们就认为是同一个对象
Student.java
public class Student {
//姓名
private String name;
//年龄
private int age;
// 无参构造、有参构造、get、set方法
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student2 student2 = (Student2) o;
return age == student2.age && Objects.equals(name, student2.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
测试类
package com.itheima.a05myset;
import java.util.HashSet;
public class A03_HashSetDemo2 {
public static void main(String[] args) {
//1.创建三个学生对象
Student s1 = new Student("zhangsan",23);
Student s2 = new Student("lisi",24);
Student s3 = new Student("wangwu",25);
Student s4 = new Student("zhangsan",23);
//2.创建集合用来添加学生
HashSet<Student> hs = new HashSet<>();
//3.添加元素
System.out.println(hs.add(s1)); // true
System.out.println(hs.add(s2)); // true
System.out.println(hs.add(s3)); // true
System.out.println(hs.add(s4)); // false
//4.打印集合
System.out.println(hs); // [Student{name = wangwu, age = 25}, Student{name = lisi, age = 24}, Student{name = zhangsan, age = 23}]
}
}

1898

被折叠的 条评论
为什么被折叠?



