HashMap的几个灵魂拷问
之前是写过一篇HashMap的原理文章的,比较基础 java基础之数据结构3(Map篇),这篇文章算是对之前内容的一个补充吧。主要讲讲HashMap的数据结构,jdk8改进优化项,以及扩容机制和线程安全问题。
数据结构
- 采取空间换取时间的思路,哈希Map内部由数组组成,数组元素又叫Bucket。
- 利用哈希函数生成 key 来找数组中对应的 index,存取操作复杂度是O(1),1.8优化了寻址算法。
- 当出现哈希冲突的时候,通过拉链法将同一个Index上的元素串起来。
- 添加元素的时候,当容量大于≥ 64 且 单向链表的节点数量大于 8 时由单向链表转为红黑树来存储元素(JDK8改进)。
- 当红黑树节点数量少到一定程度时,又会转为单向链表。

JDK1.8哈希函数和寻址算法的优化
1、哈希函数优化
hash函数源代码是通过h >>> 16
将 h 右移 16 位,因为 int 是 4 字节,32 位,所以右移 16 位后变成:左边 16 个 0 + 右边原 h 的高 16 位;最后把这两个进行异或返回。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2、寻址算法的优化
putVal() 方法寻找数组中索引位置
tab[i = (n - 1) & hash]
其中n 是这个数组的长度 length,hash 就是上面hash() 方法返回
的值;
3、为啥不用取模运算,为啥要这样设计?
如果采用取模运算,源代码思路就是先对key生成一个整数的hash值,然后对hash值和数组大小做一个相关运算,生成一个索引值。
public int hash(Object key){
return hash_code(key) % table.length;
}
但是计算机的取模运算不如位运算效率高,而且有一个数学公式,当哈希表长度 length 为 2 的整次幂时, hash & (length - 1) 的计算结果跟 hash % length 一样(前提就是数组的长度必须是2的指数次幂)。
public int hash(Object key){
return hash_code(key) & (table.length - 1);
}
HashMap的初始大小为16,也就是2^4指数次幂,进行&位运算时,有这么一个特征:
1100 1010
& 1111
--------------
1100 1010
由于数组长度太小,基本上只利用到了低4位的信息,只有当hashMap的长度达到2的10次、20次才会用到高位信息,因此hash算法进行了改进:
h = key.hashCode() ^ (h >>> 16)
先把16->32的高位向右顺移动16位,然后与原HashCode进行异或(值不相同为1。值相同为0。)运算。这样的话,HashMap 中让 hashCode 的高位也参与了寻址计算(进行扰动),即把 hashCode 高 16 位与 hashCode 进行异或算出 hash,然后根据 hash 来做寻址。
PUT元素的流程
- HashMap中PUT方法的流程?
- 通过key计算出一个hashcode
- 通过hashcode与“与操作”计算出一个数组下标
- 在把put进来的key,value封装为一个entry对象
- 判断数组下标对应的位置,是不是空,如果是空则把entry直接存在该数组位置
- 如果该下标对应的位置不为空,则需要把entry插入到链表中
- 并且还需要判断该链表中是否存在相同的key,如果存在,则更新value
- 如果是JDK7,则使用头插法
- 如果是JDK8,则会遍历链表,并且在遍历链表的过程中,统计当前链表的元素个数,如果超过8个,则先把链表转变为红黑树,并且把元素插入到红黑树中
扩容机制和扩容的优化
当HashMap中的元素个数超过容量*loadFactor
时(容量=数组大小=bucket数量),就会进行数组扩容,loadFactor的默认值为0.75,也就是说,默认情况下,数组大小为16,那么当hashmap中元素个数超过16*0.75=12
的时候,就把数组的大小扩展为 2*16=32
,即扩大一倍。具体的扩容流程是啥样的呢?核心源码和文字说明如下:
/*新容量数组桶大小为旧的table的2倍*/
void transfer(HashMapEntry[] newTable) {
/*遍历旧的数组桶table*/
int newCapacity = newTable.length;
/*如果这个数组位置上有元素且存在哈希冲突的链表结构则继续遍历链表*/
for (HashMapEntry<K, V> e : table) {
/*取当前数组索引位上单向链表的下一个元素*/
while (null != e) {
/*重新依据hash值计算元素在扩容后数组中的索引位置*/
HashMapEntry<K, V> next = e.next;
/*将数组i的元素赋值给当前链表元素的下一个节点*/
int i = indexFor(e.hash, newCapacity);
/*将链表元素放入数组位置*/
e.next = newTable[i];
/*将当前数组索引位上单向链表的下一个元素赋值给e进行新的一圈链表遍历*/
newTable[i] = e;
e = next;
}
}
}
- HashMap的扩容指的就是数组的扩容, 因为数组占用的是连续内存空间,所以数组的扩容其实只能新开一个新的数组,然后把老数组上的元素转移到新数组上来,这样才是数组的扩容
- 在HashMap中也是一样,先新建一个2被数组大小的数组
- 然后遍历老数组上的没一个位置,如果这个位置上是一个链表,就把这个链表上的元素转移到新数组上去
在这个过程中就需要遍历链表,当然jdk7,和jdk8在这个实现时是有不一样的,jdk7就是简单的遍历链表上的没一个元素,然后按每个元素的hashcode结合新数组的长度重新计算得出一个下标,而重新得到的这个数组下标很可能和之前的数组下标是不一样的,这样子就达到了一种效果,就是扩容之后,某个链表会变短,这也就达到了扩容的目的,缩短链表长度,提高了查询效率 - 而在jdk8中,因为涉及到红黑树,这个其实比较复杂,jdk8中其实还会用到一个双向链表来维护红黑树中的元素,所以jdk8中在转移某个位置上的元素时,会去判断如果这个位置是一个红黑树,那么会遍历该位置的双向链表,遍历双向链表统计哪些元素在扩容完之后还是原位置,哪些元素在扩容之后在新位置,这样遍历完双向链表后,就会得到两个子链表,一个放在原下标位置,一个放在新下标位置,如果原下标位置或新下标位置没有元素,则红黑树不用拆分,否则判断这两个子链表的长度,如果超过八,则转成红黑树放到对应的位置,否则把单向链表放到对应的位置。
- 元素转移完了之后,在把新数组对象赋值给HashMap的table属性,老数组会被回收到。
扩容为什么要引入红黑树呢?因为在元素放置过程中,如果一个对象哈希冲突,都被放置到同一个桶里,则会形成一个链表,我们知道链表查询是线性的(时间复杂度会退化到 O(n)),会严重影响存取的性能。由于红黑树的特点,查找某个特定元素,也只需要O(log n)的开销。
用哈希碰撞发起拒绝服务攻击(DOS,Denial-Of-Service attack),常见的场景是攻击者可以事先构造大量相同哈希值的数据,然后以JSON数据的形式发送给服务器,服务器端在将其构建成为Java对象过程中,通常以Hashtable或HashMap等形式存储,哈希碰撞将导致哈希表发生严重退化,算法复杂度可能上升一个数据级,进而耗费大量CPU资源。
加载因子为何设置为0.75
- 如果加载因子比较大,那么扩容发生的频率就比较低,但是他浪费的空间比较小,不过发生hash冲突的几率就比较大。
- 如果加载因子值比较小的时候,扩容的频率就会变高,因此会占用更多的空间,但是元素的存储就比较稀疏,发生哈希冲突的可能性就比较小
所以综合了空间利用率和碰撞几率,就取了一个 0.5 到 1.0 的平均数 0.75 作为加载因子。
HashMap是线程安全的吗?
HashMap在put的时候,插入的元素超过了容量(由负载因子决定)的范围就会触发扩容操作,就是rehash,这个会重新将原数组的内容重新hash到新的扩容数组中,在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在get时会出现死循环,所以HashMap是线程不安全的。
如果想要使用线程安全的HashMap,可以采用ConcurrentHashMap类,这里也有一个比较热门的知识点,这篇文章已经总结的非常简练了,直接看这个文章即可!ConcurrentHashMap面试题
JDK8的ConcurrentHashMap和JDK7的ConcurrentHashMap有什么区别?
- JDK8中新增了红黑树
- JDK7中使用的是头插法,JDK8中使用的是尾插法
- JDK7中使用了分段锁,而JDK8中没有使用分段锁了
- JDK7中使用了ReentrantLock,JDK8中没有使用ReentrantLock了,而使用了Synchronized
- JDK7中的扩容是每个Segment内部进行扩容,不会影响其他Segment,而JDK8中的扩容和HashMap的扩容类似,只不过支持了多线程扩容,并且保证了线程安全
ConcurrentHashMap是如何保证并发安全的?
JDK7中ConcurrentHashMap是通过ReentrantLock+CAS+分段思想来保证的并发安全的,在JDK7的ConcurrentHashMap中,首先有一个Segment数组,存的是Segment对象,Segment相当于一个小HashMap,Segment内部有一个HashEntry的数组,也有扩容的阈值,同时Segment继承了ReentrantLock类,同时在Segment中还提供了put,get等方法,比如Segment的put方法在一开始就会去加锁,加到锁之后才会把key,value存到Segment中去,然后释放锁。
同时在ConcurrentHashMap的put方法中,会通过CAS的方式把一个Segment对象存到Segment数组的某个位置中。
同时因为一个Segment内部存在一个HashEntry数组,所以和HashMap对比来看,相当于分段了,每段里面是一个小的HashMap,每段公用一把锁,同时在ConcurrentHashMap的构造方法中是可以设置分段的数量的,叫做并发级别concurrencyLevel.
JDK8中ConcurrentHashMap是通过synchronized+cas来实现了。在JDK8中只有一个数组,就是Node数组,Node就是key,value,hashcode封装出来的对象,和HashMap中的Entry一样,在JDK8中通过对Node数组的某个index位置的元素进行同步,达到该index位置的并发安全。同时内部也利用了CAS对数组的某个位置进行并发安全的赋值。
为何使用synchronized?
JDK8中使用synchronized加锁时,是对链表头结点和红黑树根结点来加锁的,而ConcurrentHashMap会保证,数组中某个位置的元素一定是链表的头结点或红黑树的根结点,所以JDK8中的ConcurrentHashMap在对某个桶进行并发安全控制时,只需要使用synchronized对当前那个位置的数组上的元素进行加锁即可,对于每个桶,只有获取到了第一个元素上的锁,才能操作这个桶,不管这个桶是一个链表还是红黑树。
想比于JDK7中使用ReentrantLock来加锁,因为JDK7中使用了分段锁,所以对于一个ConcurrentHashMap对象而言,分了几段就得有几个ReentrantLock对象,表示得有对应的几把锁。
而JDK8中使用synchronized关键字来加锁就会更节省内存,并且jdk也已经对synchronized的底层工作机制进行了优化,效率更好。