面试中经常会问道你对HashMap了解多少?
这个时候可能你只会回答,底层由数组+链表+红黑树的一个机构,有扩容机制...但是这远远不够,像背后的哈希计算、扰动处理、索引定位、冲突处理、扩容机制这些都应该和面试官讲讲。
Java中HashMap的底层原理
HashMap是Java中经典的键值对(Key-Value)存储容器,底层基于哈希表实现,JDK1.8后通过数组 + 链表/红黑树的混合结构大幅度优化了性能。
| 版本 | 底层结构 | 核心问题 |
|---|---|---|
| JDK 1.7 | 数组 + 链表 | 链表过长时查询退化为 O (n),且头插法扩容易导致死循环 |
| JDK 1.8+ | 数组 + 链表 / 红黑树 | 链表过长(≥8)且数组容量≥64 时,自动转为红黑树,查询复杂度优化为 O (logn); 退化条件:当红黑树节点数 ≤ 6 时,会自动退化为链表。 |
底层实现如下:
HashMap 核心原理详解
1. 哈希计算与扰动处理(hash 方法)
这是 HashMap 设计的精髓之一,目的是让哈希值更均匀地分布,减少哈希碰撞。
源码(JDK 1.8):
java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
执行步骤:
- 如果 key 为 null,直接返回哈希值 0(这就是 HashMap 允许 null 键的原因,且 null 键永远放在数组第 0 位)
- 调用 key.hashCode() 获取原始 32 位 int 哈希值
- 执行扰动处理:
h ^ (h >>> 16)- 将哈希值的高 16 位右移
- 与原哈希值进行异或运算
目的: 将高位信息混入低位,让哈希值在低位也能均匀分布,减少碰撞
如果直接使用 key.hashCode() 的原始值而不做扰动,会存在一个巨大的隐患:如果一批 Key 的原始 HashCode 高位变化很大,但低位恰好完全相同,它们会全部撞到同一个桶里。
2. 索引定位
计算出哈希值后,需要确定该键值对应该存放在数组的哪个位置(桶)。
计算公式:
java
index = (n - 1) & hash
其中 n 是数组的长度,必须是 2 的幂次方。
为什么用位运算而不是取模 %?
- 位运算的效率远高于取模运算
- 当 n 是 2 的幂次方时,
(n - 1) & hash等价于hash % n
为什么数组长度必须是 2 的幂次方?
- 保证
(n - 1)的二进制表示全是 1 - 这样与运算的结果才能覆盖数组的所有索引位置
- 如果不是 2 的幂次方,会导致某些索引位置永远无法被使用,浪费空间且增加碰撞概率
3. 冲突处理(拉链法)
当两个不同的 key 计算出相同的索引时,就发生了哈希碰撞。HashMap 使用拉链法解决冲突:将哈希值相同的元素以链表的形式存储在同一个桶中。
JDK 1.8 改进:
- 当链表长度超过阈值 8 且数组容量≥64 时,链表会转换为红黑树
- 红黑树的查询复杂度为 O(logn),远优于链表的 O(n)
- 解决了 JDK 1.7 中链表过长导致查询效率急剧下降的问题
扩容机制(面试重中之重)
当 HashMap 中的元素数量达到阈值时,会触发扩容操作,这是 HashMap 最复杂也最容易被问到的部分。
1. 扩容触发条件
元素数量 > 容量 × 加载因子
- 默认加载因子(loadFactor):0.75
- 默认初始容量(initialCapacity):16
- 默认阈值(threshold):16 × 0.75 = 12
为什么加载因子是 0.75? 这是时间和空间的折中:
- 加载因子过高(如 1.0):空间利用率高,但碰撞概率增加,查询效率下降
- 加载因子过低(如 0.5):碰撞概率低,查询效率高,但空间浪费严重
- 0.75 是经过大量测试得出的最优值
2. 扩容过程
- 创建一个新的数组,长度是原数组的 2 倍
- 重新计算原数组中每个元素在新数组中的索引位置
- 将元素复制到新数组中
- 替换原数组,更新阈值
3. JDK 1.8 扩容优化
JDK 1.7 的问题:
- 使用头插法将元素转移到新数组
- 多线程环境下,头插法会导致链表形成环形结构
- 后续查询时会陷入死循环,CPU 使用率飙升
JDK 1.8 的改进:
- 改用尾插法转移元素
- 保持元素的原有顺序
- 避免了多线程环境下的死循环问题(但 HashMap 仍然不是线程安全的)
索引重计算优化: 由于新数组长度是原数组的 2 倍,(n - 1) 的二进制表示只是多了一个最高位 1。因此,元素在新数组中的索引只有两种可能:
- 原索引
- 原索引 + 原数组长度
HashMap 不需要重新计算哈希值,只需要判断哈希值的最高位是 0 还是 1 即可:
- 如果最高位是 0,索引不变
- 如果最高位是 1,索引 = 原索引 + 原数组长度
方法1:传统计算法(需要重新计算)
java
int newIndex = hash & (newCap - 1);
方法2:快捷判断法(JDK 1.8优化)
java
if ((hash & oldCap) == 0) {
newIndex = oldIndex; // 索引不变
} else {
newIndex = oldIndex + oldCap; // 索引+oldCap
}
这大大提高了扩容的效率。
java
oldCap = 16 = 0001 0000 (二进制)
oldCap-1 = 15 = 0000 1111
newCap = 32 = 0010 0000
newCap-1 = 31 = 0001 1111
hash值示例: abcd efgh
↑
第4位
// 方法1:hash & (newCap-1)
hash: abcd efgh
newCap-1: 0001 1111
结果: 0000 efgh
// 方法2:判断hash & oldCap
hash & oldCap = 000e 0000
如果d=0: 新索引 = 0000 efgh = 老索引
如果d=1: 新索引 = 0001 efgh = 老索引(0000 efgh) + 16
线程安全问题
HashMap 是非线程安全的,在多线程环境下可能出现以下问题:
- 数据丢失: 多个线程同时插入元素时,可能会覆盖彼此的数据
- 死循环: JDK 1.7 中,多线程扩容时头插法会导致链表形成环形结构
- 数据不一致: 一个线程正在修改 HashMap,另一个线程可能读到不一致的数据
线程安全的替代方案:
- Hashtable: 所有方法都加了 synchronized 锁,效率低,不推荐使用
- Collections.synchronizedMap: 将 HashMap 包装成线程安全的 Map,同样使用 synchronized 锁,效率一般
- ConcurrentHashMap: JDK 1.5 引入,使用分段锁(JDK 1.7)和 CAS+synchronized(JDK 1.8)实现,效率高,推荐使用
常见面试题精选
1. HashMap 和 Hashtable 的区别?
| 区别点 | HashMap | Hashtable |
|---|---|---|
| 线程安全 | 非线程安全 | 线程安全 |
| null 键值 | 允许 null 键和 null 值 | 不允许 |
| 效率 | 效率高 | 效率低(所有方法都加锁) |
| 底层结构 | JDK 1.8+ 引入了红黑树 | 没有红黑树 |
| 初始容量 | 默认 16 | 默认 11 |
| 扩容 | 扩容为原来的 2 倍 | 扩容为原来的 2 倍 + 1 |
2. HashMap 和 ConcurrentHashMap 的区别?
| 区别点 | HashMap | ConcurrentHashMap |
|---|---|---|
| 线程安全实现 | 非线程安全 | JDK 1.7 分段锁,JDK 1.8 CAS+synchronized |
| 并发度 | 无 | 高并发支持 |
| null 键值 | 允许 null 键和 null 值 | 不允许 |
| 性能对比 | 单线程下性能略高 | 多线程下性能远超 Hashtable |
3. 为什么重写 equals 方法必须重写 hashCode 方法?
HashMap 是通过 key 的 hashCode 来计算索引位置,然后通过 equals 方法来比较 key 是否相等。
如果只重写了 equals 方法而没有重写 hashCode 方法,那么两个逻辑上相等的对象(equals 返回 true)会有不同的 hashCode,导致它们被存放在不同的桶中,从而无法正确获取到值。
- 场景 1:只重写 equals,不重写 hashCode
两个内容相同的 new 对象,equals 为 true,但默认 hashCode 不同;只存其中一个,用另一个内容相同的对象 get 时,会定位到不同桶,查询返回 null。 - 场景 2:只重写 hashCode,不重写 equals
两个内容相同的 new 对象,hashCode 相同,会进入同一个桶;但没重写 equals,底层默认比内存地址,判定不是同一个 key;put 会存重复数据,key 重复却覆盖不了,导致一个桶内有多个相同key。 - 场景 3:同时重写 equals + hashCode
满足规约:equals 为 true 时 hashCode 一定相同;既能进入同一个桶,桶内又能通过 equals 判定相等;put 正常覆盖,get 正常取值,HashMap 行为完全正常。
正确的约定:
- 如果两个对象 equals 返回 true,那么它们的 hashCode 必须相同
- 如果两个对象的 hashCode 相同,它们的 equals 不一定返回 true
4. JDK 1.8 中 HashMap 做了哪些优化?
- 底层结构从数组 + 链表改为数组 + 链表 / 红黑树
- 链表插入方式从头插法改为尾插法,避免多线程扩容时的死循环
- 哈希计算的扰动处理简化为一次异或运算
- 扩容时索引重计算优化,不需要重新计算哈希值
- 扩容过程中对红黑树进行了优化处理
总结
HashMap 是 Java 集合框架中最重要的类之一,也是面试中几乎必问的知识点。需要重点掌握:
- JDK 1.7 和 1.8 底层结构的差异
- 哈希计算和索引定位的原理
- 扩容机制的详细过程
- 线程安全问题及解决方案
- 常见面试题的标准答案