每日面试题01 HashMap的底层原理

一、HashMap 的核心存储结构

HashMap 是基于 ​​数组 + 链表 + 红黑树​​ 的复合数据结构实现的(JDK 1.8 及以后)。其核心设计目标是通过哈希函数将键(Key)映射到数组的某个下标位置,从而实现 O(1) 时间复杂度的增删改查操作(理想情况)。

  1. ​初始结构:动态数组​
    HashMap 底层维护一个 Node[] table 数组(JDK 1.8 起),默认初始容量为 16(DEFAULT_INITIAL_CAPACITY = 1 << 4)。数组的每个元素是一个 Node 对象,存储键值对(Key-Value)。

  2. ​哈希冲突处理:链表与红黑树​
    当不同的键通过哈希函数计算得到相同的数组下标时(哈希冲突),这些键值对会被封装成 Node 对象,以链表的形式存储在该下标位置。
    当链表长度超过阈值(​​8​​)时,为了优化查询效率(链表的查询时间复杂度为 O(n),红黑树为 O(logn)),会将链表转换为红黑树(需同时满足数组长度 ≥ 64,避免数组过小时频繁转换)。

    若红黑树中节点数量减少到 ​​6​​ 以下(且数组长度 < 64),则会退化为链表,以节省内存空间。每日面试题01 HashMap的底层原理_第1张图片

  3. ​扩容机制:负载因子与动态扩容​
    HashMap 使用 ​​负载因子(Load Factor)​​ 平衡空间与时间效率,默认负载因子为 0.75(DEFAULT_LOAD_FACTOR)。当数组中已存储的键值对数量(size)超过 ​​阈值(阈值 = 数组容量 × 负载因子)​​ 时,会触发扩容操作:

    • 新数组容量为原容量的 ​​2 倍​​(避免频繁扩容,同时利用位运算优化哈希计算)。
    • 所有现有键值对需要重新计算哈希并分配到新数组中(即“重新哈希”)。
关于hashmap的扩容机制,我在这里举一个例子。假如往hashmap中存放一百个元素,如何保证hashmap不会扩容?

假设使用默认参数(初始容量 16,负载因子 0.75)创建 HashMap,并逐步存入 100 个元素,扩容过程如下:

阶段 数组容量(Capacity) 扩容阈值(Threshold = Capacity × 0.75) 已存元素数量(Size) 是否触发扩容?
初始状态 16 12(16×0.75) 0
存入第 1~12 个元素 16 12 1~12 否(未超阈值)
存入第 13 个元素 16 12 13 ​是​​(13 > 12)
扩容后 32(16×2) 24(32×0.75) 13 否(未超新阈值)
存入第 14~24 个元素 32 24 14~24 否(未超阈值)
存入第 25 个元素 32 24 25 ​是​​(25 > 24)
扩容后 64(32×2) 48(64×0.75) 25 否(未超新阈值)
... ... ... ... ...
存入第 65~96 个元素 128(64×2) 96(128×0.75) 65~96 否(未超阈值)
存入第 97 个元素 128 96 97 ​是​​(97 > 96)
扩容后 256(128×2) 192(256×0.75) 97 否(未超新阈值)
存入第 98~100 个元素 256 192 98~100 否(未超阈值)

​结论​​:使用默认参数时,存入 100 个元素会触发多次扩容(从 16→32→64→128→256),最终数组容量为 256。

若希望存入 100 个元素时不触发扩容,需​​手动设置初始容量​​,使扩容阈值(Threshold)≥ 100。具体步骤如下:

1. 计算所需最小初始容量

扩容阈值公式:Threshold = InitialCapacity × LoadFactor
要求 Threshold ≥ 100,代入负载因子 0.75:
InitialCapacity ≥ 100 / 0.75 ≈ 133.33

2. 调整为 2 的幂次方

HashMap 会将初始容量调整为​​大于等于该值的最小 2 的幂次方​​(位运算优化哈希下标计算)。
133.33 之后的最小 2 的幂是 256(因为 2⁷=128 < 133.33,2⁸=256 ≥ 133.33)。

3. 验证阈值

初始容量设为 256 时,阈值为 256 × 0.75 = 192,大于 100。此时存入 100 个元素不会触发扩容。

