当前位置: 首页 > news >正文

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的迭代器
    • 10、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方法

boolean evict,该参数指示当前的操作是否在创建模式下。如果为 false,表示哈希表处于创建模式;如果为 true,表示哈希表处于正常操作模式。此参数通常在调整哈希表大小时使用,以避免在创建模式中触发删除操作。

evict 参数为 false 的情况:
在哈希表初始化或扩容时,通过 putMapEntries 方法调用 putVal,设置 evict 为 false。

evict 参数为 true 的情况:
正常操作(非创建模式)下,插入或更新元素时,evict 为 true,允许执行淘汰策略。

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的迭代器

    由于HashMap是数组+链表的数据结构,所以HashMap的迭代器只能循环数组,从0索引位置开始循环找到第一个非空的Node节点,如果该节点的next不会空,就继续使用Node节点的next去一个一个遍历元素,如果next为空了,再往下循环数组直到找到下一个非空的Node节点继续使用next遍历。
所以HashMap的迭代器会循环遍历所有的数组Node节点,以及Node节点链接的链表或红黑树的全部元素。

abstract class HashIterator {// 下一个要返回的节点Node<K,V> next;        // 当前的节点Node<K,V> current;     // 用于快速失败的期望修改计数int expectedModCount;  // 当前槽的索引int index;             // 构造方法HashIterator() {// 初始化期望的修改计数,等于当前的修改计数expectedModCount = modCount;// 获取哈希表Node<K,V>[] t = table;// 初始化当前节点和下一个节点为nullcurrent = next = null;// 初始化索引为0index = 0;// 如果哈希表不为空并且大小大于0,推进到第一个非空的槽if (t != null && size > 0) { // 循环直到找到第一个非空的槽do {} while (index < t.length && (next = t[index++]) == null);}}// 判断是否有下一个元素public final boolean hasNext() {return next != null;}// 获取下一个节点final Node<K,V> nextNode() {// 临时变量t用于存储哈希表Node<K,V>[] t;// e用于存储当前的下一个节点Node<K,V> e = next;// 如果哈希表的修改计数与期望的修改计数不同,抛出并发修改异常if (modCount != expectedModCount)throw new ConcurrentModificationException();// 如果下一个节点为空,抛出没有元素异常if (e == null)throw new NoSuchElementException();// 将当前节点设为下一个节点,如果下一个节点的下一个节点为空,继续寻找下一个非空的槽if ((next = (current = e).next) == null && (t = table) != null) {// 循环直到找到下一个非空的槽do {} while (index < t.length && (next = t[index++]) == null);}// 返回当前的节点return e;}}

10、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); // 如果可移动,将根节点移动到桶头
}

相关文章:

HashMap详解(含动画演示)

目录 HashMap1、HashMap的继承体系2、HashMap底层数据结构3、HashMap的构造函数①、无参构造②、有参构造1 和 有参构造2 (可以自定义初始容量和负载因子)③、有参构造3(接受一个Map参数)JDK 8之前版本的哈希方法&#xff1a;JDK 8版本的哈希方法 4、拉链法解决哈希冲突什么是拉…...

TVS的原理及选型

目录 案例描述 TVS管的功能与作用&#xff1a; TVS选型注意事项&#xff1a; 高速TVS管选型 最近项目中遇到TVS管选型错误的问题。在此对TVS的功能及选型做一个分享。 案例描述 项目中保护指标应为4-14V&#xff0c;而选型的TVS管位SMJ40CA&#xff0c;其保护电压为40V未…...

【机器学习】无监督学习:探索数据背后的隐藏模式

在机器学习的广阔领域中&#xff0c;监督学习因其直观的训练方式和广泛的应用场景&#xff0c;往往受到更多的关注。然而&#xff0c;随着数据量和数据类型的不断增长&#xff0c;无监督学习的重要性日益凸显。本文将详细介绍无监督学习的理论基础、常用算法及其在实际中的应用…...

使用Elasticsearch在同一索引中区分不同类型的文档

在使用Elasticsearch时&#xff0c;有时我们需要在同一个索引中存放不同类型的文档&#xff0c;并且这些文档的字段可能不一致。在早期版本中&#xff0c;我们可以使用types来实现&#xff0c;但在Elasticsearch 7.x及更高版本中&#xff0c;types概念已被弃用。本文将介绍如何…...

