当前位置: 首页 > news >正文

《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)操作的步骤是:
    1. 计算 key 的 hashCode() 并通过扰动函数得到 hash值。(O(1))
    2. 通过位运算 hash & (table.length - 1) 直接定位到桶的索引。(O(1))
    3. 获取桶中的第一个元素并进行比较。(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) 就一定会树化。

树化的两个条件必须同时满足:

  1. 链表的长度达到 TREEIFY_THRESHOLD (8)。
  2. 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) 。

  1. 高效的索引计算: 当 capacity 是 2 的幂次方时(例如 16,二进制为 10000),那么 capacity - 1 的二进制形式就是全 1 的掩码(例如 15,二进制为 01111)。此时,hash & (capacity - 1) 这个位与运算等价于 hash % capacity(取模运算),但位运算的执行效率比取模运算高得多。CPU 执行位运算指令通常只需要一个时钟周期。
  2. 均匀的哈希分布: 使用 (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桶的正确数据,最终使得存入的数据无法被取出,造成了数据“丢失”的严重后果。

http://www.dtcms.com/a/275858.html

相关文章:

  • LangChain 的链(Chain)
  • Java 接口与抽象类:深入解析两者的区别及应用场景
  • 【深度学习】常见评估指标Params、FLOPs、MACs
  • 牛客:HJ19 简单错误记录[华为机考][字符串]
  • 多表查询-4-外连接
  • EMC接地
  • 试用了10款翻译软件后,我只推荐这一款!完全免费还超好用
  • 6.isaac sim4.2 教程-Core API-多机器人,多任务
  • 单细胞入门(1)——介绍
  • C语言中整数编码方式(原码、反码、补码)
  • C++ 模板工厂、支持任意参数代理、模板元编程
  • 如何使用postman做接口测试?
  • dify 用postman调试参数注意
  • MOSFET驱动电路设计时,为什么“慢”开,“快”关?
  • 《Java Web程序设计》实验报告二 学习使用HTML标签、表格、表单
  • 零基础搭建监控系统:Grafana+InfluxDB 保姆级教程,5分钟可视化服务器性能!​
  • elementuiPlus+vue3手脚架后台管理系统,上生产环境之后,如何隐藏vite.config.ts的target地址
  • 游戏开发日记7.12
  • 现代C++打造音乐推荐系统:看看如何从0到1实现
  • 80. 删除有序数组中的重复项 II
  • Web学习笔记3
  • 网络检测:Linux下实时获取WiFi与热点状态
  • 游戏开发团队并非蚂蚁协作(随记):在各种“外部攻击”下保护自己的工具
  • C++中的容斥原理
  • css 判断是ios设备 是Safari浏览器
  • 敏捷开发方法全景解析
  • vue2和vue3的响应式原理
  • 【Datawhale AI 夏令营】 用AI做带货视频评论分析(二)
  • npgsql/dapper/postgresql的时区问题
  • 深入解析 LinkedList