HashMap
HashMap是Java容器里面非常重要的一个类,下面来剖析下HashMap的亮点。
hash
将不同长度的输入通过hash算法变成固定长度的输出,该输出值就是散列值。一般的,输入长度远大于输出长度。也就是说,同样的输出可以对应多个输入,这时候我们称之为hash冲突
。
hash冲突的处理方法
- 开放寻址法:当hash冲突发生时,从当前冲突位置往后找数组的空位,填入数据
- 链地址法:当发生hash冲突时,以链表的方式将数据插入到对应的数组位置后面
- 再哈希法:将冲突的hash值再次进行hash运算,直到没有冲突为止
位运算
HashMap的数组长度一定是2^n,所以可以运用位运算进行快速定位数组下标。假设数组长度为a,hash值为b,b%a=b&(a-1)。
可以在其他场景下看到位运算的影子
- 权限控制,增加权限用或运算、删除权限用与非运算、查看是否拥有某个权限用与运算
- 简单可逆加密(1^1=0;0 ^ 1=1),其中第一个1是明文,0是密文,最后一个1得到最开始的明文
- NIO中通过位运算判断请求过来的事件类型
JDK1.7中HashMap死循环分析
单线程下HashMap不会发生死循环,但是多线程环境下,在1.7版本的HashMap中可能发生死循环,导致CPU占用100%。
正常的扩容操作是这个流程。HashMap的扩容在put操作中会触发扩容,主要是下面三个方法:
添加元素的时候出发扩容条件,出发resize(int)方法:
创建新数组后,需要将旧数组元素转移到新数组:
旧数组元素转移到新数组的逻辑,采用头插法
,
综合来说,HashMap一次扩容的过程:
- 取当前table的2倍作为新table的大小
- 根据算出的新table的大小new出一个新的Entry数组来,名为newTable
- 轮询原table的每一个位置,将每个位置上连接的Entry,算出在新table上的位置,并以链表形式连接
- 原table上的所有Entry全部轮询完毕之后,意味着原table上面的所有Entry已经移到了新的table上,HashMap中的table指向newTable
举个例子:
现在hashmap中有三个元素,Hash表的size=2, 所以key = 3, 7, 5,在mod 2以后都冲突在table[1]这里了。
按照方法的代码:
对table[1]中的链表来说,进入while循环,此时e=key(3),那么next=key(7),经过计算重新定位e=key(3)在新表中的位置,并把e=key(3)挂在newTable[3]的位置
这样循环下去,将table[1]中的链表循环完成后,于是HashMap就完成了扩容
到这里为止,单线程下的HashMap扩容不会发生问题,接下来分析并发下的HashMap扩容
初始的HashMap还是:
我们现在假设有两个线程并发操作,都进入了扩容操作,我们以颜色进行区分两个线程:
回顾我们的扩容代码,我们假设,线程1执行到Entry<K,V> next = e.next;时被操作系统调度挂起了,而线程2执行完成了扩容操作
于是,在线程1,2看来,就应该是这个样子
接下来,线程1被调度回来执行:
1)
2)
3)
4)
5)
6)
7)
循环列表产生后,一旦线程1调用get(11,15之类的元素)时,就会进入一个死循环的情况,将CPU的消耗到100%。
总结
HashMap之所以在并发下的扩容造成死循环,是因为,多个线程并发进行时,因为一个线程先期完成了扩容,将原Map的链表重新散列到自己的表中,并且链表变成了倒序,后一个线程再扩容时,又进行自己的散列,再次将倒序链表变为正序链表。于是形成了一个环形链表,当get表中不存在的元素时,造成死循环。