hashmap最常问的十个问题,并给出回答
以下是HashMap最常被问到的十个核心问题及精准解答,覆盖原理、特性与关键操作:
- HashMap 是什么?它的核心作用是什么?
HashMap 是 Java 中基于哈希表实现的 Map 接口实现类,核心作用是存储 键值对(Key-Value),并通过哈希算法实现键到值的快速查找、插入和删除,平均时间复杂度为 O(1)。
-
HashMap 的底层数据结构是什么?JDK 1.7 和 1.8 有什么区别?
• JDK 1.7:底层是 数组 + 链表(数组存储哈希桶,链表解决哈希冲突)。
• JDK 1.8:底层是 数组 + 链表 + 红黑树(当链表长度超过阈值 8,且数组容量 ≥ 64 时,链表会转为红黑树,提升查询效率;当链表长度 ≤ 6 时,红黑树会退化为链表)。
-
HashMap 的哈希冲突是如何产生的?如何解决?
• 产生原因:不同 Key 经过哈希函数计算后,得到的 哈希值相同,或哈希值不同但对数组长度取模后,得到的 数组索引(桶位)相同。
• 解决方式:
-
链地址法(JDK 核心方案):冲突的 Key 对应的 Value 以链表/红黑树形式存储在同一桶位。
-
扰动函数:对 Key 的哈希值进行多次异或和移位,降低哈希冲突概率(JDK 1.8 简化为 1 次扰动,但仍有效)。
-
-
HashMap 的 Key 可以为 null 吗?Value 呢?
• Key:可以为 null,但 只能有 1 个(因为 HashMap 不允许 Key 重复,若多次 put null Key,后值会覆盖前值)。
• Value:可以为 null,且 允许有多个(不同 Key 可对应相同的 null Value)。
-
HashMap 为什么线程不安全?有哪些线程安全的替代类?
• 线程不安全原因:
◦ JDK 1.7 中,多线程扩容时可能导致 链表死循环(头插法导致链表反转)。
◦ 多线程并发 put 时,可能出现 数据覆盖(如两个线程同时判断同一桶位为空,均执行插入)。
• 线程安全替代类:
◦ ConcurrentHashMap:JDK 1.8 基于 CAS + synchronized 实现,性能优于 Hashtable。
◦ Hashtable:通过 全局 synchronized 锁 保证安全,性能较差,已较少使用。
◦ Collections.synchronizedMap(new HashMap<>()):对 HashMap 加锁包装,本质是全局锁,性能一般。
-
HashMap 的初始容量、负载因子、扩容机制是什么?
• 初始容量:默认 16(必须是 2 的幂,便于通过“哈希值 & 数组长度-1”快速计算桶位,等价于取模且效率更高)。
• 负载因子:默认 0.75(衡量数组“满”的程度,0.75 是时间效率与空间效率的平衡值)。
• 扩容机制:当 HashMap 中元素个数(size)≥ 容量 × 负载因子时,触发扩容,新容量为原容量的 2 倍,并重新计算所有元素的桶位(rehash)后迁移。
-
HashMap 的 Key 为什么推荐用不可变类(如 String、Integer)?
因为 Key 的 哈希值会影响存储位置,若 Key 是可变类(如自定义类未重写 hashCode/equals):
• 当 Key 对象被修改后,其哈希值可能变化,导致后续无法通过原 Key 找到对应的 Value(原桶位与新哈希值计算的桶位不一致)。• 不可变类(如 String)的哈希值在创建后固定,能保证 Key 对应的存储位置稳定,避免查找失效。
-
HashMap 中 hashCode() 和 equals() 方法的作用是什么?必须同时重写吗?
• hashCode():计算 Key 的哈希值,用于确定元素在数组中的 桶位索引。
• equals():当多个 Key 哈希冲突(桶位相同)时,用于判断 Key 是否“真正相等”,以确定是否覆盖 Value 或找到目标 Value。
• 必须同时重写:若只重写 equals() 而不重写 hashCode(),可能导致“逻辑相等的 Key 哈希值不同”,从而被存入 HashMap 的不同桶位,违背 Map“Key 唯一”的规则;若只重写 hashCode() 而不重写 equals(),则可能导致“哈希值相同但逻辑不相等的 Key 被判定为相等”,出现错误覆盖。
-
HashMap 和 Hashtable 的核心区别是什么?
对比维度 HashMap Hashtable
线程安全 不安全 安全(全局 synchronized)
Key/Value 为 null Key 可 null(1 个),Value 可 null(多个) Key 和 Value 均不可为 null
初始容量/扩容 初始 16,扩容为 2 倍 初始 11,扩容为 2 倍+1
哈希计算 哈希值 & (容量-1)(需容量为 2 的幂) 哈希值 % 容量(容量可为非 2 的幂) -
JDK 1.8 中 HashMap 做了哪些优化?
-
数据结构优化:引入红黑树,解决链表查询效率低的问题(链表查询 O(n),红黑树 O(logn))。
-
扩容机制优化:移除 JDK 1.7 的“头插法”,改用“尾插法”,避免多线程扩容导致的链表死循环。
-
哈希计算优化:简化扰动函数(1 次异或 + 移位),在保证哈希分散性的同时提升计算效率。
-
查询效率优化:红黑树的引入,使大量哈希冲突场景下的查询、删除效率大幅提升。
-
再来十个hashmap源码层面最常问的问题,并给出回答
以下是HashMap源码层面最常问的十个核心问题及精准解答,聚焦底层实现细节与关键逻辑:
- HashMap 中 table 数组的初始化时机是什么?
table 数组(存储哈希桶的数组)并非在构造方法中初始化,而是在 第一次调用 put() 方法时,通过 resize() 方法完成初始化(懒加载机制,避免创建空HashMap时浪费内存)。
- 源码中如何计算 Key 的桶位索引?核心公式是什么?
核心是通过“哈希值扰动 + 按位与”计算,确保索引在 table 数组范围内,公式分两步:
1. 计算扰动后哈希值:JDK 1.8 中为 hash = key == null ? 0 : (h = key.hashCode()) ^ (h >>> 16)(将 hashCode 高16位与低16位异或,减少哈希冲突)。2. 计算桶位索引:index = (table.length - 1) & hash(利用数组长度为2的幂的特性,等价于 hash % table.length,但按位与效率更高)。
-
put() 方法的核心源码逻辑是什么?
-
若 table 未初始化,调用 resize() 初始化。
-
计算 Key 的桶位索引,若该桶位为空,直接创建 Node 存入(新节点)。
-
若桶位非空:
◦ 若桶位首个节点的 Key 与当前 Key 相等(hash 相同且 equals 为 true),标记为“待覆盖节点”。
◦ 若桶位是红黑树(TreeNode),调用 putTreeVal() 插入或覆盖。
◦ 若桶位是链表,遍历链表:
◦ 找到 Key 相等的节点,标记为“待覆盖节点”并跳出。
◦ 遍历到链表尾部,创建新 Node 插入尾部(JDK 1.8 尾插法),若链表长度 ≥ 8,调用 treeifyBin() 尝试转为红黑树。
-
若存在“待覆盖节点”,用新 Value 覆盖旧 Value,并返回旧 Value。
-
若插入新节点,size 加1,若 size ≥ 阈值,调用 resize() 扩容。
-
-
resize() 方法的核心作用与源码逻辑是什么?
核心作用:初始化 table 数组,或在元素数量超阈值时扩容(新容量为原容量2倍),并迁移所有节点到新桶位。
源码逻辑:
1. 计算新容量(原容量为0时,新容量=初始容量16;否则新容量=原容量×2)和新阈值(新容量×负载因子)。2. 创建新 table 数组(容量为新容量)。3. 遍历旧 table 数组,迁移每个桶位的节点:◦ 若桶位是单个节点,直接计算新桶位并放入新 table。◦ 若桶位是链表,拆分链表为“原索引链”和“原索引+旧容量链”(利用 hash & 旧容量 判断,避免重新计算哈希),分别放入新 table 对应桶位。◦ 若桶位是红黑树,按红黑树规则拆分并迁移,必要时退化为链表。4. 新 table 替换旧 table,更新阈值。
-
JDK 1.8 为什么将链表转为红黑树的阈值设为 8?又为什么退化为链表的阈值设为 6?
• 转红黑树阈值 8:基于“泊松分布”统计,HashMap 中链表长度达到 8 的概率极低(约 0.00000006),此时链表查询效率已较差(O(n)),转为红黑树(O(logn))能显著提升性能,同时避免过早转树浪费内存。
• 退化为链表阈值 6:设置“间隔 2”的阈值(8→6),是为了避免链表与红黑树频繁转换(如长度在 7 和 8 之间波动时,反复转树/转链表),减少性能损耗。
-
treeifyBin() 方法的作用是什么?它一定会将链表转为红黑树吗?
• 核心作用:尝试将链表结构的桶位转为红黑树结构,优化查询效率。
• 不一定转树:源码中会先判断 table 数组容量,若 容量 < 64,会先调用 resize() 扩容(而非直接转树),因为此时扩容成本更低,且扩容后链表会被拆分,可能无需转树;只有当容量 ≥ 64 且链表长度 ≥ 8 时,才会真正转为红黑树。
-
HashMap 中 Node 类和 TreeNode 类的关系是什么?各自的核心属性有哪些?
• 关系:TreeNode 是 Node 的子类(static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V>,而 LinkedHashMap.Entry 又继承 HashMap.Node),用于红黑树存储。
• 核心属性:
◦ Node:hash(Key的哈希值)、key、value、next(链表下一个节点的引用)。
◦ TreeNode:除继承 Node 的属性外,新增红黑树相关属性:parent(父节点)、left(左子节点)、right(右子节点)、prev(链表前驱节点,用于红黑树转链表)、red(节点颜色,true为红,false为黑)。
-
源码中如何处理 Key 为 null 的情况?
在 put() 和 get() 方法中均有单独处理:
• put(null, value):计算哈希值时直接赋值 hash = 0,桶位索引为 0 & (table.length - 1) = 0,即固定存入 table[0] 桶位,后续逻辑与非null Key一致(判断冲突、覆盖或插入)。• get(null):直接定位到 table[0] 桶位,遍历该桶位的链表/红黑树,找到 Key 为 null 的节点并返回 Value(若不存在则返回 null)。
-
remove() 方法的源码逻辑是什么?如何处理红黑树节点的删除?
-
计算 Key 的哈希值和桶位索引,若 table 未初始化或桶位为空,直接返回 null。
-
遍历桶位节点(链表/红黑树),找到 Key 相等的目标节点:
◦ 若桶位是链表,找到目标节点后,通过修改前驱节点的 next 引用删除(跳过目标节点)。
◦ 若桶位是红黑树,调用 removeTreeNode() 方法删除目标节点,删除后会检查红黑树平衡,若节点数 ≤ 6,会将红黑树退化为链表。
- 若找到目标节点,记录其 Value,size 减1,返回旧 Value;未找到则返回 null。
-
-
JDK 1.8 中 hashCode() 相同但 equals() 不同的 Key,会如何存储?
会以“哈希冲突”的形式存储在同一桶位:
1. 计算哈希值时,两者 hashCode() 相同,经扰动后 hash 仍相同,桶位索引一致。2. 存入时,因 equals() 不同,不会覆盖已有节点,而是以链表(或红黑树)的形式追加到该桶位中(链表尾部或红黑树的对应位置)。3. 查询时,会先定位到该桶位,再通过 equals() 遍历比较,找到真正匹配的 Key 对应的 Value。
