HashMap系列之1.8put源码篇
小宋本篇文章接着带大家深入了解HashMap,这次我们来聊一聊1.8put源码关于树化的部分。
文章目录
TreeNode结构
上一章我们陆续讲解了二叉查找树和红黑树理论知识,接着我们讲一下代码层面。下面是HashMap1.8中的TreeNode结构:
/**
* 用于Tree bins 的Entry。 扩展LinkedHashMap.Entry(进而扩展Node),因此可以用作常规节点或链接节点的扩展。
*/
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
TreeNode<K,V> parent; // 红黑树父节点
TreeNode<K,V> left; //左子节点
TreeNode<K,V> right; //右子节点
TreeNode<K,V> prev; // 删除后需要取消链接,指向前一个节点(原链表中的前一个节点)
boolean red; //红黑树精髓 red
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
} //省略后续代码
/**
* HashMap.Node subclass for normal LinkedHashMap entries.
*/
static class Entry<K,V> extends HashMap.Node<K,V> {
Entry<K,V> before, after;
Entry(int hash, K key, V value, Node<K,V> next) {
super(hash, key, value, next);
}
}
TreeNode继承了LinkedHashMap内部类LinkedHashMap.Entry,LinkedHashMap.Entry又继承自HashMap.Node。这些字段跟Entry,Node中的字段一样,是使用默认访问权限的,所以子类可以直接使用父类的属性。
这里提一点为什么在1.7中我们是没有红黑树的,在1.8中要引入红黑树呢???
主要还是因为在1.7中hash冲突导致slot链化严重,影响get查询效率,
本来散链表在最理想状态的查询效率是O(1),但是在链化特别严重后会导致查询退化为O(n)。
红黑树的查询性能达到O(log(n))。
HashMap1.8(插入)中的树化
在HashMap1.8中新增了链表树化红黑树的代码,但是要同时满足两个条件才会对链表进行树化。
条件一:链表长度达到8
条件二:散列表数组长度达到64
如果没有同时满足两个条件的话只有链表长度达到8而数组长度没有达到64的话,只会触发resize
进行扩容。
这里提一句,树化以后还是可能转回链表的,当长度减少到6的时候就会转回链表了,大于6就依然保持树化结构。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//省略部分代码
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//当桶中元素个数超过阈值(8)时就进行树化
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
//省略部分代码
}
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(); //数组大小在64以下的话会进行扩容不会去树化
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
//将节点替换为TreeNode
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p; //hd指向头结点
else {
/*这里其实是将单链表转化成了双向链表,tl是p的前驱,
每次循环更新指向双链表的最后一个元素,用来和p相连,
p是当前节点*/
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
//将链表进行树化
hd.treeify(tab);
}
}
从上方中的代码中可以看到,树化操作是在put操作里面执行的,在putVal函数中有if判断桶中元素个数是否超过8,符合条件调用treeifyBin函数,在treeifBin函数中是先if判断了散列表数组大小是否低于64,低于64不会进行树化会去扩容,当符合我上文说的两个条件链表大于8,数组大于64的时候灰先将所有节点替换为TreeNode,然后再将单链表转为双链表,方便之后的遍历和移动操作。而最终的操作,实际上是调用TreeNode的方法treeify进行的。
treeify函数
上面对putVal到treeifyBin函数做了分析,继续往下走我们看treeify函数是如何进行树化操作的
final void treeify(Node<K,V>[] tab) {
//树的根节点
TreeNode<K,V> root = null;
//x是当前节点,next是后面往下遍历的节点
for (TreeNode<K,V> x = this, next; x != null; x = next) {
next = (TreeNode<K,V>)x.next;
x.left = x.right = null;
//如果根节点为null,把当前节点设置为根节点
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null;
//这里循环遍历,进行二叉查找树的插入
for (TreeNode<K,V> p = root;;) {
/*p指向遍历中的根节点,x为待插入节点,k是x的key,h是x
的hash值,ph是p的hash值,dir用来指示x节点与p的比较,-1表示
比p小,1表示比p大,不存在相等情况,因为HashMap中是不存在
两个key完全一致的情况。*/
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1;
else if (ph < h)
dir = 1;
/*如果hash值相等,
那么判断k是否实现了comparable接口,
如果实现了comparable接口就使用compareTo进行进行比较,
如果仍旧相等或者没有实现comparable接口,
则在tieBreakOrder中比较*/
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
/*这里进行左右子节点判定*/
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
/*上面有根据hash大小的判断去给定dir的值,
这里拿dir的值来设置left和right*/
if (dir <= 0)
xp.left = x;
else
xp.right = x;
root = balanceInsertion(root, x); //进行插入平衡处理
break;
}
}
}
}
moveRootToFront(tab, root); //确保给定节点是桶中的第一个元素
}
//这里不是为了整体排序,而是为了在插入中保持一致的顺序
static int tieBreakOrder(Object a, Object b) {
int d;
//用两者的类名进行比较,如果相同则使用对象默认的hashcode进行比较
if (a == null || b == null ||
(d = a.getClass().getName().
compareTo(b.getClass().getName())) == 0)
d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
-1 : 1);
return d;
}
上面在treeify的代码还是比较简单的,我们可以看到,循环遍历当前树,先会进行判断是否有根节点,如果没有的话现在插入的这个节点就作为根节点,如果存在的话,会去找到可以给该节点插入的位置,依次和遍历节点比较hash值的大小,比它大则跟其右子树节点比较,小则与其左子树节点比较,依次遍历,直到找到左子树节点或者右子树节点为null的位置进行插入。我在本章刚开始的时候讲的二叉查找树就提过这类树的一个很重要的特点,左子树节点的值小于根节点,右子树节点的值大于根节点。
balanceInsertion函数
真正复杂一点的地方在于balanceInsertion函数,这个函数中,将红黑树进行插入
平衡处理(左旋,右旋和变色操作),保证插入节点后仍保持红黑树的性质。
现在我们直接进入balanceInsertion的代码
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
x.red = true;
/*x:插入的节点,xp:父节点,xpp:祖父节点,
xppl:祖父左子节点(叔叔节点),xppr:祖父右子节点(叔叔节点)*/
for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { //死循环,直到找到根节点才结束。
//情景1:父节点为null,说明当前节点就是根节点,直接return
if ((xp = x.parent) == null) {
x.red = false; //染色为黑色(根节点规定为黑色)
return x;
}
//情景2:父节点是黑色节点或者祖父节点为null,插入后没有影响黑色完美平衡,直接返回
else if (!xp.red || (xpp = xp.parent) == null)
return root;
//情景3:插入的节点父节点和祖父节点都存在,并且其父节点是祖父节点的左节点,父节点为红色,也有两种情况(LR 或者 LL)
if (xp == (xppl = xpp.left)) {
//情景3-1:插入节点的叔叔节点存在且是红色
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;//将叔叔节点染色成黑
xp.red = false; //将父节点染色成黑
xpp.red = true;//将祖父节点染色成红
x = xpp; //最后将爷爷节点设置为当前节点进行下一轮操作
}
//情景3-2:插入节点的叔叔节点是黑色或不存在
else {
//情景3-2-1:当前插入节点是父节点的右子节点(LR的情景)
if (x == xp.right) {
root = rotateLeft(root, x = xp);//以父节点为旋转节点进行左旋,变成LL的情景
xpp = (xp = x.parent) == null ? null : xp.parent;//设置祖父节点
}
//情景3-2-2:插入节点是其父节点的左子节点
/*左旋完了之后,就回到了LL的情景进行右旋(父节点是祖父节点的左子节点,当前节点是父节点的左子节点),然后父节点又是红色,当前插入节点也是红色,违反了红黑色的性质,红色不能两两相连,所以接下来需要进行染色;*/
if (xp != null) {
xp.red = false; //将父节点染色为黑
if (xpp != null) {
xpp.red = true; //将祖父节点染色为红
root = rotateRight(root, xpp); //然后再对祖父节点右旋。
}
}
}
}
//情景4:插入的节点父节点和祖父节点都存在,并且其父节点是祖父节点的右节点,父节点为红色,也有两种情况(RL 或者 RR)
else {
//情景4-1:插入节点的叔叔节点不为空且是红色
if (xppl != null && xppl.red) {
xppl.red = false; //将叔叔节点染色成黑
xp.red = false; //将父节点染色成黑
xpp.red = true; //将祖父节点染色成红
x = xpp; //并且祖父节点设置为当前节点进行下一轮操作
}
//情景4-2:插入节点的叔叔节点是黑色或不存在
else {
//情景4-2-1:插入节点是其父节点的左子节点(RL的情景)
if (x == xp.left) {
root = rotateRight(root, x = xp); 以父节点为旋转节点进行右旋,变成RR的情景
xpp = (xp = x.parent) == null ? null : xp.parent; //设置祖父节点
}
//情景4-2-2:插入节点是其父节点的右子节点,这个时候已经变成了RR的情况,需要对祖父节点左旋来维持平衡
if (xp != null) {
xp.red = false; //将父节点染色为黑
if (xpp != null) {
xpp.red = true; //将祖父节点染色为红
root = rotateLeft(root, xpp); //再对祖父节点进行左旋
}
}
}
}
}
}
从上面我写的注释来深入分析balanceInsertion函数后会发现插入的节点x可能存在有父节点xp,祖父节点xpp和叔叔节点xppr和xppl。这里是存在四种场景的。
场景1:父节点为null,直接return插入的节点x
场景2:父节点是黑色节点或者祖父节点为null的时候,balanceInsertion有root和x两个参数,
在上层的treeify函数中已经对root节点插入了x这个子节点,所以在这里可以直接return root。
场景3:插入的节点父节点和祖父节点都存在,并且其父节点是祖父节点的左节点的时候,父节点为红色,也有两种情况(LR 或者 LL)
这个时候我们先去判定祖父节点的右节点是叔叔节点且是红色在代码中的判断
//情景3-1:插入节点的叔叔节点存在且是红色
if ((xppr = xpp.right) != null && xppr.red)
这个时候就会去变色,会去将叔叔节点xppr和父节点xp都变成黑色,然后祖父节点xpp变为红色,
然后这里会进行x = xpp的赋值,讲祖父节点设置为当前节点进行下一轮操作
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
//情景3-2:插入节点的叔叔节点是黑色或不存在
这个时候会存在两个子情况
//情景3-2-1:当前插入节点是父节点的右子节点(LR的情景)
因为我们在上面判定过当前的父节点是祖父节点的左子节点,所以这个时候插入节点是父节点的右子节点的话对应的就是LR情况。
//情景3-2-2:插入节点是其父节点的左子节(LL的情景)
这里的判定因为上面已经判断过了是父节点右子节点的情况,这里就只能是左子节点了,所以对应的是LL情景
场景4:插入的节点父节点和祖父节点都存在,并且其父节点是祖父节点的右节点,父节点为红色,也有两种情况(RR 或者 RL)
先去判定祖父节点的右节点是叔叔节点且是红色在代码中的判断
//情景4-1:插入节点的叔叔节点不为空且是红色
if ((xppr = xpp.right) != null && xppr.red)
这个时候就会去变色,会去将叔叔节点xppr和父节点xp都变成黑色,然后祖父节点xpp变为红色,
然后这里会进行x = xpp的赋值,讲祖父节点设置为当前节点进行下一轮操作
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
//情景4-2:插入节点的叔叔节点是黑色或不存在
这个时候会存在两个子情况
//情景4-2-1:插入节点是其父节点的左子节点(RL的情景)
因为我们在上面判定过当前的父节点是祖父节点的右子节点,所以这个时候插入节点是父节点的左子节点的话对应的就是LR情况。
//情景4-2-2:插入节点是其父节点的右子节点,这个时候已经变成了RR的情况,需要对祖父节点左旋来维持平衡
这里的判定因为上面已经判断过了是父节点左子节点的情况,这里就只能是右子节点了,所以对应的是RR情景
这里彻底分析了HashMap红黑树底层的平衡逻辑(变色+左旋右旋),相信各位同学也理解了,对左旋右旋的情况 LL RR LR RL 还不太清晰的可以去看下本文开头讲解的二叉树旋转方面的知识点。
左旋右旋rotateLeft函数和rotateRight函数
接着我们继续看一下TreeNode左旋和右旋的代码 rotateLeft函数和rotateRight函数
/**
* 左旋
* @param root 当前根节点
* @param p 指定的旋转节点
* @return 返回根节点(平衡涉及左旋右旋会将根节点改变,所以需要返回最新的根节点)
*/
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,TreeNode<K,V> p) {
// r:旋转节点的右子节点; pp:旋转节点的父节点, rl:旋转节点的右子节点的左子节点
TreeNode<K,V> r, pp, rl;
if (p != null && (r = p.right) != null) { //旋转节点非空并且旋转节点的右子节点非空
if ((rl = p.right = r.left) != null) //将p节点的右子节点设置为右子节点的左子节点
rl.parent = p; //将rl的父节点设置为p
if ((pp = r.parent = p.parent) == null)//将r的父节点设置为p的父节点,如果是空的话
(root = r).red = false;//染色成黑
else if (pp.left == p) //判断父节点是祖父节点的左子节点还是右子节点
pp.left = r; //如果是左子节点,那么就把祖父节点的左子节点设置为r
else
pp.right = r; //如果是右子节点,就把祖父节点的右子节点设置为r
r.left = p; //最后将r的左子节点设置为p
p.parent = r; //将p的父节点设置为r
}
return root;
}
左旋示意图:左旋p节点
pp pp
| |
p r
/ \ ----> / \
l r p rr
/ \ / \
rl rr l rl
-
1、将rl设置为p的右子节点,将rl的父节点设置为p左旋做了几件事?
2、将r的父节点设置pp,将pp的左子节点或者右子节点设置为r
3、将r的左子节点设置为p,将p的父节点设置为r
/**
* 右旋
* @param root 当前根节点
* @param p 指定的旋转节点
* @return 返回根节点(平衡涉及左旋右旋会将根节点改变,所以需要返回最新的根节点)
*/
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,TreeNode<K,V> p) {
//l:p节点的左子节点 pp:p节点的父节点 lr:p节点的左子节点的右子节点
TreeNode<K,V> l, pp, lr;
if (p != null && (l = p.left) != null) { //旋转节点p非空并且p节点的左子节点非空
if ((lr = p.left = l.right) != null) //将p节点的左子节点设置为左子节点的右子节点
lr.parent = p; //然后将p节点的左子节点的右子节点的父节点设置为p
if ((pp = l.parent = p.parent) == null) //将p节点的左子节点的父节点设置为p的父节点,如果为空的话,说明l就是根节点了
(root = l).red = false; //染色成黑
else if (pp.right == p) //判断p节点是pp节点的左子节点还是右子节点,
pp.right = l; //如果p节点是pp节点的右子节点的话,将祖父节点pp的右子节点设置为l
else //如果p节点是pp节点的左子节点的话,将祖父节点pp的左子节点设置为l
pp.left = l;
l.right = p; //最后将l节点的右子节点设置为p
p.parent = l; //将p节点的父节点设置为l
}
return root;
}
右旋示意图:右旋p节点
pp pp
| |
p l
/ \ ----> / \
l r ll p
/ \ / \
ll lr lr r
-
右旋都做了几件事?
1.将lr设置为p节点的左子节点,将lr的父节点设置为p
2.将l的父节点设置为pp,将pp的左子节点或者右子节点设置为l
3.将l的右子节点设置为p,将p的父节点设置为l上面关于hashmap底层的树化过程已经讲得很详细了,接着我们继续把treeify函数中的 moveRootToFront函数讲完。
moveRootToFront函数
/**
* 把给定节点设为数组(链表)中的第一个节点
* 确保给出的根结点是箱中的第一个节点也就是直接位于table上,
* 原本的第一个节点若不是root则将root从链表中剪下放到第一个节点的前方
*/
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeN1ode<K,V> root) {
int n;
if (root != null && tab != null && (n = tab.length) > 0) {
int index = (n - 1) & root.hash;//根据root的hash值快速定位下标
TreeNode<K,V> first = (TreeNode<K,V>)tab[index];//first指向链表第一个节点
if (root != first) { //root不是第一个节点,则将root放到第一个首节点位置
Node<K,V> rn;
tab[index] = root; //root放到table[index]位置
TreeNode<K,V> rp = root.prev; //rp=root的前一个结点
if ((rn = root.next) != null) //rn=root的后一个结点
((TreeNode<K,V>)rn).prev = rp; //rn的前指针指向root的前一个结点
if (rp != null)
rp.next = rn; rp的后指针指向root的后一个结点
if (first != null)
first.prev = root; //将原本的first放到root的后面
root.next = first;
root.prev = null;
}
/*这里是防御性编程,校验更改后的结构是否满足红黑树和双链表的特性,
checkInvariants在递归检查整棵树是否符合红黑树的性质,
若检查不符会返回false导致moveRootToFront抛出错误.
因为HashMap并没有做并发安全处理,可能在并发场景中意外破坏了结构*/
assert checkInvariants(root); //assert后面的表达式为false时会抛出错误
}
}
这里的moveRootToFront函数是确保根结点被保存在了table数组上面,如果不是的话,就将root从链表中取出,将他放到数组对应的位置上,原本在数组上的节点(该位置的链表首节点)链接到root的后面。
讲到这一步了我们的put插入导致的树化操作也快讲完了,最后再把moveRootToFront中最后assert的checkInvariants函数在分析一下
checkInvariants函数
/**
* Recursive invariant check
* 从root开始递归检查红黑树的性质,仅在检查root是否落在table上时调用
*/
static <K,V> boolean checkInvariants(TreeNode<K,V> t) {
TreeNode<K,V> tp = t.parent, tl = t.left, tr = t.right,
tb = t.prev, tn = (TreeNode<K,V>)t.next;
if (tb != null && tb.next != t)
return false;//t的前一个结点的后续应为t
if (tn != null && tn.prev != t)
return false;//t的后一个结点的前驱应为t
if (tp != null && t != tp.left && t != tp.right)
return false;//t因为t父亲的左儿子或右儿子
if (tl != null && (tl.parent != t || tl.hash > t.hash))
return false;//t的左儿子的hash值应小于t,父亲应为t
if (tr != null && (tr.parent != t || tr.hash < t.hash))
return false;//t的右儿子的hash值应大于t,父亲应为t
if (t.red && tl != null && tl.red && tr != null && tr.red)
return false;//t和t的儿子不能同时是红色
if (tl != null && !checkInvariants(tl))
return false;//递归检查t的左儿子
if (tr != null && !checkInvariants(tr))
return false;//递归检查t的右儿子
return true;
}
讲到这里相信同学们已经深刻了解了HashMap1.8底层是如何进行树化,如果有同学想了解hashMap数组+链表结构和1.7源码以及红黑树的,可以在我HashMap系列博客中了解,谢谢大家的观看,希望能给各位同学带来帮助。如果觉得博主写的还可以的,可以点赞关注。????
本文地址:https://blog.csdn.net/weixin_42236165/article/details/110099866
推荐阅读
-
Java并发系列之Semaphore源码分析
-
Java并发系列之CyclicBarrier源码分析
-
Java并发系列之ConcurrentHashMap源码分析
-
Java并发系列之CountDownLatch源码分析
-
List、Set集合系列之剖析HashSet存储原理(HashMap底层)
-
自己动手写MiniBBS系列(MVC篇)之远程链接mysql
-
spring-boot-2.0.3不一样系列之源码篇 - run方法(三)之createApplicationContext,绝对有值得你看的地方
-
死磕 java同步系列之终结篇
-
死磕 java同步系列之Phaser源码解析
-
死磕 java同步系列之CyclicBarrier源码解析——有图有真相