HashMap 在 get 和 put 时,底层流程是怎样的?
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论
- 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于
Spring AI + Spring Boot3.x + JDK 21..., 点击查看; - 《从零手撸:仿小红书(微服务架构)》 已完结,基于
Spring Cloud Alibaba + Spring Boot3.x + JDK 17..., 点击查看项目介绍; 演示链接: http://116.62.199.48:7070/; - 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/
面试考察点
面试官提出这个问题,主要想考察你对 Java 核心数据结构 HashMap 底层实现机制的掌握深度,而不仅仅是停留在 API 调用层面。具体考察点包括:
- 对
HashMap底层数据结构的理解:面试官想知道你是否清楚 JDK 8 之后HashMap采用的 “数组+链表/红黑树” 结构。 - 对哈希函数与冲突解决机制的掌握:你能否清晰说明
key如何通过哈希函数映射到数组索引,以及当多个key哈希冲突时,HashMap如何处理(链表拉链、树化)。 - 对核心流程(
put/get)细节的洞察:面试官不仅仅是想知道步骤,更是想知道你是否了解其中的关键细节,例如:hash()方法如何计算(为什么要做异或运算?)。- 如何定位桶(
(n-1) & hash的原理)。 - 什么情况下会触发扩容,扩容时发生了什么(
rehash)。 - 链表何时会转化为红黑树,何时会退化成链表。
- 对线程安全性和性能影响因素的认知:你是否了解
HashMap在并发环境下的问题,以及初始容量、负载因子等参数对性能的影响。 - 将原理应用于实践的能力:能否根据原理,给出在真实开发中正确、高效使用
HashMap的最佳实践。
核心答案
HashMap 的 get 和 put 操作都围绕其 “数组+链表/红黑树” 的核心数据结构展开。简单来说:
put(K key, V value)步骤:计算key的哈希值 -> 通过哈希值定位到数组中的桶(bucket)-> 遍历桶内的链表或红黑树 -> 若key已存在则更新值,否则插入新节点 -> 检查是否需要树化或扩容。get(Object key)步骤:计算key的哈希值 -> 定位到对应桶 -> 遍历桶内的链表或红黑树,通过equals方法匹配key-> 返回找到的值,若未找到则返回null。
深度解析
接下来,我们结合 JDK 8+ 的源码逻辑进行深入解析。
原理与机制:put 操作详解
put 操作是 HashMap 最复杂的过程,其核心流程如下(简化版源码逻辑):
-
计算哈希值 (
hash(Object key)): 首先调用key.hashCode()获得原始哈希码h,然后执行(h ^ (h >>> 16))。这一步扰动函数的目的是将哈希码的高 16 位与低 16 位进行异或,使得低位也蕴含了高位的信息,从而减少后续哈希冲突的概率。static final int hash(Object key) { int h; // 并非简单地 return key == null ? 0 : key.hashCode(); return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } -
定位桶索引 (
index = (n - 1) & hash): 数组长度n总是 2 的幂(如 16, 32)。(n-1)的二进制形式是低位全 1(例如 15 是01111)。这个按位与操作实质上是取哈希值的低位有效位作为索引,效率远高于取模运算hash % n,并且保证了结果在[0, n-1]范围内。 -
处理桶内元素: 定位到数组的
tab[i]后,判断该位置第一个节点(first)的状态:- 情况一:桶为空 (
first == null) 直接新建一个Node节点放入该位置。 - 情况二:桶不为空,且第一个节点
key匹配 (hash相等且equals为true) 找到目标节点,记录用于后续值更新。 - 情况三:桶为红黑树 (
first instanceof TreeNode) 调用红黑树的putTreeVal方法进行插入或查找。 - 情况四:桶为链表
遍历链表。若找到
key匹配的节点则记录;若未找到,则在链表尾部插入新节点,并检查链表长度是否达到树化阈值(默认TREEIFY_THRESHOLD = 8)。若达到且当前数组容量达到最小树化容量(默认MIN_TREEIFY_CAPACITY = 64),则将该链表转化为红黑树,以提高极端情况下的查询效率。
- 情况一:桶为空 (
-
更新或插入: 如果步骤 3 中找到了已存在的
key,则用新值替换旧值,并返回旧值。 如果是新插入的节点,则执行后续操作。 -
扩容检查: 增加
size(元素总数),并判断if (++size > threshold)。threshold是扩容阈值,等于capacity * loadFactor(容量 * 负载因子,默认 0.75)。 如果超过阈值,则调用resize()方法进行扩容,新建一个两倍大的数组,并重新计算所有元素在新数组中的位置(rehash)。JDK 8 的rehash过程优化了:由于新数组长度newCap也是 2 的幂,元素的新位置要么是原索引oldIndex,要么是oldIndex + oldCap,取决于原哈希值新增的那个比特位是 0 还是 1。
get 操作详解
get 操作是 put 操作查找部分的一个子集,流程相对简单:
- 计算哈希值:与
put第一步完全相同。 - 定位桶索引:与
put第二步完全相同,即(n-1) & hash。 - 查找节点:
- 如果桶为空,返回
null。 - 如果桶的第一个节点就是要找的节点(哈希相等且
key相等),直接返回。 - 如果第一个节点是
TreeNode,则调用红黑树的getTreeNode方法查找。 - 否则,遍历链表,通过
equals方法比较key,找到则返回对应节点。
- 如果桶为空,返回
对比分析与最佳实践
- JDK 7 vs JDK 8+:JDK 7 采用 “头插法” 插入链表,在多线程扩容时可能造成死循环。JDK 8 改为 “尾插法”,并引入了红黑树,极大地优化了哈希冲突严重时的查询性能(从 O(n) 优化到 O(log n))。
- 最佳实践:
- 为自定义
Key类正确重写hashCode()和equals():这是HashMap正常工作的基石。hashCode应保证分布均匀,equals应保证逻辑正确。 - 预估容量,避免频繁扩容:如果预先知道要存储的元素数量
N,创建时应指定初始容量为(N / loadFactor) + 1,例如预计存 1000 个元素,应new HashMap<>(1337)。这样可以避免插入过程中多次触发耗时的resize操作。 - 理解非线程安全:
HashMap不是线程安全的。并发put可能导致数据覆盖、遍历时ConcurrentModificationException或(在 JDK 7 中)死循环。高并发场景请使用ConcurrentHashMap。
- 为自定义
- 常见误区:
- 认为
HashMap允许null键和null值,而Hashtable不允许。 - 混淆
hashCode和equals的作用:hashCode用于快速定位桶,equals用于在桶内精确匹配。两个对象equals相等,则hashCode必须相等;反之则不必然。
- 认为
总结
HashMap 的 get 和 put 操作,本质是基于哈希值的快速定位与桶内数据结构的精确查找/插入相结合的过程,其高效性依赖于良好的哈希函数、适时的扩容以及(在 JDK 8+ 中)链表向红黑树的动态转化。