建议看本文前先看我的另一篇文章https://segmentfault.com/a/1190000045431073,其中详细介绍了HashMap1.7中的诸多细节,对理解本文大有帮助。
核心属性
transient Node<K,V>[] table;
transient int size;
transient int modCount;
int threshold;
final float loadFactor;
可以看到,除了将Entry对象换为了Node对象,其余核心属性未发生变化。
事实上,Enrty对象和Node对象内部也没有任何区别,可以理解为只是换了个名字而已。
但下面这点我们就要注意了,1.8中多出来了一个内部类:TreeNode
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // red-black tree links
TreeNode<K,V> left;
TreeNode<K,V> right;
TreeNode<K,V> prev; // needed to unlink next upon deletion
boolean red;
可以看到TreeNode继承了Entry,而Entry实际上继承了Node,也就是说,TreeNode中实际上有Node中的四个属性Key value hash next,还有他自己的五个属性,其中prev属性有什么用,我们后面来揭晓。
put
大家都在八股文中背的滚瓜烂熟了:1.8中的HashMap,在链表长度大于8且数组长度大于等于64时链表自动转化为红黑树,没错,但源码中是如何实现的呢?我们来看:
大体思路为:
- put一个元素
- 创建一个新Node并根据key的hash值算出该放入的数组下标
- 判断该位置上是否有元素
3.1 没有元素,直接将新元素放入
3.2 有元素,判断该元素是否和put的元素有相同的key
3.3 判断该位置上的是红黑树还是链表,是红黑树就执行红黑树插入逻辑,是链表就执行链表插入逻辑 - 最后判断是否需要扩容。
这里出现了问题,为什么要不管如何先进行插入呢?如果插入之后判断需要扩容,扩容时会转移元素,不是会多一次转移元素的开销吗?这里面的具体细节我还没想明白,有大佬懂得话可以在下边评论。但开销并不大,因为和1.7的逐个转移不同,1.8中的扩容转移元素是进行批量转移的,后面我们再来介绍细节。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//链表插入逻辑
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
上面的大致流程中有三个点需要细细介绍,分别是红黑树插入逻辑,链表插入逻辑,扩容,我们下面来一一介绍。
链表插入逻辑
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;
}
逻辑不难,但要注意1.8中使用的是尾插法,和头插法不同,避免了在并发条件下的混乱,我们可以看到这行代码:
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
其中的TREEIFY_THRESHOLD默认为8,这也就是我们常说的,链表大于8就转红黑树,实际上,当链表长度为8时,再执行插入才会触发树化,treeifyBin方法就是链表转红黑树的实际实现,而这个方法就有意思了,我们来看:
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
这是什么东西?还记得前面提到的TreeNode的prev属性吗,在这里用上了!该方法先将Node转化为TreeNode类型,然后给其prev和next属性赋值,最终将链表转为了双向链表!然后再调用treeify方法,真正将双向链表转为红黑树。
这不禁令人诧异,为什么要先转为双向链表?将链表转为红黑树不是很容易吗?直接遍历每个元素然后插入到红黑树里不就行了?所以,转为双向链表,应该另有用途。我们这里几下,先继续向后看。
到这里有人应该发现猫腻了,TreeNode中既有红黑树相关属性,又有双向链表相关属性prev和next,所以,这棵红黑树,同时也是一个双向链表!也就是说,数组的这个位置上,实际上还是一个链表,只不过这个链表是双向链表,同时具有红黑树的结构,如下图,两种结构实际上是一种结构。
到这的流程,看似很对,实则我们落下了致命的一步,也正是这个问题,帮我们彻底掀开了prev的面纱。
问题出在哪里?我们从红黑树的结构出发,我们知道红黑树为了调整至“平衡”会进行左旋右旋等旋转操作,而旋转操作可能会导致root发生变化,所以,按我们之前的逻辑,链表转红黑树过程中,都是一系列right、left、parent指针在发生变化,而最关键的数组该位置上的元素并没有发生变化,所以可能会导致一种后果:
看到图片应该就明白了,红黑树的root发生了变化,但从双向链表的角度来看,是这样的:
很有可能,整棵树的root节点不是链表的头节点!想想这会发生什么,在下一次往这个位置上插入元素时,会将链表的头节点当作红黑树的root节点传入进行红黑树插入逻辑,会造成严重的未知后果,所以我们要做的,就是将root节点通过链表转移到数组该位置的第一个元素。所以此时,采用prev指针能够很方便的做到这点(因为如果是单链表的话要遍历多次),源码中是这样做的
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
int n;
if (root != null && tab != null && (n = tab.length) > 0) {
int index = (n - 1) & root.hash;
TreeNode<K,V> first = (TreeNode<K,V>)tab[index];
if (root != first) {
Node<K,V> rn;
tab[index] = root;
TreeNode<K,V> rp = root.prev;
if ((rn = root.next) != null)
((TreeNode<K,V>)rn).prev = rp;
if (rp != null)
rp.next = rn;
if (first != null)
first.prev = root;
root.next = first;
root.prev = null;
}
assert checkInvariants(root);
}
}
所以,这个prev属性,在这里实际上就是帮助root节点回到链表头节点上。
扩容
1.8中的HashMap扩容,在触发时机与元素转移过程上都发生了变化。
扩容时机
还记得在1.7中,扩容触发的条件是:元素个数>threshold阈值并且要put的位置不为空
但在1.8中,去除了后面的条件,只要元素个数大于扩容阈值就进行扩容,具体是为什么这样设计?
if (++size > threshold)
resize();
元素转移
由于1.8中数组上放的可能是链表或红黑树,所以就涉及到如何转移链表与红黑树的问题,而且在扩容过程中,红黑树可能会变回链表,简单来说就是遍历数组上每个位置,判断该位置上是单个元素还是链表还是红黑树,执行不同逻辑
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 { // preserve order
//链表转移逻辑
}
}
}
下面我们来重点看链表和红黑树是如何转移的。
链表转移
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;
}
和1.7的逐个转移不同,1.8中先将链表拆分成两个链表,进行元素的整体转移,实际上,lohead和lotail,hihead和hitail就是这两条链表的头和尾,为什么只有两条呢?还记得我们在1.7中解读的元素转移的规则吗?使用每个元素的hash & (newLength-1),得到的结果只有两个,也就是旧数组上一个位置的元素映射到新数组上只可能会在两个位置上,所以这里分为一个low和一个high即可。
红黑树转移
还记得我们前面说的链表树化吗?先转为双向链表再进行树化,这里思路大致相同,首先将红黑树按位置不同拆分成两个双向链表(这里只是修改了prev和next,并没有修改left和right等红黑树属性),也就是说,看似拆为了两个双向链表,实际上仍然是一棵红黑树,那怎么完全拆开呢?还记得我们背的八股文里有:红黑树长度<=6自动转为链表,没错,拆分成两条双向链表后,如果发现有一条长度<=6,就进行链表化,如果比6大,那就执行之前的双向链表树化逻辑即可。而链表化只是将TreeNode类型转为Node类型即可,因为本来也是一条双向链表,所以逻辑较为简单。
1.7与1.8的不同
上面说了这么多,下面再来看这些八股文,想必都能说出其内部的原理了。
- 1.8中新增了红黑树,通过数组+链表+红黑树来实现
- 1.7中链表的插入是用的头插法,而1.8中则改为了尾插法
- 1.8中的因为使用了红黑树保证了插入和查询了效率,所以实际上1.8中的Hash算法实现的复杂度降低了(哈希扰动变小)
- 1.8中数组扩容的条件也发了变化,只会判断是否当前元素个数是否查过了阈值,而不再判断当前put进来的元素对应的数组下标位置是否有值。
- 1.7中是先扩容再添加新元素,1.8中是先添加新元素然后再扩容
后续
我们曾在1.7中说过,头插法导致1.7中的HashMap容易在多线程情况下发生死循环问题,那1.8中就不会有这种问题了吗?确实解决了,但又出现了其他原因导致的死循环,后续我搞明白再来介绍。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。