HashMap 底层原理深度拆解(二):putVal 完整链路解析(懒加载 · 链表遍历 · 尾插法)

我们在上一篇讲解了位运算到扰动函数,这一篇我们继续往下探讨。

注:本篇参考了一些网络资料,如有错误请见谅,如愿意,可以在评论区指出。

putVal 源码(部分)

接下来,JDK就负责拿着打包好的数据Node("Tom","123")去往table0里面塞了(数据为假定)。

当代码刚刚拿到下标 0,在真的把 Node 塞进去之前,作为极其严谨的底层代码,第一件事应该检查什么?我认为起码有以下四点。

  • 数组是否已经被创建且0号节点是否存在? ------ 命中源码的哈希冲突校验

  • 是否存在 key 相等的数据,无需存入? ------ 命中源码的数据覆盖(Overwrite)逻辑

  • 是否超限制? ------ 命中源码的扩容(Resize)逻辑

  • 如果是黑客伪造的键值对怎么办? ------ 命中源码的红黑树树化防御机制(防 DoS 攻击)。

在实际工程中,极其忌讳占着茅坑不拉屎的代码,比方说,你写出new HashMap<>()时,底层根本没有创建那个长度为16的数组!!

只有在第一次真正调用put()存储数据的时候,底层才会去初始化数值。这在也做懒加载(延迟初始化),目的是为了更加节省内存。

比方说:

你注册了一家酒店公司(new HashMap<>()),但为了省钱,你根本没盖楼(内存里没有数组)。 直到第一位客人提着行李走到你面前说要住店(put("Tom", "123")),你才大喊一声:"快!赶紧给我盖一栋 16 个房间的楼!"(触发 resize() 初始化)。 楼盖好后,客人拿着房卡来到 0 号房,你第一件事就是看一眼:房间里有人吗?没人就直接进去住。

接下来看日常代码是如何一步步砸进底层源码的:

java 复制代码
// 此时 table 数组根本没创建,等于 null!
HashMap<String, String> map = new HashMap<>();

// 第一次调 put,"Tom" 算出的下标是 0
map.put("Tom", "123");

↓ 顺着 put 点进去,来到最核心的 putVal 方法(剪取最开始的 10 行):

java 复制代码
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        
        // 【第一步:懒加载】
        // 问:酒店建了吗?如果 table 是 null,或者长度为 0
        if ((tab = table) == null || (n = tab.length) == 0)
            // 答:赶紧调用 resize() 建一栋 16 个房间的楼!
            n = (tab = resize()).length;
            
        // 【第二步:无冲突直达】
        // 问:拿到下标 i (比如 0),0号房间里有没有人?(p = tab[i] 是否等于 null)
        if ((p = tab[i = (n - 1) & hash]) == null)
            // 答:没人!直接 new 一个 Node,让 Tom 住进去!O(1) 极速完成!
            tab[i] = newNode(hash, key, value, null);
            
        else {
            // 【第三步:房间里已经有人了!(发生哈希冲突)】
            // ... 接下来就是判断 Key 是否相等、防黑客等骚操作 ...
        }
        // ...
    }

我们仔细观察一下这个if校验:if ((tab = table) == null || (n = tab.length) == 0)。现在有一个问题,那就是为什么要把(tab = table) == null 放在最前面呢?删掉又会怎么样呢?

现在来看一下

  • 如果要把这一行删掉或者一道,第一步直接去执行tabi = (n - 1) \& hash,因为此时还没有创建数组,tab还是null,程序会直接抛出NPE(NullPointerException,空指针异常),系统直接崩溃~

看完这个源码应该就理解了创建与存入的逻辑那么接下来看看其他的校验吧~

接下来看一下如果传入一个数据,是否存在数据Key值相等,无需再次存入的情况呢?

这其实就是Map的覆盖(Overwrite)逻辑,比方说:

java 复制代码
map.put("Tom", "123");
map.put("Tom", "456"); // 相同的 Key,要把 123 覆盖成 456

这里我们把Tom当做Key,数字当做Value,我们先存入Value为123的,然后再存Value为456的。当新来的Tom走进0号房间,发现里面已经坐着一个人了。问题来了,**在Java中,底层是如何严谨地判断新来的对象和房间里面坐着的对象不是同一个key的呢?**它敢不敢止痛膏Hash值相等就判断他们是同一个人?如果不敢还需要套再添加什么条件?

这里的核心其实可以归到一行源码上面:

java 复制代码
 if ((p = tab[i = (n - 1) & hash]) == null)

