☆HashMap深层原理

目录

什么是HashMap

HashMap的哈希表

JDK1.7中的HashMap 

初始容量

JDK1.7的put操作

HashMap的扩容

为什么负载因子取0.75?

HashMap的死锁问题

HashMap哈希碰撞导致的DOS问题 

JDK1.8中的HashMap 

链表为什么变成红黑树?

为什么阈值为8?

JDK1.8中的put方法 

 jdk1.8中的get方法

比较JDK1.7和JDK1.8的HashMap

关于ConcurrentHashMap

JDK1.7

JDK1.8

比较1.7与1.8


 

什么是HashMap

 

HashMap是JDK提供的双列集合类, 它继承于Map接口。jdk给出的解释是:"map接口基于哈希表的实现。"

HashMap的数据结构是哈希表,作为一种数据结构,哈希表由“桶+链表组成”,而“hashmap使用的哈希表在JDK1.8之前由数组+单向链表组成,JDK1.8开始哈希表的数据结构由数组+单向链表/红黑树组成(链表长度超过8变成红黑树)

 

HashMap集合的特点:

1.存取无序

2.可以存null值

3.多线程下不安全

 

 

HashMap的哈希表

 

HashMap的数据结构如下,以3号为例。3下面的所有内容就是“哈希桶”,也可以看作是一个链表。3这个数为“哈希值”。3号哈希值下有2个key,name3和name12。这就是哈希碰撞。

由于哈希碰撞的情况较少,因此平均的时间复杂度是O(1)

☆HashMap深层原理_第1张图片

 

 

JDK1.7中的HashMap 

 

初始容量

由源码可知,HashMap默认的初始容量为2的四次方即16,HashMap的最大容量为2的30次方

☆HashMap深层原理_第2张图片

HashMap的初始容量可以自定义,但必须得是2的幂次方,如果不是2的幂次方,它会自动向上调整。比如你设置的初始容量为17它会调整为32。因为17大于2的四次方,因此要调整为2的五次方。

原因:2的幂次方-1换算成2进制的哈希值都包含全1,全1和要存的元素哈希值按位“与”运算能求出数组下标,并且可均匀分布。

☆HashMap深层原理_第3张图片

new一个hashmap会分配一个空的初始容量的哈希表,当对这个hashmap添加元素时,才会把自定义的初始容量和负载因子赋给hashmap。

 

JDK1.7的put操作

先回忆下put之前要做的事。new一个空的哈希表,哈希表由数组加链表组成。当put时,首先要把指定的初始容量和负载因子给hashmap集合,假设初始容量为16即数组的长度为16。
由源码可知,走完两个判断之后,第一步先执行了一个hash方法。

☆HashMap深层原理_第4张图片

 

hash方法的目的是处理比较糙的哈希值,减少哈希碰撞,使所有元素分布在hash表上的位置更加均匀。 

☆HashMap深层原理_第5张图片

 

hash方法之后又走了一个indexFor方法,也就是put操作。put的思想是把不同的数均匀的存储在这16个空间中。具体算法为:拿出“初始容量”-1,再求出要放到hashmap的元素的“哈希值”,用“初始容量-1的二进制位”和“元素的哈希值”进行与操作求出的数在0-15之间,假如求出的值是4就放到数组下标为4的空间中。

如果现在又来了一个数,求出的值还是4,同样把这个数放在4里面。

☆HashMap深层原理_第6张图片

为什么要用“与”操作,取模不是也可以吗?

取模有两个缺点:其一效率低下、其二负数取模后还是负数需要做2次处理~

 

indexFor方法执行完,走了一个循环并判断,求出的key是否已经存在于hashmap中,如果存在就覆盖value值,如果不存在就执行addEntry方法,添加到链表中

☆HashMap深层原理_第7张图片

 

HashMap的扩容

上面的addEntry方法第一步便是判断当前map集合是否超过了最大容量,最大容量=初始容量x负载因子(默认为0.75)。超过的话就需要先执行resize方法进行扩容,扩容的大小为原大小的2倍。

那什么是最大容量呢?最大容量指的是“数组+链表中所有元素的数量。”

☆HashMap深层原理_第8张图片

扩容后使用下面的transfer方法重新计算哈希值,再把所有元素放到这个新的哈希表中~

☆HashMap深层原理_第9张图片

Resize的性能非常低,因此建议new hashmap就分配合适的初始容量,减少扩容的频率~ 

 

为什么负载因子取0.75?

负载因子相当于一个阀值,如果这个阀值取1就意味着容量都用光了再扩容,这种情况会造成大量的哈希冲突,哈希冲突越多越会降低查找效率。阀值如果取0.5的话,哈希冲突少了,但扩容更频繁了,扩容越频繁,调用transfer方法次数越多,也会降低查找效率。因此0.75是折中办法。(注:负载因此也可以自定义)

官方给出的解释如下 

☆HashMap深层原理_第10张图片

 

HashMap的死锁问题

hashmap是线程不安全的,多线程下可能会发生死锁。多线程扩容时,如果遇到老的哈希值要赋到新的哈希值上。而恰巧一个线程老的哈希值是另一个线程的新哈希值,另一个线程的新哈希值是上一个线程的老哈希值。这样就会形成一个“环”,即出现死锁

☆HashMap深层原理_第11张图片

 

HashMap哈希碰撞导致的DOS问题 

