目录
- HashMap
- 1、HashMap的继承体系
- 2、HashMap底层数据结构
- 3、HashMap的构造函数
- ①、无参构造
- ②、有参构造1 和 有参构造2 (可以自定义初始容量和负载因子)
- ③、有参构造3(接受一个Map参数)
- JDK 8之前版本的哈希方法:
- JDK 8版本的哈希方法
- 4、拉链法解决哈希冲突
- 什么是拉链法?
- 动画演示拉链法解决哈希冲突:
- 拉链法有哪些好处? 还有其他解决哈希冲突的方式吗?
- 5、HashMap的`put`方法
- HashMap的属性注释
- `put`方法
- `putVal`方法
- `putTreeVal`方法
- `treeifyBin` 方法
- `Node<K,V>静态内部类`
- `resize`方法
- `split`方法
- `afterNodeAccess`方法
- `afterNodeInsertion`方法
- HashMap的`put`方法执行流程图示
- 6、HashMap如何计算key的索引位置
- 为什么HashMap的容量设计成总是2的整数倍?
- 7、HashMap的`get`方法
- 8、HashMap的`remove`方法
- 9、HashMap的一些常见问题
- ①、JDK8为什么引入红黑树?
- ②、红黑树的数据结构有什么特点 ?
- ③、负载因子为什么是0.75?
- ④、为什么数组长度≥64且链表长度 ≥8才树化?
- ⑤、多线程下HashMap写操作可能出现哪些问题?
- JDK1.8之前并发扩容死链问题,动画演示:
- 丢失数据问题,代码演示:
- ⑥、JDK8之前的put方法和之后的put方法有什么区别 ?
- ⑦、HashMap的红黑树什么情况下会退化成链表?
HashMap
基于JDK8。
HashMap在我们的日常开发中是十分常用的键值对集合,我们来深入探究下HashMap的设计。
1、HashMap的继承体系
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
2、HashMap底层数据结构
这里先把答案给出。
稍后再去探究,为什么使用链表处理哈希冲突,JDK8为什么引入红黑树,红黑树的数据结构有什么特点,为什么会用64, 8, 6这几个数字作为阈值?为什么元素个数达到容量的0.75倍时就扩容?
JDK8之前 是数组 + 链表 。
JDK8 是数组 + 链表|红黑树。
链表的主要目的是解决哈希冲突(hash collision)问题。
JDK8的HashMap链表转换为红黑树的条件:
链表长度 >= 8
数组容量 >= 64
红黑树转换回链表的条件:
红黑树节点数 <= 6
3、HashMap的构造函数
①、无参构造
// 加载因子,用于控制哈希表的扩容频率
final float loadFactor;
// 默认的加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
public HashMap() {
// 使用默认的加载因子
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
②、有参构造1 和 有参构造2 (可以自定义初始容量和负载因子)
// 加载因子,用于控制哈希表的扩容频率
final float loadFactor;
// 默认的加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 哈希表的最大容量 2的30次方 1,073,741,824 10亿多
static final int MAXIMUM_CAPACITY = 1 << 30;
// 扩容阈值,当哈希表中元素个数超过这个值时,会触发扩容
int threshold;
/**
* 有参构造函数1:只接受初始容量参数
* @param initialCapacity 初始容量
*/
public HashMap(int initialCapacity) {
// 调用另一个构造函数,使用默认加载因子
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 有参构造函数2:接受初始容量和加载因子参数
* @param initialCapacity 初始容量
* @param loadFactor 加载因子
*/
public HashMap(int initialCapacity, float loadFactor) {
// 检查初始容量是否为负数,如果是负数,抛出非法参数异常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
// 如果初始容量超过最大容量,则将初始容量设置为最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
// 检查加载因子是否有效,如果小于等于0或不是一个有效数字,则抛出非法参数异常
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
// 设置实例的加载因子
this.loadFactor = loadFactor;
// 根据初始容量计算扩容阈值
this.threshold = tableSizeFor(initialCapacity);
}
/**
* 计算大于等于给定容量的最小2的幂次方值
* @param cap 给定的容量值
* @return 大于等于cap的最小2的幂次方值
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
// 将所有位置为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;
}
这里再抛一个问题,为什么我们传自定义大小的容量,HashMap要调用tableSizeFor方法取大于等于自定义容量的最小2的幂次方值。
比如我们传入35,tableSizeFor计算得出36 ,HashMap就使用36作为容量。这个问题也在后面进行探究。
③、有参构造3(接受一个Map参数)
这个构造方法就比较复杂了,涉及添加元素和扩容等操作,暂时就不展开了,后面单独去看添加元素和扩容操作。
// 加载因子,用于控制哈希表的扩容频率
final float loadFactor;
// 默认的加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 哈希表的最大容量,2的30次方,即1,073,741,824(10亿多)
static final int MAXIMUM_CAPACITY = 1 << 30;
// 哈希表的底层数组,存储键值对
transient Node<K,V>[] table;
// 扩容阈值,当哈希表中元素个数超过这个值时,会触发扩容
int threshold;
/**
* 有参构造函数3:接受一个Map参数
* @param m 初始化时用的Map
*/
public HashMap(Map<? extends K, ? extends V> m) {
// 使用默认加载因子
this.loadFactor = DEFAULT_LOAD_FACTOR;
// 将传入的Map中的所有元素添加到当前HashMap中
putMapEntries(m, false);
}
/**
* 将指定的Map中的所有元素添加到当前HashMap中
* @param m 指定的Map
* @param evict 是否驱逐旧元素(此参数在其他上下文中使用,这里传入false)
*/
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
// 获取指定Map的大小
int s = m.size();
if (s > 0) {
// 如果当前哈希表还未初始化(即底层数组为空)
if (table == null) { // 预先调整大小
// 计算预期的扩容阈值,公式为:(指定Map的大小 / 加载因子) + 1
float ft = ((float)s / loadFactor) + 1.0F;
// 如果计算结果小于最大容量,则取计算结果,否则取最大容量
int t = ((ft < (float)MAXIMUM_CAPACITY) ? (int)ft : MAXIMUM_CAPACITY);
// 如果计算结果大于当前的扩容阈值,则更新扩容阈值
if (t > threshold)
threshold = tableSizeFor(t);
}
// 如果当前哈希表已初始化,并且指定Map的大小超过了当前的扩容阈值
else if (s > threshold)
// 扩容哈希表
resize();
// 将指定Map中的每个键值对添加到当前哈希表中
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
// 使用putVal方法添加键值对
putVal(hash(key), key, value, false, evict);
}
}
}
/**
* 计算给定键的哈希值
* @param key 给定的键
* @return 哈希值
*/
final int hash(Object key) {
int h;
// 计算哈希值,并进行哈希扰动,增加哈希分布的随机性
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到这里的final int hash(Object key)
方法是对键的hashCode进行二次hash的方法。
JDK 8之前版本的哈希方法:
static int hash(int h) {
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
JDK 7的hash方法通过异或操作 (^) 和右移操作 (>>>) 对原始哈希码进行扰动,以减少冲突。
JDK 8版本的哈希方法
/**
* 计算给定键的哈希值
* @param key 给定的键
* @return 哈希值
*/
final int hash(Object key) {
int h;
// 计算哈希值,并进行哈希扰动,增加哈希分布的随机性
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
JDK 8 通过将哈希码右移16位并与原始哈希码异或 (h = key.hashCode() ^ (key.hashCode() >>> 16))
来扰动哈希码,提高哈希分布的随机性。
JDK 8的扰动方式计算步骤更简单高效,有助于减少哈希冲突,提高哈希表的性能。
4、拉链法解决哈希冲突
什么是拉链法?
拉链法就是数组和链表结合,每个数组的元素存储的是一个链表(或在JDK 8中链表长度超过一定阈值时使用红黑树)。当发生哈希冲突时,只需要将新的元素插入链表或树中。
上图中a,c,d元素由于哈希冲突,就组成了一个链表,当我们查找d时,先计算出下标index是1,发现链表的头是a不是d,就继续向下遍历链表直到找到d为止。
动画演示拉链法解决哈希冲突:
拉链法有哪些好处? 还有其他解决哈希冲突的方式吗?
拉链法解决哈希冲突的优点:
-
①.简单高效:拉链法实现起来相对简单,每个数组元素存储的是一个链表。当发生哈希冲突时,只需要将新的元素插入链表中,插入和查找操作的平均时间复杂度较低。
-
②、空间利用率高:拉链法在冲突发生时不需要额外的数组空间,只需在链表中插入新节点,节省空间。
-
③、动态扩展:JDK8使用的链表和红黑树都能动态地扩展,不需要预先分配大量内存,并且在元素很多时可以通过扩容数组(哈希表)来降低每个链表的长度,维持高效的查找性能。
-
④、易于实现的扩容机制:在拉链法中,扩容只需重新分配一个更大的数组,然后重新哈希现有的元素。这一过程较为简单(实际上JDK通过特殊的手段让重新计算扩容后的元素位置变得简单,这个手段就是数组(哈希表)的容量永远都是2的整数倍),不需要处理复杂的元素迁移问题。
其他解决哈希冲突的方式(了解下):
再哈希法(Rehashing):
使用不同的哈希函数重新计算发生冲突的元素的位置。再哈希法会在原哈希函数发生冲突时,使用一个新的哈希函数重新计算索引,需要再次计算哈希值,性能较低,特别是多次哈希冲突的情况下。
Cuckoo Hashing(布谷鸟哈希):
使用两个或更多的哈希函数和两个或更多的哈希表。当一个位置发生冲突时,将现有元素移到它的另一个可能位置(类似于布谷鸟在鸟巢中下蛋),如果新位置也有冲突,则继续迁移,直到找到一个空位或达到迁移限制。
开放地址法(Open Addressing):
- 线性探测(Linear Probing):当发生冲突时,按固定步长(通常为1)向前探测下一个位置,直到找到一个空位或回到原位置。
- 二次探测(Quadratic Probing):探测步长按平方序列增长(如1, 4, 9, 16…),以减少聚集效应。
- 双重散列(Double Hashing):使用两个不同的哈希函数,当第一个哈希函数发生冲突时,用第二个哈希函数计算探测步长。
线性散列法(Linear Hashing):
一种动态哈希方法,通过渐进式地扩展哈希表来处理冲突。在需要扩展时,不是一次性重新分配整个哈希表,而是渐进地调整部分元素的位置。
Hopscotch Hashing(跳跃哈希):
结合开放地址法和链表法的优点。当冲突发生时,在一定范围内探测并交换元素,使得链表的元素能保持接近原位置,减少查找路径长度。
5、HashMap的put
方法
(涉及到扩容、树化、反树化等操作)
HashMap的属性注释
HashMap的属性太多了,每次都在方法上面加上一些类的属性比较麻烦,这里把所有的属性都注释下。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 默认初始容量,即2的4次方,即哈希表的默认大小为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 哈希表的最大容量,即2的30次方,即1073741824
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的加载因子,即哈希表的装填因子,默认为0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 树化阈值,当链表长度 >= 8且 容量>=64时,链表转为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 反树化阈值,当红黑树节点数量小于等于6时,红黑树转为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 最小树化容量,即哈希表的最小容量为64时,链表可以转为红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
// 哈希表的底层数组,存储键值对,也就是HashMap底层的数组类型是Node<K,V>
transient Node<K,V>[] table;
// 键值对集合的视图,用于遍历哈希表中的所有键值对
transient Set<Map.Entry<K,V>> entrySet;
// 哈希表中元素的数量
transient int size;
// 哈希表结构修改的次数,用于迭代器快速失败机制
transient int modCount;
// 哈希表扩容阈值,当哈希表中元素个数超过这个值时会触发扩容
int threshold;
// 加载因子,用于控制哈希表的扩容频率
final float loadFactor;
}
put
方法
public V put(K key, V value) {
// 调用putVal方法将键值对插入到哈希表中
return putVal(hash(key), key, value, false, true);
}
putVal
方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; // 哈希表(数组)
Node<K,V> p; // 当前处理的节点
int n, i; // n为表的长度,i为计算出的索引
// 如果哈希表为空或哈希表的长度为0,则进行扩容操作
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 {
// 如果当前节点不是树节点 遍历链表进行插入操作
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 如果当前节点的下一个节点为空,表示到达链表末端
p.next = newNode(hash, key, value, null); // 在链表末尾插入新的节点
// 如果链表长度超过树化阈值 8 ,则 调用treeifyBin
// 在 treeifyBin 中会判断 哈希表容量是否 >=64 如果 哈希表容量>=64 则树化,否则先扩容
// 注意 binCount 从 0 开始计数,表示遍历链表时访问的节点数,但插入新节点时实际的节点总数是 binCount + 1
// TREEIFY_THRESHOLD - 1 = 8-1 = 7 所以binCount=7时 节点总数是 8 正好达到了阈值
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 如果找到哈希值相同并且键相同的节点
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break; // 结束循环
// 移动到链表的下一个节点,继续下一次循环
p = e;
}
}
// 如果找到了相同的键,则更新值
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 插入后进行后续处理 可以给HashMap的子类做扩展
afterNodeAccess(e);
return oldValue;
}
}
// 增加修改次数
++modCount;
// 如果当前元素个数超过阈值,则进行扩容操作
if (++size > threshold)
resize();
// 插入后进行后续处理 可以给HashMap的子类做扩展
afterNodeInsertion(evict);
return null;
}
putTreeVal
方法
添加红黑树节点
// 在红黑树中插入一个新的节点,或者返回已存在的节点
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
// kc是用于比较的类
Class<?> kc = null;
// searched表示是否已经搜索过树
boolean searched = false;
// 如果当前节点有父节点,则获取树的根节点,否则使用当前节点
TreeNode<K,V> root = (parent != null) ? root() : this;
// 从根节点开始遍历树
for (TreeNode<K,V> p = root;;) {
// dir表示比较方向,ph是当前节点的哈希值,pk是当前节点的键
int dir, ph; K pk;
// 如果当前节点的哈希值大于待插入节点的哈希值,dir设为-1(左子树)
if ((ph = p.hash) > h)
dir = -1;
// 如果当前节点的哈希值小于待插入节点的哈希值,dir设为1(右子树)
else if (ph < h)
dir = 1;
// 如果当前节点的哈希值等于待插入节点的哈希值,比较键
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
// 键相同,返回当前节点
return p;
// 如果kc为null,尝试获取k的可比较类
else if ((kc == null &&
(kc = comparableClassFor(k)) == null) ||
// 使用可比较类比较键,结果为0表示键相同
(dir = compareComparables(kc, k, pk)) == 0) {
// 如果还没有搜索过子树
if (!searched) {
TreeNode<K,V> q, ch;
// 标记为已搜索
searched = true;
// 在左子树和右子树中查找
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;
}
// 使用tie-break规则决定插入方向
dir = tieBreakOrder(k, pk);
}
// 保存当前节点为xp
TreeNode<K,V> xp = p;
// 根据dir决定向左还是向右
if ((p = (dir <= 0) ? p.left : p.right) == null) {
// 创建一个新节点
Node<K,V> xpn = xp.next;
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
// 插入到左子树或者右子树
if (dir <= 0)
xp.left = x;
else
xp.right = x;
// 更新链表结构
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
// 平衡树并将根节点移动到数组前端
moveRootToFront(tab, balanceInsertion(root, x));
// 返回null表示插入成功
return null;
}
}
}
treeifyBin
方法
将哈希桶中的链表转换为红黑树
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 检查哈希表是否为空,或者表的长度是否小于最小树化容量
// MIN_TREEIFY_CAPACITY 是一个常量(通常为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 {
// 将当前链表节点转换为树节点
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);
}
}
Node<K,V>静态内部类
HashMap的数组中保存的就是Node<K,V>
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // 键的哈希值
final K key; // 键
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;
}
}
resize
方法
扩容方法
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; // 获取当前哈希表
int oldCap = (oldTab == null) ? 0 : oldTab.length; // 获取当前哈希表的容量,如果为空则为0
int oldThr = threshold; // 获取当前的扩容阈值
int newCap, newThr = 0; // 声明新的容量和新的扩容阈值
// 如果当前哈希表的容量大于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; // 将容量和扩容阈值翻倍
}
// 如果当前容量为0但扩容阈值大于0(即初始化时的情况)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr; // 将新容量设为当前的阈值
else { // 否则使用默认初始值
newCap = DEFAULT_INITIAL_CAPACITY; // 使用默认初始容量
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); // 根据默认负载因子和默认初始容量计算新的扩容阈值
}
// 如果新的扩容阈值为0,根据负载因子和新容量计算新的扩容阈值
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; // 释放旧表的当前桶
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 { // 否则是链表节点
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; // 将高位链表放入新表
}
}
}
}
}
return newTab; // 返回新的哈希表
}
split
方法
split 方法用于在哈希表扩容时,重新分配红黑树节点到新的哈希表桶中。
在拆分过程中,原桶中的红黑树节点被分配到两个链表中。
低位链表和高位链表分别表示原桶和新桶中的元素。
这是为了保证新哈希表的负载均匀性,并且避免在扩容过程中造成哈希冲突过多。
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this; // 当前树节点
// 初始化低位和高位链表的头尾节点
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0; // 低位和高位链表的节点计数
// 遍历当前树节点的所有节点
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next; // 暂存下一个节点
e.next = null; // 断开当前节点的 next 引用
// 根据 bit 的值决定节点放入低位链表还是高位链表
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null) // 如果低位链表尾节点为空,说明是第一个节点
loHead = e; // 设置低位链表的头节点
else
loTail.next = e; // 否则将当前节点连接到尾节点
loTail = e; // 更新低位链表的尾节点
++lc; // 低位链表节点计数增加
} else {
if ((e.prev = hiTail) == null) // 如果高位链表尾节点为空,说明是第一个节点
hiHead = e; // 设置高位链表的头节点
else
hiTail.next = e; // 否则将当前节点连接到尾节点
hiTail = e; // 更新高位链表的尾节点
++hc; // 高位链表节点计数增加
}
}
// 如果低位链表不为空
if (loHead != null) {
// 如果低位链表的节点数小于等于阈值,转换为链表结构
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map); // 将低位链表转换为普通链表并放入新表
else {
tab[index] = loHead; // 否则直接将低位链表放入新表
if (hiHead != null) // 如果高位链表不为空,说明原来是红黑树结构
loHead.treeify(tab); // 将低位链表重新组织为红黑树结构
}
}
// 如果高位链表不为空
if (hiHead != null) {
// 如果高位链表的节点数小于等于阈值 默认是6,转换为链表结构
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map); // 将高位链表转换为普通链表并放入新表
else {
tab[index + bit] = hiHead; // 否则直接将高位链表放入新表
if (loHead != null) // 如果低位链表不为空,说明原来是红黑树结构
hiHead.treeify(tab); // 将高位链表重新组织为红黑树结构
}
}
}
// 树转化为链表
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null; // 初始化新的链表头节点和尾节点
// 遍历当前的树节点,将其转换为链表节点
for (Node<K,V> q = this; q != null; q = q.next) {
Node<K,V> p = map.replacementNode(q, null); // 将树节点转换为普通链表节点
if (tl == null) // 如果链表尾节点为空,说明是第一个节点
hd = p; // 设置链表头节点
else
tl.next = p; // 否则将当前节点连接到尾节点的 next 引用
tl = p; // 更新链表尾节点
}
return hd; // 返回新的链表头节点
}
afterNodeAccess
方法
// 在节点访问后进行的回调方法
void afterNodeAccess(Node<K,V> p) {
// 此方法在访问节点后被调用,具体的实现可以在子类中覆盖
// 体现了面向对象设计原则中的 开闭原则,对修改关闭对扩展开放
}
afterNodeInsertion
方法
// 在节点插入后进行的回调方法
void afterNodeInsertion(boolean evict) {
// 此方法在插入节点后被调用,具体的实现可以在子类中覆盖
// 体现了面向对象设计原则中的 开闭原则,对修改关闭对扩展开放
}
HashMap的put
方法执行流程图示
这里有个点需要注意下:
// 如果onlyIfAbsent是false 或者 oldValue 是null 就会新值替换旧值
if (!onlyIfAbsent || oldValue == null)
e.value = value;
所以调用HashMap的 putIfAbsent方法时要注意,如果key已存在且旧值是null ,那么即使key存在也会替换旧值。
代码示例:
public static void main(String[] args) {
Map<String, String> hashMap = new HashMap<>(0);
hashMap.put("a",null);
hashMap.put("b","b");
System.out.println("putIfAbsent之前"+hashMap);
// 之前的 key a 对应的 value 是null 所以仍然会替换
hashMap.putIfAbsent("a","123");
// 之前的 key b 对应的 value 是b 所以不会替换
hashMap.putIfAbsent("b","123");
System.out.println("putIfAbsent之后"+hashMap);
}
执行结果:
putIfAbsent之前{a=null, b=b}
putIfAbsent之后{a=123, b=b}
6、HashMap如何计算key的索引位置
下面是putVal
方法内的一段源码,可以看到 索引 i = (n - 1) & hash, 也就是索引等于 哈希表(数组)的长度 & key调用 hash(key)方法得到的值。
int n, i; // n为表的长度,i为计算出的索引
// 如果表为空或表的长度为0,则进行扩容操作
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);
这里我们再来探讨上面提过的哈希表容量的问题。
为什么HashMap的容量设计成总是2的整数倍?
- ①、更高效的计算索引: 我们一般计算索引都是使用 key的哈希值对容量求余数,也就是 hash%容量 ,在计算机内部直接使用%求余性能比较低,就像我们直接使用乘法符号计算2乘2 (2*2) 和使用移位运算符 2 << 1 得到的结果是一致的,但是移位运算比直接 使用乘法符号计算快的多,这是由计算机操作系统本身对算术运算的设计规则决定的。
由于 table.length 总是 2 的幂次方,那么 table.length - 1 就是一个全为 1 的二进制数,这样可以高效地与哈希值进行按位与运算,快速得到索引。
当容量n 是2的整数倍时 计算 索引 i = (n - 1) & hash 和 i = hash%n 结果是一样的。这也就解释了 HashMap的容量设计成2的整数倍的第一个好处。
比如: 容量 n = 16 , hash = 3 , 索引 i = (16-1) & 3 = 3;
索引 i = 3%16 也等于3。
-
②、更好的哈希分布: 将容量设置为 2 的幂次方,有助于避免哈希碰撞,并使得哈希值的低位和高位都能有效参与到索引计算中。因为使用按位与运算时,所有位都能参与到索引的计算中,如果容量不是 2 的幂次方,那么某些位可能永远不会参与到计算中,这会导致哈希分布不均匀,增加哈希冲突的概率。
-
③、高效的扩容操作: 这点设计是真的厉害,在扩容时,新的容量也是 2 的幂次方,这样可以保持上述优点。而且扩容后,旧表中的元素可以很容易地重新分配到新表中,只需根据元素的哈希值和新表的容量重新计算索引即可。这使得扩容操作更加高效。
扩容时只需要检查元素的哈希值的新增位是 0 还是 1 来决定它是留在原索引位置还是移动到新索引位置:
if ((e.hash & oldCap) == 0) {
// 留在原索引位置
} else {
// 移动到新索引位置
// 新索引的位置 = 旧索引位置 + 旧容量
}
更精妙的是,新索引的位置直接就等于 旧位置 + 旧容量。
这里举个例子计算一下:
1.如果 (e.hash & oldCap) == 0,元素留在原索引位置。
2.如果 (e.hash & oldCap) != 0,元素移动到新索引位置:旧索引位置 + 旧容量。
假设扩容前哈希表长度为8 , 扩容后哈希表长度为16
情况2举例说明:
假设 a的hash值为10
扩容前a的索引位置: i = 10&(8-1) = 10%8 = 2
扩容后a的索引位置:
先计算 10&8 = 2 != 0 , 新索引位置 i = 旧索引位置(2) + 旧容量(8) = 10
如果我们直接计算新索引位置 i = 10&(16-1) = 10%16 = 10 和上面计算结果一致
情况2举例说明:
假设 b的hash值为2
扩容前b的索引位置: i = 2&(8-1) = 2%8 = 2
扩容后b的索引位置:
先计算 2&8 = 0 == 0 , 新索引位置 i = 旧索引位置(2)
如果我们直接计算新索引位置 i = 2&(16-1) = 2%16 = 2 和上面计算结果一致
还有一些其他好处:
容量总是 2 的幂次方使得很多实现细节变得简单而高效。比如计算容量、计算阈值、扩容等操作都可以利用位运算来实现,减少了代码的复杂度和运行时的开销。
由于哈希表的负载因子通常设定为 0.75,当容量为 2 的幂次方时,可以保证在触发扩容时,哈希表的装载程度接近于最佳状态,避免了过度扩容或者装载过高导致的性能问题。
7、HashMap的get
方法
// 获取指定键的值
public V get(Object key) {
// 调用 getNode 方法查找键对应的节点,如果找到则返回节点的值,否则返回 null
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
// 根据 hash 值和键查找节点
final Node<K,V> getNode(int hash, Object key) {
// 定义一些局部变量
Node<K,V>[] tab; // 哈希表
Node<K,V> first, e; // 链表中的节点
int n; // 哈希表的长度
K k; // 节点中的键
// 如果哈希表不为空并且长度大于 0,并且对应哈希桶中第一个节点不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 检查第一个节点
if (first.hash == hash &&
// 比较键是否相等(引用相等或者内容相等)
((k = first.key) == key || (key != null && key.equals(k))))
// 如果第一个节点就是我们要找的,直接返回
return first;
// 如果第一个节点不是我们要找的,并且有后续节点
if ((e = first.next) != null) {
// 如果是树节点(红黑树)
if (first instanceof TreeNode)
// 在树中查找节点
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 否则在链表中查找
do {
// 比较每一个节点的哈希值和键
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 找到匹配的节点
return e;
} while ((e = e.next) != null); // 遍历链表
}
}
// 如果没有找到,返回 null
return null;
}
红黑树的查找 getTreeNode
方法就不看了,感兴趣的可以自己到源码里看。
后续写数据结构和算法之类的文章会再看这类实现特定数据结构的源码。
8、HashMap的remove
方法
/**
* 根据指定的键从HashMap中移除键值对。
* 如果键存在,则移除该键值对并返回其对应的值;否则返回null。
*
* @param key 要移除的键
* @return 被移除的值,如果键不存在则返回null
*/
public V remove(Object key) {
// 调用removeNode方法执行移除操作,并返回被移除节点的值
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
/**
* 从HashMap中移除指定哈希值和键的节点,并可选择性匹配值。
* 此方法是HashMap移除元素的核心实现。
*
* @param hash 键的哈希值
* @param key 要移除的键
* @param value 要匹配的值,如果为null则不匹配值
* @param matchValue 是否进行值匹配
* @param movable 是否允许节点移动
* @return 如果成功移除则返回被移除的节点,否则返回null
*/
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
// 定义局部变量
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 如果HashMap的表不为空,并且长度大于0,并且在计算的索引位置有节点
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 如果索引位置的节点就是我们要找的节点
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) {
// 在红黑树结构中查找节点
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
} else {
// 在链表中查找节点
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 如果找到了节点,并且不需要匹配值或值匹配成功
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode) {
// 如果是红黑树节点,移除树节点
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
} else if (node == p) {
// 如果节点是链表的头节点,直接更新表的索引位置
tab[index] = node.next;
} else {
// 否则,更新前驱节点的next引用,跳过当前节点
p.next = node.next;
}
// 更新HashMap的结构修改计数和大小
++modCount;
--size;
// 调用节点移除后的处理方法
afterNodeRemoval(node);
// 返回被移除的节点
return node;
}
}
// 如果没有找到匹配的节点,返回null
return null;
}
9、HashMap的一些常见问题
①、JDK8为什么引入红黑树?
HashMap采用哈希表的结构存储键值对,键通过哈希函数被映射到数组的某个索引位置。理想情况下,哈希函数将键均匀地分布在数组的各个位置上,但在实际应用中,不同的键可能会被映射到同一个索引位置,导致哈希冲突。
在JDK8之前,HashMap在处理哈希冲突时使用的是链表。即在同一个索引位置上存储多个键值对时,这些键值对会被存储在一个链表中。这意味着,当多个键被映射到同一个位置时,查询、插入或删除操作的时间复杂度会随着链表长度的增加而增加,最坏情况下达到O(n),其中n是链表中的元素数量。
为了优化在哈希冲突严重情况下的性能,JDK8引入了红黑树。当链表长度大于等于8时,且哈希表容量大于等于64时。链表会转换为红黑树。
红黑树是一种自平衡二叉搜索树,具有以下特点:
平衡性:红黑树通过一系列的旋转和颜色变化来保持树的平衡,使得树的高度始终保持在O(log n)。
查询效率:由于红黑树的高度是O(log n),因此在红黑树中的查询、插入和删除操作的时间复杂度是O(log n),比链表的O(n)更高效。
②、红黑树的数据结构有什么特点 ?
树结构有以下特点:
查找效率高:
通过树形结构(如二叉查找树、红黑树、B树等),可以在O(log n)时间内完成查找操作,比线性结构(如数组、链表)高效。
保持数据有序:
树结构能够在插入和删除操作后保持数据的有序性,适用于需要频繁更新和检索的数据集。
表示层次结构:
树结构用于表示具有层次关系的数据,如XML/HTML文档、组织结构图、文件系统等。
高效插入和删除:
树结构支持高效的插入和删除操作,特别是自平衡树,通过旋转和重新平衡操作,能够在O(log n)时间内完成插入和删除。
红黑树的特点
- 节点颜色:每个节点都被标记为红色或黑色。
- 根节点:根节点始终是黑色。
- 叶子节点:所有叶子节点(即空节点)都是黑色。
- 红色规则:红色节点不能有两个连续的红色父节点和子节点。
- 黑色规则:从任一节点到其每个叶节点的所有路径都包含相同数量的黑色节点。
这些规则确保红黑树在最坏情况下也能保持O(log n)的时间复杂度。通过插入和删除操作后的旋转和重新着色,红黑树能够保持平衡,避免退化成线性结构。
二叉查找树(BST)与红黑树(Red-Black Tree)的区别:
特点 | 普通二叉查找树(BST) | 红黑树(Red-Black Tree) |
---|---|---|
基本定义 | 每个节点最多有两个子节点,左子节点小于父节点,右子节点大于父节点 | 一种自平衡的二叉查找树,附加了红黑节点的颜色属性 |
平衡性 | 不保证平衡,可能会退化成线性结构 | 保持平衡,通过颜色和旋转操作维持 |
最坏情况时间复杂度 | O(n),退化成链表时 | O(log n) |
平均情况时间复杂度 | O(log n) | O(log n) |
插入复杂度 | O(log n)(平均情况),O(n)(最坏情况) | O(log n) |
删除复杂度 | O(log n)(平均情况),O(n)(最坏情况) | O(log n) |
查询复杂度 | O(log n)(平均情况),O(n)(最坏情况) | O(log n) |
结构维护 | 插入和删除不涉及复杂的维护操作 | 插入和删除需要进行旋转和重新着色来维持平衡 |
使用场景 | 简单的插入、查找操作,数据相对有序时性能较好 | 需要频繁插入、删除和查找操作时性能稳定 |
退化情况 | 当插入数据按顺序(升序或降序)时会退化成线性结构 | 通过自平衡机制,避免退化 |
普通的二叉查找树(BST)会在以下情况下退化成线性结构:
当节点按顺序(升序或降序)插入时,每个节点都只有一个子节点,导致树变成一条“链”。
例如,插入序列为1, 2, 3, 4, 5时,BST会退化成:
③、负载因子为什么是0.75?
在空间占用与查询时间之间取得较好的权衡。
大于这个值,空间节省了,但链表可能就会比较长影响性能。
小于这个值,冲突减少了,但扩容就会更频繁,空间占用多。
综合考虑实际使用场景和对性能的要求,0.75加载因子是经验上比较成熟和常用的选择。
这个值在大多数情况下能够保证HashMap在性能和空间利用率之间取得合理的平衡。
数学概率方面:
hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过8的链表出现概率是0.00000006,选择8就是为了让树化几率足够小。
添加第一个元素时,默认情况下HashMap容量是16,负载因子是0.75 。
16*0.75=12 ,也就是第一次扩容的阈值为12。
当添加第13个元素的时候,13>12,HashMap容量扩容为32;
public static void main(String[] args) throws Exception {
HashMap<String, String> map = new HashMap<>();
// 获取 HashMap 内部的 table 数组长度
Field tableField = HashMap.class.getDeclaredField("table");
tableField.setAccessible(true); // 设置可访问私有字段
map.put("1","123");
Object[] table = (Object[]) tableField.get(map);
System.out.println(table.length); // 16
map.put("2","123");
map.put("3","123");
map.put("4","123");
map.put("5","123");
map.put("6","123");
map.put("7","123");
map.put("8","123");
map.put("9","123");
map.put("10","123");
map.put("11","123");
map.put("12","123");
Object[] table1 = (Object[]) tableField.get(map);
System.out.println(table1.length); // 16
map.put("13","123");
Object[] table2 = (Object[]) tableField.get(map);
System.out.println(table2.length); // 32
}
④、为什么数组长度≥64且链表长度 ≥8才树化?
空间和时间的折中考虑:
红黑树比链表占用更多的内存空间,因为树节点通常比链表节点大。因此,在选择将链表转换成树时,需要权衡空间和时间效率。
只有在链表长度较大时,大于等于阈值8,才值得为了提升时间效率而牺牲一定的空间。
并不是所有长度大于等于8的链表都会立即树化,只有当链表长度大于等于8且数组(哈希表)长度大于等于64时才会树化。如果链表长度大于等于8但是数组长度小于64,此时会进行扩容重新散列,而不是树化。 因为数组长度小于64说明此时的数组容量还很小,此时应该考虑扩容把元素重新散列到更大的哈希表中以减少哈希冲突来提升性能。
⑤、多线程下HashMap写操作可能出现哪些问题?
JDK8之前可能会出现:
扩容死链(头插法导致),丢失数据。
JDK8的HashMap的链表采用了尾插法不会出现扩容死链问题,仍然可能会出现丢失数据问题。
JDK1.8之前并发扩容死链问题,动画演示:
这个动画我画了快3小时 ┭┮﹏┭┮ ,多看几遍 我相信就能很容易理解扩容死链形成的过程了。
最终形成了 a.next=b, b.next=a 的这种死链:
此时当我们再调用查找方法,比如 key c 的索引也是1 ,HashMap就会遍历这个死链,发现a不是b不是 再往下遍历又到了a、b,a,b 无线循环下去,就会导致 本次 get©的调用陷入死循环。
丢失数据问题,代码演示:
public static void main(String[] args) throws Exception {
HashMap<String, String> map = new HashMap<>();
// 默认容量16
Thread t1 = new Thread(() -> {
map.put("a","a"); // hash值 97 索引位置 i = (16-1) & 97 = 1
map.put("b","b"); // hash值 98 索引位置 i = (16-1) & 98 = 2
},"t1");
Thread t2 = new Thread(() -> {
map.put("1","1"); // hash值 49 索引位置 i = (16-1) & 49 = 1
map.put("2","2"); // hash值 50 索引位置 i = (16-1) & 50 = 2
},"t2");
t2.setName("t2");
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println(map);
}
可以看到 key “a” 和 “1” 会出现哈希冲突,key “b” 和 “2” 会出现哈希冲突。
正常情况下应该得到的结果是:
{1=1, a=a, 2=2, b=b}
现在我们演示下出问题的情况:
首先打断点,断点的条件设置为 线程名称是 t1或者t2才走断点。(因为JVM启动的时候也会调用putVal方法,不加条件可能会断点到很多其他线程的调用)
断点条件代码:Thread.currentThread().getName().equals("t1")||Thread.currentThread().getName().equals("t2")
①、debug运行代码,先走t1线程的断点 map.put("a","a"); // hash值 97 索引位置 i = (16-1) & 97 = 1
注意让t1只走到判断 索引位置为空的if条件里先不给数组赋值
②、此时选t2线程,此时由于 key "1"的索引位置也是1 ,而t1线程 还没来得及给数组的这个1位置赋值
所以t2线程也进入了if代码块内 。
此时让t2线程继续往下走一步赋值 map.put("1","1"); // hash值 49 索引位置 i = (16-1) & 49 = 1
, 数组的 tab[1] = (“1”,“1”)了
③、再返回t1线程,此时的tab[1] 已经有值了,是上面 t2线程赋的值。
这个时候让t1线程继续赋值就把 t2 线程在索引位置1 处赋的值覆盖掉了。
④、我们再重复上面的操作,再走t1进if语句内不赋值,然后等t2赋值后,t1再赋值。
最终就能得到丢失数据的结果。
{a=a, b=b}
可以看到 和正常情况的 {1=1, a=a, 2=2, b=b}
相比 丢失了t2线程put的数据。
⑥、JDK8之前的put方法和之后的put方法有什么区别 ?
- 1、链表插入行为不同:链表插入节点时,JDK8之前是头插法,JDK8是尾插法。
- 2、扩容行为不同:JDK8之前是大于等于阈值且没有空位时才扩容,而JDK8是大于阈值就扩容.
- 3、链表和红黑树转化:JDK8之前只有链表,JDK8引入红黑树。
⑦、HashMap的红黑树什么情况下会退化成链表?
退化情况1: 在扩容时如果拆分树时,树元素个数<=6则会退化为链表。
在HashMap进行扩容时,会对现有的桶进行重新分配元素。如果一个桶中原本是红黑树节点(TreeNode),而在进行扩容时,树中节点的数量少于等于6个,HashMap会选择将这些节点转换为链表形式。这是因为对于数量较少的节点来说,使用链表而不是红黑树可能会更节省空间和操作成本。
对应代码:
if (loHead != null) {
// 如果低位链表头结点不为空
if (lc <= UNTREEIFY_THRESHOLD)
// 如果低位链表的节点数小于等于UNTREEIFY_THRESHOLD (默认是6),将其退化成链表
tab[index] = loHead.untreeify(map);
else {
// 否则,保持为红黑树
tab[index] = loHead;
if (hiHead != null) // 如果高位链表头结点也不为空,保持红黑树结构
loHead.treeify(tab);
}
}
if (hiHead != null) {
// 如果高位链表头结点不为空
if (hc <= UNTREEIFY_THRESHOLD)
// 如果高位链表的节点数小于等于UNTREEIFY_THRESHOLD (默认是6),将其退化成链表
tab[index + bit] = hiHead.untreeify(map);
else {
// 否则,保持为红黑树
tab[index + bit] = hiHead;
if (loHead != null) // 如果低位链表头结点也不为空,保持红黑树结构
hiHead.treeify(tab);
}
}
退化情况2: remove 树节点时,若 root、root.left、root.right、root.left.left有一个为 null,也会退化为链表。
在HashMap中删除树节点时,如果根节点或其子节点的左右子节点为null,则树节点会退化为链表形式。
这是因为在红黑树中,根节点的左右子节点为null意味着红黑树的结构不再成立,因此HashMap选择将这部分节点转换为链表以保持结构的一致性和简单性。
对应代码:
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab, boolean movable) {
int n;
if (tab == null || (n = tab.length) == 0)
return; // 如果哈希表为空或长度为零,直接返回
int index = (n - 1) & hash; // 计算节点所在的桶索引
TreeNode<K,V> first = (TreeNode<K,V>)tab[index], root = first, rl;
TreeNode<K,V> succ = (TreeNode<K,V>)next, pred = prev;
if (pred == null)
tab[index] = first = succ; // 如果没有前驱节点,将桶头设置为后继节点
else
pred.next = succ; // 否则将前驱节点的 next 指向后继节点
if (succ != null)
succ.prev = pred; // 如果有后继节点,将其 prev 指向前驱节点
if (first == null)
return; // 如果桶头为空,直接返回
if (root.parent != null)
root = root.root(); // 获取红黑树的根节点
if (root == null || root.right == null || (rl = root.left) == null || rl.left == null) {
tab[index] = first.untreeify(map); // 如果红黑树的结构不再平衡,将其退化为链表
return;
}
TreeNode<K,V> p = this, pl = left, pr = right, replacement;
if (pl != null && pr != null) {
TreeNode<K,V> s = pr, sl;
while ((sl = s.left) != null)
s = sl; // 找到后继节点
boolean c = s.red; s.red = p.red; p.red = c; // 交换颜色
TreeNode<K,V> sr = s.right;
TreeNode<K,V> pp = p.parent;
if (s == pr) {
p.parent = s;
s.right = p; // 如果后继节点是右子节点,调整关系
} else {
TreeNode<K,V> sp = s.parent;
if ((p.parent = sp) != null) {
if (s == sp.left)
sp.left = p;
else
sp.right = p;
}
if ((s.right = pr) != null)
pr.parent = s; // 调整后继节点与右子节点的关系
}
p.left = null;
if ((p.right = sr) != null)
sr.parent = p; // 调整右子节点与 p 的关系
if ((s.left = pl) != null)
pl.parent = s; // 调整左子节点与后继节点的关系
if ((s.parent = pp) == null)
root = s;
else if (p == pp.left)
pp.left = s;
else
pp.right = s; // 调整后继节点与 p 父节点的关系
if (sr != null)
replacement = sr;
else
replacement = p; // 确定替代节点
} else if (pl != null)
replacement = pl; // 如果只有左子节点,替代节点为左子节点
else if (pr != null)
replacement = pr; // 如果只有右子节点,替代节点为右子节点
else
replacement = p; // 如果没有子节点,替代节点为 p
if (replacement != p) {
TreeNode<K,V> pp = replacement.parent = p.parent;
if (pp == null)
root = replacement;
else if (p == pp.left)
pp.left = replacement;
else
pp.right = replacement; // 调整替代节点与 p 父节点的关系
p.left = p.right = p.parent = null; // 清空 p 的引用
}
TreeNode<K,V> r = p.red ? root : balanceDeletion(root, replacement); // 平衡删除操作
if (replacement == p) { // 断开 p 与其父节点的关系
TreeNode<K,V> pp = p.parent;
p.parent = null;
if (pp != null) {
if (p == pp.left)
pp.left = null;
else if (p == pp.right)
pp.right = null;
}
}
if (movable)
moveRootToFront(tab, r); // 如果可移动,将根节点移动到桶头
}