解析 HashMap 源码:深入探究核心方法的实现与原理

news2024/12/25 19:50:59

在这里插入图片描述

  • 前言
  • 数据结构
  • 类属性
  • 构造方法
  • 核心方法
    • 阈值:tableSizeFor
    • 插入元素:put
    • 树化:treeifyBin
    • 扩容:resize
    • 获取元素:get
    • 删除元素:remove
    • 遍历元素:keySet、entrySet 方法
  • 总结

前言

一切的源头从类注释开始说起,翻译自 HashMap 类源码上注释

基于哈希表的 Map 接口的实现,这实现提供了所有可选的映射操作,并允许空值和空键。

HashMap 实现为基本的提供了恒定时间的性能操作:get、put 方法,假设哈希函数将元素正确地分散在桶中。 集合视图的迭代需要的时间与集合视图的`“容量(capacity)”成正比。HashMap 实例(桶的数量)加上它的大小(键值映射的数量) 因此,如果迭代性能很重要,则不要将初始容量设置得太高(或负载因子太低)

HashMap 实例有两个参数会影响其性能:初始容量 initial capacity、负载因子 load factor

容量是哈希表中桶的数量,初始容量只是创建哈希表时的容量。 负载因子是哈希表在其容量自动增加之前允许达到多满的度量。 当哈希表的条目数超过负载因子与当前容量的乘积时,哈希表将被重新哈希(即重新建立内部的数据结构),使哈希表的桶数大约增加一倍。

作为一般规则,默认负载系数 (0.75) 提供了一个很好的时间与空间成本之间的权衡

较高的值会减少空间开销,但会增加查找成本,主要反映在 HashMap 大部分会操作 > get、put
在设置其初始容量时应考虑 Map 中预期的条目数及其负载因子,以尽量减少 rehash 操作的次数

若需要将许多映射存储在一个 HashMap 实例中,创建一个足够大的容量将会使得映射的存储比让它执行自动重新散列来扩大表更有效,所以一般在实际开发中,会预测这个初始的容量并给它指定

使用相同的 hashcode 值键肯定会降低任何哈希表性能的影响,为了改善影响,HashMap 底层数据结构使用了链表+红黑树

HashMap 实现不是同步的,即不是线程安全的,若要实现线程安全应当使用Collections.synchronizedMap 方法或 ConcurrentHashMap,这最好在创建时完成,以防止对 Map 的意外不同步访问

Map m = Collections.synchronizedMap(new HashMap(…))

此类的所有“集合视图方法”返回的迭代器是快速失败 fast-fail:若映射在之后的任何时间进行了结构修改,除了通过迭代器自己的 remove 方法,普通遍历时将抛出 ConcurrentModificationException。 因此,面对并发修改后,迭代器会快速干净地失败,而不是冒着风险在不确定的时间任意的、不确定的行为操作。

后面会具体介绍 HashMap 数据结构以及它的一些核心方法、属性的部分,比如:初始容量 initial capacity、负载因子 load factor、get、resize、put 等

数据结构

在 HashMap 1.7 版本中,数据结构采用数组+链表组成
在 HashMap 1.8 版本中,数据结构采用数组+链表+红黑树组成

当一个值要存入到 HashMap 中时,会先根据 Key 以低 16、高 16 位方式计算出它的 hash 值,通过 hash 来确认要存放到数组中哪个位置;若发生 hash 值冲突时,则以链表的方式依次向后存储;当链表过长并且数组元素长度到达一定阈值时,HashMap 会将链表转换为红黑树作为存储结构

在这里插入图片描述

类属性

在阅读源码之前,先了解 HashMap 它的一些基础属性

// 默认的初始化容量:16 - 必须为 2 的幂次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 最大容量值,若隐式指定了更大的值,则使用最大容量 MAXIMUM_CAPACITY 
// 会由任意一个带参数的构造函数进行判断或 resize 方法中进行判断
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默认负载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 当链表长度到达该阈值 & table 数组容量大小到达 MIN_TREEIFY_CAPACITY 阈值会转换为红黑树
static final int TREEIFY_THRESHOLD = 8;

// 当 table 数组容量的大小到达该阈值 & 链表长度到达该阈值 TREEIFY_THRESHOLD 就会进行树化
static final int MIN_TREEIFY_CAPACITY = 64;

// Node 是 HashMap 中的内部类 > 单链表结构,用来表示 Key-Value,table 数组中存放的就是 Node
static class Node<K,V> implements Map.Entry<K,V> {
   final int hash;
   final K key;
   V value;
   Node<K,V> next;
   // ......
   public final int hashCode() {
   	  // ^ 表示相同返回 0,不同返回 1
      return Objects.hashCode(key) ^ Objects.hashCode(value);
      // Objects.hashCode(o) -> return o != null ? o.hashCode() : 0;
   }
}

// 在第一次使用时初始化
transient Node<K,V>[] table;

// 键值映射的数量
transient int size;

// 扩容的阈值
int threshold;

// 哈希表的默认负载因子,不指定默认为 0.75,构造函数中会指定
final float loadFactor;

默认的初始化容量:16、默认负载因子:0.75、默认的扩容阈值:数组长度 * loadFactor,当元素个数大于等于 threshold(容量阈值)时,HashMap 会进行扩容操作、table 数组中指向链表的引用

构造方法

// 最大容量不能大于 1 << 30 = 1073741824,扩容阈值通过 tableSizeFor 计算
public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

/**
 * 指定了初始化容量,默认负载因子为 0.75
 */
public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

/**
 * 直接实例化 HashMap,初始化容量为 16、默认负载因子为 0.75
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

/**
 * 以旧 HashMap 数据为依据,构建一个新的哈希映射集合
 */
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    putMapEntries(m, false);
}

一般在实际开发中,会使用 public HashMap(int initialCapacity) 构造方法指定初始化容量大小

核心方法

在构造方法中,通过 tableSizeFor方法初始化了扩容阈值,该方法主要用来保证 HashMap 的数组长度为 2 的幂次方的

阈值:tableSizeFor

/**
 * cap 参数是初始化容量值
 * 找出大于或等于 cap 的最小 2 的幂次方,用于作容量的阈值
 */
static final int tableSizeFor(int cap) {
    int n = cap - 1;
    // 先进行位移操作后进行异或操作,最后进行赋值
    n |= n >>> 1;
    n |= n >>> 2;
    n |= n >>> 4;
    n |= n >>> 8;
    n |= n >>> 16;
    return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

tableSizeFor 方法功能,在不考虑大于最大容量的情况(最大容量值也是 2 的幂次方整数),是直接返回大于等于输入参数的 2 的幂次方整数的。比方说:传入参数 10,结果会返回 16.

该算法让最高位的 1 后面所有的 bit 位都变为 1,最后再让结果 n + 1,就可以得到 2 次幂整数值

在这里插入图片描述

首先让 cap - 1 完成后再赋值给 n 的目的:可以令找到的目标阈值大于或等于原有值。例如:二机制数 1000,十进制数值就为 8;若不对它减 1 而直接操作,得到的结果是 10000,即十进制数 16,显而易见不是想要的结果;减去 1 后二进制数为 111,再进行操作得到的结果会是 1000,即十进制数 8,满足我们想要的结果

通过一系列的位运算能大大提高效率,在实际开发中,我们传入的初始化容量在确定总数时,一般是会传入 2、4、8 这样的值,遵循 2 的幂次方

tableSizeFor 方法执行的结果主要是为了设置 threshold 属性值,在扩容方法 resize 中会使用它来初始化数组长度

将数组长度设置为 2 的幂次方可以带来以下的好处

  1. 当数组长度为 2 的幂次方时,可以使用位运算来计算元素在数组中的下标

HashMap 中是通过 index = hash & (table.length -1) 算法来计算元素在 table 数组中存放的下标,相当于就是取元素的 hash 值与数组长度减 1 值作一个位运算,即可求出该元素在数组中的下标,该算法等价于 hash % table.length > 对数组长度求模取余,只不过只有在数组长度为 2 的幂次方时,

  1. 增加 hash 值随机性,减少 hash 值冲突

若 table.length 为 2 的幂次方,则 table.length -1 转化为二进制必然是 1111111… 这样的,如此便可以使得所有位置的 bit 位都能与 hash 值作位运算;若 table.length 不是 2 的幂次方,比如:15,table.length - 1 =14,对应的二进制数为 1110,再和 hash 值作位运算,最后一位永远为 0,浪费计算的空间

插入元素:put

resize 扩容方法也是在 put 方法中进行调用的,所以先从 put 方法开始介绍起,源码如下:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

在调用 putVal 插入 Key-Value 方法时先要计算 Key Hash 值

在 HashMap 中不是直接通过 Key hashcode 方法获取哈希值的,而是通过内部自定义实现的 hash 方法获取哈希值;当 Key 不为空时,会执行: (h = key.hashCode()) ^ (h >>> 16) 算法让高位数据与低位数据进行异或运算,变相的让高位数据参与到计算中,int 有 32 个 bit 位,右移 16 位就可以让低 16 位与高 16 位进行异或运算,也是为了增加 hash 值的随机性,减少 hash 冲突的发生

当 Key hash 值计算好了以后,再接着分析 putVal 方法,源码如下:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    // 指向 Hash 数组
    Node<K,V>[] tab;
    // 待赋值,当前需要插入的 Key-Value 节点 
    Node<K,V> p; 
    // n:数组长度、i:索引
    int n, i;
    // 延迟插入数据,先进行数组的初始化操作 > 调用 resize 方法
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    // 若数组中不包含当前通过 hash 计算后所在下标的数据,那么就新建一个 Node 节点存入数组对应的下标
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
    	// 若待插入的 Key-Value 已存在,用 e 指向该 Node,k 指向 Key 值
        Node<K,V> e; K k;
        // 当前计算所在下标的节点就是要插入的 Key-Value,则让 e 指向该节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 若 p 是 TreeNode 类型,则调用红黑树的插入操作
        // TreeNode 是 Node 子类
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
        	// 对链表进行遍历,并用 binCount 数统计链表长度
            for (int binCount = 0; ; ++binCount) {
            	// 若链表中不包含要插入的 Key-Value,则将其插入到链表尾部 > 尾插法
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    // 若链表长度大于或等于树化的阈值,也就是长度为 8 时
                    // 会调用 treeifyBin 方法进行树转换逻辑
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 若要插入的 Key-Value 已存在则终止遍历,否则继续向后遍历
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                // 继续向下遍历,取下一条节点数据进行判断
                p = e;
            }
        }
        // 若 e 不为空,说明插入的 Key-Value 已存在
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // 根据传入的 onlyIfAbsent 参数值判断是否要覆盖旧值
            // putIfAbsent 方法不会覆盖旧值、put 方法会覆盖旧值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 键值对数量大于阈值时,则进行扩容操作
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

对以上 putVal 方法源码分析流程如下:

  1. 当 table 数组为空时,会先调用 resize 方法初始化 table 数组
  2. 通过计算 Key hash 值,通过 hash & tab.length-1 算法求出下标后,若该下标位置上没有元素,也就是没有发生 hash 冲突,就新建一个 Node 节点插入进去
  3. 若发生了 hash 冲突,遍历链表查询要插入的 Key 是否已存在

若存在的话根据条件判断是否用新值替换旧值
若不存在,则将元素采用尾插法,插入到链表的尾部,并根据链表的长度决定是否要将链表转换为红黑树

  1. 键值对数量大于阈值时,则进行扩容操作 > 调用 resize 方法

putVal 方法里面有调用 treeifyBin(tab, hash);,注意:这里并不一定会真正转换为红黑树,它只满足了链表长度大于或等于树化的阈值该条件,另外一个条件未满足

树化:treeifyBin

链表树化为红黑树,要同时满足两个条件:

  1. 链表长度大于等于 8,也就是属性 > TREEIFY_THRESHOLD
  2. table 数组长度大于等于 64,也就是属性 > MIN_TREEIFY_CAPACITY

查看该 treeifyBin 方法源码便知,如下:

final void treeifyBin(Node<K,V>[] tab, int hash) {
   int n, index; Node<K,V> e;
   // 若数组长度 < 64,则调用 resize 进行扩容而不是进行树化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
        	// 将链表中的每个 Node -> TreeNode
        	// 将 TreeNode 从头到尾的顺序组装成双向链表的结构 > prev、next,
            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);
    }
}

该方法执行流程如下:

  1. 判断当前数组的长度是否大于 64,若小于,则调用 resize 方法优先扩容数组,而不是进行树化操作

因为当 table 数组容量比较小时,键值对节点 hash 碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化

  1. 将有 hash 冲突的链表 Node 节点,以从头到尾的顺序转换为 TreeNode,最终形成一个双向链表
  2. 以双向链表中的首个元素 > TreeNode,调用 treeify 方法完成最后的树化逻辑

treeify 方法在这里就不继续往下分析了,它会以红黑树的数据结构特征进行组装

性质1:结点是红色或黑色
性质2:根结点是黑色
性质3: 所有叶子都是黑色。(叶子是NIL结点
性质4:每个红色结点的两个子结点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色结点)
性质5: 从任一结点到其每个叶子的所有路径都包含相同数目的黑色结点

// LinkedHashMap.Entry extends HashMap.Node<K,V>,拥有 next 指针 > 后继节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
	TreeNode<K,V> parent; // 父节点
	TreeNode<K,V> left;	  // 左节点
	TreeNode<K,V> right;  // 右节点
	TreeNode<K,V> prev;   // 前驱节点,跟 next 属性相反的指向
	// 左旋
	static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root, TreeNode<K,V> p)
	// 右旋
	static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root, TreeNode<K,V> p) 
	// 树化
	final void treeify(Node<K,V>[] tab)
	// 查找树节点
	final TreeNode<K,V> getTreeNode(int h, Object k)
	// 拆分红黑树
	final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit)
	// 删除树节点
	final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab, boolean movable)
	// ......
}

红黑树节点不是以对象指针地址作为节点标识的,而是通过节点的哈希值、键值来确定节点位置的

扩容:resize

在执行 putVal、treeifyBin 方法时都调用了 resize 方法,进行数组的扩容的操作;同时你也可以发现在创建 HashMap 实例时并没有马上创建 table 数组,其实它采用懒加载的形式等到 putVal 方法调用时才去创建 table 数组。

HashMap 每次扩容都会新建一个 table 数组,长度、容量阈值都会变为原来的两倍,然后把原数组重新映射到新数组上,先来阅览它底层源码是如何实现的,如下:

/**
 * 初始化 table 或双倍扩容 table 大小
 * 若 table 为空,以初始容量大小分配给 table否则,因为我们是以 2 次幂扩容的,
 * 原有的节点下标要么与之前的一样,要么就以 2 次幂的偏移下标落在新的 table 中
 * @return the table
 */
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    int newCap, newThr = 0;
    // 1、若 oldCap 数组长度大于 0,说明 table 已经初始化过
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // << 1 左移一位代表扩充 2 倍大小
		// 按当前 table 数组长度 2 倍进行扩容,阈值也会变为原来的 2 倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 2、若 threshold 阈值大于 0,说明调用了有参构造方法,但数组未初始化
    else if (oldThr > 0) // initial capacity was placed in threshold
    	// 将当前阈值,设置为数组初始化容量大小
        newCap = oldThr;
    // 3、若 oldCap、threshold 都小于 0,说明调用了无参构造方法,数据并未初始化
    else {               // zero initial threshold signifies using defaults
    	// 初始化容量:16、初始化阈值:16 * 0.75=12
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 若在计算过程中,阈值溢出归零,按阈值公式重新计算
    // 预防编码人员故意将数值设大的场景
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    @SuppressWarnings({"rawtypes","unchecked"})
    // 创建新的 table 数组,数组初始化也是在这里完成的。
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 若旧 table 数组不为空,遍历旧数组的元素重新映射到新的 table 数组中
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                // 1、当前数组元素节点非链式结构,将其重新计算放入到新数组中
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                // 2、当前数组元素节点是红黑树结构,将红黑树进行拆分
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // 3、当前数据元素节点是链式结构,rehash -> 重新映射放入到新数组中
                else { // preserve order
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        // e.hash & oldCap ==0 > 索引位置不变
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        // e.hash & oldCap !=0 > 改变索引位置
                        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;
}

resize 方法执行分为几个步骤,如下:

  1. 首先判断 table 数组长度,若大于 0 说明数组已经被初始化过,在不超出最大长度:MAXIMUM_CAPACITY = 1 << 30 情况下,那就按当前 table 数组长度的 2 倍进行扩容,阈值也变为原来的两倍
  2. 若 table 数组未被初始化过,threshold 阈值大于 0 情况下,说明调用了 >
    public HashMap(int initialCapacity) | public HashMap(int initialCapacity, float loadFactor) 有参构造方法,那么就将数组大小设置为 threshold 阈值
  3. 若 table 数组未被初始化过,threshold 为 0 情况下,说明调用了 > public HashMap() 无参构造方法,那么就将数组大小设置为 16,阈值设置为 16 * 0.75 = 12.
  4. 接下来,判断旧数组是否为空,不为空说明不是第一次初始化,要将旧数组的所有元素存入到新数组中,遍历旧数组中每一个元素节点,会分为以下几步进行判断

1、当前元素节点非链式结构,通过 e.hash & (newCap - 1) 公式计算好对应的下标,将其放入到新数组中
2、当前数组元素节点是红黑树结构,进行红黑树拆分
3、当前数据元素节点是链式结构,通过 e.hash & oldCap) == 0 公式确认当前链式上的节点索引位置是否要发生变化

到这里,resize、putVal 方法都已经分析完毕,在 putVal 方法中讲到了尾插法,在这里说到了会重新映射链表节点所在的位置,在 HashMap 1.7、HashMap 1.8 上有着不同的处理方式,如下:

  1. 插入元素方式不同:HashMap 1.7 是在链表头部插入元素的,在多个线程同时进行插入时,会导致链表节点的顺序错乱,甚至形成环形引用,导致死循环问题;HashMap 1.8 是在链表尾部插入元素的,虽然避免了链表顺序错乱的问题,提高了一定的安全性,但它为了提高性能引入了红黑树结构,毕竟它的一些操作都是未加锁的,所以导致多线程环境下链表转换为红黑树时会发生死循环问题

JDK 8u40 及以后的版本中得到了解决。修复的方法是引入了一个额外的状态标记,确保只有一个线程进行链表到红黑树的转换,其他线程会等待转换完成后再进行操作,避免了死循环的问题

  1. 扩容时计算链表索引方式不同:在扩容阶段,HashMap 1.7 是一个个去计算链表元素的 hash 值从而重新映射元素索引的 > indexFor(e.hash, newCapacity);HashMap 1.8 计算链表元素时,先通过 hash & oldCap 计算后的值进行判断,若为 0 索引位置则不变,不为 0 索引位置会发生改变 > 新索引 = 原索引 + 旧数组长度.

因为扩容时使用的是 2 的幂次 > 长度扩大为原来的 2 倍,所以元素的位置要么在原来的位置,要么在原位置的基础上再移动 2 次幂的位置;因此,在扩容时,不需要再像 HashMap 1.7 实现的那样去重新计算 hash,只需要观察 hash 值与原数组长度进行位运算以后是否为 0 就可以了,为 0 代表索引不变,不为 0 索引变成原索引+ oldCap

获取元素:get

HashMap 查找元素是非常快的,查找一个元素首先要知道 Key hash 值,在 HashMap 中是通过自定义实现的 hash 方法来计算哈希值,接下来看具体的获取元素源码:


public V get(Object key) {
    Node<K,V> e;
    // hash(key) 不等于 key.hashCode
    // hash(key) > h = key.hashCode()) ^ (h >>> 16
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

final Node<K,V> getNode(int hash, Object key) {
	// 指向 table 数组
    Node<K,V>[] tab;
    // first 指向数组中对应下标的第一个节点,e 指向下一个节点
    Node<K,V> first, e; 
    // n:数组长度
    int n; K k;
    // (n - 1) & hash -> 通过数组长度 -1 和 hash 值作位运算,得到在数组中的索引位置
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        // 首先比对第一个节点是否匹配的上
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
        	// 若 first 为红黑树结果,调用查找方法
            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);
        }
    }
    return null;
}

下面来分析一下查找元素的方法流程:

  1. 首先通过自定义 hash 方法计算出 Key 哈希值,计算出所在数组中的索引
  2. 判断该数组中的索引是否存在节点数据,若没有则返回 null,代表查找不到指定的元素
  3. 若有的话,首先取出首节点 first 进行 hash 比对,比对成功就说明是要找的元素,直接返回
  4. 获取首节点的下一个节点,判断当前链表节点是否为红黑树结构,若是红黑树,则调用红黑树的方法去查找元素
  5. 若不是红黑树,说明它就是简单的链式结构,则依此向后遍历节点,挨个比对进行元素的查找

删除元素:remove

HashMap 删除元素也并不复杂,首先通过 Key 生成对应的 hash 值,定位到数组中对应索引的元素,最后根据不同的结构执行对应的删除操作,该方法源码如下:

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

final Node<K,V> removeNode(int hash, Object key, Object value,
                           boolean matchValue, boolean movable) {
    // 指向 table 数组
    Node<K,V>[] tab; 
    // 待删除元素所在的节点信息
    Node<K,V> p; 
    // 数组长度、删除元素对应的下标
    int n, index;
    // 数组不为空、数组长度大于 0、删除元素在数组中不为空
    // 1、定位到所在数组中的索引元素
    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;
        // 2、匹配到具体要删除的节点 > 普通数组结构、链式结构、红黑树结构
        // 这里可能只是普通的数组结构元素,非链式结构
        // 传入的键 hash 与定位的当前数组索引元素 hash 一致,则将 p 指向 Node
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        // p.next 不为空说明是链式结构
        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);
            }
        }
        // 3、删除节点,会进行链表、红黑树修复的操作
        // matchValue:true > 代表值必须完全匹配才删除、false > 只有 Key hash 值匹配即可删除
        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
                p.next = node.next;
            ++modCount;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

下面来详细分析一下具体的删除元素过程,如下:

  1. 通过数组长度、Key hash 值,(n - 1) & hash 计算得到数组对应下标,若不存在对应元素,则返回 null,存在时则赋值给待匹配指针节点 p
  2. 根据指针节点 p,区分不同的结构:数组结构、红黑树结构、链式结构

数组结构:p 所在元素的 Key hash 值与传入的 hash 完全匹配,即 p 节点就是要删除的节点元素
红黑树结构、链式结构的前提是 p.next 所指向的元素不为空
红黑树结构:通过红黑树查找的方法 getTreeNode 定位到要删除的红黑树节点元素
链式结构:依此向下取 next 指针后继节点元素,进行 hash 值匹配,找到要删除的链式节点元素

  1. 若定位到的删除元素不为空,执行删除节点的操作,先判别该节点结构的类型

红黑树结构:调用红黑树 removeTreeNode 方法执行删除操作,该方法内部可能因为节点变更,会发生变色、左旋、右旋等操作来保证红黑树的平衡结构
数组结构:tab[index] = node.next,由于 node 节点是普通数组元素,它的 next 指针肯定是为空的,将它的值赋值给数组索引所在处,那么该元素就已经处于删除的状态了
链式结构:p.next = node.next,在定位链式结构要删除的元素节点 node 时,p 指针其实就是 node 的前驱节点,通过此操作可以把删除节点空悬出来,即没有任何的节点引用再指向它

遍历元素:keySet、entrySet 方法

在实际开发中,HashMap 遍历操作(keySet、entrySet)也是非常常用的,但是当我们在遍历 HashMap 时进行删除节点时,会抛出 ConcurrentModificationException 异常

在使用 keySet、entrySet 遍历时,有所不同,keySet 是获取所有的 Key 返回 > Set 集合,entrySet 是获取所有的 Key、Value 返回 > Map.Entry 集合,如下:

HashMap<String, Integer> map = new HashMap<>();
map.put("1", 1);
map.put("2", 2);
map.put("3", 3);
for (String key : map.keySet()) {
    if ("2".equals(key)) {
        map.remove("2");
    }
}

for (Map.Entry<String, Integer> mapEntry : map.entrySet()) {
	String key = mapEntry.getKey();
    Integer value = mapEntry.getValue();
    if ("2".equals(mapEntry.getKey())) {
        map.remove(mapEntry.getKey());
    }
}

但是不管使用 keySet 或 entrySet 遍历,在里面调用 remove 方法删除节点,都会抛出 ConcurrentModificationException 异常,这就是前面所说的 fail-fast 快速失败机制,可以追踪它对应的源码,如下:

// keySet > 遍历方式
public final void forEach(Consumer<? super K> action) {
    Node<K,V>[] tab;
    if (action == null)
        throw new NullPointerException();
    if (size > 0 && (tab = table) != null) {
        int mc = modCount;
        for (int i = 0; i < tab.length; ++i) {
            for (Node<K,V> e = tab[i]; e != null; e = e.next)
                action.accept(e.key);
        }
        if (modCount != mc)
            throw new ConcurrentModificationException();
    }
}

// entrySet > 遍历方式
public final void forEach(Consumer<? super Map.Entry<K,V>> action) {
    Node<K,V>[] tab;
    if (action == null)
        throw new NullPointerException();
    if (size > 0 && (tab = table) != null) {
        int mc = modCount;
        for (int i = 0; i < tab.length; ++i) {
            for (Node<K,V> e = tab[i]; e != null; e = e.next)
                action.accept(e);
        }
        if (modCount != mc)
            throw new ConcurrentModificationException();
    }
}

上面两种方式,都会判断 modCount != mc 是否相等,它用来表示集合被修改的次数,修改指的是插入节点或删除节点,可以回去看看上面插入删除的源码,在最后都会对 modCount 进行自增

当我们遍历 HashMap 时,每次遍历下一个节点时都会对 modCount 进行判断,若与原来的值不一致,则说明集合被修改过了,然后就会抛出异常,这是 Java 集合的一个特性,我们这里以 keySet 为例,所以我们在开发中要移除节点时,不得不使用迭代器 Iterator 进行节点的移除操作

以 EntrySet 为例,EntrySet#iterator 方法主要是实例化一个 EntryIterator 返回,在它内部其实是继承至 HashIterator 抽象类来操作节点的,源码如下:

abstract class HashIterator {
    Node<K,V> next;        // 下一个要返回的 Entry 节点
    Node<K,V> current;     // 当前 Entry 节点
    int expectedModCount;  // 为了防止 fast-fail 
    int index;             // current slot

    HashIterator() {
        expectedModCount = modCount;
        Node<K,V>[] t = table;
        current = next = null;
        index = 0;
        if (t != null && size > 0) { // advance to first entry
            do {} while (index < t.length && (next = t[index++]) == null);
        }
    }

    public final boolean hasNext() {
        return next != null;
    }

    final Node<K,V> nextNode() {
        Node<K,V>[] t;
        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;
    }

    public final void remove() {
        Node<K,V> p = current;
        if (p == null)
            throw new IllegalStateException();
        if (modCount != expectedModCount)
            throw new ConcurrentModificationException();
        current = null;
        K key = p.key;
        removeNode(hash(key), key, null, false, false);
        // 重新赋值
        expectedModCount = modCount;
    }
}

在初始化 HashIterator 时,会把 modCount 变量值赋值给 expectedModCount,在删除元素时会重新进行赋值,然后通过迭代器遍历时会对比 modCount、expectedModCount 是否相同,在这里,迭代器其实就是维护了一个 expectedModCount 变量来确保集合移除的操作与遍历时是同步进行的,当然,这只能是确保在单线程的情况下执行是安全的

最后总结一点,在遍历时的顺序与插入顺序不一致情况是如何的?

遍历:通过以上 HashIterator 源码可以看出,从前到后遍历数组 bucket,依此从数组中找到包含节点的 bucket,若该数组 bucket 不为空,则一个一个遍历该 bucket,直至该 bucket 为空才直接遍历下一个数组 bucket,直到遍历结束,从遍历上来说,可以看出它是顺序性的取出节点数据的

插入:插入元素时,它是先让通过 Key hash 值定位到具体的数组 bucket,若该数组 bucket 不为空,说明 hash 冲突了,则以链表的结构存入元素进去;若为空,新建一个数组 bucket,存入当前元素;最终,会由于链表过长 & 数组长度过长,形成红黑树结构

总结

该篇博文,主要分析 HashMap 类所使用的数据结构,基于数组、链表、红黑树,说到了它的初始容量 initial capacity、负载因子 load factor 等核心属性,重点分析了该类的一些核心方法:tableSizeFor-阈值如何计划、putVal-插入元素如何懒加载扩容以及数组转换为链表,链表又是如何转换为红黑树结构的、resize-在底层是如何基于 2 次幂实现数组扩容的以及链表结构的形成、红黑树的转换操作,最后,简要分析了获取元素、删除元素、遍历元素时移除元素的 fast-fail 快速失败机制是如何产生的。

如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!

推荐专栏:Spring、MySQL,订阅一波不再迷路

大家的「关注❤️ + 点赞👍 + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!

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

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

相关文章

【Java se】集合——迭代器(Iterator接口)的实现原理

目录 一、迭代器的应用——遍历集合 步骤1&#xff1a;通过集合获取迭代器 步骤2&#xff1a;使用while循环 案例展示&#xff1a; 二、跟踪源代码 #1. 通过集合获取迭代器 #2. 通过成员方法next( ) 获取每一个集合元素对象 #3. 通过成员方法hasNext( )判断是否进行下一次…

计算机组成原理 | 理解二进制编码

二进制的转换 二进制——> 十进制&#xff1a; 从右到左的第 N 位&#xff0c;乘上一个 2 的 N 次方&#xff0c;然后加起来&#xff0c;就变成了一个十进制数例如二进制数&#xff1a;0011&#xff0c;对应的十进制表示&#xff0c;就是 0 2 3 0 2 2 1 2 1 1 2 0…

阿里云斩获 4 项年度云原生技术服务优秀案例

日前&#xff0c;在 ICT 中国2023 高层论坛-云原生产业发展论坛上&#xff0c;由阿里云容器服务提供技术支持的 “数禾科技”和“智联招聘” 两大案例以及阿里云云原生 AI 套件、云原生 FinOps 成本分析套件两大产品技术方案&#xff0c;共同获得 2023 年度云原生应用实践先锋—…

oai核心网启动多切片自动生成方法

简介 启动一个切片需要&#xff1a; 核心网侧&#xff1a; 启动核心网yaml文件及相关配置文件&#xff08;datebase conf healthscripts&#xff09; 对应业务的sever &#xff08;如&#xff09;基站侧&#xff1a; 虚拟机 启动ueransim的yaml文件及相关配置 代理程序&#…

拿捏指针(一)---对指针的基本认识(初级)

文章目录 指针是什么&#xff1f;指针的定义指针的大小 指针类型指针有哪些类型&#xff1f;指针不同类型有什么意义&#xff1f; 野指针野指针的成因如何避免野指针&#xff1f; 指针运算指针 - 整数指针 - 指针指针的关系运算 二级指针 指针是什么&#xff1f; 指针的定义 …

DNDC模型建模方法及在土壤碳储量、温室气体排放、农田减排、土地变化、气候变化

由于全球变暖、大气中温室气体浓度逐年增加等问题的出现&#xff0c;“双碳”行动特别是碳中和已经在世界范围形成广泛影响。国家领导人在多次重要会议上讲到&#xff0c;要把“双碳”纳入经济社会发展和生态文明建设整体布局。同时&#xff0c;提到要把减污降碳协同增效作为促…

MySQL----索引

文章目录 一、索引的概念二、索引的作用索引的副作用创建索引的依据 三、索引的分类和创建3.1普通索引创建直接索引修改表方式创建创建表的时指定索引&#xff08;不推荐使用&#xff09; 3.2唯一索引直接创建唯一索引修改表方式创建创建表时指定 3.3主键索引创建表的时指定修改…

2024年天津农学院专升本拟招生专业限制报考范围

天津农学院2024年升本拟招生专业及报考范围 物流管理 科 类&#xff1a;文史、理工 专业报考范围&#xff1a;不限 人力资源管理 科 类&#xff1a;文史、理工 专业报考范围&#xff1a;不限 水产养殖学 科 类&#xff1a; 理工 专业报考范围如…

微服务springcloud 06.feign框架,配合ribbon 负载均衡和重试,配合hystrix 降级,监控和熔断测试

feign是ribbon hystrix 的整合 01.新建 sp09-feign 项目 第一步&#xff1a; 第二步&#xff1a;选择依赖&#xff1a; pom.xml 需要添加 sp01-commons 依赖&#xff1a; <?xml version"1.0" encoding"UTF-8"?> <project xmlns"http://…

游泳可以戴的耳机有哪些?四款专业的游泳耳机推荐

现在人们都开始热衷于运动健身&#xff0c;运动时大多会听音乐&#xff0c;市面上的运动耳机层出不穷&#xff0c;多数都是蓝牙耳机&#xff0c;但是有一些运动不太适合。 例如游泳&#xff0c;其他运动都可以将手机放在附近&#xff0c;但是游泳就不行了。所以游泳时可以听歌的…

【云服务】阿里云服务器镜像备份到本地

​ 首先&#xff0c;让我们了解一下阿里云平台上自定义镜像的功能。通过自定义镜像&#xff0c;用户可以将云服务器的当前状态保存为镜像&#xff0c;以便在需要时快速恢复到该状态。此外&#xff0c;自定义镜像还可以作为模板创建新的云服务器&#xff0c;方便用户快速部署相同…

1742_C语言中的指针与数组

全部学习汇总&#xff1a; GreyZhang/c_basic: little bits of c. (github.com) 之所以常常把数组与指针联系到一块儿是因为数组的名字在很多时候等同于指向数组首元素的指针。在写程序的时候&#xff0c;这常常会给我们带来很多方便。尤其是需要把数组作为一个函数的处理对象时…

Java并发(十一)----线程五种状态与六种状态

1、五种状态 这是从 操作系统 层面来描述的 【初始状态】仅是在语言层面创建了线程对象&#xff0c;还未与操作系统线程关联 【可运行状态】&#xff08;就绪状态&#xff09;指该线程已经被创建&#xff08;与操作系统线程关联&#xff09;&#xff0c;可以由 CPU 调度执行 …

Android Studio实现贪吃蛇小游戏

项目目录 一、项目概述二、开发环境三、详细设计四、运行演示五、项目总结 一、项目概述 贪吃蛇是一款经典的街机游戏&#xff0c;不仅在电子游戏史上占有一席之地&#xff0c;也在很多人的童年回忆中留下了深刻的印象。在游戏中&#xff0c;玩家需要操纵一条蛇通过吃食物来增…

leetcode222. 完全二叉树的节点个数(java)

完全二叉树的节点个数 leetcode222. 完全二叉树的节点个数题目描述 递归广度优先遍历二叉树专题 leetcode222. 完全二叉树的节点个数 来源&#xff1a;力扣&#xff08;LeetCode&#xff09; 链接&#xff1a;https://leetcode.cn/problems/count-complete-tree-nodes 题目描述…

Linux下的打包和压缩/解压解包

文章目录 一、打包和压缩二、Linux下进行打包和压缩1.zip指令&#xff0c;unzip指令2.tar指令 一、打包和压缩 打包呢就是把所有东西装在一起&#xff0c;然后压缩就是将这一包东西给它合理摆放&#xff0c;腾出更多的空间&#xff0c;以便放更多的东西。 压缩可以将如果东西是…

【github加载不出来】github 加载不出来、获取GitHub官方CDN地址、修改系统Hosts文件 刷新缓存

目录 github 加载不出来获取GitHub官方CDN地址修改系统Hosts文件刷新缓存 github 加载不出来 获取GitHub官方CDN地址 https://www.ipaddress.com/打开后如图&#xff0c;右上角搜索查 查找这三个DNS链接的解析地址 http://github.com http://assets-cdn.github.com http://git…

java springboot整合Druid数据源配置

整合的最后一块 我们整合一个数据源 Druid 我们还是打开idea 创建一个项目 路径和版本调一下 路径选一个好的目录就可以了 至于版本 最好是 java 8 JDK 1.8 然后 Next 下一步 这里 spring boot 的版本记得选一下 不要搞太高 2.几即可 Druid 在这里 显然也是找不到的 所以 我…

安卓端Google隐私沙盒归因报告聚焦

自2022年2月Google首次提出将推出隐私沙盒至今已一年有余。现在&#xff0c;安卓端的隐私沙盒Beta测试已针对特定Android13设备正式开始。作为早期测试者&#xff0c;Adjust很高兴与 Google一同迈出增强用户隐私的第一步&#xff0c;并在接下来的旅程中继续携手同行。为帮助移动…

framework编译应用代码

代码编译 APP或Service代码单编调试 1、在aosp文件目录下在将环境变量加载到内存中&#xff0c;在终端中输入下面命令 source build/envsetup.sh 2、选择平台编译选项 lunch 3、输入后会出现一个选择列表&#xff0c;然后输入你想要的项目的序号即可。如下所示我这里选择的7…