乐于分享
好东西不私藏

面试必考!HashMap的put方法源码逐行解析,看完再也不怕被问

面试必考!HashMap的put方法源码逐行解析,看完再也不怕被问

导读:HashMap的put方法,堪称面试”死亡三连问”的C位——怎么存的?hash怎么算的?扩容什么时候触发?今天我们逐行拆解JDK1.8源码,让你从”背答案”升级到”讲原理”。

🔥 先看全貌:put方法整体流程

别急着看代码,先搞清楚put方法到底干了什么事:
5步走:
算hash—— 把key的hashcode扰动一下
定位桶—— 用hash算出数组下标
判空—— 桶里没东西?直接放
遍历冲突—— 有东西?找key相等的位置
扩容判断—— 元素太多?扩容
下面逐行拆解,每一步都不放过。

第1步:hash扰动——为什么不是直接用hashCode?

static final int hash(Object key) {    int h;    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);}
逐行翻译:
key是null?hash=0,所以HashMap支持null键(HashTable不行!)
key不是null?拿到hashCode,然后高16位异或低16位
为什么要异或?
举个🌰:假设数组长度n=16(默认值)
定位桶的公式:i = (n-1) & hash
n-1 = 15 =0000 0000 0000 0000 0000 0000 0000 1111
这个&运算只看hash的低4位,高28位全废了!
所以扰动的作用:让高位也参与运算,减少碰撞概率

💡 面试加分话术:”JDK1.8的扰动只做了一次异或,JDK1.7做了4次(9次扰动)。1.8认为一次就够了,省CPU嘛。”


第2步:putVal——核心方法,100行源码的精华

public V put(K key, V value) {    return putVal(hash(key), key, value, falsetrue);}
put方法就一行,真正干活的是putVal。5个参数:
hash:扰动后的hash值
key、value:要存的键值对
onlyIfAbsent:false表示覆盖已存在的值
evict:true表示正在创建模式(和afterNodeInsertion回调有关)
接下来逐段拆解putVal:

第3步:定位桶——第一个关键判断

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {    Node<K,V>[] tab; Node<K,V> p; int n, i;    // ① 数组还没初始化?先扩容(懒初始化)    if ((tab = table) == null || (n = tab.length) == 0)        n = (tab = resize()).length;    // ② 定位桶:用(n-1)&hash,等价于hash%n,但位运算更快    if ((p = tab[i = (n - 1) & hash]) == null)        tab[i] = newNode(hash, key, value, null); // 桶为空,直接插入
两个重点:
① 懒初始化
HashMap的数组不是构造时创建的,而是第一次put时才resize()创建。为啥?省内存嘛,你new了HashMap但啥也没存,干嘛先占16个坑?
② (n-1) & hash 等价于 hash % n
前提是n是2的幂。这就是HashMap容量必须是2的幂的原因——位运算比取模快一个数量级。

💡 面试追问:”HashMap容量为什么是2的幂?”——答:为了用位运算替代取模,提升性能。tableSizeFor方法保证容量是2的幂。


第4步:处理hash冲突——三种情况

    else {        Node<K,V> e; K k;        // 情况1:桶里第一个节点的key就和我要放的一样        if (p.hash == hash &&            ((k = p.key) == key || (key != null && key.equals(k))))            e = p;        // 情况2:桶里已经变成红黑树了        else if (p instanceof TreeNode)            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);        // 情况3:还是链表,遍历找位置        else {            for (int binCount = 0; ; ++binCount) {                if ((e = p.next) == null) {                    p.next = newNode(hash, key, valuenull); // 尾插法!                    // 链表长度达到8,转红黑树                    if (binCount >= TREEIFY_THRESHOLD - 1// -1 for 1st                        treeifyBin(tab, hash);                    break;                }                if (e.hash == hash &&                    ((k = e.key) == key || (key != null && key.equals(k))))                    break;                p = e;            }        }
三种冲突情况:
情况 条件 处理方式
key相等 hash相同 && (==或equals) 覆盖旧值
红黑树 节点是TreeNode 调用putTreeVal
链表 其他情况 尾插法遍历插入
⚠️ 两个面试高频追问:
Q1:为什么用尾插法而不是头插法?
JDK1.7是头插法,多线程扩容时会形成环形链表,导致死循环。JDK1.8改成尾插法,扩容时链表顺序不变,避免了这个问题。
但这不代表HashMap线程安全了!依然可能丢数据。
Q2:链表转红黑树的阈值为什么是8?
源码注释写了:泊松分布下,链表长度达到8的概率是0.00000006(亿分之六)。也就是说正常情况基本不可能达到8,达到了说明hash函数有问题或者被攻击了。

💡 红黑树节点是链表节点的2倍大,所以转树是”最后手段”,不是”越早越好”。而且还有个下限:红黑树节点<=6时会退化回链表(UNTREEIFY_THRESHOLD=6)。


第5步:覆盖旧值 & 扩容判断

        // 如果找到了已存在的key,覆盖旧值        if (e != null) { // existing mapping for key            V oldValue = e.value;            if (!onlyIfAbsent || oldValue == null)                e.value = value;            afterNodeAccess(e); // 回调,LinkedHashMap用的            return oldValue;    // 返回旧值!put的返回值就是这么来的        }    }    ++modCount;    // 扩容判断:元素数量 > 扩容阈值    if (++size > threshold)        resize();    afterNodeInsertion(evict); // 回调,LinkedHashMap用的    return null// 新插入返回null
覆盖逻辑:
onlyIfAbsent=false(put方法默认)→ 无脑覆盖
onlyIfAbsent=true(putIfAbsent方法)→ 旧值为null才覆盖
扩容阈值:
threshold = capacity * loadFactor
默认:16 * 0.75 = 12
也就是说,存到第13个元素时触发扩容

💡 面试追问:”为什么负载因子是0.75?”太小(0.5)→ 浪费空间;太大(1.0)→ 冲突太多。0.75是时间和空间的折中。源码注释还说了:0.75让threshold恰好是整数(16*0.75=12)。


📊 全流程总结表

🧠 小测验:你真的懂了吗

💬 评论区顺便说说你选了哪个,以及为什么——说出理由才算真懂了!

🎁 彩蛋答案(先思考,再看答案)

这些是面试追问的完整答案,先自己想想再看哦 👇

Q1:put和putIfAbsent的区别是什么?
Q2:HashMap的key如果是自定义对象,必须重写哪两个方法?为什么?
Q3:扩容resize()方法里,链表是怎么拆分到新数组的?
Q4:为什么红黑树退化回链表的阈值是6而不是8?

做对了几道?评论区见,聊聊你的理解 💬

📌 版权声明:原创不易,转载请注明出处。觉得有用就点个在看👍,你的支持是我持续输出的动力!