这一段嵌套有点多,为了便于理解,我把这行源码打开,之后就是这样:

java 复制代码
// 第 1 步:用我们之前学的极速位运算,算出应该去几号房间。
// 把算出来的结果,赋值给变量 i。
i = (n - 1) & hash;

// 第 2 步:拿着算出来的房号 i,去 table 数组里找对应的房间。
// 拿到房间里原有的东西(可能有人,也可能是 null)。
Node<K,V> tempNode = tab[i];

// 第 3 步:把房间里的东西,赋值给一个名叫 p 的局部变量。
// 此时,p 就攥住了这个房间的第一手数据!
p = tempNode;

// 第 4 步:判断 p 手里攥着的是不是空气(null)?
if (p == null) {
    // 房间没人,直接放数据
}

我们用CPU的视角去看一下段源码

顺着原源码的嵌套顺序,带着具体数据走一遍: 假设 hash 算出来,对应的格子是 0 号。

  1. 最内层括号 (i = (n - 1) & hash) : CPU 先算 (n - 1) & hash 等于 0。然后执行 i = 0此时,局部变量 i 变成了 0。这个括号整体返回了 0

  2. 往外走一层 tab[...] : 代码变成了 tab[0]。CPU 去内存里看一眼 0 号房间,发现里面空空如也(返回 null)。

  3. 再往外一层括号 (p = ...) : 代码变成了 (p = null)此时,局部变量 p 变成了 null。而且这个括号整体返回了 null

  4. 最外层判断 ... == null : 代码变成了 null == null。结果为 true!进 if 分支,直接放数据!

想象酒店保安(CPU)去查房。

  • 普通保安(拆成 4 行) :先掏出计算器算房号(步骤1);走到房间门口(步骤2);把房间里的人揪出来看一眼,记在小本本 p 上(步骤3);然后看着小本本想:"哦,原来没人"(步骤4)。动作很慢。

  • 特种兵保安(JDK作者的一行流) :一边跑一边算,跑到门口一脚踹开门,伸手进去一抓(抓到的东西顺手命名为 p),抓了个空(== null),瞬间把新客人丢进去!一气呵成,没有任何多余的停顿和中间变量~

java 复制代码
从内到外,执行顺序:

    ④ 最外层比较结果是否为 null
    │
    │   ③ 把格子里的东西赋值给 p
    │   │
    │   │   ② 访问这个下标的数组格子 tab[i]
    │   │   │
    │   │   │   ① 最内层算下标 i
    ▼   ▼   ▼   ▼
if ((p = tab[i = (n - 1) & hash]) == null)

到这里相信你已经对这段代码有了一个基础的认知了,明白如何将一个Hash值经过源码中四行操作得出该下标是否能够存入进去了,那么就又有问题来了,为什么非要用一个p去接收呢,如果我认为这样写太复杂了,精简一下去掉这个p呢?

大概代码是这样:

java 复制代码
// 算出 i,并直接判断 tab[i] 是否为空
i = (n - 1) & hash;
if (tab[i] == null) {
    tab[i] = newNode(...);
} else {
    // 房间有人,处理冲突
    // ...
}

为什么非要在 if 里面强行定义一个 p = tab[i] 把它存下来?如果像我这样不存 p,直接用 tab[i] 去判断,在执行到 else(房间已经有人的情况)时,进行相应的处理不也能跑吗?为什么JDK作者还需要这样大费周章拿一个p接收?

如果不写 p = tab[i],而是每次都直接访问 tab[i],程序当然也能正常运行,但这样会带来两个问题。

首先是性能上的考虑。tab[i] 是一次数组访问,每次使用时都需要重新根据下标定位数组元素。而将头节点保存到局部变量 p 后,后续无论访问 p.hashp.key 还是 p.next,都可以直接通过这个局部引用完成,不需要反复从数组中取出同一个节点。虽然现代 JVM 会对这类代码进行大量优化,但从源码设计的角度来看,使用局部变量缓存引用依然是一种经典且高效的写法。

其次,也是更重要的一点,p 不仅仅是为了减少重复访问,它还承担着链表头指针的作用。

一旦发生哈希冲突,这个桶中就可能挂着一条链表。后续无论是判断 Key 是否重复,还是遍历整条链表寻找插入位置,都需要有一个指针不断向后移动。而 p 恰好就是这条链表的起点,随着遍历不断执行 p = p.next,程序便可以顺着整条链表一直查找下去。

