List、Set、HashSet、HashMap原理

目录

1、Jave的集合框架图

2、数组和链表

(1)数组

(2)链表

3、ArrayLsit

(1)RandomAccess

(2)深拷贝和浅拷贝

(3) Serializable

4、LinkedList

5、HastSet

6、HashMap(1.7和1.8版本)

(1)Hashmap的数据结构

(2)Hash算法

(3)HashMap   1.7【数组+链表】

构造函数

put方法

扩容 

多线程并发扩容的时候就会导致链表成环

(4)HashMap   1.8【数组+链表+红黑树】

JDK8的扩容


1、Jave的集合框架图

2、数组和链表

(1)数组

数组其实就是在堆中的一段连续的存储空间,数组的特性就是元素是有序的,而且元素是可以重复的,因为是可以依靠下标来找到对应的元素,所以元素是可以重复的,通常情况下,查询比较多的情况下,使用数组,插入,删除比较多的就可以使用链表。

数组的查找公式:

这里可以看到,数组的其实的地址是100,我们存在于数组中的元素,例如a[2]在内存中存储位置就是 100+2*4 = 108,这就是数组在内存中查找元素的方式

数组元素的插入和删除的过程

 这里我们看到,插入和删除元素的话,这个元素的后面的元素都需要向后或向前移动,这里也就是数组插入和删除比较慢的原因,数组的查找的时间复杂度是o(1),而插入和删除的时间复杂度是o(n),所以我们如果对数组的修改不是很频繁而是查询比较频繁的情况下,那么就可以使用数组来存储我们的元素

(2)链表

链表并不需要一段连续的存储空间来进行存储,链表在堆中的存储空间并不需要是连续的,在每个链表节点中维护了下一个链表内存地址的指针,所以我们可以通过前一个链表节点来找到下一个链表节点

链表的话又分为了单向链表和双向链表

单项链表

 可以看到,链表中的节点元素存储了下一个元素所在的内存地址的指针

链表的查找

 我们链表的查找只能从头到尾依次进行查找,所以链表的查询时间复杂度是o(n)

插入和删除

 

 我们可以看到,插入或者删除就是将指针的指向进行改变就可以实现,所以插入和删除的时间复杂度就是o(1),所以链表的适用场景就是在插入和删除比较多,查询比较少的场景。

Collection集合元素中我们主要介绍以下几种

3、ArrayLsit

ArrayList底层使用的数组的方式来实现,所以特性就是元素是有序,而且元素是可以重复的

(1)RandomAccess

是为了提高随机访问的效率

RandomAccess 作用_ACMer_xbb的博客-CSDN博客_randomaccess

实现Cloneable接口,重写clone方法、方法内容默认调用父类的clone方法。

(2)深拷贝和浅拷贝

浅拷贝

深拷贝

(3) Serializable

 从源码中看到,ArrayList在执行构造函数的过程就已经初始化了数组的大小,如果我们没有指定大小,那么默认的大小就是10,如果我们指定了默认大小,那么数组的大小就是我们指定的大小

add方法

我们这里看到,add有两个方法,一个是话是将元素添加到数组的最后,时间复杂度是o(1),另一个add方法的话是在指定下标位置插入元素,需要做元素的位置的后移,所以时间复杂度是o(n)。在添加元素之前要做数组大小的判断,如果数组容量不够,那么就需要扩容。

扩容源码分析

grow方法就是扩容的源代码

 最后将数组的指针执行了新创建出来的数组,原先的数组没有任何的GC Root引用着,所以会在GC执行的时候被回收

4、LinkedList

LinkedList的底层是采用的双向链表进行实现的

从Node节点可以看出,底层是使用的双向链表来实现的

构造函数中没有进行什么操作,所以链表的并没有设置对应的初始化长度

 add方法

这里可以看到,就是普通的链表元素的插入操作,比较简单,基本都可以看懂,这里就不作讲述

5、HastSet

HashSet底层是采用的HashMap来实现的,特点就是元素是无序,且元素是不可以重复的

 

通过add方法可以看出,底层采用hashmap进行存储,将值存储在hashmap的key中,这也是为什么元素是无序且不可以重复的原因,而value值就使用一个静态的object变量来存储 

6、HashMap(1.7和1.8版本)

HashMap是基于hash算法实现的,通过put(key,value)进行存值,通过get(key)进行取值,通过key.hashcode()获得hash值,通过hash值将value值存入bucket中,如果hash值相同也就是hash冲突(或者叫hash碰撞),hash冲突少于8个使用链表,多于八个(也就是长度为9的时候会转换为红黑树)使用红黑树来实现,如果长度又小于等于6便会重新从红黑树变成链表

(1)Hashmap的数据结构

JDK7    数组、链表

JDK8    数组、链表、红黑树

数组的特性(查询快,插入慢)