驾校在线考试系统源码 手机+PC+平板自适应

Thinkphp在线考题源码 驾校在线考试系统 手机PC平板 自适应&#xff0c;机动车驾驶培训学校驾校类网站源码带手机端 运行环境&#xff1a;phpmysql 内附安装说明 驾校在线考试系统源码 手机PC平板自适应...

c++的多态,继承,抽象类,虚函数表,虚函数等题目+分析

目录 题目 代码题 分析 主观题 题目 代码题 class A { public:virtual void func(int val 1) {std::cout << "A->" << val << std::endl;}virtual void test() { func(); } };class B : public A { public:void func(int val 0) { std…...

利用 Qwen-VL 进行私有化部署第一个 AI 多模态大模型

Hi~&#xff01;这里是奋斗的小羊&#xff0c;很荣幸您能阅读我的文章&#xff0c;诚请评论指点&#xff0c;欢迎欢迎 ~~ &#x1f4a5;&#x1f4a5;个人主页&#xff1a;奋斗的小羊 &#x1f4a5;&#x1f4a5;所属专栏&#xff1a;C语言 &#x1f680;本系列文章为个人学习…...

王思聪隐形女儿曝光

王思聪"隐形"女儿曝光&#xff01;黄一鸣独自面对怀孕风波&#xff0c;坚持生下爱情结晶近日&#xff0c;娱乐圈掀起了一场惊天波澜&#xff01;前王思聪绯闻女友黄一鸣在接受专访时&#xff0c;大胆揭露了她与王思聪之间的爱恨纠葛&#xff0c;并首度公开承认&#…...

学习笔记——网络管理与运维——SNMP(SNMP原理)

四、SNMP原理 SNMP的工作原理基于客户端-服务器模型。其中&#xff0c;网络管理系统是客户端&#xff0c;而网络设备是服务器。客户端向服务器发送请求消息(即"Get"或"Set"命令)来获取或修改服务器的信息。服务器收到请求消息后&#xff0c;会返回相应的响…...

基于STM32和人工智能的自动驾驶小车系统

目录 引言环境准备自动驾驶小车系统基础代码实现&#xff1a;实现自动驾驶小车系统 4.1 数据采集模块4.2 数据处理与分析4.3 控制系统4.4 用户界面与数据可视化应用场景&#xff1a;自动驾驶应用与优化问题解决方案与优化收尾与总结 1. 引言 随着人工智能和嵌入式系统技术的…...

简单介绍vim

文章目录 前言一、Vim的特点二、安装Vim三、设置Vim配置文件的位置&#xff1a;编辑配置文件&#xff1a;添加配置选项&#xff1a;保存并退出编辑器&#xff1a;快速配置验证设置&#xff1a; 总结 前言 Vim是一款强大的文本编辑器&#xff0c;被广泛用于各种编程和文本编辑任…...

使用本地数据对transformers模型进行微调训练

模型 transformers模型是使用比较多的模型&#xff0c;奈何各个都是体积大&#xff0c;找了一个使用人多不是很大的模型进行训练。 需要魔法 bert-base-uncased模型仓库地址 huggingface下的所有仓库都是git的&#xff0c;也就意味着你可以使用 git clone 可以下载仓库内所有的…...

Java面试题:讨论何时需要创建自定义异常类,并展示如何实现一个自定义异常

在Java中&#xff0c;创建自定义异常类的目的是为了更加清晰和有意义地表示特定的错误情况&#xff0c;增强代码的可读性和可维护性。以下是一些需要创建自定义异常类的常见场景以及如何实现一个自定义异常。 何时需要创建自定义异常类 特定业务逻辑错误&#xff1a; 当业务逻…...

什么是进程

自学python如何成为大佬(目录):https://blog.csdn.net/weixin_67859959/article/details/139049996?spm1001.2014.3001.5501 在了解进程之前&#xff0c;我们需要知道多任务的概念。多任务&#xff0c;顾名思义&#xff0c;就是指操作系统能够执行多个任务。例如&#xff0c;…...

电脑提示d3dcompiler_47.dll丢失的解决方法,实测靠谱的5种方法

在计算机使用过程中&#xff0c;缺失d3dcompiler_47.dll这一系统文件是一个常见问题&#xff0c;尤其是对于游戏和图形密集型应用程序用户来说尤为重要。这个文件是DirectX软件工具包的一部分&#xff0c;主要用于处理图形渲染的应用程序接口的核心元素。当你在运行游戏或某些软…...

SQLserver前五讲课堂笔记

第一讲 基本内容 为什么要学习数据库系统?什么是数据库?什么是数据库系统?什么是数据库管理系统&#xff1f;本课程学什么以及学到什么程度? 重点难点 一组概念的区分&#xff1a;数据库、数据库系统和数据库管理系统熟悉表 的相关要素及术语熟悉数据库系统的构成(工作…...

深度学习项目十六:根据训练好的权重文件推理图片--YOLO系列

文章目录 根据训练好的权重文件推理图片--YOLO系列一、自己构建YOLOv5推理代码1.1 对数据集进行模型训练1.2 对数据集进行模型推理检测1.3 自己编写推理函数1.3.1 针对单张进行推理1.3.2 针对文件夹下的图片进行推理二、自己构建YOLOv8推理代码2.1 对数据集进行模型训练2.2 对数…...

敏感信息加密操作,让开发的系统更加的安全可靠!!

敏感信息加密操作&#xff0c;让开发的系统更加的安全可靠&#xff01;&#xff01;Jasypt&#xff08;Java Simplified Encryption&#xff09;是一个开源的Java库&#xff0c;用于简化加密操作。https://mp.weixin.qq.com/s/sPBV8Ej46YJsElImodRjAQ...

第四篇:精通Docker构建:Dockerfile的艺术与策略

精通Docker构建&#xff1a;Dockerfile的艺术与策略 1. 开篇&#xff1a;探索Docker的革命 在探讨我们的主题之前&#xff0c;让我们先回顾一下Docker的概念。Docker是一个开源平台&#xff0c;用于自动化应用程序的部署、扩展和管理&#xff0c;这一切都是在轻量级的容器中进…...

Linux下Cmake安装或版本更新

下载Cmake源码 https://cmake.org/download/ 找到对应的版本和类型 放进linux环境解压 编译 安装 tar -vxvf cmake-3.13.0.tar.gz cd cmake-3.13.0 ./bootstrap make make install设置环境变量 vi ~/.bashrc在文件尾加入 export PATH/your_path/cmake-3.13.0/bin:$PAT…...

人工智能体验工程师面试

在面试人工智能体验工程师时,面试官可能会从多个方面来考察候选人的能力和经验。以下是人工智能体验工程师面试题: 基础知识考察: 请简述人工智能、机器学习和深度学习的关系与区别。请解释神经网络的基本原理,以及它在人工智能中的应用。描述一种你熟悉的深度学习模型,并…...

科研——BIBM论文修改和提交

文章目录 引言投递流程Latex翻译流程latex模板使用bib文件正文修改 反馈时间线等待审稿结果 引言 第一轮投递快结束了&#xff0c;这里得加快进度&#xff0c;二十号截至&#xff0c;这里得在截至之前投一下&#xff0c;这里翻译整理一下投递的流程 投递流程 投递链接论文是…...

【bug】配置SpringCloudAlibaba AI的maven依赖问题

问题描述 尝鲜alibaba的ai模块&#xff0c;maven依赖一直报找不到包&#xff0c;报错如下 Unresolved dependency: org.springframework.ai:spring-ai-core:jar:0.8.1原因分析&#xff1a; 由于是按照官方文档配置的&#xff0c;所以检查了很多遍maven配置&#xff0c;加上去…...

人工智能和机器学习的应用日益广泛,在医疗健康领域的具体应用是什么?

人工智能&#xff08;AI&#xff09;和机器学习&#xff08;ML&#xff09;在医疗健康领域的应用日益广泛&#xff0c;涵盖了从疾病预测、辅助诊断、药物研发到健康管理等多个方面。以下是一些具体的应用实例和成功案例&#xff1a; 疾病预测与辅助诊断&#xff1a;机器学习算…...

前端:鼠标点击实现高亮特效

一、实现思路 获取鼠标点击位置 通过鼠标点击位置设置高亮裁剪动画 二、效果展示 三、按钮组件代码 <template><buttonclass"blueBut"click"clickHandler":style"{backgroundColor: clickBut ? rgb(31, 67, 117) : rgb(128, 128, 128),…...

【计算机网络体系结构】计算机网络体系结构实验-DNS模拟器实验

一、DNS模拟器实验 拓扑图 1. 服务器ip 2. 服务器填写记录 3. 客户端ip以及连接到DNS服务器 4. ping测试...

【profinet】从站开发要点

目录 0、常见缩写及关键字注释 1、profinet简介 2、profinet协议栈 3、profinet数据帧 4、profinet网络解决方案示例 5、Application areas 注&#xff1a;本文主要简述profinet从站开发涉及到的知识点。【不足之处后续慢慢补充】。 0、常见缩写及关键字注释 MRP: Media…...

浮点数的进制转换

浮点数的进制转换涉及到将十进制&#xff08;基数为10&#xff09;的浮点数转换为其他进制&#xff08;如二进制、八进制、十六进制等&#xff09;。以下是将十进制浮点数转换为其他进制的基本步骤&#xff1a; ### 1. 分离整数部分和小数部分&#xff1a; 将浮点数分为整数部…...

vue-饼形图-详细

显示效果 代码 <template> <div style"height: 350px;"> <div :class"className" :style"{height:height,width:width}"></div> </div> </template> <script> import * as echarts from echarts; req…...

MySQL-备份+日志:介质故障与数据库恢复

目录 第1关&#xff1a;备份与恢复 任务描述 相关知识 MySQL的恢复机制 MySQL的备份与恢复工具 …...

网站建设重要意义/汉中seo培训

工业触摸屏一体机-迅为7寸触控一体机工业人机界面_安卓触摸屏&#xff0c;适用于自动售货机、人机界面、自动终端、广告机、触摸控制系统 接口支持&#xff1a;全网通4G模块、WIFI蓝牙模块、千兆以太网、CAN总线、RS-485模块 系统支持&#xff1a;Android4.4/5.1安卓系统 CPU处…...

名医工作室 网站建设/搜索历史记录

本文介绍了Android 带箭头的指引tipLayout实现示例代码&#xff0c;分享给大家&#xff0c;具体如下&#xff1a;如上是从UI接过来的设计图&#xff0c;要求三角形指示器需要动态对齐上面的文本&#xff0c;需要动态的实现对其三角形。引用方式compile com.xiaowei:TriangleTip…...

东莞常平镇房价多少/广州专做优化的科技公司

题目 句子仅由小写字母&#xff08;‘a’ 到 ‘z’&#xff09;、数字&#xff08;‘0’ 到 ‘9’&#xff09;、连字符&#xff08;’-’&#xff09;、标点符号&#xff08;’!’、’.’ 和 ‘,’&#xff09;以及空格&#xff08;’ &#xff09;组成。每个句子可以根据空格…...

天津网站搭建/电商运营自学网站

安装es 1、检查java版本&#xff0c;安装java版本至少是1.8以上 java -version2、新增用户组 用户 groupadd es useradd es -g es -p 1234563、下载es&#xff0c;并解压&#xff0c;将文件权限赋予es wget https://artifacts.elastic.co/downloads/elasticsearch/elasticsearc…...

wordpress 页面 分类/友情链接

今天真的颇为激动&#xff0c;1年没有玩RN&#xff0c;竟然被最新的RN版本0.55.4创建Hello折腾了半天&#xff0c;想当年刚玩RN创建环境用了3天&#xff0c; 想想现在也是不容易啊半天就搞定了&#xff0c;目测以后创建的话也就1-2个小时就搞定了吧&#xff0c;哈哈 &#xff0…...

中小学网络云平台/正规seo需要多少钱

技术负责人 vs产品负责人上次我提出了产品负责人应该做的事情 -或至少考虑要做。 即使快速浏览该列表&#xff0c;也会告诉您产品负责人会很忙。 因此&#xff0c;在这篇文章中&#xff0c;我想建议一些产品负责人不要这样做。 产品负责人切割代码不应是切割代码 由产品负责…...