因此,p 的存在并不仅仅是为了提高访问效率,更是为了让后续链表遍历拥有一个可以不断移动的工作指针。这也是 JDK 作者选择先保存头节点,再进行后续判断和遍历的重要原因。

tab[i] 就像是前台挂在墙上的房卡。

  • 不拿 p 的呆子:看一眼墙上,发现 5 号房卡不在(发生冲突)。然后他想知道这房卡被谁拿走了,于是他又抬头看了一眼墙上的登记本,每次想看信息都要抬头找一次。

  • JDK 源码 :一把将墙上的登记信息扯下来攥在自己手里(赋值给 p 。发现房间已经有人了?没关系,登记本就在我手里(p),我直接低头看就行了,不用再抬头找了~

假如0号房间有人人,并且这个人已经被攥在了指针p手里。这时候新来的数据Tom(新Key),要和房间里面的p(老Key)对个眼神。如果他们俩是同一个人(Key完全相等),那就直接覆盖原来的值。

现在我们已经知道,如何判断头节点是不是我们要找的对象了。 但如果头节点不是,那就说明发生冲突的这个格子里,已经挂着一条链表(或红黑树)了。接下来,底层逻辑必须顺着这根藤往下摸:

  1. 如果是红黑树:按树的规则查找/插入。

  2. 如果是链表 :逐个往下找。找到了相同的 Key,就替换;如果一直摸到链表尾巴都没找到,就把新节点挂在尾巴上(尾插法)。而且,一旦挂上后发现链表太长,就要立马把链表转换成红黑树。

好我们接下来先进入代码中的else部分。来看看被誉为教科书级别的防御性编程(高健壮性)的代码是什么样子~

java 复制代码
else {
            Node<K,V> e; K k;
            
            // 【关键源码】请死死盯住这一行!这就是判断新老 Key 是否完全相同的终极逻辑!
            // p 就是房间里坐着的老节点,hash 和 key 是你要存的新节点的参数
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k)))) {
                
                // 如果这个 if 满足了,说明是同一个 Key!
                // 把老节点 p 赋值给临时变量 e,准备在后续覆盖它的 Value
                e = p;
                
            } else if (...) {
               // ... 找下一个节点(树或者链表)...
            }

这里可以看到,作者判断它俩是不是同一个Key,没有简单粗暴直接写:if (key.equals(p.key)),而是写了一串很长且难懂的条件:

p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))

这里就有几个需要思考的点了,仔细看下面这段判断条件

  1. 首先比较p.hash == hash
  2. 然后再比较p.key == key(内容地址是否相同)
  3. 最后才比较key.equals(p.key)(内容是否相同)

这里我们可以想一想,为什么要把p.hash == hash放在第一步进行校验,如果不校验Hash,上来就开始调用key.equals(p.key)去判断两个内容是不是一样的又会怎么样呢?这会涉及到一个核心知识点:短路防御机制

短路防御机制

在java中,整数比较(p.hash == hash)只需要在CPU寄存器里耗费极少的一个时钟周期,而对象比较(key.equals(p.key))则需要进入方法栈,如果需要传入一整本书的内容甚至会去逐字对比大量字符串,两者之间的性能差距是成百上千倍的。代码在做复杂判断时,一定要遵循"最快,最容易失败的条件放在最前面,最终最耗时的判断放在最后"的原则。

举个例子,假设 HashMap 中已经存放了 100 万个字符串。当新的 Key 进入桶以后,如果直接调用 equals() 去逐个比较字符串内容,那么每次比较都可能遍历大量字符。而 hash 只是一个整数,只需要 CPU 做一次整数比较即可完成。因此,大部分情况下,只要发现 hash 不相等,就可以立即返回 false,根本不会进入 equals()

这就是 HashMap 为什么把 hash == hash 放在最前面的原因:用最便宜的判断,尽可能过滤掉绝大多数无效比较。

后续代码如下:

java 复制代码
// ... (前面判断头节点 p 结束) ...
        // 【情况 1】如果这个节点已经进化成红黑树了
        } else if (p instanceof TreeNode) {
            // 直接走 O(logN) 的红黑树插入逻辑
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            
        // 【情况 2】它还是一条普通的单向链表,准备顺藤摸瓜!
        } else {
            // 死循环遍历,binCount 记录当前遍历到了第几个节点
            for (int binCount = 0; ; ++binCount) {
                
                // 1. e = p.next 往下走。如果走到头了(等于 null)还没找到!
                if ((e = p.next) == null) {
                    // 【核心源码 A:尾插法】把新节点挂在链表的最末尾
                    p.next = newNode(hash, key, value, null);
                    
                    // 【核心源码 B:触发树化】如果链表长度达到 8(binCount 从 0 开始,>=7 即第8个)
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash); // 变身红黑树!
                    break;
                }
                
                // 2. 如果在遍历过程中,发现了完全相等的 Key
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break; // 找到了相同的 Key,直接打断循环,留给后面去执行 Value 的覆盖!
                    
                // 3. 指针后移,继续下一轮循环
                p = e;
            }
        }