采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);对于一般的插入删除操作, 涉及到数组元素的移动,其平均复杂度也为O(n)

查询速度快:我们只需要得到元素的索引,便可以通过索引直接获取到元素的值

插入删除的速度慢:这是因为要维持一段连续的存储单元,所以如果要是插入或者删除某个元素,那么元素的索引就会进行重新排列

例如我们现在有一段连续的存储单元,要进行插入操作,如果将一个值插入到7和8之间

这里插入之后后面的元素就需要向后移动,也就是牵一发而动全身

链表(查询慢,插入快)

链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的 对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1), 而查找操作需要遍历链表逐一进行比对,复杂度为O(n)

(2)Hash算法

哈希算法(也叫散列),就是把任意长度的值(key)通过散列算法变换成固定长度的key(地址)通过这个地址进行访问的数据结构

它通过把关键码值映射到表中的一个,位置来访问记录,以加快查找的速度

上面的话是其中一种hash算法的实现,通过每一个字母的hash值,将全部值相加,再%(一个特定的值)

下面我们使用的是String字符串的hash算法

通过计算,这里得出位置的分布

这里为什么由张三指向刘一,这是因为这里使用的是头插法,jdk1.8之后才用的尾插法

当然,上面是我们自己实现的hash算法,在HashMap中是使用的位运算来实现的hash算法

(3)HashMap   1.7【数组+链表】

这里的默认加载因为为什么是0.75,从下面的注释中可以看出,其实就是在空间和时间中取得的最优值,因为如果我们的加载因为过高,就会导致链表的长度过长,链表的查询时间复杂度是o(n),并且在扩容的时候会导致时间过长,如果加载因为过小,就会导致数组很多空间还没用到就进行扩容,那么就会造成空间的浪费,所以这里是取了一个空间和时间的最优值。

(扩容的阈值=数组长度*加载因子)

为什么我们HashMap的数组长度需要是2的幂次方,这里主要是HashMap中使用到了位运算,位运算是最接近计算机底层的运算,所以执行的效率是最高的,比我们直接去进行取模效率要高10倍左右,而且我们在进行hash运算的时候使用到了&运算,所以这里就规定了我们的数组长度必须是2的幂次方,这个后面的源码中可以体现

构造函数

这里我们看到默认的无参构造函数的数组大小就是16,加载因子是0.75,如果我们自己设置了数组的长度,那么hashmap中的数组长度就是大于等于这个数值的2的幂次方,例如我们设置了数组的长度是11,那么数组的长度就是16,如果设置为17,那么数组的长度就是32

hashmap在构造函数中并没有初始化数组,只是在变量中存储了我们数组的长度和对应的加载因子,数组的初始化是在第一次put方法时候进行初始化的

put方法

 在进入put方法的时候,如果是第一次进行put,那么就对我们的数组进行初始化

 对key进行对应的hash算法,这里实现的hash算法是通过位运算来实现的,这里不做过多的研究,主要是为了让我们的数组元素分布的更加均匀,而且这种hash算法使用了位运算,所以效率是比较高的,indexFor就是为了计算元素的下标位置,这里是通过&运算,&运算的效率是比较高的,在length的长度是2的幂次方的情况下,h&(length-1)等同于 h%length,这也是HashMap的长度为什么要是2的幂次方的原因

 

扩容 

这里看下扩容的代码resize

下图可以看到扩容的方法就是transfer方法(这里主要是用到了头插法)

 

 从上面扩容的代码可以看到,for循环就是对我们原数组上面元素的遍历,while循环是判断是否有链表,将链表元素也转移到新的数组

假设现在有下面这个hashmap需要进行扩容

扩容后的数组

 我们这里对数组的第一列进行扩容迁移,其他列都是一样的方式

