【Java】HashSet

一、总述

HashSet 中它是没有什么新的方法的,因此我们只需要研究它的底层原理就行了。

HashSet集合 底层采取 哈希表 存储数据的。

哈希表 是一种对于增删改查数据性能都比较好的数据结构

在Java的不同版本中,哈希表的组成是不一样的。

JDK8以前:数组 + 链表

JDK8开始:数组 + 链表 + 红黑树

在哈希表中有一个非常非常重要的值:哈希值。


二、哈希值

1)介绍

哈希值:对象的整数表现形式。

哈希表在底层是有数组存在的,如果你要添加一个数据,它不是从 0索引 挨个往后存储的,而是根据 int index = (数组长度 - 1) & 哈希值; 这个公式算出元素在哈希表中应存入的位置。

image-20240427155254561

如果现在要拿一个对象进行计算,就需要先将对象变成整数才能计算,此时这个整数有个专业名词:哈希值。

在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()

image-20240427160919189

这里的模版默认采用 JKD7 以上的版本,直接点击 Next 即可。

image-20240427161236195

这里会默认把所有的属性全部写上,这里我们也不用去选择,直接也点击 Next 就好

image-20240427161246894

然后 Create 即可

image-20240427161300282

此时就会发现,IDEA已经帮你重写好了 equals方法

image-20240427161347858

此时就会发现,不同的对象只要属性值相同,计算出的哈希值就是一样的。

System.out.println(s1.hashCode());//-1461067292
System.out.println(s2.hashCode());//-1461067292

3、在小部分情况下,不同的属性值或者不同的地址值计算出来的哈希值也有可能一样,一旦发生了这种情况,我们就会把它叫做:哈希碰撞

例如字符串,字符串底层其实也重写好了 hashCode(),它也是根据里面的 "abc" 来进行计算的。

我们可以来看一下,ctrl + N 搜索 java.lang包 下的 String

image-20240427161615405

然后 ctrl + F12 搜索 hashCode()

image-20240427161723464

可以发现它已经重写了,并且在重写的时候它是按照字符串内部的属性来计算的哈希值。

image-20240427161745146

回到测试类中,分别使用 "abc"、"abD" 来打印一下它们的哈希值,可以发现是一样的,此时就发生了哈希碰撞。

System.out.println("abc".hashCode()); // 96354
System.out.println("acD".hashCode());//96354

三、底层原理

JDK8以前:数组 + 链表

当我们创建了一个 HashSet集合,在底层第一步:创建一个默认长度为16,默认加载因子为0.75的数组,数组名为tablet。

此时数组中什么也没存,因此默认初始化值为 null

0.75 先在这里留个印象,待会在添加数据的时候就清除了。

image-20240427162634533


1)情况1

在添加数据的时候,它不是从 0索引 开始一个一个往后添加的,而是根据 元素的哈希值数组的长度 计算出当前元素应存入的位置。

公式为:int index = (数组长度 - 1) & 哈希值;,得到的整数就是这个整数应存入的位置。

假设此时添加的元素计算出来应存入的位置(索引)为 4,此时它就会判断,判断 4索引 的位置是不是 null,如果是 null,就会将元素直接添加进去

image-20240427163033711

接下来添加第二个元素,其实就是重复刚刚的过程。

首先获取这个元素的哈希值,拿着哈希值跟数组的长度进行计算,算出应存入的位置。

假设它计算的位置是 1索引,就会进行判断 1索引 上是不是 null,如果是 null 就会直接存进去。

image-20240427163226742

但是就会有一个问题:如果不是 null 呢?


2)情况2

如果当前计算的值不是 null,表示已经有元素,此时会去调用 equals() 比较对象内部的属性值是否相同。

例如现在添加的第三个元素,拿着属性值跟数组的长度计算了一下,计算出应存入的位置也是 4,此时就会判断 4索引 上是不是 null,如果是 null 直接添加。

image-20240427163431434

但是现在它不是 null,表示有元素,则调用 equals() 比较对象内部的属性值。

如果属性值一样,那么当前的元素会进行舍弃;如果不一样,就会添加新的元素形成链表。

前面不同JKD版本的操作方式都是一样的,但是到这里,此时添加的时候,不同的JDK版本就会有分歧了。

JDK8以前:新元素存入数组,老元素挂在新元素下面