接下来又有两个新的问题出现了

1. 为什么要用尾插法(JDK8+)而不是和JDK7一样使用头插法呢

这是因为,JDK7的头插法在多线程并发扩容的时候,容易导致链表节点指针反转,一旦发生错乱,就会形成环形链表。下一次查询时走到这个死环里面,CPU就会疯狂运转,容易飙升到100%。JDK8改为尾插法就彻底杜绝了并发死循环的问题。

在 JDK 1.7 时代,HashMap 作者设计扩容时的节点迁移,使用的是头插法

作者的初衷是基于时间局部性原理(Temporal Locality):刚刚插入的数据,极有可能在接下来马上被查询。用头插法把最新插入的节点放在链表最前面,查询效率最高。

但他做了一个妥协:HashMap 本就不是为并发设计的,所以他没有在扩容逻辑里做任何并发控制。原理是什么?(死循环是怎么发生的)

我们来推演一下指针反转环形链表 产生的三步曲。假设原来哈希表某一个桶里的链表是 A -> B -> null

场景: 线程 1 和线程 2 同时触发扩容。

  • 第一步:线程 1 挂起,记下现场

    线程 1 率先进入 transfer 方法,执行到 Entry<K,V> next = e.next; 这一行。

    此时它手里的情况是:e = A(当前要搬的节点),next = B(A 的下一个节点)。

    就在这一瞬间,CPU 时间片耗尽,线程 1 被挂起,但它的局部变量 enext 被保存在自己的线程栈里,纹丝不动。

    第二步:线程 2 抢跑,完成扩容

    线程 2 获得 CPU,从头到尾完整执行了一遍 transfer。JDK 7 的头插法逻辑是:遍历旧链表,每拿到一个节点,就把它插到新数组对应桶的最前面

    于是原链表 A -> B -> null 被迁移到新数组后,顺序完全颠倒,变成了 B -> A -> null

    注意这一行的内存变化:此时堆内存中,B.next 指向的是 A

    第三步:线程 1 恢复,死环诞生

    线程 1 重新获得 CPU,它压根不知道线程 2 已经动过了内存。它只相信自己手里的局部变量:e = Anext = B

    继续执行头插法:

  • 先把 A 插入新数组:A.next = newTable[i](此时 newTable[i]null),newTable[i] = AA 插入完毕。

  • 指针后移:e = next,即 e = B

    成了。

    此时这个桶里的链表是 A -> B -> A -> B -> ...,一个完美的环形链表。

    这个环一旦形成,后续任何 get() 操作落到了这个桶里,while (e != null) 永远遍历不到头,CPU 直接拉满 100%。这就是 JDK 7 死循环的完整链条。

    • A.next,发现是 B(线程 1 第一步时自己设的)。

    • 头插 AA.next = newTable[i],此时 newTable[i]B,所以 A.next = B

    • newTable[i] = Ae = B

    回到循环头,处理 A

    于是 next = A

    • 接着头插:B.next = newTable[i],而此时的 newTable[i] 恰好就是刚刚插进去的 A。所以 B.next = A

    • 再把 B 设为新头节点:newTable[i] = B

    • 最后指针后移:e = next,即 e = A

    然后处理 B

    • 执行到 Entry<K,V> next = e.next;,线程 1 去读取 B.next

    • 注意:这块内存已经被线程 2 改过了,B.next 现在指向的是 A

  • 此时环形链表已经形成,如果此时有用户的请求来执行get(key),刚好这个key哈希到了这个有死环的桶里。HashMap的查询逻辑是遍历单向链表 while(e != null)。因为这是一个环,e永远不可能等于null,这个线程就会陷入死循环,不断疯狂占用CPU周期,导致这个CPU核心满载100%。

  • 因为JDK8引入了红黑树,同时也把链表插入改成了尾插法。**尾插法最大的特点是保持原有的链表顺序不变,**即使线程1挂起被线程2抢占执行2,线程1醒来后顺着链表往下找,前后关系依然是一致的,绝对不会出现B.next = A这种逆向反转,因此彻底杜绝了环形链表的产生