for (Entry<K,V> e : table) {
    while(null != e) {
        Entry<K,V> next = e.next;
        if (rehash) {
            e.hash = null == e.key ? 0 : hash(e.key);
        }
        int i = indexFor(e.hash, newCapacity);

从上面几行代码可以看到,有一个e指针和一个next指针(e指针指向第一个元素,next指向下一个元素),这里假设rehash之后的值是在新数组的下标为2的地方

 

e.next = newTable[i];

因为一开始newTable[i]是没有任何值的

 

newTable[i] = e;
e = next;

 

进入下一次循环,此时e指向了杨过

for (Entry<K,V> e : table) {
    while(null != e) {
        Entry<K,V> next = e.next;
        if (rehash) {
            e.hash = null == e.key ? 0 : hash(e.key);
        }
        int i = indexFor(e.hash, newCapacity);

 

e.next = newTable[i];

 

newTable[i] = e;

e = next;

 

 继续下一次循环

从上面的代码我们可以看到,1.7的扩容利用了头插法,会发现扩容前和扩容后链表的元素位置头尾发生了倒转

多线程并发扩容的时候就会导致链表成环

假设我们现在有一个static修饰的HashMap,这个HashMap就是独有的一份,线程共享

假设现在有线程 T1  和线程 T2进行并发扩容,在并发扩容的情况下,各自线程都有自己新扩容出来的数组

 当T1线程执行到这一步,也就是我们的next指针和e指针已经有对应的指向,此时时间片给T2抢走了,T2要进行扩容

 线程T1的指针刚指向了对应的元素,此时就被T2抢走了,并且 线程T2进行了正常情况下的扩容

 此时T2扩容完成,但是我们的T1线程的指针还指向了对应的元素,那么T1继续进行扩容操作

e.next = newTable[i];

 

e = next;

继续进行下一次循环

for (Entry<K,V> e : table) {
    while(null != e) {
        Entry<K,V> next = e.next;
        if (rehash) {
            e.hash = null == e.key ? 0 : hash(e.key);
        }
        int i = indexFor(e.hash, newCapacity);

 此时e指针执行了杨过,而next指针指向了张三

e.next = newTable[i];
newTable[i] = e;

 

e = next;

 此时再进入下一次循环,那么可以知道,下一次循环中,因为要将table[i]赋值给e.next,所以最终导致的效果就是如下图所示

 杨过的next是张三,张三的next是杨过,形成了一个闭环,那么在下一次有相同元素进行插入的时候,下图的for循环就永远不会为null,这里就会导致CPU 100%的问题,也就是1.7中并发扩容导致链表闭环的不安全问题

 

(4)HashMap   1.8【数组+链表+红黑树】

 

 

 JDK8在链表长度大于8的时候,也就是链表长度是9的时候会转换为红黑树,如果长度小于等于6的时候又会转换为链表

注意:容量>=64才会链表转红黑树,否则优先扩容

为什么不直接使用红黑树呢?

这是因为红黑树虽然查询的效率高,但是我们需要维持树结构,所以需要进行左旋等操作,所以在长度比较小的时候,链表是比红黑树更加适合,而且树节点占有的空间是比链表节点要大的

JDK8的扩容

JDK8的扩容跟JDK7是完全不同了,采用的是高低位指针+尾插法来进行扩容

 扩容后

 那么这里的高低位指针是怎么实现的呢?

这里的高位指针就是hiHead和hiTail,低位指针就是loHead和loHail

在进行扩容的时候,并没有进行rehash,而是将我们的元素的hash&原来数组的长度,那么就只能是两个值,一个是0,另一个就是原数组长度,如果算出来是0的元素,那么就通过loHead指向这个元素,并且最后指向了loHail,而计算出来的值是原先数组长度的元素,那么就使用高位指针hiHead指向这个元素,并且在最后指向了hiHail。

低位指针的链表元素是放在新数组中的原先旧数组的位置,例如原先数组的位置是2,那么低位指针的元素就是放在新数组的2的位置,而高位指针就是2+原先数组长度的位置,例如原先数组长度是16,那么就是放在18的位置,并且尾插法顺序也是跟原来的顺序是一样的

所以为了可以满足高低位指针,数组的长度也必须是2的幂次方

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
HashSetSet 接口的一种实现,它底层使用哈希表(实际上是一个 HashMap 实例)来支持。HashSet 使用 Hash 算法来存储集合中的元素,因此具有较好的存取和查找性能。 在 HashSet 中,元素的存储位置是根据元素的哈希值来确定的。当向 HashSet 中添加元素时,首先会调用元素的 hashCode() 方法来获取其哈希值,然后根据哈希值找到对应的存储位置。如果在同一个位置已经有元素了,那么会利用 equals() 方法来判断这两个元素是否相等。如果相等,则不会添加重复元素;如果不相等,则会将新元素添加到 HashSet 中。因此,为了保证元素的唯一性,我们需要正确重写元素的 equals() 和 hashCode() 方法。 需要注意的是,存储在 HashSet 中的对象所在类必须满足重写 hashCode() 和 equals() 方法的条件,否则可能会导致 HashSet 无法正确判断元素的唯一性。通过正确重写 hashCode() 和 equals() 方法,我们可以确保在 HashSet 中存储的对象是唯一的。<span class="em">1</span><span class="em">2</span><span class="em">3</span> #### 引用[.reference_title] - *1* *2* [认真研究Java集合之HashSet 的实现原理](https://blog.csdn.net/J080624/article/details/86616379)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] - *3* [关于HashSet的存储原理](https://blog.csdn.net/Lim_B/article/details/121429464)[target="_blank" data-report-click={"spm":"1018.2226.3001.9630","extra":{"utm_source":"vip_chatgpt_common_search_pc_result","utm_medium":"distribute.pc_search_result.none-task-cask-2~all~insert_cask~default-1-null.142^v93^chatsearchT3_2"}}] [.reference_item style="max-width: 50%"] [ .reference_list ]

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值