JDK8JDK8以后:新元素直接挂在老元素下面

首先我们来看JDK8以前的操作:新元素存数组中,老元素往下移,挂载新元素的后面形成链表

image-20240427163841526

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

image-20240427164015163

继续往下,添加第四个元素,同样的,会利用第四个元素的哈希值跟数组的长度进行计算,算出当前的这个元素在数组中应存入的位置。

该位置如果为 null,则直接添加。

假设它现在计算出来的位置还是 4,但是 4索引 不是 null,此时它就会去调用对象内部的 equals() 比较对象内部的属性值。

4索引 下挂了一条链表,此时它会从链表的第一个元素开始调用 equals(),依次跟链表上的每一个元素进行比较,如果跟链表中所有元素都不一样,就会添加新的元素。

JDK8以前:新元素存数组中,老元素往下移,挂载新元素的后面。

但是现在我们使用的都是 8 以后的版本,所以直接将新元素挂载下面就行了

image-20240427164714071

3)情况3

此时再来添加第五个元素,根据刚刚的过程,首先还是需要计算出应存入的位置,可以发现这个位置里面已经有元素了,此时它就会通过 equals() 依次跟链表上的每一个元素进行比较,如果跟所有的元素都不一样,才会添加新的元素。

但是在比较到第三个的时候它发现了:这两个元素是一样的。

image-20240427164945669

如果一样,当前的元素就会舍弃不存,不会到集合中进行添加。

通过这种方式,HashSet() 就能保证元素的唯一。


4)情况4:0.75加载因子

现在我们再回过头来看这里的 0.75 加载因子。

当我们按照刚刚的方式,不断的去添加元素,上面数组中的元素会越来越多,这个时候就会用到加载因子了。

这个加载因子其实就是 HashSet 的扩容时机,当数组中存了 16 × 0.75 = 12 个元素的时候,此时数组就会扩容成原先的两倍,即从 16 扩容到 32


5)情况5

还有种情况,当链表的长度大于8,并且数组长度大于等于64,当前的链表就会自动转为红黑树,从而提高操作效率。

image-20240427165532909

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

image-20240427165637363


四、总结

1、组成结构

JDK8以前:数组 + 链表

JDK8及JDK8以后:数组 + 链表 + 红黑树


2、添加元素的过程

其中加载因子是用来扩容的。

image-20240427170035383

3、注意点

① JDK8以后,当链表的长度大于8,并且数组长度大于等于64,当前的链表就会自动转为红黑树,从而提高操作效率。

② HashSet集合存储自定义类型元素,要想实现元素的唯一,要求必须重写 hashCode方法equals方法

如果没有重写,那么不管是 hashCode() 还是 equals(),在底层都是使用地址值进行 计算 / 比较,但是地址值对我们来讲意义并不是很大,而且每一个对象的地址值还不是一样的,因此我们需要重写它们两个。

重写 hashCode() 的目的是根据属性值去计算哈希值,重写 equals() 的目的:在比较的时候比的也是对象内部的属性值。

③ 如果在以后,我们存储的不是 Studnet,而是 StringInteger ,此时就不需要我们自己重写 equals() 了,因为这两个类都是Java提供的,它已经重写好了。


五、HashSet 的三个问题

问题1:HashSet 为什么存和取的顺序不一样?

问题2:HashSet 为什么没有索引?

问题3:HashSet 是利用什么机制保证数据去重的?


问题1:HashSet 为什么存和取的顺序不一样?

以下面的哈希表为例,HashSet 在遍历的时候,是从数组的 0索引 然后遍历每个索引的链表。

此时 0索引 位置是 null,因此它会跳过。

1索引 下面挂了一条链表,所以它会把链表里面所有的元素遍历完毕。

然后再去看 2索引、3索引,由于都是 null,因此跳过。

再去看 4索引4索引 也是链表,所以它会继续讲这个链表遍历完毕。

如果数组里面存的不是链表,而是红黑树,那么也会使用我们以前的讲解方式将这棵树遍历完毕。

image-20240427171414748

我们分别给这个哈希表里面的每一个元素编上号

第一个黄色的元素,就一定是第一个添加到数组当中的元素吗?不一定,因此它的存和取的顺序是有可能不一样的。

image-20240427171455710

问题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}]
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值