jdk8之HashMap resize原理详解


resize()方法的作用

resize()方法会在HashMap的键值对达到“阈值”后进行数组扩容,而扩容时会调用resize()方法,此外,在jdk1.7中数组的容量是在HashMap初始化的时候就已经赋予,而在jdk1.8中是在put第一个元素的时候才会赋予数组容量,而put第一个元素的时候也会调用resize()方法。

resize()方法jdk1.8中和1.7中实现有什么不同

JDK1.7

resize()源码


jdk8之HashMap resize原理详解

解释

在1.7中,resize()方法的原理为(图中假设hashMap的数组大小为4.实际上默认是16)


jdk8之HashMap resize原理详解


  1. 先新建一个数组,数组长度为原数组的2倍
  2. 循环遍历原数组的每一个键值对的,得到键的Hashcode然后与新数组的长度(此处为8)进行&运算得到新数组的位置。然后把键值对放到对应的位置。故扩容之后可能会变成这样
    而在jdk1.8中虽然扩容之后的数组也是一样是上面那样,但是在计算元素位置的方式上不太一样,jdk1.7需要与新的数组长度进行重新hash运算,这个方式是相对耗性能的,而在1.8中对这一步进行了优化,下面我们来详细解释一下进行了那些优化。

JDK1.8

resize() 源码

<code>final Node[] resize() {
Node[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold

}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node[] newTab = (Node[])new Node[newCap];
table = newTab;
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode)e).split(this, newTab, j, oldCap);
else { // preserve order
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;

hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
/<code>

这一长串的代码确实看的很晕,但是如果你跟着源代码一步步调试其实上面部分理解并不困难,困难的点在于for循环中的代码。

<code>Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}

else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead; //这里很重要,新的位置为原老所处的位置+原数组的长度,为什么是这个值呢?下面解释
}
12345678910111213141516171819202122232425262728
/<code>

而newTab[j + oldCap] = hiHead;这一步,是一个非常巧妙的地方,也是本文分析的重点。

解释

经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,经过rehash之后,**元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。对应的就是下方的resize的注释。**对应的就是下方的resize的注释。

jdk8之HashMap resize原理详解

看下图可以明白这句话的意思,n为table的长度,图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希值(也就是根据key1算出来的hashcode值)与高位与运算的结果。

jdk8之HashMap resize原理详解

因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+oldCap”。这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。

再解释:为什么刚好原位置+原数组长度就会等于新的数组中的位置呢?

要搞明白这个问题首先要清楚

  1. HashMap的数组长度恒定为2的n次方,也就是说只会为2 4 8 16 。。。。。这种数。源码中有限制,也就是说即使你创建HashMap的时候是写的
<code>Map<string> hashMap = new HashMap<>(13);
1/<string>/<code>

最后数组长度也会变成16,而不是你的13. 会取与你传入的数最近的一个2的n次方的数。

<code>public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
123456789101112/<code>
<code>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;
}
123456789/<code>

那么明确这一点有什么用呢?我们知道2,4,8,16,32所对应的二进制分别为

<code>2:  0000 0000 0000 0000 0000 0000 0000 0010
4: 0000 0000 0000 0000 0000 0000 0000 0100
8: 0000 0000 0000 0000 0000 0000 0000 1000
16: 0000 0000 0000 0000 0000 0000 0001 0000
32: 0000 0000 0000 0000 0000 0000 0010 0000
12345/<code>

而我们知道,0在做位与运算时与任何一个数运算结果都恒为0

<code>0 & 1 = 0
0 & 0 = 0
12/<code>

故看源码中

<code> if ((e.hash & oldCap) == 0) 
1/<code>

这一步是否为0只需要看元素的二进制数对应数组长度的二进制数1那个位置是否为0.假设某个元素的hashcode为52:

jdk8之HashMap resize原理详解

而假设某个元素的hashcode为100:

jdk8之HashMap resize原理详解

而通过源码可以看出0就还是在原来的位置。不为0就需要变动位置了,新的位置为元素在原数组的位置+原数组的长度,那么为什么是这样呢?我们接着看看之前我们先使用jdk1.7中的方式重新进行hash运算HashMap在运算元素位置的时候使用为 数组长度-1。也就是15.31这种数15 31 对应的二进制为

<code>15:0000 0000 0000 0000 0000 0000 0000 1111
31: 0000 0000 0000 0000 0000 0000 0001 1111

12/<code>

这里需要注意的是上面之所以是16&52,16&100是因为只是做判断而已,而非计算位置的方式,计算位置使用的事length-1.千万不要看迷糊了。

<code> if ((e.hash & oldCap) == 0)  //仅仅是判断元素是否需要换位置,不要理解为元素的新位置
1/<code>

这一步才是计算位置,使用的是length-1.

jdk8之HashMap resize原理详解

16扩容后变成32.那么1.7中计算元素的位置方式为 31&52, 31&100.我们把他与扩容前的15&52。15&100做对比看看

jdk8之HashMap resize原理详解

可以看到,扩容前和扩容后的位置是否一样完全取决于多出来的那一位是与新数组计算后值是为0还是1.为0则新位置与原位置相同,不需要换位置,不为零则需要换位置。而为什么新的位置是原位置+原数组长度,是因为每次换的位置只是前面多了一个1而已。为什么是前面多1,因为数组扩容为原来的两倍也是高位进1,比如15是0000 1111,而31就是0001 1111. 那么新位置的变化的高位进1为。而每一次高位进1都是在加上原数组长度的过程。

jdk8之HashMap resize原理详解

正好1+2=3 3+4=7 7+8=15 。也就验证了新的位置为原位置+原数组长度。

总结

jdk1.8中在计算新位置的时候并没有跟1.7中一样重新进行hash运算,而是用了原位置+原数组长度这样一种很巧妙的方式,而这个结果与hash运算得到的结果是一致的,只是会更块。


分享到:


相關文章: