HashMap是一个以空间换时间,内部以数组+链表\红黑树实现的散列表。HashMap的具体原理我们不做深入仔细分析,这类文章网上较多,且HashMap在面试中命中率极高。本文以jdk1.8为例,只分析里面我认为值得拿出来分析的有关数据结构和算法的部分来讲解。

<>HashMap的长度

<>JDK1.8实现


HashMap的初始默认长度是16.HashMap在jdk1.8上做了一层优化,创建时并没有创建Node数组,只有首次put元素的时候才创建。但是我们在创建时也可以指定数组的长度,HashMap会将长度重新转化为2的n次方。具体为什么是2的n次方,后面再分析。

<>初始长度源码及案例分析
//java.util.HashMap#tableSizeFor /** * Returns a power of two size for the
given target capacity. */ static final int tableSizeFor(int cap) { int n = cap
- 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
这里主要运用了位或操作和无符号右移操作。
位或操作:两个位都为0时,结果才为0
无符号右移:不论正负,高位均补0.可参考博客 有符号右移>>,无符号右移>>>
<https://blog.csdn.net/bushqiang/article/details/79394211>
tableSizeFor方法很简单,但是却充分用到了我们大一学到的大学计算机基础内容,主要是位运算和移位运算符,关于其他的可自行扩展(如左移( <<
)、右移( >> ) 、无符号右移( >>> ) 、位与( & ) 、位或( | )、位非( ~ )、位异或( ^ ))。
首先cap为我们传入的想要初始化数组的值,这里会对cap进行一个减一的操作,然后依次进行无符号右移和或运算。现假定我们传入默认长度16.
static final int tableSizeFor(int cap) { //cap:10000 //n:1111 int n = cap - 1;
//n >>> 1:111 1111|111:1111 n |= n >>> 1; //n >>> 2:11 1111|10:1111 n |= n >>>
2; //n >>> 4:0 1111|0:1111 n |= n >>> 4; //n >>> 8:0 1111|0:1111 n |= n >>> 8;
//n >>> 16:0 1111|0:1111 n |= n >>> 16; return (n < 0) ? 1 : (n >=
MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;//return 16 }
假定传入13
static final int tableSizeFor13(int cap) { //cap:1101 //n:1100 int n = cap -
1; //n >>> 1:110 1100|110:1110 n |= n >>> 1; //n >>> 2:10 1110|11:1111 n |= n
>>> 2; //n >>> 4:0 1111|0:1111 n |= n >>> 4; //n >>> 8:0 1111|0:1111 n |= n >>>
8; //n >>> 16:0 1111|0:1111 n |= n >>> 16; //return 16 return (n < 0) ? 1 : (n
>= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }

从传入的16和13流程中得到的每一个结果,我们可以知道这里使用位或运算和无符号右移,就是把各个位都变成1。首先对于大于8但是小于等于16的数字,减去1之后,左起第4为肯定是1,这样通过不断的无符号右移,一位一位的把右边的0和1数字编程1,位或运算后,又把移位后的数字填充到当前n。
再详细说一下:对于大于8但是小于等于16,一定满足1*的位(代表0或1的任意数字)。
当进行第一步 n |= n >>>1 时,n >>> 1 = 1 ,则 n |= n >>>1 = 11.
这时n至少有前2个最高位均为1,那么就可以进行2位无符号右移。n >>> 2 = 1111,则n |= n >>>1 = 1111.
这时n至少有前4个最高位为1,那么就可以进行4位的无符号右移等等往下操作。所以这里必然可以得到一个2的n次方减1的数字,最后返回
一个2的n次方减1加1等于2的n次方。逻辑结束.

<>JDK1.7实现
// Find a power of 2 >= initialCapacity int capacity = 1; while (capacity <
initialCapacity) capacity <<= 1;
jdk1.7中直接从2的0次方开始,每次翻倍,直到得到大于等于传入的initialCapacity的值,即table的size.

<>为什么是2的n次方

在java.util.HashMap#putVal方法中
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value,
null);

这里在计算key的hash值具体放在数组的哪个元素时,用到了位与运算,用hash值与(n-1)位与运算得到一个范围在(0-n-1)及数组下标范围的具体下标,效率比hash
mod n 要快。除此之外,还有一个好处就是2的n次方减1的所有位均为1,这是可以进行位与操作的非常关键的信息,所以其值才等价于取模。
private static void getMod() { long times = Integer.MAX_VALUE; int n = (int)
Math.pow(2d, 6d); int index = 0; long start = System.currentTimeMillis(); for
(int i = 0; i < times; i++) { index = i & (n - 1); } long end1 =
System.currentTimeMillis(); for (int i = 0; i < times; i++) { index = i % n; }
long end2 = System.currentTimeMillis(); System.out.println("& cost : " + (end1
- start)); System.out.println("% cost : " + (end2 - end1)); //& cost : 1611 //%
cost : 5933 }
<>扩容过程中的高并发问题

<>JDK1.7在高并发的死循环问题

先看下put过程中,对于有冲突时,会将新元素放在链表的最前面
void createEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e =
table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e);
size++; }
JDK1.7正常的put过程中的resize中的transfer方法实现,会从链表的最前面往后开始复制到新数组中。
/** * 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 = newTable[i]; newTable[i] = e; e = next; } } }

这里会对HashMap的原数组元素一次取出,并遍历链表,对每个元素重新选取新数组的下标,并放在新数组元素的最前面。正常情况下,假设原数组长度为16,扩容后数组长度为32,假设元素63和31均在原数组下标为15,63的next是31。resize后63和31均在下标为31的元素上,且31的next为63.所以正常情况下,resize后,可能会出现这种元素先后关系倒置的链表。
那么高并发情况下,假设有两个线程同时对上述场景进行resize操作,线程一在运行到if
(rehash)这步时,e=63,next=31,即63->31,线程一被调度挂起。线程二现在也进行resize操作,且正常完成,则线程二的关系为31指向63,即31->63。

线程一继续运行

* 当前线程一状态e=63,next=31
* 继续运行,newTable[i]=e=63,e=next=31,由于e不为空,继续运行while循环
* e=31,next=63,则newTable[i] = e =
31,则运行e.next=newTable[i]=63后,线程一的newTable[i]指向关系为31->63
* 继续运行e=next=63,next=null, 运行e.next=newTable[i]
,则63.next=newTable[i]=31->63,即63->31->63,形成了一个环。
* resize过程正常结束,但是链表中形成了一个环
* 此时调用map.get(127),则一直会在会在newTable[31]处一直死循环e=e.next,引起CPU飙升到100%
<>JDK1.8怎么解决这个问题

JDK1.7的处理方式是
for (int binCount = 0; ; ++binCount) { if ((e = p.next) == null) { p.next =
newNode(hash, key, value, null); if (binCount >= TREEIFY_THRESHOLD - 1) // -1
for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) ==
key || (key != null && key.equals(k)))) break; p = e; }
这里只有当p.next为null时,才创建新的Node节点作为链表最后一个元素。
if ((e = p.next) == null) { p.next = newNode(hash, key, value, null);
这时在resize并发的情况下,及时会从链表前面开始复制到新数组中,也不会出现链表元素相互指向next形成环的问题了。

友情链接
KaDraw流程图
API参考文档
OK工具箱
云服务器优惠
阿里云优惠券
腾讯云优惠券
华为云优惠券
站点信息
问题反馈
邮箱:ixiaoyang8@qq.com
QQ群:637538335
关注微信