HashMap详解

面试中经常会问道你对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);
}

执行步骤:

  1. 如果 key 为 null,直接返回哈希值 0(这就是 HashMap 允许 null 键的原因,且 null 键永远放在数组第 0 位)
  2. 调用 key.hashCode() 获取原始 32 位 int 哈希值
  3. 执行扰动处理: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. 扩容过程

  1. 创建一个新的数组,长度是原数组的 2 倍
  2. 重新计算原数组中每个元素在新数组中的索引位置
  3. 将元素复制到新数组中
  4. 替换原数组,更新阈值

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 做了哪些优化?

  1. 底层结构从数组 + 链表改为数组 + 链表 / 红黑树
  2. 链表插入方式从头插法改为尾插法,避免多线程扩容时的死循环
  3. 哈希计算的扰动处理简化为一次异或运算
  4. 扩容时索引重计算优化,不需要重新计算哈希值
  5. 扩容过程中对红黑树进行了优化处理

总结

HashMap 是 Java 集合框架中最重要的类之一,也是面试中几乎必问的知识点。需要重点掌握:

  1. JDK 1.7 和 1.8 底层结构的差异
  2. 哈希计算和索引定位的原理
  3. 扩容机制的详细过程
  4. 线程安全问题及解决方案
  5. 常见面试题的标准答案
相关推荐
汉克老师2 小时前
GESP2025年3月认证C++五级( 第三部分编程题(1、平均分配))
c++·算法·贪心算法·排序·gesp5级·gesp五级
Yzzz-F4 小时前
Problem - 2205D - Codeforces
算法
智者知已应修善业5 小时前
【51单片机2个按键控制流水灯运行与暂停】2023-9-6
c++·经验分享·笔记·算法·51单片机
Halo_tjn5 小时前
Java Set集合相关知识点
java·开发语言·算法
生成论实验室6 小时前
《事件关系阴阳博弈动力学:识势应势之道》第四篇:降U动力学——认知确定度的自驱演化
人工智能·科技·神经网络·算法·架构
AI科技星6 小时前
全域数学·72分册:场计算机卷【乖乖数学】
算法·机器学习·数学建模·数据挖掘·量子计算
科研前沿7 小时前
镜像孪生VS视频孪生核心技术产品核心优势
大数据·人工智能·算法·重构·空间计算
水蓝烟雨7 小时前
1931. 用三种不同颜色为网格涂色
算法·leetcode
晨曦夜月7 小时前
map与unordered_map区别
算法·哈希算法