性质1:每个节点要么是黑色,要么是红色。
性质2:根节点是黑色。
性质3:每个叶子节点(NIL)是黑色。
性质4:每个红色结点的两个子结点一定都是黑色。(不能有两个连续的红色节点)
性质5:任意一结点到每个叶子结点的路径都包含数量相同的黑结点(黑色平衡)。
叶子节点(NIL节点):为了红黑树平衡而添加的空节点
由于以上提到的性质约束确保了红黑树的关键特性:从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。 这就保证了这个树大致上是平衡的
自己能搞定的自消化;
自己不能搞定的叫兄弟帮忙;
兄弟都帮忙不了的,通过父母,找远方亲戚。
新插入节点默认是红色,如果是黑色的话那么当前分支上就会多出一个黑色节点出来,从而破坏了黑色平衡
1. 一个节点
当插入一个元素为5的节点时,由于是新插入的节点,所以应该是红色。但是该树只有一个节点,也就是root根节点,根据红黑树定义2可得,该节点变为黑色。
2. 两个节点
当已经有一个根节点插入第二个节点元素为x时,分为两种情况。当x>5时,该节点为右节点。当x<5时,该节点为左节点。
3.三个节点
在已存在的两个节点产生的这两种情况来看,再添加一个元素,会有以下6种情况
3.1第二个节点作为root右子树情况
3.2第二个节点作为root左子树情况
上面存在的六种情况,由于其中两种已经是平衡的红黑树所以不需要旋转。其余的四种情况我们要进一步分析,如何旋转才能让他成为红黑树。
左旋:以某个节点作为旋转点,其右子节点变为旋转节点的父节点,右子节点的左子节点变为旋转节点的右子节点,左子节点保持不变。
右旋:以某个节点作为旋转点,其左子节点变为旋转节点的父节点,左子节点的右子节点变为旋转节点的左子节点,右子节点保持不变。
1. 情况一变红黑树
由图可知,明显该树左边太重了,所有的节点都是左子树,那我们应该向右旋转。以元素为10的节点为旋转点,左子节点5变成他的父节点。左子节点5的右子节点变为旋转节点的左子节点,由于是NIL节点所以在此不再画出。然后进行变色。
2. 情况二变红黑树
由图可知,情况二的树右边太重了,所有的节点都是右子树,那我们应该向左旋转。以元素为5的节点为旋转点,右子节点10变成他的父节点。右子节点10的左子节点变为旋转节点的右子节点,由于是NIL节点所以在此不再画出。然后进行变色。
3. 情况三变红黑树
如图所示,情况三刚开始我们无法判定是向左旋还是向右旋。那我们就看他的部分子树,元素10节点和元素x节点如果向右旋转生成的树结构那是不是就和情况二一样了。此时节点为5的右子树为x节点,x节点右子树是元素为10的节点。这就与情况二一样了,再通过左旋并变色处理变成红黑树。
4. 情况四变红黑树
如图所示,元素5的节点和元素x节点先进行左旋,然后整个树结构与情况一一样,再进行右旋,并进行变色处理,就成为了一个红黑树。
5. 四种情况总结
在分析HashMap红黑树部分源码之前我们需要先搞懂 “HashMap 的 hash 方法的原理是什么?”
首先看一下hash方法的源码(JDK 8 中的 HashMap):
static final int hash(Object key) {
int h;
// key.hashCode():返回散列值也就是hashcode
// ^ :按位异或
// >>>:无符号右移,忽略符号位,空位都以0补齐
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
其中看到在获得hash值时将key的hashCode异或上其无符号右移16位,Hashmap这么做原因:
防止一些实现比较差的 hashCode() 方法,使用扰动函数之后可以减少碰撞,进一步降低hash冲突的几率。
打个比方, 当我们的数组长度n为16的时候,哈希码(字符串“abcabcabcabcabc”的key对应的哈希码)对(16-1)与操作,对于多个key生成的hashCode,只要哈希码的后4位为0,不论高位怎么变化,最终的结果均为0。 如下图所示:
1954974080(HashCode) | 111 0100 1000 0110 1000 1001 1000 0000 |
2^4-1=15(length-1) | 000 0000 0000 0000 0000 0000 0000 1111 |
&运算 | 000 0000 0000 0000 0000 0000 0000 0000 |
而加上高16位异或低16位的“扰动函数”后,结果如下:
原HashCode | 1954974080 | 111 0100 1000 0110 1000 1001 1000 0000 |
(>>>16)无符号右移16位 | 29830 | 000 0000 0000 0000 0111 0100 1000 0110 |
^(异或)运算 | 1955003654 | 111 0100 1000 0110 1111 1101 0000 0110 |
2^4-1=15(length-1) | 15 | 000 0000 0000 0000 0000 0000 0000 1111 |
&(与)运算 | 6 | 000 0000 0000 0000 0000 0000 0000 0110 |
可以看到: 扰动函数优化前:1954974080 % 16 = 1954974080 & (16 - 1) = 0
扰动函数优化后:1955003654 % 16 = 1955003654 & (16 - 1) = 6
很显然,减少了碰撞的几率。
右移16位,自己的高半区和低半区异或,就是为了混合原始哈希码的高位和低位,以此来加大低位随机性。
思考:为什么这里还需要取模运算呢?为什么hash % n
等价于 hash & (n - 1)
呢?
这是因为,key.hashCode()
是用来获取键位的哈希值的,理论上,哈希值是一个 int 类型,范围从-2147483648
到 2147483648
。前后加起来大概 40 亿的映射空间,只要哈希值映射得比较均匀松散,一般是不会出现哈希碰撞的。
但问题是一个 40 亿长度的数组,内存是放不下的。HashMap 扩容之前的数组初始大小只有 16,所以这个哈希值是不能直接拿来用的,用之前要和数组的长度做取模运算,用得到的余数来访问数组下标才行。
其中的 (n - 1) & hash
正是取模运算,就是把哈希值和(数组长度-1)做了一个“与”运算。
可能大家在疑惑:取模运算难道不该用 % 吗?为什么要用 & 呢?
这是因为 & 运算比 % 更加高效,并且当 n 为 2 的 整次数幂时,存在下面这样一个公式。
a % n = a & (n-1)
用2n 替换n 就是:
a % n = a & (2n -1)
我们来验证一下,假如 a = 14,n = 16,也就是 24。
14%16, 14的二进制为1110,16的二进制为1 0000,16 - 1 = 15的二进制为1111,1110&1111=1110,也就是0*
20+1*
21+1*
22+1*
23=14, 14%16刚好也等于14。
这也正好解释了为什么 HashMap 的数组长度要取 2 的整次方。
因为(数组长度-1)正好相当于一个“低位掩码”——这个掩码的低位最好全是 1,这样 & 操作才有意义,否则结果就肯定是 0,那么 & 操作就没有意义了。
a&b 操作的结果是:a、b 中对应位同时为 1,则对应结果位为 1,否则为 0
2 的整次幂刚好是偶数,偶数-1 是奇数,奇数的二进制最后一位是 1,保证了 hash &(length-1) 的最后一位可能为 0,也可能为 1(这取决于 h 的值),即 & 运算后的结果可能为偶数,也可能为奇数,这样便可以保证哈希值的均匀性。
& 操作的结果就是将哈希值的高位全部归零,只保留低位值,用来做数组下标访问。
HashMap数据存储的过程先根据key获得hash值,通过
(n - 1) & hash
判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同的话,直接覆盖,不相同就通过链地址法解决冲突。
当满足散列表上的一条链表节点数大于等于8时会进入treeifyBin(tab, hash)方法。将Node节点转换为TreeNode节点,但是TreeNode节点之间通过前后指针相连,并不是左右子树相连。所以我称它为半成品树,源码如下:
// hash:Key 的散列值(经过扰动)
// onlyIfAbsent:如果为 true,不会覆盖旧值
// evict:是否驱逐最早的节点(在 LinkedHashMap 中使用,我们先忽略)
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// 数组
Node<K,V>[] tab;
// 目标桶(同一个桶中节点的散列值有可能不同)
Node<K,V> p;
// 数组长度
int n;
// 桶的位置
int i;
// 1. 如果数组为空,则使用扩容函数创建(说明数组的创建时机在首次 put 操作时)
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2. (n - 1) & hash:散列值转数组下标,与 Java 7 的 indexFor() 方法相似
if ((p = tab[i = (n - 1) & hash]) == null)
// 3. 如果是桶中的第一个节点,则创建并插入 Node 节点
tab[i] = newNode(hash, key, value, null);
else {
// 4. 如果不是桶中的第一个节点(即发生哈希冲突),需要插入链表或红黑树
// e:最终匹配的节点
Node<K,V> e;
// 节点上的 Key
K k;
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
// 4.1 如果桶的根节点与 Key 相等,则将匹配到根节点
// p.hash == hash:快捷比较(同一个桶中节点的散列值有可能不同,如果散列值不同,键不可能相同)
// (k = p.key) == key:快捷比较(同一个对象)
// key != null && key.equals(k):判断两个对象 equals 相同
e = p;
else if (p instanceof TreeNode)
// 4.2 如果桶是红黑树结构,则采用红黑树的插入方式
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 4.3 如果桶是链表结构,则采用链表的插入方式:
// 4.3.1 遍历链表找到 Key 相等的节点
// 4.3.2 否则使用尾插法添加新节点
// 4.3.3 链表节点数超过树化阈值,则将链表转为红黑树
for (int binCount = 0; ; ++binCount) {
// 尾插法(Java 7 使用头插法)
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 链表节点数超过树化阈值,则将链表转为红黑树
treeifyBin(tab, hash);
break;
}
// 找到 Key 相等的节点
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 4.4 新 Value 替换旧 Value(新增节点时不会走到这个分支)
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 访问节点回(用于 LinkedHashMap,默认为空实现)
afterNodeAccess(e);
return oldValue;
}
}
// 修改记录
++modCount;
// 5. 如果键值对数量大于扩容阈值,则触发扩容
if (++size > threshold)
resize();
// 新增节点回调(用于 LinkedHashMap,默认为空实现)
afterNodeInsertion(evict);
return null;
}
这就看出
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
HashMap属于懒加载
解释一下 p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))
?
这个问题等价于问 HashMap 如何确定键值对的位置:
1、首先,HashMap 会对键 Key 计算 hashCode() 并添加扰动,得到扰动后的散列值 hash。随后通过对数组长度取余映射到数组下标中;
2、然后,当数组下标的桶中存在多个节点时,HashMap 需要遍历桶找到与 Key 相等的节点,以区分是更新还是添加。为了提高效率,就有了 if 语句中的多次判断:
- 2.1 p.hash == hash 快捷判断: 同一个桶中节点的散列值有可能不同,如果散列值不同,键一定不相等:
- 2.2 (k = p.key) == key 快捷判断:同一个对象;
- 2.3 key != null && key.equals(k) 最终判断:判断两个键 Key 是否相等,即 equals 相等。
再解释一下为什么转换红黑树的条件是binCount >= TREEIFY_THRESHOLD - 1
;所以阈值是7还是8?大于等于还是大于?
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 链表节点数超过树化阈值,则将链表转为红黑树
treeifyBin(tab, hash);
重点在这一段代码
// 遍历链表,只在两种情况下才会跳出循环
for (int binCount = 0; ; ++binCount) {
//第一种:已经遍历到尾部,在最后插入新节点跳出,因节点数量+1 判断是否需要树化
if ((e = p.next) == null) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
// 判断是否需要树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 第二种:e指向的节点与要插入节点的key相同,此次put为覆盖操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
遍历过程中p从第一个节点遍历到最后一个节点
但由于binCount是从0开始计数,所以在做树化判断时binCount的值等于 链表长度 - 1(注意此时的链表长度没有算新插入的节点)
判断条件为 binCount >= TREEIFY_THRESHOLD - 1 => binCount+1(链表长度) >= TREEIFY_THRESHOLD
但此时链表新插入了一个节点
p.next = newNode(hash, key, value, null);
所以链表树化的那一刻,它的真实长度应该时binCount+1+1 => 链表长度>TREEIFY_THRESHOLD(8)
即:
链表长度大于8时,treeifyBin()方法被调用
(在做树化判断时,链表长度 = binCount+1(从零计数)+1(新插入节点) = bincount +2)
(判断条件: (bincount >= 8-1) => (bincount>=7) => (bincount+2>=9) => (链表长度>=9) 长度是整数 大于等于9也就是大于8)
综上所述,HashMap 是通过 hashCode() 定位桶,通过 equals() 确定键值对。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 默认数组容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 数组最大容量:2^30(高位 0100,低位都是 0)
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认负载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// (Java 8 新增)桶的树化阈值:8
static final int TREEIFY_THRESHOLD = 8;
// (Java 8 新增)桶的还原阈值:6(在扩容时,当原有的红黑树内数量 <= 6时,则将红黑树还原成链表)
static final int UNTREEIFY_THRESHOLD = 6;
// (Java 8 新增)树化的最小容量:64(只有整个散列表的长度满足最小容量要求时才允许链表树化,否则会直接扩容,而不是树化)
static final int MIN_TREEIFY_CAPACITY = 64;
// 底层数组(每个元素是一个单链表或红黑树)
transient Node<K,V>[] table;
// entrySet() 返回值缓存
transient Set<Map.Entry<K,V>> entrySet;
// 有效键值对数量
transient int size;
// 扩容阈值(容量 * 装载因子)
int threshold;
// 装载因子上限
final float loadFactor;
// 修改计数
transient int modCount;
// 链表节点(一个 Node 等于一个键值对)
static class Node<K,V> implements Map.Entry<K,V> {
// 哈希值(相同链表上 Key 的哈希值可能相同)
final int hash;
// Key(一个散列表上 Key 的 equals() 一定不同)
final K key;
// Value(Value 不影响节点位置)
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
// Node 的 hashCode 取 Key 和 Value 的 hashCode
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
// 两个 Node 的 Key 和 Value 都相等,才认为相等
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
// (Java 8 新增)红黑树节点
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;
TreeNode(int hash, K key, V val, Node<K,V> next) {
super(hash, key, val, next);
}
// 返回树的根节点
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
}
}
我们注意到static final float DEFAULT_LOAD_FACTOR = 0.75f;
而且HashMap当中很多地方都有它的身影。为什么HashMap的加载因子一定是0.75?而不是0.8,0.6?
加载因子是用来表示 HashMap 中数据的填满程度:
加载因子 = 填入哈希表中的数据个数 / 哈希表的长度
这就意味着:
这就必须在“哈希冲突”与“空间利用率”两者之间有所取舍,尽量保持平衡,谁也不碍着谁。
为什么加载因子会选择 0.75 呢?为什么不是0.8、0.6呢?
这跟统计学里的一个很重要的原理——泊松分布有关。
是时候上维基百科了:
泊松分布,是一种统计与概率学里常见到的离散概率分布,由法国数学家西莫恩·德尼·泊松在1838年时提出。它会对随机事件的发生次数进行建模,适用于涉及计算在给定的时间段、距离、面积等范围内发生随机事件的次数的应用情形。
https://www.ruanyifeng.com/blog/2015/06/poisson-distribution.html
在 HashMap 的 doc 文档里,曾有这么一段描述:
Because TreeNodes are about twice the size of regular nodes, we
use them only when bins contain enough nodes to warrant use
(see TREEIFY_THRESHOLD). And when they become too small (due to
removal or resizing) they are converted back to plain bins. In
usages with well-distributed user hashCodes, tree bins are
rarely used. Ideally, under random hashCodes, the frequency of
nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because of
resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million
大致的意思就是:
因为 TreeNode(红黑树)的大小约为链表节点的两倍,所以我们只有在一个拉链已经拉了足够节点的时候才会转为tree(参考TREEIFY_THRESHOLD)。并且,当这个hash桶的节点因为移除或者扩容后resize数量变小的时候,我们会将树再转为拉链。如果一个用户的数据的hashcode值分布得很均匀的话,就会很少使用到红黑树。
理想情况下,我们使用随机的hashcode值,加载因子为0.75情况,尽管由于粒度调整会产生较大的方差,节点的分布频率仍然会服从参数为0.5的泊松分布。链表的长度为 8 发生的概率仅有 0.00000006。
这个意思就是一般不会转为红黑树,这是一种保底原则,如果我们重写了不好的hashCode方法很可能会转为红黑树,一般是分布散列良好,分布均匀,符合泊松分布,各个长度命中概率递减,长度为8时,概率为0.00000006,小于千万分之一,通常情况下,红黑树没有时间优势,反而会增加空间负担,所以用8作为默认阈值
那么为什么退化阀值UNTREEIFY_THRESHOLD =6而不是8呢?那么8将成为一个临界值,时而树化,时而退化,此时会非常影响性能,因此,我们需要一个比8小的退化阀值;那为什么是6呢?
源码中也说了,考虑到内存(树节点比普通节点内存大2倍,以及避免反复转化),所以,退化阀值最多为6。
虽然这段话的本意更多的是表示 jdk 8中为什么拉链长度超过8的时候进行了红黑树转换,但提到了 0.75 这个加载因子——但这并不是为什么加载因子是 0.75 的答案。
为了搞清楚为啥,我查阅大量资料,发现这位大牛给出了他的见解,详情参考:
https://segmentfault.com/a/1190000023308658
里面提到了一个概念:二项分布。
在做一件事情的时候,其结果的概率只有2种情况,和抛硬币一样,不是正面就是反面。
为此,我们做了 N 次实验,那么在每次试验中只有两种可能的结果,并且每次实验是独立的,不同实验之间互不影响,每次实验成功的概率都是一样的。
以此理论为基础,我们来做这样的实验:我们往哈希表中扔数据,如果发生哈希冲突就为失败,否则为成功。
我们可以设想,实验的hash值是随机的,并且经过hash运算的键都会映射到hash表的地址空间上,那么这个结果也是随机的。所以,每次put的时候就相当于我们在扔一个16面(我们先假设默认长度为16)的骰子,扔骰子实验那肯定是相互独立的。碰撞发生即扔了n次有出现重复数字。
然后,我们的目的是啥呢?
就是掷了k次骰子,没有一次是相同的概率,需要尽可能的大些,一般意义上我们肯定要大于0.5(这个数是个理想数,但是我是能接受的)。
于是,n次事件里面,碰撞为0的概率:
这个概率值需要大于0.5,我们认为这样的hashmap可以提供很低的碰撞率。所以:
这时候,我们对于该公式其实最想求的时候长度s的时候,n为多少次就应该进行扩容了?而负载因子则是 n/s 的值。所以推导如下:
所以可以得到
其中
这就是一个求 ∞⋅0
函数极限问题,这里我们先令s = m + 1(m -> ∞)则转化为
我们再令x = 1 m \frac{1}{m} m1(x -> 0)则有
所以
考虑到 HashMap的容量有一个要求:它必须是2的n 次幂(这个文章前面讲过了)。当加载因子选择了0.75就可以保证它与容量的乘积为整数。
16x0.75=12
32x0.75=24
除了 0.75,0.5~1 之间还有 0.625(5/8)、0.875(7/8)可选,从中位数的角度,挑 0.75 比较完美。另外,维基百科上说,拉链法(解决哈希冲突的一种)的加载因子最好限制在 0.7-0.8以下,超过0.8,查表时的CPU缓存不命中(cache missing)会按照指数曲线上升。
综上,0.75 是个比较完美的选择。
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 散列表为空或者长度小于64时
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 {
// 根据Node节点创建新的TreeNode节点
TreeNode<K,V> p = replacementTreeNode(e, null);
// 尾指针为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);
}
}
由以上源码我们可以注意到resize() 扩容方法在多个地方都有出现,主体流程分为 3步:
扩容又分为 2 种情况:
再散列的步骤不好理解,这里解释下:
oldCap = 0 0 0 0 1 0 0 0 0 0 // 32
oldCap - 1 = 0 0 0 0 0 1 1 1 1 1 // 31
newCap = 0 0 0 1 0 0 0 0 0 0 // 64
newCap - 1 = 0 0 0 0 1 1 1 1 1 1 // 63^增加 1 个有效位参与映射
// 扩容
final Node<K,V>[] resize() {
// 旧数组
Node<K,V>[] oldTab = table;
// 旧容量
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 旧扩容阈值
int oldThr = threshold;
// 新容量
int newCap = 0;
// 新扩容阈值
int newThr = 0;
// 1. 计算扩容后的新容量和新扩容阈值
// 旧容量大于 0,说明不是第一次添加元素
if (oldCap > 0) {
// 如果旧容量大于等于 2^30 次幂,则无法扩容。此时,将扩容阈值调整到整数最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 数组容量和扩容阈值扩大为原来的 2 倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 旧容量为 0,需要初始化数组
else if (oldThr > 0)
// (带初始容量和负载因子的构造方法走这里)
// 使用构造方法中计算的最近 2 的整数幂作为数组容量
newCap = oldThr;
else {
// (无参构造方法走这里)
// 使用默认 16 长度作为初始容量
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;
// 2. 创建新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 3. 将旧数组上的键值对再散列到新数组上
if (oldTab != null) {
// 遍历旧数组上的每个桶
for (int j = 0; j < oldCap; ++j) {
// 桶的根节点
Node<K,V> e;
// 桶的根节点不为 null
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
// 3.1 桶的根节点,直接再散列
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 3.2 以红黑树的方式再散列,思路与 3.3 链表的方式相似
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
// 3.3 以链表的形式再散列
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 3.3.1 若散列值新参与映射的位为 0,那么映射到原始位置上
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 3.3.2 若散列值新参与映射的位为 1,那么映射到原始位置 + 旧数组容量的位置上
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.链表节点数大于等于8
2.散列表长度大于等于64
为什么要在设置桶的树化阈值(8)后,还要设置树化的最小容量(64)?
这是为了避免无效的树化。
在散列表的容量较低时,添加数据时很容易会触发扩容。此时,一部分原本已经树化的桶会由于长度下降而退还回链表。因此,红黑树为树化操作设置了最小容量要求:如果链表长度达到树化阈值,但散列表整体的长度未达到最小容量要求,那么就直接扩容,而不是在桶上树化。
再说一下为什么HashMap的最大容量时230:由于int类型限制了该变量的长度为4个字节共32个二进制位,按理说可以向左移动31位即2的31次幂,这里为什么不是2的31次方,而是2的30次方呢?
事实上由于二进制数字中最高的一位也就是最左边的一位是符号位,用来表示正负之分(0为正,1为负),所以只能向左移动30位,而不能移动到处在最高位的符号位,所以最大容量只能是2的30次方。
treeify(Node
方法就可以分为先成为一个二叉搜索树,再调用balanceInsertion(root, x)
方法通过旋转变色成为红黑树。
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
// 遍历循环半成品树节点
for (TreeNode<K,V> x = this, next; x != null; x = next) {
// 头节点指针的下一个节点是第一个树节点
next = (TreeNode<K,V>)x.next;
x.left = x.right = 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;;) {
int dir, ph;
K pk = p.key;
// 所遍历的树节点hash值大于要插入的节点hash值,向左子树继续遍历
if ((ph = p.hash) > h)
dir = -1;
// 所遍历的树节点hash值小于要插入的节点hash值,向右子树继续遍历
else if (ph < h)
dir = 1;
// 如果要插入的节点hash值等于遍历所在节点hash,hash相等时,通过内存地址进行比较
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0)
//说明红黑树中没有与之相等的 那就必须进行插入操作。
// 分出插入节点是左节点还是右节点
dir = tieBreakOrder(k, pk);
TreeNode<K,V> xp = p;
// 根据dir区分要继续遍历左节点还是右节点
// 当下一个节点为null的时候说明已经找到要插入的树节点所在的位置
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// 要插入的树节点父指针 指向 调整成树后遍历所得树节点
x.parent = xp;
// 根据dir区分出插入节点放入左节点还是右节点
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 插入完成后是一个二叉搜索树,需要变色或旋转成为红黑树
root = balanceInsertion(root, x);
break;
}
}
}
}
// 检验root节点是不是第一个节点
moveRootToFront(tab, root);
}
这里是从叶节点遍历到root根节点,从部分到整体一步步满足红黑树的条件。新插入的节点根据是父节点的左子树还是右子树,以及父节点、爷爷节点和叔叔节点的颜色可以分为不同的情况,根据不同的情况分别进行左旋和右旋。
rotateLeft(TreeNode
rotateRight(TreeNode
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root,
TreeNode<K,V> x) {
// 此处就是节点新增原理提到的新插入节点默认为红色
x.red = true;
// 遍历树x节点一直到root节点
for (TreeNode<K,V> 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)) {
// 如果叔叔节点不为空并且是红色
// xpp
// / \
// xp(R) Red
if ((xppr = xpp.right) != null && xppr.red) {
xppr.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
// 如果叔叔节点为空或者不为空是黑色
else {
// 如果该节点是右节点
// xpp xpp
// / \ /
// xp(R) black xp(R)
// \ \
// x(R) x(R)
if (x == xp.right) {
// 左旋
root = rotateLeft(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
// 如果该节点是左节点
// xpp xpp
// / \ /
// xp(R) black xp(R)
// / /
// x(R) x(R)
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
// xpp(R) xpp(R)
// / \ /
// xp(B) black xp(B)
// / /
// x(R) x(R)
// 右旋将的到的新树赋给root,再次遍历
root = rotateRight(root, xpp);
}
}
}
}
// 如果父节点是爷爷节点的右子树
else {
// 如果叔叔节点不为空并且是红色
// xpp
// / \
// Red xp(R)
if (xppl != null && xppl.red) {
xppl.red = false;
xp.red = false;
xpp.red = true;
x = xpp;
}
// 如果叔叔节点为空或者不为空是黑色
else {
// 如果该节点是左节点
// xpp xpp
// \ / \
// xp(R) black xp(R)
// / /
// x(R) x(R)
if (x == xp.left) {
// 右旋
root = rotateRight(root, x = xp);
xpp = (xp = x.parent) == null ? null : xp.parent;
}
// 如果该节点是右节点
// xpp xpp
// \ / \
// xp(R) black xp(R)
// \ \
// x(R) x(R)
if (xp != null) {
xp.red = false;
if (xpp != null) {
xpp.red = true;
// xpp(R) xpp(R)
// \ / \
// xp(B) black xp(B)
// \ \
// x(R) x(R)
// 左旋
root = rotateLeft(root, xpp);
}
}
}
}
}
}
我根据源码将不同的情况下的左旋或右旋结果,用注释表示了出来。大家可以与那四种情况结合分析。
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root,
TreeNode<K,V> p) {
TreeNode<K,V> r, pp, rl;
if (p != null && (r = p.right) != null) {
// p的右节点指向r的左孩子(即rl),如果rl不为空,其父节点指向p;
// p
// \
// r
// /
// rl
if ((rl = p.right = r.left) != null)
rl.parent = p;
// r
// /
// p
// \
// rl
//------------------------------------
// p节点为根节点,直接root指向r,同时颜色置为黑色(根节点颜色都为黑色)
if ((pp = r.parent = p.parent) == null)
(root = r).red = false;
// 如果该节点是右节点
// pp pp
// / \ /
// p(R) black p(R)
// \ \
// r(R) r(R)
else if (pp.left == p)
pp.left = r;
// 走完该方法后的图形
// pp pp
// / \ /
// r(R) black r(R)
// / /
// p(R) p(R)
//---------------------------------
// pp pp
// \ \
// p(R) p(R)
// \ / \
// r(B) black r(B)
// \ \
// x(R) t x(R)
else
pp.right = r;
r.left = p;
p.parent = r;
// 走完该方法后的图形
// pp pp
// \ \
// r(B) r(B)
// / \ / \
// p(R) x(R) p(R) x(R)
// /
// black
}
return root;
}
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root,
TreeNode<K,V> p) {
TreeNode<K,V> l, pp, lr;
if (p != null && (l = p.left) != null) {
// p的左节点指向l的右孩子(即lr),如果lr不为空,其父节点指向p;
// p
// /
// l
// \
// lr
if ((lr = p.left = l.right) != null)
lr.parent = p;
// l
// \
// p
// /
// lr
//------------------------------------
// 如果pp为null,说明p节点为根节点,直接root指向l,同时颜色置为黑色(根节点颜色都为黑色)
if ((pp = l.parent = p.parent) == null)
(root = l).red = false;
// pp pp
// \ / \
// p(R) black p(R)
// / /
// l(R) l(R)
else if (pp.right == p)
pp.right = l;
// 走完该方法后的图形
// pp pp
// \ / \
// l(R) black l(R)
// \ \
// p(R) p(R)
// --------------------------------------
// pp(B) pp(B)
// / /
// p(R) p(R)
// / \ /
// l(B) black l(B)
// / /
// x(R) x(R)
else
pp.left = l;
l.right = p;
p.parent = l;
// 走完该方法后的图形
// pp(B) pp(B)
// / /
// l(B) l(B)
// / \ / \
// x(R) p(R) x(R) p(R)
// \
// black
}
return root;
}
插入新节点从root节点往下遍历分为4种情况:
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
// 获取根节点
TreeNode<K,V> root = (parent != null) ? root() : this;
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
// 如果要插入的节点hash值小于遍历所在节点hash,遍历左子树
if ((ph = p.hash) > h)
dir = -1;
// 如果要插入的节点hash值大于遍历所在节点hash,遍历右子树
else if (ph < h)
dir = 1;
// 如果要插入的节点hash值等于遍历所在节点hash,并且
// key值相等返回该节点
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
// 如果要插入的节点hash值等于遍历所在节点hash,但是key不等时,此时发生hash冲突
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
// 在左右子树递归的寻找 是否有key的hash相同 并且equals相同的节点
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
// (ch = p.left) != null 左子树不为空
// (ch = p.right) != null 右子树不为空
// (q = ch.find(h, k, kc)) != null) 递归查找hash值相等的并且key也相等
// 如果找到hash值相等的则返回该节点
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;
}
//说明红黑树中没有与之相等的 那就必须进行插入操作。
// 分出插入节点是左节点还是右节点
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
// 如果dir小于0,那p等于p的左子树节点,不为null则继续遍历
// 如果dir大于0,那p等于p的右子树节点,不为null则继续遍历
// 当为null时说明是叶子节点则执行下面方法
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
// 由于TreeNode继承了Node,创建一个新的TreeNode节点将要插入的
// hash、key、value存入
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
// dir小于0,新节点为左节点
if (dir <= 0)
xp.left = x;
// dir大于0,新节点为右节点
else
xp.right = x;
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
// balanceInsertion(root, x)方法让一个树成为红黑树,并返回根节点
// moveRootToFront,检验root节点是不是第一个节点
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}