死循环的罪魁祸首是以下这段代码:

java 复制代码
void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            // 【核心必记代码】线程1在这里被挂起!
            // 此时 e = A, next = B
            Entry<K,V> next = e.next; 
            
            if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); }
            int i = indexFor(e.hash, newCapacity);
            
            // 【头插法逻辑】将当前节点指向新数组头节点
            e.next = newTable[i]; 
            // 将当前节点设为新的头节点
            newTable[i] = e;      
            
            // 指针后移,处理下一个
            e = next;             
        }
    }
}

设计思想总结: JDK 1.7 追求极致的局部性缓存命中率(头插),牺牲了容错率。JDK 1.8 追求结构稳定性(尾插法 + 红黑树),因为在链表长度达到 8 以后转为红黑树,局部性优势已经被树结构的 O(log N) 抹平了,所以果断放弃头插法。

铁律: 在任何可能存在并发写入的场景,绝对不能使用 HashMap。即使 JDK 1.8 解决了死循环问题,它依然是不安全的。虽然没有死循环了,但依然存在数据丢失/覆盖 的问题。比如在 put 源码中,如果没有 hash 冲突,会直接执行 tab[i] = newNode(...)。如果有两个线程同时进入这个 if 分支,后执行的线程会把前一个线程的数据直接覆盖掉。应该使用什么我认为先不放在这里,不然篇幅会很长,留到后面和其他知识点一起好一些。

2.为什么要把新节点挂上去之后再去判断 binCount >= 7

因为链表长度达到 8 时触发,binCount 只是一个循环计数器。当 binCount = 7 时,表示前面已经遍历了 7 个旧节点都没找到,刚刚挂上去的是第 8 个节点,此时立刻触发变身(变为红黑树)。

treeifyBin 这行不是无条件转树的。点进去看源码,第一行就是一个拦路虎:

java 复制代码
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    resize();

MIN_TREEIFY_CAPACITY 默认是 64。也就是说:链表长度达到 8 时,如果数组总长度还没到 64,JDK 不会转红黑树,而是优先扩容。

原因很简单:数组太小的时候,冲突本来就是因为空间不够造成的,转树属于"吃错药"。先把数组翻倍,大概率冲突就散了,完全不需要走复杂耗时的树化逻辑。只有数组长度 >= 64 且链表长度 >= 8 时,才真正执行 treeifyBin 转成红黑树。

java 复制代码
进入 0 号房间 (头节点 p 不是你要找的 Key)
       │
       ▼
是否已经是红黑树? ──(是)──► 调用 putTreeVal() 走树状插入逻辑
       │
     (否)
       ▼
开启 for 循环往下找:
[节点 1] ─(不是)─► [节点 2] ─(不是)─► null
                                   │
                                   ▼
                             挂在尾巴上 (newNode)
                                   │
                                   ▼
                           总数达到 8 个了吗?
                           ├── (是) ──► 调用 treeifyBin() 将当前链表重构成红黑树
                           └── (否) ──► 结束,直接返回

关于扩容和树化防御,本篇只开了个头。

putVal 末尾的 resize() 涉及数组翻倍、节点迁移、JDK 8 高低位拆分优化,内容比本篇只多不少。而树化防 DoS 攻击涉及哈希碰撞的恶意利用、hashCode 的可预测性问题,也需要单独篇幅讲透。

这两块我们下篇见(也可能并非下篇,但是扩容是绝对在下篇)。

相关推荐
Sam_Deep_Thinking1 小时前
一个靠谱的C端网关服务应该包含什么内容
java·程序员·系统架构
@三十一Y1 小时前
C++:红黑树的实现
开发语言·c++
杨运交1 小时前
[055][调度模块]Spring动态任务调度框架的设计与实现
java·后端·spring
白狐_7982 小时前
408 数据结构算法题 01:线性表暴力求解保分指南
java·数据结构·算法
流云鹤2 小时前
03Java学习day(3)
java·学习
乐观勇敢坚强的老彭2 小时前
C++信奥:开关门、开关灯问题
开发语言·c++·算法
冻柠檬飞冰走茶2 小时前
PTA基础编程题目集 7-31 字符串循环左移(C语言实现)
c语言·开发语言·数据结构·算法
tqs_123452 小时前
Agent智能体+Skill插件引擎+Milvus混合检索 技术沉淀
java·python·milvus
严同学正在努力3 小时前
从备份到恢复:我用 30 分钟恢复了误删的核心业务表
android·java·数据库·ai