hashMap相关


————————————————————————————————————

HashMap

HashMap介绍

特点

存储形式key value

无序不可重复,key值保障不会重复

初始化容量16,官网推荐为2的倍数,为了散列均匀,提交存取效率,默认加载因子0.75,到75%的时候会扩容,扩容:扩容之后是原容里2倍。0.75是对时间和空间上的一个平衡选择。

key 存储的时候会调用底层hashcode(),hashcode是一串数字,然后会进行取余操作

时间复杂度 每一次取数据O(1) , 大多数每一次插入数据O(1) ,理论上增删改查都是O(1)

HashMap在 JDK1.7和 JDK1.8中的区别

  • 组成结构

    • 在 JDK1.7 中,由"数组+链表"组成,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的。

      在JDK1.8中,则是由"数组+链表+红黑树"组成,当链表长度大于 8 并且 数组的长度大于 64 的时候,再向链表中添加元素链表就会转化成红黑树,如果当前数组的长度小于64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。

  • JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,头插法就是能够提高插入的效率,但是在并发情况下也会容易出现循环链表问题。尾插法,能够避免出现循环链表的问题。

    • 为什么从头插法改成尾插法?
      • 在1.7中,是没有红黑树的并发的情况下单链表过长会成环发生死循环
      • 在1.8中,尾插法就可以解决这个问题
  • 1.7中hash值是可变的1.8中hash是final修饰,不可变,因为有rehash的操作。1.8中hash是final修饰,也就是说hash值一旦确定,就不会再重新计算hash值了。而且为了转换为红黑树新增了一个TreeNode节点。

  • JDK1.7中,HashMap存储的是Entry对象

    JDK1.8当中,HashMap存储的是实现Entry接口Node对象

  • 扩容机制

    • 1.7中是先扩容后插入,1.8中是先插入再扩容
    • JDK1.7的时候是先扩容后插入的,扩容过程中会将原来的数据,放入到新的数组中,但是会重新计算hash值进行分配,这样就会导致无论这一次插入是不是发生hash冲突都需要进行扩容,如果这次插入的并没有发生Hash冲突的话,那么就会造成一次无效扩容,但是在1.8的时候是先插入再扩容的,优点是可以减少1.7的一次无效的扩容,因为如果这次插入没有发生Hash冲突的话,那么其实就不会造成扩容

JDK1.7中HashMap头插法死循环的原因

在jdk1.7中HashMap底层使用的是数组加链表的形式,并且在数据插入的时候采用的是头插法,也就是说新插入的数据会从链表的头结点进行插入。比如说扩容之前是有ABC三个节点依次挂在一条链表上。

第一步:这时候有两个线程T1和T2都准备对HashMap进行扩容操作,此时T1和T2指向的都是链表的头结点A,而T1和T2的下一个节点分别是T1.next和T2.next,他们都指向B,

image-20221030090047701

第二步:开始扩容,这个时候,假设T2的时间片正好用完,进入了休眠状态,而线程T1开始执行扩容操作,一直到T1扩容完成后,T2才被唤醒,这个时候T2.next的指向依然没有变。

image-20221030090329966

因为HashMap采用的是头插法线程1执行后,链表中的节点顺序发生了变化,但是线程2对这一切还是不知道的,它的执行还是不变的,所以此时T1执行完成,T2开始执行,死循环就发生了。因为T1扩容完成后,B节点的下一个节点是A,而T2线程指向的首节点是A,第二个节点是B,这个顺序刚好和T1扩容之后的节点顺序是相反的。T1执行完之后是B到A,这样A和B两个节点就发生了死循环。

HashMap的底层原理

  • JDK1.8之前,HashMap由数组+链表组成,数组是主体,链表为了解决冲突问题,HashMap通过key的hashcode经过扰动函数处理得到hash值,然后经过计算(n-1)&hash判断存储位置,当前如果和当前位置的值相同则覆盖,不同则拉链法解决冲突。扰动函数可以减少碰撞。

  • JDK1.8之后,HashMap加了红黑树,当链表长度大于阈值(默认为8),数组长度大于64的时候,会执行链表转红黑树,来加快搜索速度。只有当数组长度大于等于64的时候,才会执行转换红黑树以减少搜索时间,否则通过resize()方法对数组进行扩容。

HashMap的扩容机制

我们创建一个HashMap的集合对象,底层node数组默认大小是16,当集合的存储容量达到一个临界值的时候会扩容,loadFactor * capacity (负载因子 * 容量),负载因子默认是0.75。

  • 在jdk1.8中 ,一条链表的元素个数已经到8个,并且table数组长度达到64了,链表就会树化成红黑树
  • 如果仅仅是链表元素个数到8 , 数组长度还没到64 , 那么就不会树化,而是数组长度会扩容为2倍 , 然后重新哈希,当然链表的长度可能超过8
  • 但是如果红黑树的元素个数小于6 那么就会还原成链表, 当红黑树的元素个数不小于32的时候才会再次扩容

解决Hash冲突的方法

解决Hash冲突方法有:开放定址法、再哈希法、链地址法(拉链法)、建立公共溢出区。HashMap中采用的是拉链法

​ ①:开放定址法,也称为线性探测法,就是从发生冲突的那个位置开始,按照一定的次序从 hash 表中找到一个空闲的位置,然后把发生冲突的元素存入到这个空闲位置中。(基本思想就是,如果p=H(key)出现冲突时,则以p为基础,再次hash,I p1=H§ ,如果p1再次出现冲突,则以p1为基础,以此类推,直到找到一个不冲突的哈希地址pi。因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次hash,所以只能在删除的节点上做标记,而不能真正删除节点。)ThreadLocal 就用到了线性探测法来解决 hash 冲突的。

​ 比如像这样一种情况,在 hash 表索引 1 的位置存了一个 key=name,当再次添加key=hobby 时,hash 计算得到的索引也是 1,这个就是 hash 冲突。而开放定址法,就是按顺序向前找到一个空闲的位置来存储冲突的 key。

​ ②:链式寻址法,这是一种非常常见的方法,简单理解就是把存在 hash 冲突的 key,以单向链表的方式来存储,比如 HashMap 就是采用链式寻址法来实现的。

​ ③:再 hash 法,就是当通过某个 hash 函数计算的 key 存在冲突时,再用另外一个hash 函数对这个 key 做 hash,一直运算直到不再产生冲突。这种方式不易产生堆集,但是会增加计算时间,性能影响较大。

​ ④:建立公共溢出区,就是把 hash 表分为基本表和溢出表两个部分,凡事存在冲突的元素,一律放入到溢出表中。

为什么在解决hash冲突的时候选择先用链表,再转红黑树?

因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。当元素小于8个的时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,红黑树搜索时间复杂度是O(logn),而链表是O(n),此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。

因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。

HashMap为什么线程不安全

  • 多线程下扩容可能造成死循环。JDK1.7中的HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的岀现,形成死循环。所以在 JDK1.8 改成了尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会岀现环形链表的问题。

  • 多线程的put可能导致元素的丢失。多线程同时执行put操作,如果计算岀来的索引位置是相同的,那会造成前一个key被后一个key覆盖,从而导致元素的丢失。此问题在JDK 1.7和JDK 1.8中 都存在。

  • put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold(临界点进行扩容)而导致 rehash(重新计算哈希值),线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和JDK 1.8中都存在。

一般用什么作为HashMap的key?

一般用Integer、 String 这种不可变类当HashMap当key,而且String最为常用。
● 因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就是HashMap中的键往往都使用字符串的原因。
● 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的重写了hashCode(以及equals()方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值