HashMap底层分析
我们先来看一下他的组成
hashmap底层是由数组跟链表组成的(这是在jdk1.7之前)
在jdk17.之后还引用了红黑树
JDK1.8中:
**使用一个Node数组来存储数据,但这个Node可能是链表结构,也可能是红黑树结构
**
如果插入的key的hashcode相同,那么这些key也会被定位到Node数组的同一个格子里。
如果同一个格子里的key不超过8个,使用链表结构存储。
如果超过了8个,那么会调用treeifyBin函数,将链表转换为红黑树。
那么即使hashcode完全相同,由于红黑树的特定,查找某个特定元素,也只需要O(log n)的开销
也就是说put/get的操作的时间复杂度只有O(log n)
**备注:当数组大小已经超过64并且链表中的元素个数超过默认设定(8个)时,将链表转化为红黑树**
,数组主要是用来存hashcode的值,链表主要是用来解决hash冲突,对于hash冲突的解决主要有四种方法1.开放寻\定址法2.再哈希发3.链地址法4.建立公共溢出区
hash冲突是什么呢?
hash冲突主要是因为散列表(也可以理解为存储hash值的表)是有限的,当数据足够大的时候,难免会出现hashcode相同的时候,这个就叫hash冲突.
简而言之就是两个不同数据,通过一定的计算,得到的hashcode是相同的,不过一般不会相同,我们要避免相同的情况,因而就有了解决hash冲突的方法
解决hash冲突的方法
上面我们已经提到过了有四种方法解决hash冲突
-
开放定址法:
所谓的开放定址法就是一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入
公式为:fi(key) = (f(key)+di) MOD m (di=1,2,3,……,m-1)
※ 用开放定址法解决冲突的做法是:当冲突发生时,使用某种探测技术在散列表中形成一个探测序列。沿此序列逐个单元地查找,直到找到给定的关键字,或者碰到一个开放的地址(即该地址单元为空)为止(若要插入,在探查到开放的地址,则可将待插入的新结点存人该地址单元)。查找时探测到开放的地址则表明表 中无待查的关键字,即查找失败。
比如说,我们的关键字集合为{12,67,56,16,25,37,22,29,15,47,48,34},表长为12。我们用散列函数f(key) = key mod (表长) 即12
从上述集合种我们每一个元素都按照这个函数进行计算,
第一个元素12 计算完下标为0,对应如下
下标 0 1 2 3 4 5 6 7 8 9 10 关键字 12 第二个元素67 计算完下标为7,对应如下
下标 0 1 2 3 4 5 6 7 8 9 10 关键字 12 67
当计算前S个数{12,67,56,16,25}时,都是没有冲突的散列地址,直接存入:
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 12 | 25 | 16 | 67 | 56 |
接着往下走,当计算到37这个值时,发现f(37) = 1,此时就与25所在的位置冲突。 于是我们应用上面的公式f(37) = (f(37)+1) mod 12 = 2。于是将37存入下标为2的位置.这就是 开放定址法
下标 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|---|
关键字 | 12 | 25 | 37 | 16 | 67 | 56 |
-
再哈希法
再哈希法又叫双哈希法,有多个不同的Hash函数,当发生冲突时,使用第二个,第三个,….,等哈希函数,计算地址,直到无冲突。虽然不易发生聚集,但是增加了计算时间。
即通过不同的算法去得到不同的下标.
-
链地址法
链地址法的基本思想是:每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表连接起来,如:
键值对k2, v2与键值对k1, v1通过计算后的索引值都为2,这时及产生冲突,但是可以通道next指针将k2, k1所在的节点连接起来,这样就解决了哈希的冲突问题
- 建立公共溢出区:
这种方法的基本思想是:将哈希表分为基本表和溢出表两部分,凡是和基本表发生冲突的元素,一律填入溢出表
HashMap的扩容原理
当HashMap中的元素个数超过数组大小(数组总大小length,不是数组中个数size)loadFactor时,就会进行数组扩容,loadFactor的默认值为0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中元素个数超过160.75=12(这个值就是代码中的threshold值,也叫做临界值)的时候,通过一个resize()方法就把数组的大小扩展为 2*16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常消耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。
总得来说,就是拷贝旧的数据元素,从新新建一个更大容量的空间,然后进行数据复制!
Hashmap为什么多线程不安全
因为hashmap在扩容时会产生环形链表,什么是环形链表,为什么会产生环形链表呢?
主要在这扩容的过程。当多个线程同时对这个HashMap进行put操作,而察觉到内存容量不够,需要进行扩容时,多个线程会同时执行resize操作,而这就出现问题了,问题的原因分析如下:
首先,在HashMap扩容时,会改变链表中的元素的顺序,将元素从链表头部插入。PS:说是为了避免尾部遍历,这一部分不是本博客的主要介绍内容,后面再说。
而环形链表就在这一时刻发生,以下模拟2个线程同时扩容。假设,当前hashmap的空间为2(临界值为1),hashcode分别为0和1,在散列地址0处有元素A和B,这时候要添加元素C,C经过hash运算,得到散列地址为1,这时候由于超过了临界值,空间不够,需要调用resize方法进行扩容,那么在多线程条件下,会出现条件竞争,模拟过程如下:
这个过程为,先将A复制到新的hash表中,然后接着复制B到链头(A的前边:B.next=A),本来B.next=null,到此也就结束了(跟线程二一样的过程),但是,由于线程二扩容的原因,B.next=A,所以,这里继续复制A,让A.next=B,由此,环形链表出现:B.next=A; A.next=B
既然hashmap不安全,于是有了ConcurrentHashMap
下面我们来谈一下为什么要使用ConcurrentHashMap。
在并发编程中使用HashMap可能导致程序死循环。而使用线程安全的HashTable效率又非常低下,基于以上两个原因,便有了ConcurrentHashMap的登场机会
1)线程不安全的HashMap
在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。HashMap在并发执行put操作时会引起死循环,是因为多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。
2)效率低下的HashTable
HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法,其他线程也访问HashTable的同步方法时,会进入阻塞或轮询状态。如线程1使用put进行元素添加,线程2不但不能使用put方法添加元素,也不能使用get方法来获取元素,所以竞争越激烈效率越低。
3) ConcurrentHashMap的锁分段技术可有效提升并发访问率
HashTable容器在竞争激烈的并发环境下表现出效率低下的原因是所有访问HashTable的线程都必须竞争同一把锁,假如容器里有多把锁,每一把锁用于锁容器其中一部分数据,那么当多线程访问容器里不同数据段的数据时,线程间就不会存在锁竞争,从而可以有效提高并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
深入了解ConcurrentHashMap
并发访问效率,这就是ConcurrentHashMap所使用的锁分段技术。首先将数据分成一段一段地存储,然后给每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问。
深入了解ConcurrentHashMap
见ConcurrentHashMap底层