今天回顾hashmap源码的时候发现一个很有意思的地方,那就是jdk1.8在hashmap扩容上面的优化

首先大家可能都知道,1.8比1.7多出了一个红黑树化的操作,当然在扩容的时候也要对红黑树进行重排,然而今天要说的并不是这个,而是针对数组中的链表项的处理优化

关于hashmap的源码都十分精妙,有时间可以多看看。

首先上1.7的源码

void resize(int newCapacity) {
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
if (oldCapacity == MAXIMUM_CAPACITY) { //当当前数据长度已经达到最大容量
threshold = Integer.MAX_VALUE;
return;
} Entry[] newTable = new Entry[newCapacity]; // 创建新的数组
boolean oldAltHashing = useAltHashing;
useAltHashing |= sun.misc.VM.isBooted() &&
(newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
boolean rehash = oldAltHashing ^ useAltHashing; // 是否需要重新计算hash值
transfer(newTable, rehash); // 将table的数据转移到新的table中
table = newTable; // 数组重新赋值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); //重新计算阈值
}

很显然,是遍历整个map中的每一个节点(如果是链表就再对其循环),每一个节点新的放置位置=[hash &  (newCapacity - 1)]------indexFor方法。

transfer(newTable, rehash);  // 将table的数据转移到新的table中

此方法会根据rehash来判断是否重新计算hashCode,然后放置到新的数组中:

 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;
}
}
}

这是transfer将当前数组中各节点e移动到新数组的  i  位置上的核心代码,为了避免调用put方法,它直接取 e.next = newTable[i];

例如:

oldtable[ i ]为:A->B->null

newtable[ j ]为:X->Y->null

移动oldtable[ i ]到newTable[ j ]中,步骤如下:

1. e指向A;

2. e.next指向newTable[ j ]也就是X,所以A->X;

3. newTable[ j ]指向A,所以此时newTable[ j ]为A->X->Y->null

4. e指向B;

类似循环操作1,2,3

最后newTable[ j ]结果为:B->A->X->Y->null,变成了逆序。

并且,并发的时候可能会发生死锁:

假如线程1在刚执行完 Entry<K,V> next = e.next;  后,此时e指向A,next指向B,

然后被B线程抢占,然后B完整执行完扩容后,此时newTable[ j ]为:B->A->X->Y->null,

然后A线程恢复执行,e.next = newTable[i];,此时newTable[ j ]为:B<->A    X->Y->null,(已经形成环,并且后面链表丢失)

           newTable[i] = e;,此时newTable[ j ]为:A<->B

继续循环,发现已经形成环,没有null了, while(null != e) 永远跳不出循环,所以会形成死锁。

可以看出形成环的主要原因是因为形成了逆序,应该怎么解决呢?

下面是1.8的代码

 final Node<K,V>[] resize() {
//保存旧的 Hash 数组
Node<K,V>[] 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;
}
else if (oldThr > 0)
newCap = oldThr;
else {
//阀值和容量使用默认值
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"})
//创建新的 Hash 表
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//遍历旧的 Hash 表
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> 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<K,V>)e).split(this, newTab, j, oldCap);
else {
//以链表形式存在的节点;
//这一段就是新优化的地方,见下面分析
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> 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;
}

嗯。。很多,

主要逻辑就是,循环数组内每一个元素

1、是普通节点,直接和1.7一样放置;

2、红黑树,调用 split 修剪方法进行拆分放置(不是本文要点,略);

3、是链表………………

是不是链表那段没看懂?下面举一个例子就好明白了:

假如现在容量为初始容量16,再假如5,21,37,53的hash自己(二进制),

所以在oldTab中的存储位置就都是 hash & (16 - 1)【16-1就是二进制1111,就是取最后四位】,

5  :00000101

21:00010101

37:00100101

53:00110101

四个数与(16-1)相与后都是0101

即原始链为:5--->21--->37--->53---->null

此时进入代码中 do-while 循环,对链表节点进行遍历,判断是留下还是去新的链表:

lo就是扩容后仍然在原地的元素链表

hi就是扩容后下标为  原位置+原容量  的元素链表,从而不需要重新计算hash。

因为扩容后计算存储位置就是  hash & (32 - 1)【取后5位】,但是并不需要再计算一次位置,

此处只需要判断左边新增的那一位(右数第5位)是否为1即可判断此节点是留在原地lo还是移动去高位hi:(e.hash & oldCap) == 0 (oldCap是16也就是10000,相与即取新的那一位)

5  :00000101——————》0留在原地  lo链表

21:00010101——————》1移向高位  hi链表

37:00100101——————》0留在原地  lo链表

53:00110101——————》1移向高位  hi链表

第一轮循环

loHead:5

loTail:5

其他:null

第二轮循环

loHead:5

loTail:5

hiHead:21

hiTail:21

第三轮循环

loHead:5 (5.next = 37)

loTail:37

hiHead:21

hiTail:21

。。。

所以循环结束之后

loHead:5

loTail:37

hiHead:21

hiTail:53

lo:5--->37---->null

hi:21--->53---->null

退出循环后只需要判断lo,hi是否为空,然后把各自链表头结点直接放到对应位置上即可完成整个链表的移动。

原理是:利用了尾指针Tail,完成了尾部插入,不会造成逆序,所以也不会产生并发死锁的问题。

这种方法对比1.7中算法的优点是

1、不管怎么样都不需要重新再计算hash;

2、放过去的链表内元素的相对顺序不会改变;

3、不会在并发扩容中发生死锁。

注意,时间复杂度并没有减少

有以上分析同样可以得出hashmap扩容的开销很大,日常开发中应该根据实际需要设定合适的  初始容量  和  负载因子 ,这对提高程序性能有不小帮助。

04-15 21:48