HashMap简介以下代码都是基于java8的版本
源码:
public class HashMapextends AbstractMap implements Map , Cloneable, Serializable { //...... }
jdk1.7和jdk1.8的差距还是比较大的,1.8引入了红黑树,尾插入
HashMap主要使用API// 获得指定键的值 V get(Object key); // 添加键值对 V put(K key, V value); // 将指定Map中的键值对 复制到 此Map中 void putAll(Map extends K, ? extends V> m); // 删除该键值对 V remove(Object key); // 判断是否存在该键的键值对;是 则返回true boolean containsKey(Object key); // 判断是否存在该值的键值对;是 则返回true boolean containsValue(Object value); // 单独抽取key序列,将所有key生成一个Set SetHashMap的属性keySet(); // 单独value序列,将所有value生成一个Collection Collection values(); // 清除哈希表中的所有键值对 void clear(); // 返回哈希表中所有 键值对的数量 = 数组中的键值对 + 链表中的键值对 int size(); // 判断HashMap是否为空;size == 0时 表示为 空 boolean isEmpty();
//序列化id private static final long serialVersionUID = 362498820763181265L; //默认初始容量 - 必须是 2 的幂。,通过位移运算得到是16. static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 //最大容量,在两个带参数的构造函数中的任何一个隐式指定更高的值时使用。 必须是 2 的幂 <= 1<<30。 static final int MAXIMUM_CAPACITY = 1 << 30; //在构造函数中未指定时使用的负载因子。为什么是0.75 ,元素的空间利用率 static final float DEFAULT_LOAD_FACTOR = 0.75f; //使用树而不是列表的 bin 计数阈值。 将元素添加到至少具有这么多节点的 bin 时,bin 会转换为树。 该值必须大于 2 且至少应为 8,以与树移除中关于在收缩时转换回普通 bin 的假设相匹配。 (就是数组==>树的阈值之一) static final int TREEIFY_THRESHOLD = 8; //在调整大小操作期间取消(拆分)bin 的 bin 计数阈值。 应小于 TREEIFY_THRESHOLD,最多为 6 以在移除下进行收缩检测,(就是树==>数组的阈值) static final int UNTREEIFY_THRESHOLD = 6; //可以将 bin 树化的最小表容量。 (否则,如果 bin 中的节点过多,则表将调整大小。) 应至少为 4 * TREEIFY_THRESHOLD,以避免调整大小和树化阈值之间发生冲突,(就是数组==>树的阈值之一) static final int MIN_TREEIFY_CAPACITY = 64; //表,在第一次使用时初始化,并根据需要调整大小。 分配时,长度始终是 2 的幂。 (我们还在某些操作中容忍长度为零,以允许当前不需要的引导机制。) transient Node构造函数 无参构造函数[] table; //保存缓存的 entrySet()。 请注意,AbstractMap 字段用于 keySet() 和 values()。 transient Set > entrySet; //此映射中包含的键值映射的数量。 也就是常用的 map.size() 返回的那个数,表示目前里面有多少个键值对 transient int size; //该 HashMap 被结构修改的次数,该字段用于在 HashMap 的 Collection-views 上创建迭代器快速失败。 (请参阅 ConcurrentModificationException) transient int modCount; //要调整大小的下一个大小值(容量 * 负载因子)。 int threshold; //哈希表的负载因子。其实就是前面的那个0.75f final float loadFactor;
如果不传入参数,则使用默认无参构造方法创建HashMap对象,如下:
// 构造一个具有默认初始容量 (16) 和默认负载因子 (0.75) 的空HashMap 。
// 这里就看出来了其实loadFactor 就是 0.75f
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted 所有其他字段默认
}
带初始容量的构造函数
传入参数,代表指定HashMap的初始容量;如果参数小于0,则抛出 IllegalArgumentException
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
带初始容量和负载因子的构造函数
初始容量
- 如果初始容量小于0,则抛出 IllegalArgumentException。
- 如果初始容量大于最大容量时,设置初始容量等于最大容量
负载因子
- 如果负载因子为非正数, 则抛出 IllegalArgumentException。
- 设置负载因子为0.75f
调整容量
- 要调整大小的下一个大小值(容量 * 负载因子)
public HashMap(int initialCapacity, float loadFactor) {
//如果初始容量小于0,则抛出 IllegalArgumentException。
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 如果初始容量大于最大容量时,设置初始容量等于最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//如果负载因子为非正数, 则抛出 IllegalArgumentException。
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
// 设置负载因子为0.75f
this.loadFactor = loadFactor;
// 调整阈值
this.threshold = tableSizeFor(initialCapacity);
}
// Float.isNaN 如果指定的数字是非数字 (NaN) 值,则返回true否则返回false 。
public static boolean isNaN(float v) {
return (v != v);
}
下面来看一下 tableSizeFor 方法
根据我们传入的容量计算一个大于等于该容量的最小的2的N次方,例如传 9,容量为2^4=16
// 返回给定目标容量的二次幂。
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;
}
我们先不看第一行“int n = cap - 1”,先看下面的5行计算。
|=(或等于):例如:a |= b ,可以转成:a = a | b。或运算规则:0|0=0; 0|1=1; 1|0=1; 1|1=1;有1为1。
>>>(无符号右移):例如 a >>> b 指的是将 a 向右移动 b 指定的位数,右移后左边空出的位用零来填充,移出右边的位被丢弃。
n |= n >>> 1; // 等同于 n = n | (n >>> 1)
假设 n 的值为 0010 0001,则该计算如下图:
相信你应该看出来,这5个公式会通过最高位的1,拿到2个1、4个1、8个1、16个1、32个1。当然,有多少个1,取决于我们的入参有多大,但肯定的是经过这5个计算,得到的值是一个低位全是1的值,最后返回的时候 +1,则会得到一个比n 大的 2 的N次方。
这时再看开头的 cap - 1 就很简单了,这是为了处理 cap 本身就是 2 的N次方的情况。
计算机底层是二进制的,移位和或运算是非常快的,所以这个方法的效率很高。(对于移位、 或运算 等不明白的小伙伴,这一块可是要费点力气了)
带Map的构造函数使用与指定Map相同的映射构造一个新的HashMap 。
新的HashMap是使用默认负载因子 (0.75) 创建的,初始容量足以在指定的Map 中保存映射。
// 使用指定的map 创建新map
public HashMap(Map extends K, ? extends V> m) {
// 默认的负载因子 0.75f
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
putMapEntries
下面看一下 putMapEntries 方法, 该方法的作用:将传入的子Map中的全部元素逐个添加到HashMap中
final void putMapEntries(Map extends K, ? extends V> m, boolean evict) {
// 得到新传进来的m的大小
int s = m.size();
// 判断大小是否大于0
if (s > 0) {
// 判断当前哈希表是否为null
if (table == null) { // pre-size
//计算容量 当前(当前m的大小/负载因子 = 最大容量) 然后在加一 得到m最大的容量
float ft = ((float)s / loadFactor) + 1.0F;
//判断传入的m的最大容量是否小于允许最大容量值,得到最终容量值
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
//判断容量t是否大于当前map 的阈值,
if (t > threshold)
// 重新计算得到一个大于等于t 的最小的2次幂,说白了就是调整map的阈值。
threshold = tableSizeFor(t);
}
//传进来的m大小 大于当前 map 的阈值时,需要扩容
else if (s > threshold)
// 扩容
resize();
// 遍历传入的m,取出每一个键值对,存入当前map中
for (Map.Entry extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
resize 扩容
下面看一下 resize 方法,面试时最经常问的hashmap扩容机制就在这个地方,注意newCap = oldCap << 1这句,扩容就在这,扩大两倍。
下面看一下resize 的源码,注释基本写的很明白了。按照我自己的理解写的。如有不对的,欢迎指正。
// 扩容 final Node[] resize() { // 先创建一个临时变量,存储当前的table Node [] oldTab = table; //获取原来的table的长度(大小) int oldCap = (oldTab == null) ? 0 : oldTab.length; // 创建临时变量存储旧的阈值 int oldThr = threshold; // 创建新容量、阈值,默认都是0 int newCap, newThr = 0; // 判断旧容量是否大于0 if (oldCap > 0) { // 判断旧容量大于等于 允许的最大值,2^30 if (oldCap >= MAXIMUM_CAPACITY) { // 设置当前阈值为Integer的最大值。2^31-1 threshold = Integer.MAX_VALUE; // 返回旧table return oldTab; } // 设置新容量是旧容量的两倍,新容量是否小于允许的最大值,旧容量是否大于默认的16. else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 设置新阈值是旧阈值的两倍 newThr = oldThr << 1; // double threshold 阈值翻倍 } // 判断旧阈值是否大于0 else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; // 旧容量和旧阈值都不大于0,则使用默认的大小和容量 else { // zero initial threshold signifies using defaults // 设置新容量为默认容量16 newCap = DEFAULT_INITIAL_CAPACITY; // 设置新阈值为 负载因子0.75f * 默认容量 16 = 12; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 新阈值 等于 0 if (newThr == 0) { // 临时阈值 = 新容量 * 负载因子0.75f float ft = (float)newCap * loadFactor; // 设置新的阈值 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } // 更新map的阈值字段 threshold = newThr; // 创建新的table键值对,容量是刚刚确定的新容量 @SuppressWarnings({"rawtypes","unchecked"}) Node [] newTab = (Node [])new Node[newCap]; // 将map 的table指向新table table = newTab; // 旧table不为null if (oldTab != null) { // 遍历oldTab,取出每一个键值对,存入到新table,这里的 ++j 其实和j++没区别。 for (int j = 0; j < oldCap; ++j) { Node e; // 创建一个临时变量指向oldTab中的第j个键值对, if ((e = oldTab[j]) != null) { // 将置为null,释放内存,方便gc oldTab[j] = null; // 如果当前e 没有第二个元素 if (e.next == null) // 计算新表的索引位置,直接该位置 newTab[e.hash & (newCap - 1)] = e; // 判断当前的e是不是红黑树 else if (e instanceof TreeNode) // 将 node 转为 treeNode,之所以能转换是因为 treeNode 是 node 的子类 // 拆分树 ((TreeNode )e).split(this, newTab, j, oldCap); // 当前节不是红黑树,不是null,并且还有下一个元素。此时为链表 else { // preserve order // 链表优化重hash的代码块 Node loHead = null, loTail = null; Node hiHead = null, hiTail = null; Node next; // 取出来该链表中的所有节点, do { next = e.next; // 如果计算得到的是0,则新表索引位置为“原索引位置” if ((e.hash & oldCap) == 0) { //此时loTail为null,意味着lo链表还没有元素, if (loTail == null) // loHead指向e,也就是设置第一个元素 loHead = e; // lo链表追加 else loTail.next = e; // 赋值 loTail = e; } // 计算得到的索引不是0,则新表索引位置为“原索引 + oldCap 位置 else { if (hiTail == null) hiHead = e; else hiTail.next = e; hiTail = e; } } while ((e = next) != null);// 后移 //不为null,表示新表索引位置为“原索引位置” if (loTail != null) { // 设置loTail为最后一个节点 loTail.next = null; // 放入新数组中 newTab[j] = loHead; } // 新表索引位置为“原索引 + oldCap 位置” if (hiTail != null) { hiTail.next = null; newTab[j + oldCap] = hiHead; } } } } } return newTab; }
整体的resize方法,也就是扩容逻辑如下图
个人认为resize方法中有几个点需要注意:
-
一个是计算新索引的位置(e.hash & oldCap),
-
另一个是红黑树的处理(split)。
我们来看一下为什么红黑树和链表都是通过 e.hash & oldCap == 0 来定位在新表的索引位置?
为什么是e.hash & oldCap 得到索引位置呢,因为在put 的时候 (n - 1) & hash 得到索引位置
举个例子,扩容前 table 的容量n为16,a 节点和 b 节点在扩容前处于同一索引位置。
扩容后,table 长度n为32,新表的 n - 1 只比老表的 n - 1 在高位多了一个1(图中标红的1)。
因为 2 个节点在老表是同一个索引位置,因此计算新表的索引位置时,只取决于新表在高位多出来的这一位(图中标红1),而这一位的值刚好等于 oldCap。
因此会存在两种情况:1) (e.hash & oldCap) == 0 ,则新表索引位置为“原索引位置” ;2)(e.hash & oldCap) != 0,则新表索引位置为”原索引 + oldCap 位置”。
还不理解的话 网上还有一个说明,讲的比较详细的。
split 红黑树的拆分再看一下红黑树的处理(split)。
这一块难点就是 低位红黑树和高位红黑树的处理,至于(e.hash & bit) == 0 这个刚刚已经讲过是为什么了。
final void split(HashMapmap, Node [] tab, int index, int bit) { //获得调用此方法的节点b TreeNode b = this; // 重新链接到 lo 和 hi 列表,保留顺序 // Relink into lo and hi lists, preserving order TreeNode loHead = null, loTail = null; // 存储索引位置为:“原索引位置”的节点 TreeNode hiHead = null, hiTail = null; // 存储索引位置为:“原索引+oldCap”的节点 //lc 低位红黑树的节点数,hc 高位红黑树的节点数 int lc = 0, hc = 0; // 从节点b开始,遍历整个红黑树节点 for (TreeNode e = b, next; e != null; e = next) { // next赋值为e的下个节点 next = (TreeNode )e.next; // 得到e的next后,将e的next指向null 以便垃圾收集器回收 e.next = null; //注意此处&运算的数组容量没有-1 //那么数组的容量值二进制表达必定为:1000...,所以此处计算只有两个结果,1或者0 //0:TreeNode在新数组的位置是原位置,1:原位置加上旧数组容量值的位置 if ((e.hash & bit) == 0) { //将loTail节点变成e节点的前节点, //若loTail节点不存在,代表该节点为第一个节点 if ((e.prev = loTail) == null) //将e节点赋值给loHead节点,loHead指向第一个节点 loHead = e; else //存在则将e节点赋值给loTail的后节点 loTail.next = e; //将e节点赋值给loTail节点 loTail = e; //计算低位红黑树的节点数 ++lc; } //以下操作和上方操作一样 else { if ((e.prev = hiTail) == null) hiHead = e; else hiTail.next = e; hiTail = e; ++hc; } } //如果低位红黑树存在 if (loHead != null) { //如果低位红黑树节点小于等于红黑树瓦解阈值6, if (lc <= UNTREEIFY_THRESHOLD) // 低位红黑树转为链表 tab[index] = loHead.untreeify(map); else { //否则将低位红黑树根节点放到数组上 tab[index] = loHead; if (hiHead != null) // (else is already treeified) 不存在则说明原先的节点都在当前红黑树上。不用变化 //如果高位红黑树存在,则将低位红黑树重新树化, // 虽然当前已经是红黑树了,但是节点改变了,所以要重新再来一遍,梳理节点 loHead.treeify(tab); } } //以下操作与上方操作一样 if (hiHead != null) { if (hc <= UNTREEIFY_THRESHOLD) tab[index + bit] = hiHead.untreeify(map); else { tab[index + bit] = hiHead; if (loHead != null) hiHead.treeify(tab); } } }
以上对红黑树的处理 涉及到 树化 和 反树化 ,也就是链表和红黑树的互相转换,下面我们来看一下树化的代码
treeify链表转红黑树,参数为HashMap的元素数组
// 形成从此节点链接的树。 final void treeify(NodetieBreakOrder[] tab) { TreeNode root = null; // 定义树的根节点 // 遍历链表,x指向当前节点、next指向下一个节点 for (TreeNode x = this, next; x != null; x = next) { next = (TreeNode )x.next; // 下一个节点 x.left = x.right = null; // 设置当前节点的左右节点为空 // 如果还没有根节点 if (root == null) { x.parent = null; // 当前节点的父节点设为空 x.red = false; // 当前节点的红色属性设为false(把当前节点设为黑色) root = x; // 根节点指向到当前节点(当前节点设置为根节点) } // 如果已经存在根节点了 else { K k = x.key; // 取得当前链表节点的key int h = x.hash; // 取得当前链表节点的hash值 Class> kc = null; // 定义key所属的Class // 从根节点开始遍历,此遍历没有设置边界,只能从内部跳出 for (TreeNode p = root;;) { // GOTO1 // dir 标识方向(左右)、ph标识当前树节点的hash值 int dir, ph; K pk = p.key; // 当前树节点的key // 如果当前树节点hash值 大于 当前链表节点的hash值 if ((ph = p.hash) > h) dir = -1; // 标识当前链表节点会放到当前树节点的左侧 else if (ph < h) dir = 1; // 右侧 else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) dir = tieBreakOrder(k, pk); TreeNode xp = p; // 保存当前树节点 if ((p = (dir <= 0) ? p.left : p.right) == null) { x.parent = xp; // 当前链表节点 作为 当前树节点的子节点 if (dir <= 0) xp.left = x; // 作为左孩子 else xp.right = x; // 作为右孩子 // 进行红黑树的插入平衡(通过左旋、右旋和改变节点颜色来保证当前树符合红黑树的要求) root = balanceInsertion(root, x); break; } } } } // 把所有的链表节点都遍历完之后,最终构造出来的树可能经历多个平衡操作,根节点目前到底是链表的哪一个节点是不确定的 // 因为我们要基于树来做查找, 确保给定的根是其 tab 的第一个节点。 moveRootToFront(tab, root); }
比较a和b的大小,-1:a<=b;1:a>b
static int tieBreakOrder(Object a, Object b) {
int d;
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;
}
balanceInsertion
红黑树的插入平衡(通过左旋、右旋和改变节点颜色来保证当前树符合红黑树的要求
// 红黑树的插入平衡(通过左旋、右旋和改变节点颜色来保证当前树符合红黑树的要求 staticmoveRootToFrontTreeNode balanceInsertion(TreeNode root, TreeNode x) { x.red = true; // 从内部终止循环 for (TreeNode xp, xpp, xppl, xppr;;) { if ((xp = x.parent) == null) { x.red = false; return x; } else if (!xp.red || (xpp = xp.parent) == null) return root; if (xp == (xppl = xpp.left)) { if ((xppr = xpp.right) != null && xppr.red) { xppr.red = false; xp.red = false; xpp.red = true; x = xpp; } else { if (x == xp.right) { root = rotateLeft(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; } if (xp != null) { xp.red = false; if (xpp != null) { xpp.red = true; root = rotateRight(root, xpp); } } } } else { if (xppl != null && xppl.red) { xppl.red = false; xp.red = false; xpp.red = true; x = xpp; } else { if (x == xp.left) { root = rotateRight(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; } if (xp != null) { xp.red = false; if (xpp != null) { xpp.red = true; root = rotateLeft(root, xpp); } } } } } }
该方法的作用是, 确保给定的根是其 tab 的第一个节点。
staticcheckInvariantsvoid moveRootToFront(Node [] tab, TreeNode root) { int n; if (root != null && tab != null && (n = tab.length) > 0) { int index = (n - 1) & root.hash; TreeNode first = (TreeNode )tab[index]; if (root != first) { Node rn; tab[index] = root; TreeNode rp = root.prev; if ((rn = root.next) != null) ((TreeNode )rn).prev = rp; if (rp != null) rp.next = rn; if (first != null) first.prev = root; root.next = first; root.prev = null; } assert checkInvariants(root); } }
检查不变的节点
staticuntreeifyboolean checkInvariants(TreeNode t) { TreeNode tp = t.parent, tl = t.left, tr = t.right, tb = t.prev, tn = (TreeNode )t.next; if (tb != null && tb.next != t) return false; if (tn != null && tn.prev != t) return false; if (tp != null && t != tp.left && t != tp.right) return false; if (tl != null && (tl.parent != t || tl.hash > t.hash)) return false; if (tr != null && (tr.parent != t || tr.hash < t.hash)) return false; if (t.red && tl != null && tl.red && tr != null && tr.red) return false; if (tl != null && !checkInvariants(tl)) return false; if (tr != null && !checkInvariants(tr)) return false; return true; }
将红黑树节点转为链表节点, 当节点<=6个时会被触发。
final Node添加元素untreeify(HashMap map) { Node hd = null, tl = null; // hd指向头节点, tl指向尾节点 // 从调用该方法的节点, 即链表的头节点开始遍历, 将所有节点全转为链表节点 for (Node q = this; q != null; q = q.next) { // 调用replacementNode方法,将树节点构建成链表节点 Node p = map.replacementNode(q, null); // 如果tl为null, 则代表当前节点为第一个节点, 将hd指向p if (tl == null) hd = p; // 否则, 将尾节点的next指向当前节点p,也就是进行链表追加 else tl.next = p; // 每次循环q都会后移一个,同理p也就是后移之后构建出来的链表节点 tl = p; // 将tl节点指向链表节点p, 即尾节点 } // 返回转换后的链表的头节点 return hd; } // 构建链表节点 Node replacementNode(Node p, Node next) { return new Node<>(p.hash, p.key, p.value, next); }
jdk1.7和jdk1.8的区别
put流程图 put将指定的键值对添加到map中,真正初始化哈希表(初始化存储数组table)的时候就在第1次添加键值对时,即第1次调用put()时。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
hash – 计算 key 的 hash 值
拿到 key 的 hashCode,并将 hashCode 的高16位和 hashCode 进行异或(XOR)运算,得到最终的 hash 值。
该函数在JDK 1.7 和 1.8 中的实现不同,但原理一样 = 扰动函数 = 使得根据key生成的哈希码(hash值)分布更加均匀、更具备随机性,避免出现hash值冲突(即指不同key但生成同1个hash值)
JDK 1.7 做了9次扰动处理(4次位运算 + 5次异或运算)
JDK 1.8 简化了扰动函数 , 只做了2次扰动(1次位运算 + 1次异或运算)
// JDK 1.7实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 4次位运算 + 5次异或运算(9次扰动)
static final int hash(int h) {
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
// JDK 1.8实现:将 键key 转换成 哈希码(hash值)操作 = 使用hashCode() + 1次位运算 + 1次异或运算(2次扰动)
// 1. 取hashCode值: h = key.hashCode()
// 2. 高位参与低位的运算:h ^ (h >>> 16)
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
// a. 当key = null时,hash值 = 0,所以HashMap的key 可为null
// 注:对比HashTable,HashTable对key直接hashCode(),若key为null时,会抛出异常,所以HashTable的key不可为null
// b. 当key ≠ null时,则通过先计算出 key的 hashCode()(记为h),然后 对哈希码进行 扰动处理: 按位 异或(^) 哈希码自身右移16位后的二进制
}
hash常见疑惑
为什么要将 hashCode 的高16位参与运算?
例如下图,在 table 的长度较小的时候,此处等于8。如果不加入高位运算,由于 n - 1 是 0000 0111,所以结果只取决于 hash 值的低3位,无论高位怎么变化,索引计算结果都是一样的。
如果我们将高位参与运算,则索引计算结果就不会仅取决于低位。索引冲突的情况就会大大减少
为什么不直接采用经过hashCode()处理的哈希码 作为 存储数组table的下标位置?
- 结论:容易出现 哈希码 与 数组大小范围不匹配的情况,即 计算出来的哈希码可能 不在数组大小范围内,从而导致无法匹配存储位置
原因描述
为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标?
- 结论:根据HashMap的容量大小(数组长度),按需取 哈希码一定数量的低位 作为存储的数组下标位置,从而 解决 “哈希码与数组大小范围不匹配” 的问题
- 具体解决方案描述
为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?
- 结论:加大哈希码低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性 & 均匀性,最终减少Hash冲突
put的真正存放逻辑
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab是当前table数组, n 是tab数组的长度,i为数组索引,p为数组索引i位置的节点
Node[] tab; Node p; int n, i;
// 1. 若哈希表的数组tab为空,则 通过resize() 创建,
//所以,初始化哈希表的时机是第1次调用put函数时,初始化创建
if ((tab = table) == null || (n = tab.length) == 0){
n = (tab = resize()).length;
}
// 2. 计算数组索引i = (n - 1) & hash,即插入数组的位置。
// 3. 获取到i位置的节点p,判断是否存在。
// 若p为null,表示当前位置没有元素,则直接在该数组位置新建节点,插入完毕
if ((p = tab[i = (n - 1) & hash]) == null){
tab[i] = newNode(hash, key, value, null);
}
// p!=null,当前i位置有元素,此时发生了Hash冲突,需要在继续判断
else {
// e 临时存储当前节点p,k 当前节点p的key
Node e; K k;
// a. 若p节点的hash和key与新元素hash和key相同,就用e节点临时存储下p节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k)))){
e = p;
}
// b. 若p节点是红黑树,则直接在树中插入或者更新键值对
else if (p instanceof TreeNode){
e = ((TreeNode)p).putTreeval(this, tab, hash, key, value); ->>分析3
}
// c. 若p节点是链表,则在链表中插入 or 更新键值对
else {
// d. 从p结点的下一个节点开始遍历链表,
for (int binCount = 0; ; ++binCount) {
// 先将 e 指向p节点的下一个节点,再判断p节点是不是链表的最后一个节点,
if ((e = p.next) == null) {
// 进入到这里说明循环到最后一个节点了也没找到与新数据key相同的节点。
// 将新数据使用尾插入追加到p节点后面
p.next = newNode(hash, key, value, null);
// 插入节点后,判断是否需要链表转红黑树,
// 链表元素数大于8才转,因为这里是从第二个节点开始的,所以 TREEIFY_THRESHOLD - 1 = 7 ,又因为binCount是从0开始的,所以用的是>=号。
// 例如bigCount=7,表示循环了进行了7次,加上原来的那个头节点,表示该链表原先有8个节点,然后新元素又进行了尾插入,此时该链表就有9个元素了,所以此时就得树化操作
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash); // 树化操作
// 终止循环
break;
}
// 遍历链表的key找到与新数据key相同的节点,然后终止循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 本轮循环没找到与新数据key相同的节点,则节点后移,进行下次循环
p = e;
}
}
// 对d情况的后续操作:e != null 表示key已存在,直接用新value 覆盖 旧value & 返回旧value
if (e != null) {
V oldValue = e.value;
//此处onlyIfAbsent 是固定值 false,所以这个if是必进入的
if (!onlyIfAbsent || oldValue == null){
// 直接用新value 覆盖 旧value & 返回旧value
e.value = value;
}
afterNodeAccess(e); // 替换旧值时会调用的方法(默认实现为空)
return oldValue;
}
}
// 记录该map被修改的次数,主要用于多线程并发时候
++modCount;
// 插入成功后,若实际存在的键值对数量size > 扩容阈值threshold 则进行扩容
if (++size > threshold){
resize();
}
afterNodeInsertion(evict);// 插入成功时会调用的方法(默认实现为空)
return null;
}
void afterNodeAccess(Node e) {}
void afterNodeInsertion(boolean evict) {}
putTreeval
向红黑树插入 or 更新数据(键值对),遍历红黑树,找到与新数据key相同的节点,新数据value替换旧数据的value,找不到相同的key则创建新节点并插入。
final TreeNoderootputTreeval(HashMap map, Node [] tab, int h, K k, V v) { Class> kc = null; // 是否调用find方法进行查找,默认没调用 boolean searched = false; // 查找根节点, 索引位置的头节点并不一定为红黑树的根节点, // 此处的this就是调用该方法的TreeNode实例, TreeNode root = (parent != null) ? root() : this; // 将根节点赋值给p节点,从根节点开始遍历红黑树,从内部终止遍历 for (TreeNode p = root;;) { //dir:表示向哪个子树查找,-1左,1右; ph:当前树节点的hash,pk:当前树节点的key int dir, ph; K pk; // 将当前节点p的hash赋值给ph, // 并且新数据的hash小于当前树节点的hash,则向p的左子树查找 if ((ph = p.hash) > h) dir = -1;//dir赋值为-1, // 向p的右子树查找 else if (ph < h) dir = 1;//dir赋值为1, // 当前树节点的key等于新数据的key,直接返回当前节点 else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; // 如果k为null并且k所属的类没有实现Comparable接口 或者 k和p节点的key相等(dir==0) else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) { // 还没有调用find方法进行查找 if (!searched) { TreeNode q, ch; searched = true;// 改为已经调用find方法进行查找了, // 从p节点的左节点和右节点分别调用find方法进行查找, 如果查找到目标节点则并终止循环,返回q; if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null) || ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null)) return q; } // 使用定义的一套规则来比较p节点的key和新数据的key大小, 用来决定向左还是向右查找 dir = tieBreakOrder(k, pk);// dir<0 则代表 k xp = p; // dir<=0则向p左边查找,否则向p右边查找,如果为null,则代表该位置即为x的目标位置 if ((p = (dir <= 0) ? p.left : p.right) == null) { // 走进来代表已经找到x的位置,只需将x放到该位置即可 Node xpn = xp.next; // 创建新的节点, 其中x的next节点为xpn, 即将x节点插入xp与xpn之间 TreeNode x = map.newTreeNode(h, k, v, xpn); // 调整x、xp、xpn之间的属性关系 if (dir <= 0) // 如果时dir <= 0, 则代表x节点为xp的左节点 xp.left = x; else // 如果时dir> 0, 则代表x节点为xp的右节点 xp.right = x; xp.next = x; // 将xp的next节点设置为x x.parent = x.prev = xp; // 将x的parent和prev节点设置为xp // 如果xpn不为空,则将xpn的prev节点设置为x节点,与上文的x节点的next节点对应 if (xpn != null) ((TreeNode )xpn).prev = x; // 进行红黑树的插入平衡调整 moveRootToFront(tab, balanceInsertion(root, x)); return null; } } }
查找红黑树的根节点,通过判断有没有父节点来找出根节点
final TreeNodefindroot() { for (TreeNode r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } }
从调用此方法的节点开始查找, 通过hash值和key找到对应的节点。查找过程无非就是,比较hash,判断往左找还是往右找,特殊情况就是 一边为空 那就只往另一边找,比较key是否相等,相等就找到了。
final TreeNodefind(int h, Object k, Class> kc) { // 1.将p节点赋值为调用此方法的节点,即为红黑树根节点 TreeNode p = this; // 2.从p节点开始向下遍历 do { // ph p的hash,pk p的key int ph, dir; K pk; TreeNode pl = p.left, pr = p.right, q; // 3.如果传入的hash值小于p节点的hash值,则往p节点的左边遍历 if ((ph = p.hash) > h) p = pl; // 4.如果传入的hash值大于p节点的hash值,则往p节点的右边遍历 else if (ph < h) p = pr; // 5.如果传入的hash值和key值等于p节点的hash值和key值,则p节点为目标节点,返回p节点 else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; // 6.p节点的左节点为空则将向右遍历 else if (pl == null) p = pr; // 7.p节点的右节点为空则向左遍历 else if (pr == null) p = pl; // 8.将p节点与k进行比较 else if ((kc != null || (kc = comparableClassFor(k)) != null) && // 8.1 kc不为空代表k实现了Comparable (dir = compareComparables(kc, k, pk)) != 0)// 8.2 k pk则dir>0 // 8.3 k treeifyBin 将数组的某个索引里的链表转为红黑树
final void treeifyBin(Node查找元素 get[] tab, int hash) { // n:当前数组长度,index:hash经过计算得到的索引,e:index索引位置的元素 int n, index; Node e; // 当前数组为空或者当前数组长度小于数组转为红黑树的阈值64时,需要扩容 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); // 计算得到索引index,并且取出来index索引对应的节点e,并且 e 不是null, else if ((e = tab[index = (n - 1) & hash]) != null) { // hd 存头节点,tl TreeNode hd = null, tl = null; // 从e节点开始遍历链表 do { // 将链表节点e转红黑树节点p TreeNode p = replacementTreeNode(e, null); // 如果是第一次遍历,将头节点赋值给hd if (tl == null) hd = p; // 如果不是第一次遍历,则处理当前节点的prev属性和上一个节点的next属性 else { p.prev = tl; // 当前节点的prev属性设为上一个节点 tl.next = p; // 上一个节点的next属性设置为当前节点 } // 将p节点赋值给tl, tl = p; } while ((e = e.next) != null); //后移,找下一个节点,再继续遍历 // 将table该索引位置赋值为hd头节点,如果该节点不为空,则以头节点(hd)为根节点, 构建红黑树 if ((tab[index] = hd) != null) hd.treeify(tab); } } // For treeifyBin 将指定的链表节点转为树节点 TreeNode replacementTreeNode(Node p, Node next) { return new TreeNode<>(p.hash, p.key, p.value, next); } 根据指定的key,查找对应的value值,找不到返回null,后续操作geit的结果的时候一定要判断非null,否则会出现空指针异常。
public V get(Object key) { NodegetNodee; return (e = getNode(hash(key), key)) == null ? null : e.value; } 根据key的hash和key,查找节点。找不到返回null
final NodegetTreeNodegetNode(int hash, Object key) { // tab:当前map的数组,first:当前hash对应的索引位置上的节点,e:遍历过程中临时存储的节点, // n:tab数组的长度,k:first节点的key Node [] tab; Node first, e; int n; K k; // 1.对table进行校验:table不为空 && table长度大于0 && // hash对应的索引位置上的节点不为空 if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) { // 判断第一个节点是不是要找的元素,比较hash值和key是否和入参的一样,如果一样,直接返回第一个节点 if (first.hash == hash && // always check first node ((k = first.key) == key || (key != null && key.equals(k)))){ return first; } // 第一个节点不是要找的元素, // 取出来第二个节点,并且第二个节点不为null,说明还没走到该节点链的最后 if ((e = first.next) != null) { // 如果第一个节点是红黑树类型 if (first instanceof TreeNode){ // 调用红黑树的查找目标节点方法getTreeNode return ((TreeNode )first).getTreeNode(hash, key); } // 前提条件:第一个节点不为null,并且也不是红黑树,而且还有下一个节点,那么该索引位置的元素类型就是链表,从第二个节点开始遍历该链表, do { // 找到了,返回节点 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } while ((e = e.next) != null);// e指针后移,并且下一个节点不为null则继续遍历,不为null表示没到链表最后呢 } } // 没找到返回null return null; } 在红黑树中根据key和key的hash 查找对应的树节点,找不到返回null,这里要先找到根节点,然后从根节点再去查找树节点。root 和 find 方法之前已经讲解过了。
final TreeNode判断key是否存在 containsKeygetTreeNode(int h, Object k) { // 如果当前调用该方法的红黑树节点还有父级节点,说明该红黑树节点不是根节点,所以需要调用 root() 方法找到根节点, // 如果当前调用该方法的红黑树节点没有父级节点,说明该红黑树节点就是根节点, // 找到根节点后,根节点调用find方法去查找目标节点 return ((parent != null) ? root() : this).find(h, k, null); } 判断key是否存在,实际上调用的还是刚刚那个getNode,找到就返回ture,找不到返回false
public boolean containsKey(Object key) { return getNode(hash(key), key) != null; }判断value是否存在 containsValue根据给定的value查找当前map中是否有和value相同的节点,有的话返true,没有返回false;
public boolean containsValue(Object value) { // tab:当前map的数组,v:目标元素的value Node删除元素 remove(Object key)[] tab; V v; // 首先判断当前数组不为null 并且 含有的元素大于0 if ((tab = table) != null && size > 0) { // 遍历该数组 for (int i = 0; i < tab.length; ++i) { // 遍历数组中每个索引位置的的链表,并且该位置不为null, // 其实可以改成while循环的。不知为什么开发jdk的这帮人这么喜欢用for循环 for (Node e = tab[i]; e != null; e = e.next) { // 如果节点的value与入参value相等,就直接返回true,return 会停止循环并且退出方法。 if ((v = e.value) == value || (value != null && value.equals(v))) return true; } } } // 找不到则返回false return false; } 根据指定的key删除元素,若删除成功则返回被删除的元素的value,删除失败返回null
public V remove(Object key) { // 被删除的元素 NoderemoveNodee; return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } 移除某个节点,根据指定的key hash 和另外的两个条件进行移除。
matchValue=true,表示仅在value相等时删除,=false,表示value不相等的时候也可以删除该节点。
HashMap.remove(key) 不去判断值相不相等。
HashMap.EntrySet.remove(key)、HashMap.remove(key,value)、linkedHashMap.linkedEntrySet.remove(key)需要判断value相不相等
final NoderemoveTreeNoderemoveNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { // tab:当前map 的数组,p:hash对应的数组索引index位置上的节点,n:数组长度,index:hash对应的数组索引 // 这几个值在hashMap的源码中很常见 Node [] tab; Node p; int n, index; //前提判断 数组不为空,并且长度大于0 并且 // hash对应的数组索引位置上的节点p也不为null if ((tab = table) != null && (n = tab.length) > 0 && (p = tab[index = (n - 1) & hash]) != null) { // node:被移除的节点,e:当前头节点的下一个节点,k:e节点的key,v:被移除节点node 的value Node node = null, e; K k; V v; // 如果第一个节点p就是目标节点,则将node指向第一个节点p if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))){ node = p; } // 第一个节点不是,那就看看第一个节点还有没有下一个元素。 // 如果有第二个节点 else if ((e = p.next) != null) { // 如果刚刚第一个节点是红黑树 if (p instanceof TreeNode){ // 调用红黑树的查询节点的方法,getTreeNode 已经在上文讲过了 node = ((TreeNode )p).getTreeNode(hash, key); } // 第一个节点不是红黑树,并且还有第二个节点,那就说明,这里是链表了 else { // 那么开始循环链表,从第二个节点开始循环,因为第一个节点已经处理过了 do { // 判断e节点是不是目标节点,是的话就将node指向e,并且终止循环 if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } // e节点不是目标节点,那就将p节点指向e节点, // 然后while里面e节点后移,在进入循环后发现e是目标节点了,退出循环,退出后此时p节点还是e节点的前一个节点,也就保证了在整个循环的过程中,p节点始终是e节点的前一个节点。 p = e; } while ((e = e.next) != null);// e指针后移,并且下一个节点不为null则继续遍历,不为null表示没到链表最后呢。是不是似曾相识的感觉。 } } // 找到目标节点了 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { // 如果目标节点是红黑树 if (node instanceof TreeNode){ // 调用红黑树的删除节点方法 ((TreeNode )node).removeTreeNode(this, tab, movable); } // 目标节点是p节点, // 还记得之前 如果第一个节点p就是目标节点,则将node指向第一个节点p else if (node == p){ // 将目标节点的下一个节点作为该索引位置的第一个元素 // 也就是跳过目标节点,指向目标节点的下一位 tab[index] = node.next; } // 这里就是遍历链表找到了目标节点 else{ // 注意 进入到这里 node 其实也是指向 e的,说白了 node 就是 e,下面用node来替代e的登场 // p节点始终作为node的上一个节点,p.next始终指向目标节点node // 现在将p.next 指向目标节点node的next,这样跳过了目标节点node,就把node移除掉了 p.next = node.next; } // 记录map结构被修改的次数,主要用于并发编程 ++modCount; // 记录table存储了多少键值对,因为移除了一个,所以此处就减一,其实用size-- 也一样,不明白为啥非得用 --size --size; // 该方法在hashMap中是空方法,主要是供linkedHashMap使用,因为linkedHashMap重写了该方法 afterNodeRemoval(node); //返回被移除的节点 return node; } } // 没找到 返回null return null; } 红黑树的节点移除,还要根据movable判断删除时是否移动其他节点。谁调用该方法就删除谁,
movable - 如果为false,则在删除时不移动其他节点
final void removeTreeNode(HashMapremoveTreeNode 疑问map, Node [] tab, boolean movable) { // --- 链表的处理start --- int n; // 1.table为空或者length为0直接返回 if (tab == null || (n = tab.length) == 0) return; // 2.根据调用者的hash计算出索引的位置,也就是 根据将要被移除的node节点的hash进行计算 int index = (n - 1) & hash; // 3.first:当前index位置的节点,root:当前index位置的节点,作为根节点,rl:root的左节点 TreeNode first = (TreeNode )tab[index], root = first, rl; // 4.succ:目标节点node.next节点,pred:目标节点node.prev节点 TreeNode succ = (TreeNode )next, pred = prev; // 5.如果pred节点为空,则代表目标节点node为头节点, if (pred == null){ // 则将table索引位置和first都指向succ节点(node.next节点) tab[index] = first = succ; } // 6.否则将pred的next属性指向succ节点(node.next节点) else{ // 这块有一点点饶,主要是因为他这个变量搞得,其实等同于 node.prev.next = node.next; // 原来是 pred.next=node->node.next=succ // 现在是 pred.next= node.next=succ,跳过了node,也就相当于把node删除了 pred.next = succ; } // 7.如果succ节点(node.next节点)不为空, if (succ != null){ // 则将succ.prev(node.next.prev)节点设置为pred(node.prev), 与前面对应 // 等同于 node.next.prev = node.prev; succ.prev = pred; } // 8.如果first节点为null,则代表该索引位置没有节点则直接返回 // 这个if其实可以放在上方第3点后面,第4点前面,因为直接判断索引位置就是null,压根不用在找下个节点 if (first == null){ return; } // 9.如果root的父节点不为空 if (root.parent != null){ // 则从该root节点开始去查找根节点,得到根节点之后,将root指向真正的根节点 root = root.root(); } // 10.通过root节点来判断此红黑树是否太小, 如果是太小了则调用untreeify方法转为链表节点并返回 // (转链表后就无需再进行下面的红黑树处理) // 太小的判定依据:根节点为null,或者根的右节点为null,或者根的左节点为null,或者根的左节点的左节点为null // 是根据节点数来判断的,并没有遍历整个红黑树去统计节点数是否小于等于阈值6 if (root == null || root.right == null || (rl = root.left) == null || rl.left == null) { tab[index] = first.untreeify(map); // too small return; } // --- 链表的处理end --- // --- 以下代码为红黑树的处理 --- // 11.p:目标节点node,pl:p的左节点,pr:p的右节点,replacement:被删除掉的节点 TreeNode p = this, pl = left, pr = right, replacement; // 12.如果p的左和右节点都不为空时 if (pl != null && pr != null) { // 12.1 将s指向pr(p的右节点),sl:s的左节点 TreeNode s = pr, sl; // 12.2 向左一直查找,跳出循环时,s为没有左节点的节点 while ((sl = s.left) != null){ s = sl; } // 12.3 交换p节点和s节点的颜色 boolean c = s.red; s.red = p.red; p.red = c; TreeNode sr = s.right; // s的右节点 TreeNode pp = p.parent; // p的父节点 // --- 第一次调整和第二次调整:将p节点和s节点进行了位置调换 --- // 12.4 第一次调整 // 如果p的右节点即为s节点,则将p和s交换位置,原先是s.parent = p;p.right = s; if (s == pr) { p.parent = s; s.right = p; } else { // 将sp指向s的父节点 TreeNode sp = s.parent; // 将sp作为p的父节点 if ((p.parent = sp) != null) { // 如果s节点为sp的左节点,则将sp的左节点指向p,此时sp的的左节点s变成了p节点 if (s == sp.left){ sp.left = p; } // 否则s节点为sp的右节点,则将sp的右节点指向p,此时sp的的右节点s变成了p节点 else{ sp.right = p; } // 完成了p和s的交换位置 } // s的右节点指向p的右节点 if ((s.right = pr) != null) // 如果pr不为空,则将pr的父节点指向s,此时p的右节点变成了s的右节点 pr.parent = s; } // 12.5 第二次调整 // 将p的左节点赋值为空,pl已经保存了该节点 p.left = null; // 将p节点的右节点指向sr,如果sr不为空,则将sr的父节点指向p节点,此时s的右节点变成了p的右节点 if ((p.right = sr) != null) sr.parent = p; // 将s节点的左节点赋值为pl,如果pl不为空,则将pl的父节点指向s节点,此时p的左节点变成了s的左节点 if ((s.left = pl) != null) pl.parent = s; // 将s的父节点赋值为p的父节点pp // 如果pp为空,则p节点为root节点, 交换后s成为新的root节点 if ((s.parent = pp) == null) root = s; // 如果p不为root节点, 并且p是pp的左节点,则将pp的左节点赋值为s节点 else if (p == pp.left) pp.left = s; // 如果p不为root节点, 并且p是pp的右节点,则将pp的右节点赋值为s节点 else pp.right = s; // 12.6 寻找replacement节点,用来替换掉p节点 // 12.6.1 如果sr不为空,则replacement节点为sr,因为s没有左节点,所以使用s的右节点来替换p的位置 if (sr != null) replacement = sr; // 12.6.1 如果sr为空,则s为叶子节点,replacement为p本身,只需要将p节点直接去除即可 else replacement = p; } // 13.承接12点的判断,如果p的左节点不为空,右节点为空,replacement节点为p的左节点 else if (pl != null) replacement = pl; // 14.如果p的右节点不为空,左节点为空,replacement节点为p的右节点 else if (pr != null) replacement = pr; // 15.如果p的左右节点都为空, 即p为叶子节点, replacement节点为p节点本身 else replacement = p; // 16.第三次调整:使用replacement节点替换掉p节点的位置,将p节点移除 if (replacement != p) { // 如果p节点不是叶子节点 // 16.1 将p节点的父节点赋值给replacement节点的父节点, 同时赋值给pp节点 TreeNode pp = replacement.parent = p.parent; // 16.2 如果p没有父节点, 即p为root节点,则将root节点赋值为replacement节点即可 if (pp == null) root = replacement; // 16.3 如果p不是root节点, 并且p为pp的左节点,则将pp的左节点赋值为替换节点replacement else if (p == pp.left) pp.left = replacement; // 16.4 如果p不是root节点, 并且p为pp的右节点,则将pp的右节点赋值为替换节点replacement else pp.right = replacement; // 16.5 p节点的位置已经被完整的替换为replacement, 将p节点清空, 以便垃圾收集器回收 p.left = p.right = p.parent = null; } // 17.如果p节点不为红色则进行红黑树删除平衡调整 // (如果删除的节点是红色则不会破坏红黑树的平衡无需调整) TreeNode r = p.red ? root : balanceDeletion(root, replacement); // 18.如果p节点为叶子节点, 则简单的将p节点去除即可 if (replacement == p) { TreeNode pp = p.parent; // 18.1 将p的parent属性设置为空 p.parent = null; if (pp != null) { // 18.2 如果p节点为父节点的左节点,则将父节点的左节点赋值为空 if (p == pp.left) pp.left = null; // 18.3 如果p节点为父节点的右节点, 则将父节点的右节点赋值为空 else if (p == pp.right) pp.right = null; } } if (movable) // 19.将root节点移到索引位置的头节点 moveRootToFront(tab, r); } 第一点:为什么 sr 是 replacement 的首选,p 为备选?
解析:首先我们看 sr 是什么?从代码中可以看到 sr 第一次被赋值时,是在 s 节点进行了向左穷遍历结束后,因此此时 s 节点是没有左节点的,sr 即为 s 节点的右节点。而从上面的第一次调整和第二次调整我们知道,p 节点已经跟 s 节点进行了位置调换,所以此时 sr 其实是 p 节点的右节点,并且 p 节点没有左节点,因此要移除 p 节点,只需要将 p 节点的右节点 sr 覆盖掉 p 节点即可,因此 sr 是 replacement 的首选,而如果 sr 为空,则代表 p 节点为叶子节点,此时将 p 节点直接移除即可。
第二点:关于红黑树的平衡调整? 这点也是比较难的部分
removeTreeNode 图解红黑树的操作涉及的操作比较复杂,三言两语无法说清。有兴趣的可以去单独学习,本文由于篇幅关系暂不详细介绍红黑树的具体操作,在这简单的介绍:红黑树是一种自平衡二叉树,拥有优秀的查询和插入/删除性能,广泛应用于关联数组。
对比 AVL 树,AVL 要求每个节点的左右子树的高度之差的绝对值(平衡因子)最多为 1,而红黑树通过适当的放低该条件(红黑树限制从根到叶子的最长的可能路径不多于最短的可能路径的两倍长,结果是这个树大致上是平衡的),以此来减少插入/删除时的平衡调整耗时,从而获取更好的性能,而这虽然会导致红黑树的查询会比 AVL 稍慢,但相比插入/删除时获取的时间,这个付出在大多数情况下显然是值得的。
在 HashMap 中的应用:HashMap 在进行插入和删除时有可能会触发红黑树的插入平衡调整(balanceInsertion 方法)或删除平衡调整(balanceDeletion 方法),调整的方式主要有以下手段:左旋转(rotateLeft 方法)、右旋转(rotateRight 方法)、改变节点颜色(x.red = false、x.red = true),进行调整的原因是为了维持红黑树的数据结构。
本图解忽略红黑树的颜色,请注意。
下面的图解是代码中的最复杂的情况,即流程最长的那个,p 节点不为根节点,p 节点有左右节点,s 节点不为 pr 节点,s 节点有右节点。
另外,第一次调整和第二次调整的是本人根据代码而设定的,将第一次和第二次调整合起来看会更容易理解(看第1和3两棵树)。
如下:第一次调整 + 第二次调整:将 p 节点和 s 节点进行了位置调换,选出要替换掉 p 节点的 replacement
balanceDeletion
第三次调整:将 replacement 节点覆盖掉 p 节点。红黑树的删除平衡调整,第一个输入参数是整棵红黑树的根节点,第二个输入参数是待删除节点或是其继承者,搞清楚了输入参数,下面我们就开始分析丧心病狂的balanceDeletion方法。
staticTreeNode balanceDeletion(TreeNode root, TreeNode x) { //注意,传进来的x节点子树的黑节点数,肯定是比x的兄弟节点子树的黑节点数少1 for (TreeNode xp, xpl, xpr;;) { if (x == null || x == root)//如果x是root return root; else if ((xp = x.parent) == null) {//(说明是循环后更新x后,使得x指向了root)但x没有父节点 x.red = false; return x; } else if (x.red) {//如果x不是root(有父节点),且x为红色(这好办,直接把x变成黑色,让x子树的黑节点+1.多次循环可到达此分支) x.red = false; return root; } //接下来两个分支,x必为黑色 else if ((xpl = xp.left) == x) {//如果x是xp的左孩子 if ((xpr = xp.right) != null && xpr.red) { xpr.red = false; xp.red = true; root = rotateLeft(root, xp); xpr = (xp = x.parent) == null ? null : xp.right; } if (xpr == null) x = xp; else { TreeNode sl = xpr.left, sr = xpr.right; if ((sr == null || !sr.red) && (sl == null || !sl.red)) { xpr.red = true; x = xp; } else { if (sr == null || !sr.red) { if (sl != null) sl.red = false; xpr.red = true; root = rotateRight(root, xpr); xpr = (xp = x.parent) == null ? null : xp.right; } if (xpr != null) { xpr.red = (xp == null) ? false : xp.red; if ((sr = xpr.right) != null) sr.red = false; } if (xp != null) { xp.red = false; root = rotateLeft(root, xp); } x = root; } } } else { // symmetric//如果x是xp的右孩子 if (xpl != null && xpl.red) { xpl.red = false; xp.red = true; root = rotateRight(root, xp); xpl = (xp = x.parent) == null ? null : xp.left; } //经过上面if,不管它有没有执行,x的兄弟xpl肯定为黑色节点了 if (xpl == null) x = xp; else { TreeNode sl = xpl.left, sr = xpl.right; //这种情况说明xpl的孩子里没有红色节点 if ((sl == null || !sl.red) && (sr == null || !sr.red)) { xpl.red = true; x = xp; } else {//这种情况说明xpl的孩子里有红色节点 //如果sr为红色,则走此分支;sr其他情况则不会 if (sl == null || !sl.red) { if (sr != null) sr.red = false; xpl.red = true; root = rotateLeft(root, xpl); xpl = (xp = x.parent) == null ? null : xp.left; } if (xpl != null) { //xpl最终会旋转到之前xp的位置,并保持xp的颜色 xpl.red = (xp == null) ? false : xp.red; if ((sl = xpl.left) != null) sl.red = false; } if (xp != null) { xp.red = false; root = rotateRight(root, xp); } x = root;//下一次循环直接返回 } } } } }
- 提前说明一下,当说到“x节点子树的黑节点数n”是指:从x节点到它的子树的任意一个叶子节点的路径上的黑色节点个数都等于n。
- 整个函数是一个循环过程,可能会经过若干次循环。不管是刚调用此函数的第一次循环,或者是以后的循环,每次循环体刚开始时,x节点子树的黑节点数,肯定是比x的兄弟节点子树的黑节点数少1,这是由removeTreeNode函数来做保证的(由于删掉了一个黑色节点,所以黑节点数少1)。既然知道了x的黑节点数,比x的兄弟节点饿黑节点数少1,那么就需要通过调整来使得平衡。
- if (x == null || x == root)分支,如果x是root,则直接返回root。上一次循环执行了x = root后,会进入此分支。
- else if ((xp = x.parent) == null)分支,x的父节点xp为null,但xp为null说明x为root,但这样的话则只会进入上面的if (x == null || x == root)分支了,所以我认为此分支不可能进入。
- else if (x.red)分支,说明x不是root节点,且x为红色。这好办,直接把x变成黑色,让x的黑节点数+1。这样x的黑节点数就和x的兄弟节点的黑节点数一样了,也就到达了平衡。
- 接下来的两个分支,说明x不是root节点,且x为黑色,所以调整过程要稍微复杂一点了。但这两个分支是完全对称的,所以我只会讲一个分支。由于removeTreeNode函数的保证(总是以删除节点的后继作为替换节点,这里后继是指刚好大于删除节点的那个节点),所以调用此函数时,x肯定是xp的右孩子,所以我接下来讲解else if ((xpl = xp.left) == x)的else分支。
- 接下来这个大图是整个函数的else if ((xpl = xp.left) == x)的else分支的所有过程,每个过程都有标号以方便讲解。节点除标明为黑色或者红色外,灰色则代表不清楚此节点的颜色。建议读者对照着大图、源码和本博客同时进行查阅。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3K8rEWF6-1637751202309)(https://gitee.com/lixiaogou/pictures/raw/master/typora/20211124165509.jpg)]
if (xpl != null && xpl.red)这个分支可能执行,可能不执行。
如果xpl为红色,那么则会进入此if (xpl != null && xpl.red)分支,如下图所示。如果xpl为红色,那么xp和xpl的孩子的颜色都必为黑色节点。而之前说过,刚开始时x的黑节点数,比x的兄弟节点饿黑节点数少1,我们假设x的黑节点数为n,那么xpl作为它的兄弟节点,xpl的黑节点数则为n+1,由于xpl是红色的不属于黑色节点,那么可推理出xpl的两个孩子的黑节点数也为n+1。
如果xpl为红色,且执行完if (xpl != null && xpl.red)分支后,如下图所示。调整后,x的兄弟节点变成了一个黑色节点。对比上下图发现,通过旋转操作后,使得x和一个黑节点数为n+1的黑色节点成为了兄弟。
如果xpl为黑色,那么则不会进入此if (xpl != null && xpl.red)分支,如下图所示。xpl的黑节点数为n+1,比x多1。
对比如果xpl为红色,和如果xpl为黑色的两种情况的最终结果,如下图所示,可以发现两种情况最终结果的共同点是:x的兄弟节点必为黑色,但此时兄弟节点的黑节点数多1,所以还需要调整。而两种情况的差异点是:xp的颜色。这也是后面要执行xpl.red = (xp == null) ? false : xp.red(把xp的颜色赋给xpl)的原因。
如果xpl为null,那么则不会进入此if (xpl != null && xpl.red)分支,如下图所示。我认为此分支不可能进入。
接下来讲解if (xpl == null)的else分支里的逻辑(根据上一条分析,所以是认为不可能进入if (xpl == null)分支的),在大图中是虚线以下的过程。
虚线下的过程,只能操作到x节点,xp节点(x的父节点),xpl节点(x的兄弟节点),sl节点(x的兄弟节点的左孩子)和sr节点(x的兄弟节点的右孩子),即只能操作这上下三层节点。这也是为什么虚线上的过程最后总会调整为xpl节点为黑色节点的情况,因为这样的话,xpl节点的两个孩子sl和sr的黑节点数就为n,而x节点本身的黑节点数也为n。只有找到了黑节点数都为n的节点们后,才方便进行调整,那之后就根据各种情况来再平衡就好了。
if (xpl == null)的else分支的初始状态如下图(注意,此初始状态是从过程(4)(4)(4)而来的,所以虚线下的过程都是过程(4)(4)(4)接下来的过程。其实还可以画出从过程(6)(6)(6)而来的初始状态,但不必画出了)。由于xpl的黑节点数为n+1,则它自身为黑色,所以推理出,它的左右孩子的黑节点则为n。
很有必要说明一下if ((sl == null || !sl.red) && (sr == null || !sr.red))分支和它的else分支的各种情况,如下图所示,它的else分支里,sl和sr中必有一个节点是红色的。而且在else分支里,当sr为红色时,必然还会进入if (sl == null || !sl.red)子分支。
如果进入了if ((sl == null || !sl.red) && (sr == null || !sr.red))分支,如下图所示。那么说明“sl为null或sl为黑色”和“sr为null或sr为黑色”这两件事都成立,可见过程(8)(8)(8)时,x的兄弟节点的两个孩子都是黑色节点,这样的话根本没有操作空间使得x和x的兄弟节点平衡(但凡x的兄弟节点的两个孩子有一个红色节点,也不至于这样)。过程(9)(9)(9)里,所以只好另xpl为红色,这样xpl和它的兄弟节点平衡了(黑节点数一样),但由于这里是通过让xpl的黑节点数少1来使得平衡的,且xp的颜色我们又没有变过(这里考虑了虚线上的两种情况的差异点,即xp刚开始的颜色都有可能),所以不管xp的初始颜色是什么,xp必然比xp的兄弟节点的黑节点数少1,所以还是不平衡的,然后继续循环。如果考虑xp初始为黑色,那么过程(9)(9)(9)里,xp的黑节点数为n+1,xp的兄弟节点的黑节点数为n+2。
如果进入了if ((sl == null || !sl.red) && (sr == null || !sr.red))的else分支,如下图所示。那么说明“sl为null或sl为黑色”和“sr为null或sr为黑色”这两件事不是都成立的。观察逻辑可以发现,else分支里可以分为两种情况:1.如果sr为红色,此时不管sl的颜色。 2.如果sr为黑色,sl为红色。其实这两种情况的共同点就是sr和sl中至少有一个红色节点了。
假设情况为“如果sr为红色,此时不管sl的颜色”,因为此时sl的颜色无论为什么对过程不会有影响。如下图所示,为这种情况的开始过程和结束过程。发现过程(16)(16)(16)时,整个树已经平衡了,结束后会将x指向root(x = root),下次循环就会直接退出啦。且过程(10)(10)(10)里xp这个位置,对应到过程(16)(16)(16)里则变成了xpl这个节点,且过程(10)(10)(10)里xp的颜色还可能为黑色,那么过程(16)(16)(16)的xpl会和过程(10)(10)(10)里xp的颜色一致(虚线下的三行过程都保证了这一点)。这是通过将xp的颜色赋给xpl(xpl.red = (xp == null) ? false : xp.red),再右旋xp(rotateRight(root, xp))来保证的,这样,就把虚线上的差异点考虑在内了。
再假设情况为“如果sr为黑色,sl为红色”,如下图所示,为这种情况的开始过程和结束过程。发现过程(20)(20)(20)时,整个树已经平衡了,结束后会将x指向root(x = root),下次循环就会直接退出啦。同样的,过程(17)(17)(17)里xp这个位置对应过过程(20)(20)(20)里会保持相同位置的节点颜色一致。
虚线下的第二行过程(过程(10)(10)(10)到过程(16)(16)(16))和第三行过程(过程(17)(17)(17)到过程(20)(20)(20)),除了开始过程和结束过程外,中间过程里我只给那些调整过程中黑节点数不变的节点标注出来了黑节点数,其他没有标注出来的节点只需要在结束过程里进行确认就好了。
之所以虚线下的第二行过程和第三行过程要进行区分,是因为sr是否为红色,需要进行的调整操作是不一样的。比如过程过程(10)(10)(10)如果走的是第三行过程的流程,如下图所示,最终会造成sl和xp这两个兄弟节点不是平衡的。
总结一下:
rotateLeft 左旋
- 和balanceInsertion一样,此balanceDeletion函数同样只处理三层树的结构。
- 每次循环体里,除非进入那些直接return的终点,那么循环体开始时,x节点总是比x节点的兄弟节点的黑节点数少1的。
- 虚线下的过程,其主要技巧(指的是虚线下第二行和第三行。第一行是先让自己和兄弟平衡,但却是通过不是让自己加1,而是让兄弟减1,所以还需要x往上移动,往更高层借红色节点)是通过借用颜色为红色的兄弟节点的左右孩子,只要有一个孩子是红色的,就可以借用。而借用其实就是,通过旋转操作把红色节点弄到自己的子树里,然后通过红色变黑色,让自己子树的黑节点数加1,从达到平衡。
- 大图中,到达虚线时的过程,x的兄弟节点总会是黑色的。根据前提“x节点总是比x节点的兄弟节点的黑节点数少1”,而兄弟节点又是黑色,可以推理出“x的兄弟节点的两个孩子的黑节点数,和x节点一样大”,找到了一样大的节点,之后才好处理。
p:图示中的 E,r:图示中的 S
// p 图示中的 E,r 图示中的 S staticTreeNode rotateLeft(TreeNode root, TreeNode p) { TreeNode r, pp, rl; if (p != null && (r = p.right) != null) { // 将 3 结点的中间部分挂在左节点 p(原始父节点)下 if ((rl = p.right = r.left) != null) rl.parent = p; // 将红链接中的右节点 r(原始子节点)上移,左节点(原始父节点)下移为右节点的子节点 // 子树(可能是包含根节点的完整二叉树)在父节点 pp 中的位置保持不变 if ((pp = r.parent = p.parent) == null) (root = r).red = false;// 原始父节点即为根节点 else if (pp.left == p) pp.left = r; else pp.right = r; // 父子节点反转 r.left = p; p.parent = r; } return root; }
- 初始状态下:r为p的right child、pp为p的parent、rl为r的left child。
- p为rotateLeft函数要处理那个的节点。作为此函数的入参,一般认为p必有一个right child,即认为if (p != null && (r = p.right) != null)分支一定能进入。
- if ((rl = p.right = r.left) != null) rl.parent = p;中,我们先认为r.left肯定不为null(其实无论它为不为null对旋转结果都没有影响,后面会讲到),那么把这一句拆成rl = r.left和p.right = rl,其过程如下图所示。
- 示意图中,节点无颜色代表并不关心该节点的颜色,黑色箭头为左右孩子指针,绿色箭头为父亲指针。刚改变过指向的指针会用太阳标志标识出来。
- 接下来的if else嵌套有三个分支,这里不按照代码顺序分析,先假设程序会进入else if (pp.left == p)分支,此时说明之前的if分支没有进入,即pp不为null,且p为pp的左孩子。这里我们把pp = r.parent = p.parent拆分为pp = p.parent和r.parent = pp。这样,从r.parent = pp开始执行到最后的示意图如下:
- 再假设程序会进入最后的else分支,说明pp不为null,且p为pp的右孩子。同样的,我们把pp = r.parent = p.parent拆分为pp = p.parent和r.parent = pp。这样,从r.parent = pp开始执行到最后的示意图如下:
- 最后再假设程序会进入if ((pp = r.parent = p.parent) == null)分支,说明pp为null。同样的,我们把pp = r.parent = p.parent拆分为pp = p.parent和r.parent = pp。但进入这个分支说明pp为null,这样,从r.parent = pp(实际是r.parent = null)开始执行到最后的示意图如下:
- 此函数并不关心旋转后红黑树是否平衡,它只负责完成旋转的任务,所以,是此函数的调用者负责维持平衡。
- 此函数的完整流程示意图如下。将三种情况对比分析后,可以发现,第4步和第5步都是为了处理好pp和r之间的连接,pp作为p的父节点,是整个旋转部分的上层,旋转后pp还是会与下层保持相同的孩子关系(原来p是pp的什么孩子,现在r就会是pp的什么孩子)(第三种情况由于pp为null,所以就不用处理pp和r之间的连接)。
- 第6步和第7步都是为了完成旋转的后半部分,即处理好p与r之间的连接,让p成为r的左孩子,完成左旋的任务。由于之前(第4、5步)已经处理好了p的父节点pp的孩子关系,所以可以改变p.parent了(反过来想,如果先执行第6、7步再执行第4、5步会导致pp节点再也找不到了,因为第7步会改变p.parent)。
- 第3步都是为了完成旋转的前半部分,即处理好p与rl之间的连接,让rl成为p的右孩子。
rotateRight 右旋
- 若将最终旋转的结果总结一下,再忽略掉pp节点(因为pp节点其实不属于旋转部分,它只是等旋转好了以后再与新的旋转部分维持相同的孩子关系),可得出如下示意图。可以发现这种旋转十分巧妙,旋转后p节点的左孩子不会受到影响、r节点的右孩子不会受到影响、rl节点的左右孩子都不会受到影响。
- r.left是否存在,对旋转结果也不会产生本质影响。它只是会让p节点的右孩子为null。
p:图示中的 S,l:图示中的 E
// p 图示中的 S,l 图示中的 E staticTreeNode rotateRight(TreeNode root, TreeNode p) { TreeNode l, pp, lr; if (p != null && (l = p.left) != null) { // 将 3 结点的中间部分挂在右节点 p(原始父节点)下 if ((lr = p.left = l.right) != null) lr.parent = p; // 将红链接中的左节点 l(原始子节点)上移,右节点(原始父节点)下移为左节点的子节点 // 子树(可能是包含根节点的完整二叉树)在父节点 pp 中的位置保持不变 if ((pp = l.parent = p.parent) == null) (root = l).red = false; else if (pp.right == p) pp.right = l; else pp.left = l; // 父子节点反转 l.right = p; p.parent = l; } return root; }
- 初始状态下:l为p的left child、pp为p的parent、lr为l的right child。
- 由于右旋和左旋完全类似,分析过程完全和上面章节类似,所以接下来只做重要讲解。
- if ((lr = p.left = l.right) != null)分支:lr = p.left = l.right拆分为lr = l.right和p.left = lr,然后接下来执行lr.parent = p。
- 将pp = l.parent = p.parent拆分为pp = p.parent和l.parent = pp。
- 先假设进入最后的else分支(代表pp.left == p成立),示意图如下:
- 再假设进入else if (pp.right == p)分支,示意图如下:
- 最后假设进入if ((pp = l.parent = p.parent) == null)分支,示意图如下:
- 完整流程示意图:
左旋右旋总结
- 右旋总结示意图:
- 不管是左旋还是右旋,pp节点其实都不算是旋转的部分,因为在旋转后,它只是与新的旋转部分保持相同的孩子关系。
- 从左旋、右旋的总结示意图里可以看出,没有画出来的子树部分之所以不用画,是因为在旋转后子树会保持相同的相对位置。比如,左旋总结示意图中:p的左子树还会是p的左子树,r的右子树还会是r的右子树。右旋总结示意图中:p的右子树还会是p的右子树,l的左子树还会是l的左子树。



