HashMap详解(含动画演示)

目录

  • 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的大小 / 加载因子) + 1float 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次方,即哈希表的默认大小为16static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;// 哈希表的最大容量,即2的30次方,即1073741824static final int MAXIMUM_CAPACITY = 1 << 30;// 默认的加载因子,即哈希表的装填因子,默认为0.75static 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);}// 保存当前节点为xpTreeNode<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;elsexp.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; // 获取当前哈希表的容量,如果为空则为0int oldThr = threshold; // 获取当前的扩容阈值int newCap, newThr = 0; // 声明新的容量和新的扩容阈值// 如果当前哈希表的容量大于0if (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 thresholdnewCap = 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; // 设置低位链表的头节点elseloTail.next = e; // 追加到低位链表的尾部loTail = e; // 更新低位链表的尾节点}else { // 如果是高位链表if (hiTail == null) // 如果高位链表为空hiHead = e; // 设置高位链表的头节点elsehiTail.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; // 设置低位链表的头节点elseloTail.next = e; // 否则将当前节点连接到尾节点loTail = e; // 更新低位链表的尾节点++lc; // 低位链表节点计数增加} else {if ((e.prev = hiTail) == null) // 如果高位链表尾节点为空,说明是第一个节点hiHead = e; // 设置高位链表的头节点elsehiTail.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; // 设置链表头节点elsetl.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 方法查找键对应的节点,如果找到则返回节点的值,否则返回 nullNode<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);  // 遍历链表}}// 如果没有找到,返回 nullreturn 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;}}// 如果没有找到匹配的节点,返回nullreturn 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);  // 16map.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);  // 16map.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<>();// 默认容量16Thread t1 = new Thread(() -> {map.put("a","a"); // hash值 97  索引位置 i = (16-1) & 97 = 1map.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 = 1map.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; // 如果没有前驱节点,将桶头设置为后继节点elsepred.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;elsesp.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;elsepp.right = s; // 调整后继节点与 p 父节点的关系if (sr != null)replacement = sr;elsereplacement = p; // 确定替代节点} else if (pl != null)replacement = pl; // 如果只有左子节点,替代节点为左子节点else if (pr != null)replacement = pr; // 如果只有右子节点,替代节点为右子节点elsereplacement = p; // 如果没有子节点,替代节点为 pif (replacement != p) {TreeNode<K,V> pp = replacement.parent = p.parent;if (pp == null)root = replacement;else if (p == pp.left)pp.left = replacement;elsepp.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); // 如果可移动,将根节点移动到桶头
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/853573.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

正解 x86 Linux 内存管理

1&#xff0c;机器解析的思路 发现网络上大量的教程&#xff0c;多是以讹传讹地讲解 Linux 内存管理&#xff1b; 都是在讲&#xff1a; 逻辑地址 -> 线性地址 -> 物理地址 这个转换关系是怎么发生的。 上面这个过程确实是程序运行时地址的翻译顺序&#xff1b; …

使用消息队列(MQ)实现MySQL持久化存储与MySQL server has gone away问题解决

在现代应用程序开发中&#xff0c;消息队列&#xff08;MQ&#xff09;扮演着重要的角色。它们可以帮助我们解决异步通信和解耦系统组件之间的依赖关系。而其中一个常见的需求是将消息队列中的数据持久化到数据库中&#xff0c;以确保数据的安全性和可靠性。在本文中&#xff0…

【AI学习】Together AI的新研究:Together MoA(Mixture of Agents)

第一次听说Mixture of Agents&#xff0c;原来Agent也能混合&#xff0c;有意思&#xff01; 介绍 Together AI的最新研究“Together MoA”&#xff08;Mixture of Agents&#xff0c;代理混合&#xff09;是一种创新的方法&#xff0c;旨在通过结合多个开源大型语言模型&…

【尚庭公寓SpringBoot + Vue 项目实战】公寓管理(十一)

【尚庭公寓SpringBoot Vue 项目实战】公寓管理&#xff08;十一&#xff09; 文章目录 【尚庭公寓SpringBoot Vue 项目实战】公寓管理&#xff08;十一&#xff09;1、业务介绍2、逻辑模型介绍3、接口开发3.1、保存或更新公寓信息3.2、根据条件分页查询详细信息3.3、根据ID获…

读AI新生:破解人机共存密码笔记02进化

1. 人工智能的标准模型 1.1. 机器优化人类提供的固定目标 1.1.1. 是一条死胡同 1.1.1.1. 当你走进死胡同时&#xff0c;你最好掉头返回&#xff0c;找出走错的地方 1.2. 问题不在于我们可能无法做好构建人工智能系统的工作&…

关于阿里云效流水线自动部署项目教程

1、登录阿里云效:阿里云登录 - 欢迎登录阿里云&#xff0c;安全稳定的云计算服务平台 2、点击左侧流水线&#xff1a; 3、在流水线界面&#xff0c;新建流水线 4、我的是php代码&#xff0c;因此选择php模版 5、创建之后添加流程线源&#xff0c;如下图 6、选择相应的源头。比…

【odoo】右上角的提示(通知服务)

概要 在Odoo中&#xff0c;右上角的提示&#xff08;通知服务&#xff09;用于显示系统消息、警告、错误或信息提示。这个功能通过 JavaScript 在前端实现&#xff0c;并且使用 Odoo Web 框架提供的现成功能。 提示类型 信息提示 (Info)&#xff1a;用于显示普通信息。这类提示…

