0、前言
本博文部分文字及图片参考自以下三篇文章,其余内容为本人经过思考及总结后所写,仅作为学习分享使用,如有侵权,请联系本人删除,谢谢。
1、HashMap基本原理
众所周知,HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。
HashMap数组每一个元素的初始值都是Null。
对于HashMap,我们最常使用的是两个方法:Get 和 Put。
Put方法的原理
调用Put方法的时候发生了什么呢?
比如调用 hashMap.put("apple", 0) ,插入一个Key为“apple"的元素。这时候我们需要利用一个哈希函数来确定Entry的插入位置(index):
index = Hash(“apple”)
假定最后计算出的index是2,那么结果如下:
但是,因为HashMap的长度是有限的,当插入的Entry越来越多时,再完美的Hash函数也难免会出现index冲突的情况。比如下面这样:
这时候该怎么办呢?我们可以利用链表来解决。
HashMap数组的每一个元素不止是一个Entry对象,也是一个链表的头节点。每一个Entry对象通过Next指针指向它的下一个Entry节点。当新来的Entry映射到冲突的数组位置时,只需要插入到对应的链表即可:
- 需要注意的是,新来的Entry节点插入链表时,使用的是“头插法”。之所以把Entry6放在头节点,是因为HashMap的发明者认为,后插入的Entry被查找的可能性更大。
Get方法的原理
使用Get方法根据Key来查找Value的时候,发生了什么呢?
首先依然会把输入的Key做一次Hash映射,得到对应的index:
index = Hash(“apple”)
由于刚才所说的Hash冲突,同一个位置有可能匹配到多个Entry,这时候就需要顺着对应链表的头节点,一个一个向下来查找。假设我们要查找的Key是“apple”:
第一步,我们查看的是头节点Entry6,Entry6的Key是banana,显然不是我们要找的结果。
第二步,我们查看的是Next节点Entry1,Entry1的Key是apple,正是我们要找的结果。
在这里get方法会沿着链表一直往下寻找,直到找到了key为apple的节点。若找到链表最尾端的时候(e.next=null)还找不到的话,则返回null
- 所以当此处出现双向循环链表的时候,那么程序就会出现死循环,因为e.next永远不会等于null
2、HashMap默认初始长度是多少,为什么?
默认长度是16,并且每次自动扩展或手动初始化时,长度必须是2的幂次方。
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
之前说过,从Key映射到HashMap数组的对应位置,会用到一个Hash函数,如何实现一个尽量均匀分布的Hash函数呢?我们通过利用Key的HashCode值来做某种运算。
通常情况下,Key的HashCode值会是一个比较大的值,但我们HashMap的初始长度只有16,所以我们必须采取某些方法来将这个HashCode值和Map的长度值做一个映射转换
常见的做法就是将Key的HashCode值和Map的长度值进行求模运算,但模运算效率低,为了实现高效的算法,HashMap采用了位运算的算法。
如何进行位运算呢?有如下的公式(Length是HashMap的长度):
index = key.hashCode() & (Length - 1)
下面我们以“book"的Key来演示整个过程:
1、计算book的hashcode,结果为十进制的3029737,二进制的101110001110101110 1001。
2、假定HashMap长度是默认的16,计算Length-1的结果为十进制的15,二进制的1111。
3、把以上两个结果做与运算,101110001110101110 1001 & 1111 = 1001,十进制是9,所以 index=9。
可以说,Hash算法最终得到的index结果,完全取决于Key的Hashcode值的最后几位。
HashMap长度必须是2的幂次方,这样才能保证Length-1的二进制形式全是1。因为假如Length-1的值为1000的话,那么其他数和1000进行与运算之后,结果就只有1000或0000这两种情况,这样就会造成大量的冲突,显然不符合Hash算法均匀分布的原则。
3、HashMap的扩展
HashMap的容量是有限的。当经过多次元素插入,使得HashMap达到一定饱和度时,Key映射位置发生冲突的几率会逐渐提高。这时候,HashMap需要扩展它的长度,也就是进行Resize。
影响发生Resize的因素有两个:
1、Capacity:HashMap的当前长度。
2、LoadFactor:HashMap负载因子,默认值为0.75f。
衡量HashMap是否进行Resize的条件如下,也就是说当HashMap中存储的数据量超过总量的0.75倍的时候,则认为该HashMap已经超过负载,需要进行Resize:
Resize的步骤
1、扩容
- 创建一个新的Entry空数组,长度是原数组的2倍。
2、ReHash
遍历原Entry数组,把所有的Entry重新Hash到新数组。为什么要重新Hash呢?因为长度扩大以后,Hash的规则也随之改变。
让我们回顾一下Hash公式:
index = key.hashCode() & (Length - 1)
当原数组长度为8时,Hash运算是和111B做与运算;新数组长度为16,Hash运算是和1111B做与运算。Hash结果显然不同。
Resize前的HashMap:
Resize后的HashMap:
ReHash的Java代码如下:
/** * Transfers all entries from current table to newTable. */ void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; 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]; // 然后让e成为链表中的第一个节点 newTable[i] = e; e = next; } } }
HashMap的扩展在多线程下会造成死循环
如当前HashMap中存在两个元素a和b,其中他们存放的地址冲突,即hash(a) = hash(b) = 0,此时该哈希表的内存图如下:
假如现在有两个线程分别对该HashMap执行put操作,此时HashMap由于容量不够就需要进行扩容了,假设线程1先执行,在执行完Entry<K,V> next = e.next;这一句之后,cpu的时间片切换到了线程2上了,并且线程2顺利地执行完毕,此时我们先看线程2执行完后的内存图是怎样的。
此时又轮到线程1执行了,我们回顾下rehash的代码
/** * Transfers all entries from current table to newTable. */ void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { // 线程1执行完后停在了这里,e=0x001,next=0x009 Entry<K,V> next = e.next; // 线程1又执行了 // 与之前不同的是,原本是0x009.next = null,现在变成了0x009.next = 0x001 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在while代码块中执行了三次
- 1、e=0x001,next=0x009,然后newTable[2] = 0x001,0x001.next = null
- 2、e=0x009,next=0x001,然后newTable[2] = 0x009,0x009.next = 0x001
- 3、e=0x001,next=null,然后newTable[2] = 0x001,0x001.next = 0x009
最终内存图如下:
此时当调用Get查找一个不存在的Key,而这个Key的Hash结果恰好等于2的时候,由于位置2带有环形链表,所以程序将会进入死循环!
4、ConcurrentHashMap
由于多线程在操作HashMap时会出现环形链表进而导致死循环的问题,所以此时就必须寻找解决方法。
Hashtable或Collections.synchronizedMap均能保证线程的安全性,但两者都使用了带有阻塞的悲观锁,性能不高。
在并发环境下,ConcurrentHashMap能到兼顾线程的安全性以及运行的效率,替代了Hashtable。
ConcurrentHashMap通过使用Segment的方式来减少悲观锁的产生。
其原理有点类似于jvm堆内存分配对象时所使用的本地线程分配缓冲(TLAB),每个线程有一个自己专属的区域,各个线程在自己的区域中执行代码,互不干扰。
ConcurrentHashMap则可以看成一个二级哈希表,首先其维护的哈希表中存储的均为Segment对象,而各个Segment对象中同时也维护了一个哈希表,哈希表里面存放的才是真正我们要用的entry对象。
如果两个线程同时操作两个Segment中的两个哈希表,那么自然也就不会出现线程安全性问题了,两者可以同时执行。
这样子设计之后,当我们put进一个元素时,就需要进行两次hash值的获取,第一次先获取key所对应的Segment的位置,第二次再获取key在Segment中所对应entry对象的真正位置。
当然,AB线程也有可能同时操作到同一个Segment,为了保障线程的安全性问题,Segment的写入是需要上锁的,因此对同一Segment的并发写入会被阻塞(由于只对写操作上锁,所以并发读或一个线程读一个线程写的情况并不会被阻塞)。
由此可见,ConcurrentHashMap当中每个Segment各自持有一把锁。在保证线程安全的同时降低了锁的粒度(降低了线程阻塞的可能性),让并发操作效率更高。
总结ConcurrentHashMap的get步骤和put步骤如下:
get
1、为输入的Key做Hash运算,得到hash值。
2、通过hash值,定位到对应的Segment对象
3、再次通过hash值,定位到Segment当中数组的具体位置。
put
1、为输入的Key做Hash运算,得到hash值。
2、通过hash值,定位到对应的Segment对象
3、获取可重入锁
4、再次通过hash值,定位到Segment当中数组的具体位置。
5、插入或覆盖HashEntry对象。
6、释放锁。
ConcurrentHashMap如何保障size()方法数据的一致性?
ConcurrentHashMap中的size方法是通过将各个Segment内部的元素数量汇总起来从而得出ConcurrentHashMap元素的总数量的。
假如size方法在统计完Segment1之后,准备统计Segment2的数量时,另一个线程往Segment1插入了一个元素,同时比size方法更先运行完毕。那么在size方法运行完成之后,所得出的数量值就会比map的总数量就会少了一个。那么ConcurrentHashMap是如何保证size方法数据的一致性的呢?
ConcurrentHashMap是使用了类似于CAS乐观锁的思想来保证size方法统计时不会出现问题,其步骤如下:
1、遍历所有的Segment,把所有Segment的修改次数累加起来(我们在看集合源代码的时候经常会看到modCount++的这个操作,其实modCount变量就是用来统计当前集合修改次数用的)。
2、在第一步遍历的时候,同时把Segment中内部的元素数量累加起来,得到size值。
3、再一次统计所有Segment修改次数的总和。
4、判断所有Segment的总修改次数是否大于我们第一步所统计的修改次数。如果大于,说明统计过程中有修改,重新统计(跳回第一步),记录尝试次数+1;如果不是。说明没有修改,统计结束。
5、如果尝试次数超过阈值,则对每一个Segment加锁,再重新统计。
6、再次判断所有Segment的总修改次数是否大于上一次的总修改次数。由于已经加锁,次数一定和上次相等。
7、释放锁,统计结束。