先解释一下相关的位操作符:
&:按位与,同1为1,否则为0
|:按位或,有1为1,否则为0
^:按位异或,不同为1,否则为0
‘>>’:有符号右移,即从右到左,高位补0,低位抛弃
‘<<’:有符号左移,即从左到右,低位补0,高位抛弃,
一、resize()
resize方法源码注释定义为初始化或者扩容方法。当表数组为空或者长度为0,则为初始化。若表实际的数据长度大于 负载因子(loadFactory * 定义数据长度),则为扩容方法
final Node<K,V>[] resize() {
//复制出一份当前数组视图
Node<K,V>[] oldTab = table;
//当前数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//获取当前需要扩容的size
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)
//当前数组长度 <= 0 && 当前定义长度 > 0
newCap = oldThr;
else {
//使用默认的
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//什么时候会出现newThr为空的情况,上面的几个if很清楚的看出来当定义了容量,但是实际上还没有对数组进行初始化的时候
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<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//这是真正的扩容方法,如果是初始化,是不会执行的
if (oldTab != null) {
//循环数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
//保存当前位置的头结点,并将当前位置置空
oldTab[j] = null;
//如果当前链表的长度为1,即只有头结点,那么可以直接获取头结点的新hash值,并且确定位置,直接插入到新的数组中
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//处理树结构的重hash
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//这里为什么 e.hash & oldCap
//旧数组上的数据就会根据(e.hash & oldCap) 是否等于0这个算法,被很巧妙地分为2类:
//① 等于0时,则将该头节点放到新数组时的索引位置等于其在旧数组时的索引位置,记为低位区链表lo开头-low;
//② 不等于0时,则将该头节点放到新数组时的索引位置等于其在旧数组时的索引位置再加上旧数组长度,记为高位区链表hi开头high.
if ((e.hash & oldCap) == 0) {
//如果loTail为空,当前则为loTail
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;
}
通过resize()方法可以看到,该方法作为初始化数组和扩容方法。首先需要判断当前容器指定的容量大小以及扩容阈值,
- 如果当前指定的容量大小大于0,只要不超过最大容量,则 新的容器容量定义为 当前的容量 * 2,新的阈值也为当前阈值 * 2.
- 当前指定容量 <=0 && 当前阈值 > 0,新容量 = 当前阈值
- 剩下的就是阈值和容量都没有指定,则使用初始化HashMap的时候指定的相关值
判断完成之后,会创建出一个新容量大小长度的数组,作为当前HashMap的新数组,再继续进行数据迁移(从旧数组转移到新数组)
在迁移的时候可能有下面三种情况
- 当前bucket只有一个元素,通过 node.hash & (newCap - 1) 确定新数组中的位置。newCap - 1这样就获得了一个低位掩码, 如newCap = 16, 减1得到15,二进制值为1111,进行与操作,获得的是node.hash的低四位值,获得了bucket的下标
- 有的bucket存储的可能是红黑树结构,这个后面再说1
- 正常的链表数据进行重新定位。通过(e.hash & oldCap) 是否等于0,将链表中的数据分为了两部分,与原数组索引一致和原数组索引+原数组长度。为什么这样判断就能决定呢?
在put和get的时候,都是通过 node.hash & (cap - 1) 通过低位来确定bucket的位置,那么在扩容迁移的时候通过(e.hash & oldCap)直接确定是原位置还是 原数组长度+原位置呢?
原数组的长度一定是 2的次幂,我们假设第一次是16,即oldCap = 16(1 0 0 0 0),那么这次需要扩容的长度为32,即newCap = 32(1 0 0 0 0 0)。假设当前结点 node 的hash值为 1 0 1 0 1 0 1 0 1,node在原数组中的位置为 x x x x x 0 1 0 1(x 表示未知 0 或 1) & (16 - 1) = 0 1 0 1 = 5。我们可以看到在16 变成 32过程中,其实就是原最高位置为0,并且追加一位 1。如果 e.hash & 16 = 0, 说明e.hash第五位(从左往右)的值一定是1,如果不是在扩容,而是在put的时候,就是1 0 1 0 1 & (32 - 1) = 16 + 5 = 21。反之,第五位为0,那么 &(32 - 1)和 &(16 - 1)的结果是完全一样的。所以我们完全可以确定可以通过(e.hash & oldCap)是不是等于0,来确定当前结点在新扩容的数组中的位置。
所以使用了低位结点loHead、loTail 和高位结点hiHead、hiTail来保存新的链表,并且将新链表直接追加到新数组的位置,并且在扩容途中完全不会再次发生hash碰撞。
二、hash()
HashMap中hash方法可以说是代码最少的方法之一了,但是却用了大量的注释去解释用意。
分析一下具体的做法,将(h = key.hashCode()) ^ (h >>> 16) 该操作将高16位与低16位一起进行了异或操作,使得高位也参与了具体的hash,这比只有低16位参与hash降低了哈希碰撞的几率。hash()方法只是为了尽量将结果分散,减少哈希碰撞。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
三、tableSizeFor(int cap)
该方法是获得指定容量的下一个2的次幂的数值
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;
}
为什么要先cap - 1? 不减1的话,对于本身就是2的次幂的数,会错误。
一直右移可以使得int32位内的数值全部可以计算到。
JDK1.7出现死循环的原因
在1.7及之前,HashMap将数据插入链表的时候用的是头插法(头插法肯定是要快很多的),但是使用头插法在多线程下的rehash()操作就会出现问题了,就是老生常谈的HashMap死循环问题。
假设现在有一个容量为16(初始容量)的HashMap,bucket为2的位置有a,b,c三个数据
恰好啊,恰好,达到了扩容阈值,需要扩容了。这个时候需要进行rehash啊,又恰好,a,b,c三个数据都需要移动到(hashMap.size() + bucket)的位置,那么在多线程下会出现什么问题呢?我们先看jdk1.7的关于resize的源代码
//按新的容量扩容Hash表
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];
transfer(newTable, initHashSeedAsNeeded(newCapacity));//将老的表中的数据拷贝到新的结构中
table = newTable;//修改HashMap的底层数组
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改阀值
}
//将老的表中的数据拷贝到新的结构中
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) {//如果是重新Hash,则需要重新计算hash值
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);//定位Hash桶
e.next = newTable[i];//元素连接到桶中,这里相当于单链表的插入,总是插入在最前面
newTable[i] = e;//newTable[i]的值总是最新插入的值
e = next;//继续下一个元素
}
}
}
当一个线程进行扩容操作的时候,假设为thread1,操作示意图如下
在单线程的时候,就是按照头插法一直插下去,然后再将新的数组直接替换旧的数组。但是当thread1将c插入到新的bucket的头结点的时候,thread1的时间片用完了,还没来得及替换为hashMap的数组,thread2又发现需要做扩容了,它同样复制一份oldTable,再重新resize,并且在执行到Entry<K,V> next = e.next; 被挂起,这个时候在thread2中,e = a, e.next = next = b。这个时候就出现了下图的情况
在这之后,整个hashMap的table在这个bucket位置获取数据,都会出现死循环的问题。
重点(面试至少要说出来这个):thread1没有替换oldTbale时间片执行完成,thread2从Entry<K,V> next = e.next; 继续执行(已经获得了a.next),不然a.next会被thread1设置为null或者新bucket的头结点,不会出现这种情况
路漫漫其修远兮,本菜鸟还将上下而求索!!!
同学们,冲冲冲
关于红黑树的部分,后续补上。 ↩︎