一、总述
在 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}]
}
}