归根结底是因为哈希碰撞会影响查询性能,这时候如果有“法外狂徒”基于某种类型的hash规则,构造了成千上万个哈希值一样的数据,此时查找的效率就会被大大减慢。如图下图中3个数的哈希值都是2112。

Dos指拒绝服务,当哈希值增多到服务器短时间内处理不过来时,服务器会因此处于忙碌状态并且不断的被消耗网络资源和系统资源,这就是DOS。

☆HashMap深层原理_第12张图片

 

 

JDK1.8中的HashMap 

 

 

链表为什么变成红黑树?

完全平衡二叉树的查找效率高,链表的增删改效率高,红黑树效率介于二者中间。原因在于HashMap不仅有查找操作,还有增删改操作。

注意:并不是链表中的元素个数大于8就转换为红黑树,还会判断一下当前数组的长度,数组长度小于64时,会进行扩容,只有当链表中元素个数>8且数组长度>=64时才会将链表转换为红黑树

 

为什么阈值为8?

使用默认hash计算hashcode符合参数为0.5的泊松分布,可以发现这种方式计算的哈希值超过8的概率已经非常非常小了,所以选择了8作为阈值。

☆HashMap深层原理_第13张图片

 

JDK1.8中的put方法 

JDK1.8的put方法比1.7多了一倍,原因在于它创建了一个TreeBean的方法,把链表构造成树的方法,如果阈值为8,他就会把链表转换成树

☆HashMap深层原理_第14张图片

 

 jdk1.8中的get方法

如果数组中就查到了匹配的元素就直接返回,否则查找树中是否存在,否则再查链表中是否存在 

☆HashMap深层原理_第15张图片

 

 

比较JDK1.7和JDK1.8的HashMap

 

1.JDK8使用了红黑树

2.JDK7中链表的插入采用头插法,JDK8采用尾插法。

头插法无需遍历链表速度快但多线程扩容时会出现死锁问题。JDK1.8采用尾插法,因为一定会计算链表当前的节点数,也就是一定会去遍历链表,所以采用尾插。

3.JDK7的Hash算法比JDK8更复杂。

hash越复杂,生成的hashcode越散列,越不容易出现哈希冲突。即JDK7只能通过优化hash算法来提高查询性能。而JDK8使用了红黑树,提高了哈希冲突下的查询性能,因此Hash算法更简单,毕竟哈希算法的复杂度是以消耗CPU为代价的。

4.JDK7和JDK8扩容方式不一致

JDK8扩容时会先算出当前位置上哪些元素在新数组的低位,哪些元素在新数组的高位,然后一次性转移。而JDK7则一次转移一个元素

 

 

关于ConcurrentHashMap

 

之前我们学过HashTable,它在多线程下是安全的。但HashTable采用全局加锁,导致高并发下效率低下。

因此JUC中的ConcurrentHashMap就称为高并发下使用HashMap的关键。JDK1.7和JDK1.8它们的实现方式不同

 

JDK1.7

在JDK1.7中ConcurrentHashMap采用了数组+Segment+分段锁的方式实现。

 

1.Segment(分段锁)

ConcurrentHashMap中的分段锁称为Segment,它即类似于HashMap的结构,即内部拥有一个Entry数组,数组中的每个元素又是一个链表,同时又是一个ReentrantLock(Segment继承了ReentrantLock)。

 

2.内部结构

ConcurrentHashMap使用分段锁技术,将数据分成一段一段的存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,能够实现真正的并发访问。下图是ConcurrentHashMap的内部结构图:

☆HashMap深层原理_第16张图片

ConcurrentHashMap定位一个元素的过程需要进行两次Hash操作。第一次Hash定位到Segment,第二次Hash定位到元素所在的链表的头部。

 

3.该结构的优劣

坏处

这一种结构的带来的副作用是Hash的过程要比普通的HashMap要长

好处

写操作的时候可以只对元素所在的Segment进行加锁即可,不会影响到其他的Segment,这样,在最理想的情况下,ConcurrentHashMap可以最高同时支持Segment数量大小的写操作(刚好这些写操作都非常平均地分布在所有的Segment上)。

所以,通过这种结构,ConcurrentHashMap的并发能力可以大大的提高。

 

JDK1.8

在JDK1.8中ConcurrentHashMap采用了数组+链表+红黑树的方式实现。看着好像和普通的hashmap没什么区别,实则这种方式内部使用了大量的CAS操作。

☆HashMap深层原理_第17张图片

CAS是compare and swap的缩写,即比较并交换。cas是一种基于锁的操作,而且是乐观锁。在java中锁分为乐观锁和悲观锁。悲观锁是将资源锁住,等一个之前获得锁的线程释放锁之后,下一个线程才可以访问。而乐观锁采取了一种宽泛的态度,通过某种方式不加锁来处理资源,比如通过给记录加version来获取数据,性能较悲观锁有很大的提高。

CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存地址里面的值和A的值是一样的,那么就将内存里面的值更新成B。CAS是通过无限循环来获取数据的,如果在第一轮循环中,a线程获取地址里面的值被b线程修改了,那么a线程需要自旋,到下次循环才有可能机会执行。

JDK8中彻底放弃了Segment转而采用的是Node,其设计思想也不再是JDK1.7中的分段锁思想。

Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。

 

比较1.7与1.8

可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。

1.数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。

2.保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。

3.锁的粒度:原来是对需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁(Node)。

4.链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。

5.查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

 

 

你可能感兴趣的:([笔记本],[JavaEE])