HashMap 的 put 完整流程和扩容条件是什么?
未标记
考查重点:桶定位、冲突处理与扩容。hash 定桶、桶内查重更新/插入,必要时树化,计数过阈值扩容;按具体 JDK 核容量与迁移
本题目录
参考答案
核心回答
以 OpenJDK 8u412 的 HashMap 为例,put 的主线是计算 hash、定位桶、查重或插入、必要时调整桶结构,最后更新计数并检查扩容:
- 对键的
hashCode做扰动;null键的内部hash为 0。表尚未分配时,先通过resize完成初始化。 - 用“内部
hash与数组长度减一按位与”定位桶。空桶直接放新节点。 - 桶非空时,先检查首节点是否为同一个键;是树桶则走树插入,否则遍历链表。找到相等键就更新值并返回旧值;没有相等键才追加新节点。
- 链表新增节点后达到相应条件,会尝试树化;表容量不足时优先扩容。
- 新增映射才增加
size等计数;如果新增后的size大于threshold,再执行扩容。已有键的普通值替换不会增加size,也不会因此触发这一负载阈值检查。
因此,扩容既可能来自元素数量超过阈值,也可能来自长冲突链在小表上的处理;首次分配数组同样复用了 resize 方法,不能把这些情况都理解成“原表已经装满”。
相等键怎样判断
HashMap 不是只比较桶位置,也不是只比较 hash。普通节点会先比较内部 hash,再检查键是否为同一引用,或满足 equals。不同键可能同桶,也可能具有相同 hash,所以仍要判断键的相等性。
查到已有键时替换的是值,键值映射数量不变。put 返回旧值;返回 null 既可能表示此前没有这个键,也可能表示旧值就是 null,因为 HashMap 允许 null 值。
键在存入后若改变了影响 hashCode、equals 的字段,可能无法按预期定位或匹配原节点。通常应选择等价关系稳定的键,不能指望扩容修复可变键造成的问题。
扩容阈值和树化条件怎样配合
size 是映射条数,capacity 是桶数组长度,二者不同。threshold 通常与容量、负载因子有关;默认负载因子是 0.75。例如容量为 16 时,常规阈值为 12,在没有提前因冲突触发扩容的情况下,插入第 13 个不同键后超过阈值,扩为 32。
树化针对的是单个桶的冲突程度,不是全表的 size。长链达到尝试树化的条件时,如果桶数组还小,扩容可能先把冲突分散到两个桶,所以优先扩容;容量足够时才转换为树。不能把“链表长”和“全表装载率高”当成同一条件,也不应只背一个节点数就断言一定树化。
默认构造时还没有实际分配桶数组,第一次 put 才进行分配。容量达到实现的最大限制后,也不会无限翻倍。
扩容具体搬哪些数据
resize 分配更大的桶数组,遍历旧桶并迁移节点。对容量翻倍时的普通链表,只需检查节点内部 hash 与旧容量按位与的结果:为 0 的留在原下标,非 0 的移动到“原下标加旧容量”;两个分组内部保持相对顺序。树桶则通过相应拆分逻辑处理,必要时恢复为链表。
例如内部 hash 为 3、19,在容量 16 时都落在下标 3;扩为 32 后分别落在 3、19。这是利用节点保存的 hash 重新定位,不是重新调用每个键的 hashCode。扩容仍要处理旧节点和桶数组,会有时间与空间成本,不能认为这条位运算让整个扩容变成 O(1)。
追问:put 的流程完整,为什么仍不支持并发
这些检查、指针修改、计数和迁移没有为多个线程建立完整同步协议。两个线程都发现同一桶为空后写入,就可能覆盖入口;替换值、遍历、扩容也要遵守同步要求。需要共享更新时使用适合的并发容器或统一外部同步,不能把尾插、红黑树或 fail-fast 当成线程安全机制。
参考资料
- 国内:JavaGuide:HashMap 源码分析:用于串联 putVal 的各分支,以及新增计数、阈值判断、树化和 resize 的调用关系。
- 国内:美团技术团队:Java 8 系列之重新认识 HashMap:用于解释已有键更新与新键插入的区别,以及扩容时按新增位拆分旧桶的过程。
- 国内:沉默王二:Java HashMap 详解:用于解释内部 hash、桶下标和装载率,并用容量变化说明冲突节点怎样重新分布。
- 国外:OpenJDK:JEP 180:用于解释长冲突链树化的目标,以及为什么不能将实现优化理解为接口和遍历顺序保证的改变。
- 国外:Oracle:HashMap,Java SE 8:用于核对 null、负载因子、外部同步与 fail-fast 的契约,限定源码流程的使用条件。