四、关键注意点
  1. ​负载因子的权衡​​:
    负载因子 0.75 是空间与时间的平衡:

    • 若负载因子过大(如 1.0),阈值增大,空间利用率提高,但哈希冲突概率上升,链表/红黑树变长,查询性能下降。
    • 若负载因子过小(如 0.5),阈值减小,空间浪费,但冲突概率降低,查询性能更稳定。
  2. ​初始化容量的“坑”​​:

    • 若手动设置初始容量为 100,HashMap 会自动调整为大于等于 100 的最小 2 的幂(即 128)。此时阈值为 128 × 0.75 = 96,存入第 97 个元素时仍会触发扩容(到 256)。
    • 因此,​​正确的做法是设置初始容量为 ceil(100 / 0.75) = 134​(实际调整为 256),确保阈值 ≥ 100。
  3. ​红黑树的额外空间开销​​:
    即使未触发数组扩容,若某个桶的链表长度超过 8(且数组长度 ≥ 64),会转换为红黑树,占用更多内存。因此,极端情况下(如大量哈希冲突),即使元素数量未达阈值,也可能因红黑树转换导致内存占用上升。

疑问:为什么红黑树退化为链表的阈值设置为6,而不是其他值?

在 HashMap(JDK 1.8+)的设计中,红黑树退化为链表的阈值设置为 ​​6​​(而非 7 或 8),主要与 ​​避免频繁结构切换​​、​​内存与时间的权衡​​ 以及 ​​统计概率​​ 有关。以下是具体原因分析:

一、避免频繁的结构切换(核心原因)

红黑树与链表的转换需要额外的计算和内存操作(如红黑树的旋转、颜色调整,或链表节点的指针修改),频繁切换会显著影响性能。若阈值设置为 7 或 8,可能导致以下问题:

1. 阈值 8 → 7 → 8 的“震荡”问题

假设阈值设置为 8(红黑树转链表的临界值),当节点数从 8 减少到 7 时,会退化为链表;若后续插入新节点,节点数回到 8 时又需转回红黑树。这种 ​​频繁的转换​​ 会导致额外的计算开销(如红黑树的破坏与修复),降低性能。

2. 设置 6 作为“安全缓冲区”

将退化阈值设为 6,意味着红黑树节点数需 ​​连续低于 6​​ 才会退化为链表。此时,节点数在 6 到 8 之间会保持红黑树结构(即使短暂波动),避免了因少量节点增减导致的频繁切换。例如:

  • 插入节点使红黑树节点数从 6→7→8:保持红黑树;
  • 删除节点使节点数从 8→7→6:仍保持红黑树;
  • 仅当节点数降至 5 及以下时,才退化为链表。

这种设计通过 ​​6→7→8 的缓冲区间​​,有效减少了结构切换的频率。

二、统计概率:冲突的低概率场景

哈希冲突的概率分布遵循 ​​泊松分布​​(Poisson Distribution)。根据统计,当链表长度(或红黑树节点数)为 8 时,冲突的概率已极低(约 0.0001%);而当节点数降至 6 时,冲突的概率进一步降低,此时红黑树的 ​​查询性能优势​​ 相对于链表的 ​​内存开销​​ 已不再明显。

具体数据参考:
  • 链表查询时间复杂度为 O(n),红黑树为 O(logn)。
  • 当节点数为 6 时,链表查询最多需要 6 次比较;红黑树最多需要 log₂6 ≈ 2.58 次(实际为 3 次)。
  • 但红黑树的每个节点需要额外存储父指针、左右子指针和颜色标记(约 3-4 个额外引用),内存占用约为链表节点的 1.5 倍(链表节点仅需存储键值对和下一个节点指针)。

因此,当节点数较少时(如 ≤6),链表的 ​​内存效率更高​​,而查询性能的损失可忽略不计。

三、与扩容机制的协同设计

HashMap 的扩容策略中,当数组长度小于 64 时,即使链表长度超过 8,也会优先扩容而非转换为红黑树(JDK 1.8 规定:​​数组长度 ≥64 且链表长度 ≥8 时才转红黑树​​)。这一设计与退化阈值(6)协同工作,进一步降低了红黑树的使用频率:

  • ​数组长度 <64​​:优先扩容(扩大容量,降低冲突概率),避免因少量冲突就转换为复杂的红黑树。
  • ​数组长度 ≥64​​:此时哈希冲突的概率已较高,转换为红黑树可优化查询性能。

当数组长度 ≥64 时,若红黑树节点数降至 6 以下,说明冲突已缓解(可能因扩容或数据分布变化),此时退化为链表可节省内存。

红黑树退化为链表的阈值设置为 6,核心原因是 ​​避免频繁的结构切换​​(6 与 8 之间预留了 7 的缓冲区间),同时结合 ​​内存效率​​(节点数少时链表更省内存)和 ​​统计概率​​(节点数少时红黑树优势不明显)。这一设计在时间效率(减少切换开销)和空间效率(节省内存)之间取得了平衡。

你可能感兴趣的:(每日面试题,java,开发语言)