Huggingface-cli 登录最新版(2024)

安装Huggingface-cli pip install -U "huggingface_hub[cli]"设置好git的邮箱和用户名和huggingface的github账号一致 git config --global user.mail xxx git config --global user.name xxx登录 复制token&#xff0c;划红线的地方&#xff0c;在命令行中点击右…

微软OneDrive简介:特点、应用场景、使用方法、注意事项

还是大剑师兰特&#xff1a;曾是美国某知名大学计算机专业研究生&#xff0c;现为航空航海领域高级前端工程师&#xff1b;CSDN知名博主&#xff0c;GIS领域优质创作者&#xff0c;深耕openlayers、leaflet、mapbox、cesium&#xff0c;canvas&#xff0c;webgl&#xff0c;ech…

利用Cesium和JS实现地点点聚合功能

引言 在实现基于地图的业务场景时&#xff0c;当地图上需要展示过多的标记点时&#xff0c;大量的分散点会使地图上显得杂乱无章&#xff0c;导致标记点对地图上的其他重要信息造成遮挡和混淆&#xff0c;降低地图整体的可读性。 标记点的聚合就很好的解决了这些痛点的同时&a…

[大模型]XVERSE-MoE-A4.2B Transformers 部署调用

XVERSE-MoE-A4.2B介绍 XVERSE-MoE-A4.2B 是由深圳元象科技自主研发的支持多语言的大语言模型&#xff08;Large Language Model&#xff09;&#xff0c;使用混合专家模型&#xff08;MoE&#xff0c;Mixture-of-experts&#xff09;架构&#xff0c;模型的总参数规模为 258 亿…

apt-get update和apt-get upgrade的区别

apt-get update apt-get update 命令用于更新本地软件包列表。具体来说&#xff0c;做了以下事情&#xff1a; ①从 /etc/apt/sources.list 文件和 /etc/apt/sources.list.d/ 目录下的所有文件中读取软件源配置。 ②连接到这些软件源&#xff0c;并下载最新的软件包列表。 ③将…

Python 潮流周刊#56:NumPy 2.0 里更快速的字符串函数

△△请给“Python猫”加星标 &#xff0c;以免错过文章推送 本周刊由 Python猫 出品&#xff0c;精心筛选国内外的 250 信息源&#xff0c;为你挑选最值得分享的文章、教程、开源项目、软件工具、播客和视频、热门话题等内容。愿景&#xff1a;帮助所有读者精进 Python 技术&am…

Mysql(一):深入理解Mysql索引底层数据结构与算法

众所众知&#xff0c;MySql的查询效率以及查询方式&#xff0c;基本上和索引息息相关&#xff0c;所以&#xff0c;我们一定要对MySql的索引有一个具体到数据底层上的认知。 这一次也是借着整理的机会&#xff0c;和大家一起重新复习一下MySql的索引底层。 本节也主要有一下的…

【PL理论】(25) C- 语言:表达式求值的推理规则 | 执行语句的推理规则 | 语句执行的推理规则

&#x1f4ad; 写在前面&#xff1a;本章我们将继续更新我们的 "C-" 语言&#xff0c;更新表达式求值的推理规则、执行语句的推理规则以及语句执行的推理规则。 目录 0x00 C- 语言更新&#xff1a;表达式求值的推理规则 0x01 C- 语言更新&#xff1a;执行语句的推…

网页五子棋对战项目测试(selenium+Junit5)

目录 网页五子棋对战项目介绍 网页五子棋对战测试的思维导图​ 网页五子棋对战的UI自动化测试 测试一&#xff1a;测试注册界面 测试二&#xff1a;测试登陆界面 测试三&#xff1a;测试游戏大厅界面 测试四&#xff1a;测试游戏房间界面以及观战房间界面 测试五&#…

软考-架构设计师-综合知识总结(试卷:2009~2022)(下篇)

说明 本文档对2009到2022年试卷的综合知识进行了归纳总结&#xff0c;同时对叶宏主编的《系统架构设计师教程》划分重点。 第十七章&#xff1a;通信系统架构设计 17.2 考题总结 第十八章&#xff1a;安全架构设计 18.1 重要知识点 18.2 考题总结 第十九章&#xff1a;大数据…

传统后端SQL数据层替代解决方案: 内置数据源+JdbcTemplate+H2数据库 详解

内置数据源 我们回顾一下druid数据源的配置方式 通过type属性指定数据源的类型 导入依赖 starter就使用了spring的自动装配功能 格式二是在引入druid的依赖的基础上 进行的一种配置方式 Tomcat内部也可以进行数据源的配置 轻量级中最快的数据源对象 我们切换德鲁伊连接池 我…

OpenDevin 环境配置及踩坑指南

不惧怕任何环境配置 首先 clone 项目&#xff0c;然后查看开发者文档&#xff1a;https://github.com/OpenDevin/OpenDevin/blob/main/Development.md make setup-config 自定义 LLM 配置 首先这个 devin 写的是支持自定义的 LLM 配置&#xff0c;并且提供了交互式命令供我们…

DzzOffice集成功能最丰富的开源PHP+MySQL办公系统套件

DzzOffice是一套开源办公套件&#xff0c;旨在为企业和团队提供类似“Google企业应用套件”和“微软Office365”的协同办公平台。以下是对DzzOffice的详细介绍&#xff1a; 主要功能和应用&#xff1a; 网盘&#xff1a;支持企业、团队文件的集中管理&#xff0c;提供文件标签…