在JDK1.7中,HASHMAP是由数组+链表实现的,原理图如下:
HashMap map = new HashMap(); // 伪初始化
map.put("键","值"); // 真初始化
1、HashMap初始化操作
- HashMap的构造方法在执行时会初始化一个数组table,大小为0。
- HashMap的PUT方法在执行时首先会判断table的大小是否为0,如果为0则会进行真初始化,也叫做延迟初始化。
- 当进行真初始化时,数组的默认大小为16,当然也可以调用HashMap的有参构造方法由你来指定一个数组的初始化容量,但是注意,并不是你真正说了算,比如你现在想让数组的初始化容量为6,那么HashMap会生成一个大小为8的数组,如果你想数组的初始化容量为20,那么HashMap会生成一个大小为32的数组,也就是你想初始化一个大小为n的数组,但是HashMap会初始化一个大小大于等于n的二次方数的一个数组
2、HashMap存储操作
- 对于PUT方法,当无需对table进行初始化或已经初始化完了之后,它接下来的主要任务是将key和value存到数组或链表中。那么怎么将一个keyvalue给存到数组中去呢?
- 我们知道,如果我们想往数组中存入数据,我们首先得有一个数组下标,而我们在进行PUT的时候并不需要再传一个参数来作为数组的下标,那么HashMap中下标是怎么获取来的呢?答案为哈希算法,这也是为什么叫HashMap而不叫其他MAP。
- 对于哈希算法,了解过的人应该都知道它需要一个哈希函数,这个函数接收一个参数返回一个HashCode,哈希函数的特点是对于相同的参数那么返回的HashCode肯定是相同的,对于不相同的参数,函数会尽可能的去返回不相同的HashCode,所以换一个角度理解,对于哈希函数,给不相同的参数可能会返回相同的HashCode,这个就叫哈希冲突或哈希碰撞。
- 那么我们能直接把这个HashCode来作为数组下标吗,另外一个很重要的问题是我们到底应该对key做哈希运算还是对value做哈希运算,还是对keyvalue同时做哈希运算?
- 那么这个时候我们就要考虑到GET方法了,因为GET只需要传入一个key作为参数,而实际上GET方法的逻辑就是通过把key进行哈希运算快速的得到数组下标,从而快速的找到key所对应的value。所以对于PUT方法虽然传入了两个参数,但是只能对key进行哈希运算得到数组下标,这样才能方便GET方法快速查找
- 所以答案是:put时只对key做哈希运算
3、HashMap获取数组下标的方法
- 但是还有一个问题就是,HashCode它能直接作为数组下标吗?HashCode它通常是一个比较大的数字,比如:
System.out.println("键".hashCode()); // 38190
// 为什么是这个结果,大家自行去看String类中的hashCode方法
- 所以我们不可能直接把hashCode这么大的一个数字作为数组下标,那怎么办?大家可能通常会想到取模运算,比如对上面的例子“键的hashCode为38190”然后对hashmap的数组长度进行取余数计算下标,这样可以吗?这样我觉得也是可以的,就是效率比较低而已
- 所以HashMap没有用取模这种方式,而是使用了key的hashCode的二进制与数组长度的二进制进行逻辑与运算得出下标
static int indexFor(int h, int length) {
// assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
//h-key的hashCode,length-map的数组长度
return h & (length-1);
}
- 这个方法就是JDK1.7HashMap中PUT和GET方法中获取数组下标的方法
- 这个方法中h代表hashcode,length代表数组长度。我们发现它是用的逻辑与操作,那么问题就来了,逻辑与操作能准确的算出来一个数组下标?我们来算算,假设hashcode是01010101(二进制表示),length为00010000(16的二进制表示),那么h & (length-1)则为:
h: 0101 0101
15: 0000 1111
&
0000 0101
- 对于上面这个运行结果的取值方法我们来讨论一下:因为15的高四位都是0,低四位都是1,而与操作的逻辑是两个运算位都为1结果才为1,所以对于上面这个运算结果的高四位肯定都是0,而低四位和h的低四位是一样的,所以结果的取值范围就是h的低四位的一个取值范围:0000-1111,也就是0至15,所以这个结果是符合数组下标的取值范围的。
- 那么假设length为17呢?那么h & (length-1)则为:
h: 0101 0101
16: 0001 0000
&
0001 0000
- 当length为17时,上面的运算的结果取值范围只有两个值,要么是0000 0000,要么是0001 000,这是不太好的
- 所以我们发现,如果我们想把HashCode转换为覆盖数组下标取值范围的下标,跟我们的length是非常相关的,length如果是16,那么减一之后就是15(0000 1111),正是这种高位都为0,低位都为1的二级制数才保证了可以对任意一个hashcode经过逻辑与操作后得到的结果是我们想要的数组下标。这就是为什么在真初始化HashMap的时候,对于数组的长度一定要是二次方数,二次方数和算数组下标是息息相关的,而这种位运算是要比取模更快的。
4、结论
- 所以到此我们可以理一下:在调用PUT方法时,会对传入的key进行哈希运算得到一个hashcode,然后再通过逻辑与操作得到一个数组下标,最后将key+value存在这个数组下标处。
- 确定了key+value该存的位置之后,上文说过,对于不同的参数可能会得到相同的HashCode,也就是会发生哈希冲突,反应到HashMap中就是,当PUT两个不同的key时可能会得到相同的HashCode从而得到相同的数组下标,其实在HashMap中就算key所对应的HashCode不一样,那么也有可能在经过逻辑与操作之后得到相同的数组下标,那么这时HashMap是如何处理的呢?对,就是链表
5、HashMap存储key冲突
- HashMap在PUT的时候会发生冲突,而解决冲突的方式就是在同一个数组下标中引入链表结构来解决,这也就是HashMap的数据结构为什么是数组加动态链表的数据结构的原因
- HashMap在插入时会按照上图所示的方式进行插入,我们考虑链表的插入效率,将节点3插在链表的头部是最快的,也就是JDK1.7版本中HashMap所以的头插法
- 那么按照上图这种插入办法,会出现一个问题:当我们需要get(节点2)时,我们先将节点2的key进行哈希然后算出下标,拿到下标后可以定位到数组中的节点,然后再比较key是否相等,但是发现节点1不等于节点2,所以不是最终的结果,但是节点1存在下一个节点,所以可以顺着向下的指针找到节点2。
- 那么当我们需要get(节点3)时呢,我们发现是找不到节点3的,所以当我们把节点简单的插在链表的头部是不行的。
- 那HashMap是怎么实现的呢?HashMap确实是将节点插在链表的头部,但是在插完之后HashMap会将整个链表向下移动一位,移动完之后就会变成:
- 第三行代码也是PUT,而这个时候在HashMap里会将value覆盖,也就是key="1"对应的value最终为"3",而第三行代码返回的value将会是2。
- 我们现在来考虑这个PUT它是如何实现的,其实很简单,第三行代码的逻辑也是先对"1"计算哈希值以及对应的数组下标,有了数组下标之后就可以找到对应的位置的链表,而在将新节点插入到链表之前,还需要判断一下当前新节点的key值是不是已经在这个链表上存在,所以需要先去遍历当前这个位置的链表,在遍历的过程中如果找到了相同的key则会进行value的覆盖,并且返回oldvalue。
- 好,写到这里其实对于HashMap的PUT的主要逻辑也差不多了,总结一下:
-
PUT(key,value) int hashcode = key.hashCode(); int index = hashcode & (数组长度-1) 遍历index位置的链表,如果存在相同的key,则进行value覆盖,并且返回之前的value值 将key,value封装为节点对象(Entry) 将节点插在index位置上的链表的头部
- 将链表头节点移动到数组上
这是最核心的7步,然后在这个过程中还有很重要的一步就是扩容,而扩容是发生在插入节点之前的,也就是步骤4和5之间的。
6、总结
- 因为jdk1.7版本的HashMap在插入数据的时候使用的是头插法,所以在数据扩容的时候会产生死循环问题,在1.8版本之后已经优化,插入时使用了尾插法来解决这个问题
7、HashMap扩容死循环问题
HashMap
的线程不安全主要是发生在扩容函数中,即根源是在transfer函数中,并采用头插法将元素迁移到新数组中。头插法会将链表的顺序翻转,这也是形成死循环的关键点,而jdk1.8之后进行扩容元素插入时使用的是尾插法。- put的时候导致的多线程数据不一致。
比如有两个线程A和B,首先A希望插入一个key-value对到HashMap中,
首先计算记录所要落到的桶的索引坐标,然后获取到该桶里面的链表头结点,
此时线程A的时间片用完了,而此时线程B被调度得以执行,和线程A一样执行
,只不过线程B成功将记录插到了桶里面,假设线程A插入的记录计算出来的
桶索引和线程B要插入的记录计算出来的桶索引是一样的,那么当线程B成功插入之后,
线程A再次被调度运行时,它依然持有过期的链表头但是它对此一无所知,
以至于它认为它应该这样做,如此一来就覆盖了线程B插入的记录,
这样线程B插入的记录就凭空消失了,造成了数据不一致的行为。
- 另外一个比较明显的线程不安全的问题是HashMap的get操作可能因为resize而引起死循环(cpu100%),具体分析如下:
我们假设有两个线程同时需要执行resize操作,我们原来的桶数量为2,记录数为3,
需要resize桶到4,原来的记录分别为:[3,A],[7,B],[5,C],在原来的map里面,
我们发现这三个entry都落到了第二个桶里面,如下图:
- 假设线程thread1执行到了transfer方法的Entry next = e.next这一句,然后时间片用完了,此时的e = [3,A], next = [7,B]。线程thread2被调度执行并且顺利完成了resize操作,需要注意的是,此时的[7,B]的next为[3,A]。此时线程thread1重新被调度运行,此时的thread1持有的引用是已经被thread2 resize之后的结果。线程thread1首先将[3,A]迁移到新的数组上,然后再处理[7,B],而[7,B]被链接到了[3,A]的后面,处理完[7,B]之后,就需要处理[7,B]的next了啊,而通过thread2的resize之后,[7,B]的next变为了[3,A],此时,[3,A]和[7,B]形成了环形链表,在get的时候,如果get的key的桶索引和[3,A]和[7,B]一样,那么就会陷入死循环。