《Java HashMap底层原理全解析(源码+性能+面试)》
目录
1 认识HashMap
1.1 成员变量分析
1.2 时间复杂度
1. 平均情况: O(1)
2. 最坏情况 : O(N) 或 O(logN)
2 核心方法详解
2.1 get( )方法
2.2 put方法
2.3 resize()方法
2.4 remove() 方法
2.5 jdk8中rehash优化详解
2.6 hash()方法详解
原理详解:
3 HashMap相关知识补充
1 对 null 键和 null 值的处理
2 线程安全性与 ConcurrentModificationException
3 MIN_TREEIFY_CAPACITY 的作用
4 高频面试题与深度解析
4.1 HashMap 的底层工作原理是什么?
4.2 HashMap 的容量为什么总是 2 的幂次方?
4.3 对比 JDK 7 和 JDK 8 中 HashMap 的主要区别。
4.4 HashMap 是线程安全的吗?如果不是,有什么替代方案?
4.5 hashCode() 和 equals() 方法之间有什么约定?如果违反这些约定,HashMap 会出现什么问题?
1 认识HashMap
1.1 成员变量分析
DEFAULT_INITIAL_CAPACITY 初始容量,默认值16
MAXIMUM_CAPACITY 最大容量,2的30次方
DEFAULT_LOAD_FACTOR 默认负载因子 0.75f
TREEIFY_THRESHOLD 链表转为红黑树的阈值,默认值为 8
UNTREEIFY_THRESHOLD 红黑树退化为链表的阈值,默认值为 6
MIN_TREEIFY_CAPACITY 启用树化的最小数组容量,默认值为 64
transient Node<K,V>[] table HashMap 底层哈希桶数组
transient Set<Map.Entry<K,V>> entrySet 视图缓存
size 当前 Map 中键值对的数量
modCount 结构性修改计数器(支持 fail-fast)
threshold 当前容量下允许的最大元素个数(容量 × 负载因子)
loadFactor 当前负载因子
1.2 时间复杂度
操作 | 平均情况 | 最坏情况 |
put (添加/更新) | O(1) | (log N)或O(N) |
get (获取) | O(1) | O(log N)或O(N) |
remove (删除) | O(1) | O(log N)或O(N) |
containsKey (检查键) | O(1) | O(log N)或O(N) |
1. 平均情况: O(1)
这是HashMap最主要的优势。在理想情况下,哈希函数能将键均匀地分布在各个桶(bucket)中,每个桶只有一个或极少数几个元素。
- 查找过程: get(key)操作的步骤是:
-
- 计算 key 的 hashCode() 并通过扰动函数得到 hash值。(O(1))
- 通过位运算 hash & (table.length - 1) 直接定位到桶的索引。(O(1))
- 获取桶中的第一个元素并进行比较。(O(1))
由于每一步都是常数时间操作,所以 get, put , remove 的平均时间复杂度都是 O(1)。
2. 最坏情况 : O(N) 或 O(logN)
最坏情况发生在大量不同的键产生了相同的哈希值(哈希碰撞),导致所有元素都集中在一个桶中。
- JDK 8 之前: O(N) 在 JDK 8 之前,哈希碰撞的解决方案只有链表法。当所有 N 个元素都落入同一个桶时,这个桶就退化成一个长度为 N 的链表。此时,无论是 get, put 还是 remove,都需要从头到尾遍历这个链表来查找目标元素,因此时间复杂度为 O(N)。
- JDK 8 及之后: O(logN) 为了优化这个最坏情况,JDK 8 引入了红黑树。当同一个桶中的链表长度达到 TREEIFY_THRESHOLD (8) 且哈希表总容量不小于 MIN_TREEIFY_CAPACITY (64) 时,这个链表会转化为一个自平衡的红黑树。 红黑树是一种高效的二叉查找树,其查找、插入、删除操作的平均和最坏时间复杂度都是 O(logk),其中 k 是树中的节点数。在所有 N 个元素都落入同一个桶的最坏情况下,k=N,因此时间复杂度从 O(N) 优化到了 O(logN)。
2 核心方法详解
2.1 get( )方法
1 判断表不为null 且非空 且key对应的头节点不为null
2 判断头节点hash值和查找节点hash值是否相等 且 头节点的key值和查找节点key值是否相等 相等则直接返回头节点
3 头节点不是查找节点并且存在下一个节点,继续向下找
- 若头节点属于树节点,在调用getTreeNode()方法查找
-
- 树化之后HashMap底层采用红黑树的数据结构,根据节点的hashcode构建红黑树,左孩子哈希值小于父节点,右孩子哈希值大于父节点
- 查询时,根据hash值查找,若hash值等于查找hash,则判断是否相同key, 因为多个不同 key可能 hash相等 ,key相等返回该节点;key不相等,尝试compare比较,进一步二分;key不可比,没有实现compare接口时,右子树递归查询,找不到再回左子树
- 头节点非树节点,则沿着链表继续向下找,直到找到相等的节点返回
- 未找到查询节点,返回null
2.2 put方法
1 判断表是否为空,为空则调用resize()方法初始化
2 通过 hash & (table.length - 1) 这个高效的位运算来确定该键值对在table数组中的存储位(桶索引) ,如果该位置没有元素,则直接创建一个新的Node节点存入
3 如果该位置已经有元素(发生哈希冲突)
- 若是链表,则遍历链表,如果找到 key 相同的节点就覆盖 value,如果遍历到末尾也没找到,则在链表尾部插入新节点(尾插法)。在插入后,检查链表长度是否达到树化阈值(8),如果达到则将链表转化为红黑树。
- 若是红黑树,则按照红黑树的规则插入新节点。
5 插入节点后,结构修改次数+1,判断当前大小是否超过阈值,超过阈值则调用resize()方法进行扩容操作
2.3 resize()方法
1 据当前数组是否已初始化、容量是否达到上限、是否已设置阈值等情况,分别处理首次初始化、常规扩容(容量与阈值翻倍)和边界限制
2 根据新的容量,创建table
3 遍历旧数组中的每个桶,将其中的节点根据 hash & oldCap 判断是否迁移到低位桶(原 index)或高位桶(index + oldCap),通过链表尾插法分别构建新桶中的链表结构,并处理红黑树节点分裂,最终完成数据在新数组中的平衡
2.4 remove() 方法
remove() 方法是另一个核心操作,其逻辑与 get() 和 put() 类似,都需要先定位到节点,然后执行删除操作
1 定位节点: 首先,通过 hash() 方法计算 key 的哈希值,并找到对应的桶(bucket)
2 查找并删除:
- 检查桶的头节点是否是目标节点。
- 如果不是,则根据节点类型(链表或红黑树)进行遍历查找。
- 在链表中: 找到目标节点后,通过修改前一个节点的 next 指针,将其从链表中“摘除”
- 在红黑树中: 找到目标节点后,执行红黑树的删除逻辑,并可能需要进行树的平衡调整
3 处理反树化: 如果删除的是红黑树中的一个节点,并且删除后树中的节点数量下降到了 UNTREEIFY_THRESHOLD(默认为 6),则会触发反树化 (Untreeify) 过程,将这棵红黑树重新退化为链表,以节省空间并为后续少量节点的增删提高效率
4返回并更新: 删除成功后,返回被删除的 value,并递减 size,递增 modCount 。如果未找到 key,则返回 null
2.5 jdk8中rehash优化详解
HashMap在扩容时,需要对每个节点进行rehash操作,但在jdk8做了一个优化,避免去rehash整个hash值,节点在rehash时新的index只有两种情况,等于旧index或index+oldCapacity,原因如下:
每个key根据hash值映射到哈希桶中:index = hash & (table.length - 1)
每个key的hash值不变,table.length - 1的值会因为扩容发生变化
假设一个key的hash值为01101,以oldCapacity=16为例,扩容完之后newCapacity=32,hash值第5位为0
则旧index计算方式为:idnex=01101 & 1111(16-1) =1101=13 (&:两个1为1,否则为0)
新index计算方式为:index=01101 & 11111(32-1)=01101=13
由此看出,index值未发生变化,所以该key值不需要移动
假设另一个key的hash值为11101,oldCapacity=16,newCapacity=32,hash值第5位为1
则旧index计算方式为:idnex=11101 & 1111(16-1) =1101=13
新index计算方式为:index=11101 & 11111(32-1)=11101=29
oldIndex=13,newIndex=29,即newIndex=oldIndex+oldCapacity
综上可以看出,扩容时对一个key值进行rehash时,该key的newIndex只有两种情况,等于index或index+oldCapacity,而这个newIndex的判断依据取决于hash值新多出的一位是0还是1,是0则保留原位index,是1则为index+oldCapacity
对应代码为resize方法中该部分代码
2.6 hash()方法详解
如果key==null,则返回0的hash值
反之,用key的hashcode二进制位与hashcode无符号右移16位后的二进制做异或操作
hashcode >>> 16 后,原高16位被移动到低16位位置,参与与原 hashcode 的异或运算
则后16位异或操作可解释为 hashcode的低16位 ^ hashcode的高16位,这样就可以把高位信息混入到低位信息中,降低哈希冲突的概率
原理详解:
key的index = hash & (table.length - 1),对于每个不同的key来说,hash值可能不同,而table.length - 1相同,则index的值取决于hash值是否相同,若不同则index不同,若相同则index相同,会出现哈希冲突
注意:在 & 操作中,相同为0,不同为1,而table.length的长度一般不会太大,比如table.length=32时,对应二进制位100000,在32bit中前26位均为0,在&运算时,不管hash值前26位是否为1,结果前26为一定是0,那么index值最终取决于hash值的后6位 & 100000(补充:很多数据(特别是短字符串、递增数字)在低位变化非常小或不变 ),分析可以得出,为了避免哈希冲突,哈希算法计算出的hash值低位应尽量避免重复
而Hashmap的hash算法是扰动函数 ,将hashcode的高16位与hashcode的低16位做^操作后,返回值的低位包含hashcode的高位与低位,则hash值低位重复的概率会大大降低
假设两个key:h1 = 0x12340001 ,h2 = 0x56780001
它们的低位完全一致 0x0001,
若不扰动 → h1 & 0xF == h2 & 0xF,index结果相同,哈希冲突
执行扰动:
h1 ^ (h1 >>> 16) = 0x12340001 ^ 0x00001234 = 0x12341235
h2 ^ (h2 >>> 16) = 0x56780001 ^ 0x00005678 = 0x56785679
虽然原始低位一样,但扰动后的 hash 低位已不同,在 hash & (table.length - 1)运算时,index结果不同
3 HashMap相关知识补充
1 对 null 键和 null 值的处理
这是一个 HashMap 的重要特性:
- 允许一个 null 键: HashMap 最多只允许一个 key 为 null 。当 key == null 时,它的哈希值被特殊处理为 0,因此它总是被存储在 table[0] 这个桶中。
- 允许多个 null 值: HashMap 对 value 没有限制,可以存储任意数量的 null 值。
与之对比,Hashtable (一个较旧的、线程安全的类) 不允许任何 null 键或 null 值。
2 线程安全性与 ConcurrentModificationException
- 非线程安全: HashMap 是 非线程安全 的。如果在多线程环境下对 HashMap 进行结构性修改(如 put, remove),而没有进行外部同步,可能会导致数据不一致。在 JDK 7 及更早版本中,并发 resize 甚至可能导致链表形成环,使得 get() 操作陷入死循环。
- Fail-Fast (快速失败) 机制: HashMap 的迭代器(通过 entrySet() , keySet() , values() 获取)是快速失败的。在创建迭代器后,如果 HashMap 的结构被修改(即 modCount 发生变化),迭代器在下一次操作(如 next() , remove() )时会立即抛出 ConcurrentModificationException。这是为了防止在迭代过程中对集合进行修改而导致不可预期的行为。
- 线程安全的替代方案:
-
- Collections.synchronizedMap(new HashMap(...)) : 使用 Collections 工具类包装 HashMap,其原理是对每个方法都加上 synchronized 锁,性能较差。
- ConcurrentHashMap: 推荐在并发环境中使用。它采用了更高效的分段锁(JDK 7)或 CAS + synchronized(JDK 8+)机制,提供了更高的并发性能。
3 MIN_TREEIFY_CAPACITY 的作用
MIN_TREEIFY_CAPACITY (最小树化容量,默认 64),但它的作用机制值得强调。一个桶的链表并不是只要达到 TREEIFY_THRESHOLD (8) 就一定会树化。
树化的两个条件必须同时满足:
- 链表的长度达到 TREEIFY_THRESHOLD (8)。
- HashMap 的总容量 table.length 达到 MIN_TREEIFY_CAPACITY (64)。
如果只满足条件1,而不满足条件2,HashMap 不会进行树化,而是会优先选择执行 resize() 操作进行扩容。这样做的目的是为了避免在 HashMap 容量还很小的时候,为个别繁忙的桶创建红黑树,因为此时更优的策略是扩容整个哈希表来减少哈希冲突。
4 高频面试题与深度解析
4.1 HashMap 的底层工作原理是什么?
回答思路: 这是一个开放性问题,旨在考察你对 HashMap 整体架构的理解。回答应由浅入深,覆盖数据结构、核心流程和关键机制。
精确答案:HashMap 的底层工作原理基于哈希表(Hash Table)或称散列表。在 JDK 8 中,它的核心数据结构是一个Node<K,V>类型的数组(transient Node<K,V>[] table),结合了链表和红黑树来解决哈希冲突。
其工作流程可以概括为以下几步:
- put(key, value) 存入数据: 首先,HashMap 调用 key 对象的 hashCode() 方法,并将返回的 hashCode 通过一个扰动函数(高16位与低16位异或)计算出一个 hash 值,目的是为了让哈希值分布更均匀,减少冲突。
- 然后,通过 hash & (table.length - 1) 这个高效的位运算来确定该键值对在 table 数组中的存储位置(桶索引)。
- 如果该位置没有元素,则直接创建一个新的 Node 节点存入。
- 如果该位置已经有元素(发生哈希冲突),则:
-
- 判断该位置的头节点是否就是要找的 key,如果是则直接覆盖 value。
- 如果不是,则判断当前桶的结构是链表还是红黑树。
- 若是链表,则遍历链表,如果找到 key 相同的节点就覆盖 value,如果遍历到末尾也没找到,则在链表尾部插入新节点(尾插法)。在插入后,检查链表长度是否达到树化阈值(8),如果达到则将链表转化为红黑树。
- 若是红黑树,则按照红黑树的规则插入新节点。
- 最后,在插入新节点后,size 加一,并检查 size 是否超过了阈值 (threshold = capacity * loadFactor),如果超过则进行 resize() 扩容。
- get(key) 获取数据:
-
- 获取数据的流程与 put 的前半部分类似:先计算 key 的 hash 值,再通过位运算找到对应的桶。
- 如果桶为空,返回 null。
- 如果桶不为空,则判断桶内第一个元素的 key 是否匹配。
- 如果不匹配,则根据桶的结构(链表或红黑树)进行查找,直到找到 key 相同的节点或遍历结束。
这个设计使得 HashMap 在理想情况下可以达到 O(1) 的存取效率,并通过红黑树将最坏情况下的性能从 O(N) 优化到 O(logN)。
4.2 HashMap 的容量为什么总是 2 的幂次方?
回答思路: 这个问题考察的是对 HashMap 性能优化的理解,核心在于位运算。
精确答案: HashMap 的容量(capacity)被设计为 2 的幂次方,主要是为了一个性能目标:实现快速、均匀的哈希值分布。这体现在计算桶索引的公式上:index = hash & (capacity - 1) 。
- 高效的索引计算: 当 capacity 是 2 的幂次方时(例如 16,二进制为 10000),那么 capacity - 1 的二进制形式就是全 1 的掩码(例如 15,二进制为 01111)。此时,hash & (capacity - 1) 这个位与运算等价于 hash % capacity(取模运算),但位运算的执行效率比取模运算高得多。CPU 执行位运算指令通常只需要一个时钟周期。
- 均匀的哈希分布: 使用 (capacity - 1) 作为掩码,可以确保 hash 值的低位被充分利用。只要哈希函数本身是均匀的,那么 & 运算的结果也会均匀地分布在 [0, capacity - 1] 这个区间内,从而减少哈希冲突。
反例: 如果 capacity 不是 2 的幂次方,比如 17(二进制 10001),那么 capacity - 1 就是 16(二进制 10000)。hash & 16 的结果只可能是 0 或 16,这意味着一半以上的桶将永远不会被使用,哈希冲突会急剧增加,HashMap 的性能会严重退化。
因此,即使你在构造函数中传入一个非 2 的幂次方的初始容量,HashMap 内部也会通过 tableSizeFor() 方法将其调整为大于等于该值的最小的 2 的幂次方。
4.3 对比 JDK 7 和 JDK 8 中 HashMap 的主要区别。
回答思路: 这是一个经典的进阶问题,考察你是否了解 HashMap 的演进历史和优化点。
精确答案: JDK 8 对 HashMap 进行了重大的优化,主要区别体现在以下几点:
- 数据结构:
-
- JDK 7: 底层是数组 + 链表。即使哈希冲突严重,也只是将链表拉长。 JDK 8: 底层是数组 + 链表 + 红黑树。当链表长度超过 TREEIFY_THRESHOLD (8) 时,会转化为红黑树,将严重冲突情况下的查找时间从 O(N) 优化到 O(logN)。
- 链表插入方式 (处理冲突时):
-
- JDK 7: 使用头插法。新来的元素会被放在链表的头部。
- JDK 8: 使用尾插法。新来的元素会被放在链表的尾部。
- 为什么改动? 因为在多线程环境下,JDK 7 的头插法在 resize 时可能导致链表形成环形链表,从而引发死循环。而尾插法在 resize 时能保持链表现有顺序,不会产生此问题。当然,HashMap 本身非线程安全,这只是一个附带的改进。
- resize 时的 rehash 逻辑:
-
- JDK 7: 需要重新计算每个元素的 hash 值,并重新计算它在新 table 中的索引。
- JDK 8: 做了巧妙的优化。扩容是容量翻倍,因此一个桶中的元素在新 table 中只可能分布在两个位置:原索引位置或原索引 + 旧容量位置。具体去哪个位置,取决于元素 hash 值的某一个特定比特位是 0 还是 1 (if ((e.hash & oldCap) == 0))。这避免了重新计算哈希的成本,并且可以很方便地将一个旧桶的链表拆分成两个新链表。
- hash 函数的实现:
-
- JDK 7: 扰动函数进行了 4 次位运算和异或,相对复杂。
- JDK 8: 简化了扰动函数,只做了一次高 16 位和低 16 位的异或,h ^ (h >>> 16)。官方认为这样的简化在性能和哈希分布效果上取得了很好的平衡。
4.4 HashMap 是线程安全的吗?如果不是,有什么替代方案?
回答思路: 考察并发编程知识,以及对 Java 集合框架的熟悉程度。
精确答案:HashMap 是非线程安全的。
- 为什么不安全? 在没有外部同步的情况下,多线程同时对 HashMap 进行结构性修改(put, remove 等)会引发多种问题:
- 数据覆盖: 并发的 put 操作可能导致一个线程的数据被另一个线程覆盖,造成数据丢失。
- ConcurrentModificationException: 如果一个线程正在迭代 HashMap(通过 keySet, entrySet 等),而另一个线程修改了 HashMap 的结构,会触发 fail-fast 机制,抛出此异常。
- 死循环 (JDK 7): 如前所述,并发 resize 时,头插法可能导致环形链表。
替代方案:
- Hashtable: 是一个历史悠久的线程安全类,它通过在每个公开方法上使用 synchronized 关键字来保证线程安全。但这种“一刀切”的全局锁导致其并发性能极差,所有线程都必须竞争同一把锁,现已不推荐使用。
- Collections.synchronizedMap(new HashMap(...)): 这是 Collections 工具类提供的一个包装器方法。其原理与 Hashtable 类似,也是对所有操作进行全局同步,性能同样低下。
- ConcurrentHashMap: 这是并发场景下的首选方案。它提供了高得多的并发性能。
-
- JDK 7 中的实现: 采用分段锁(Segment)技术。它将整个哈希表分成多个段(Segment),每个段拥有自己的锁。不同段的操作可以并发执行,显著提高了并发度。
- JDK 8 中的实现: 放弃了分段锁,改为采用 CAS (Compare-And-Swap) + synchronized。在 put 操作中,如果桶为空,则使用 CAS 来尝试添加节点。如果发生冲突,则使用 synchronized 锁住桶的头节点,实现了更细粒度的锁定,并发性能进一步提升。
4.5 hashCode() 和 equals() 方法之间有什么约定?如果违反这些约定,HashMap 会出现什么问题?
HashMap 的高效运作,精妙地依赖于 hashCode() 和 equals() 方法之间一个必须严格遵守的约定:如果两个对象通过 equals() 方法比较是相等的,那么它们的 hashCode() 值也必须完全相等。 这个约定构成了 HashMap 两阶段查找机制的基石。
第一阶段,hashCode() 扮演着“快速定位”的角色,如同一个地址的“楼号”,当你需要存入或查找一个对象时,HashMap 会首先索取其哈希码,通过这个数值迅速计算出它应该被放置的存储桶(bucket),这一步极大地缩小了搜索范围,保证了操作的效率。然而,由于不同的对象可能计算出相同的哈希码(即“哈希冲突”),所以仅凭“楼号”还不足以精确认定。
于是便进入了第二阶段,equals() 方法开始扮演“精确认定”的角色,如同“门牌号”,它会在那个小小的存储桶内,逐一比较对象,直到找到那个与目标对象内容完全匹配的“钥匙”。
倘若我们破坏了这个约定,尤其是在最常见的情形下——只重写了 equals() 而忘记重写 hashCode(),那么整个机制就会崩溃。因为当你存入一个 key1 时,它根据 Object 类的默认 hashCode()(通常是内存地址)被放入了A桶;而当你试图用一个内容与key1完全相同但实例不同的key2去取数据时,key2会因为拥有一个不同的默认hashCode()而被引导去寻找B桶,导致它永远也找不到位于A桶的正确数据,最终使得存入的数据无法被取出,造成了数据“丢失”的严重后果。