HashMap理解

4 阅读3分钟

1、HashMap核心结构

HashMap = 数组 + 链表 + 红黑树

  • 数组(哈希桶):通过哈希值快速定位,时间复杂度O(1)

  • 链表:解决哈希冲突(拉链法)

  • 红黑树:当链表过长时保证查询效率O(logn)

2、HashMap参数设计

初始容量:16

#设计思想:2的幂次方,便于位运算替代取模,提升计算效率

负载因子:0.75

#设计思想:基于泊松分布和数学统计,是冲突概率与空间利用的最佳平衡点

树化阈值:链表长度 > 8 且数组长度 >= 64

退化阈值:红黑树节点 <= 6时,退化为链表

#注意:6与8的差距是防止频繁转换的缓冲区间

3、put方法执行流程

1. 计算key的hash值
2. 如果数组为空,调用resize()初始化
3. 计算索引位置 i = (n-1) & hash
4. 查找该位置:
   a. 无节点:直接插入Node
   b. 有节点但key相同:覆盖value
   c. 红黑树节点:调用putTreeVal
   d. 链表节点:遍历链表
       - 找到相同key:覆盖
       - 未找到:尾插法添加
         * 链表长度≥8:尝试树化
         * 满足树化条件:转为红黑树
5. 判断size > threshold:调用resize()扩容

4、get方法执行流程

1. 计算key的hash值
2. 计算索引位置 i = (n-1) & hash
3. 查找该位置:
   a. 第一个节点匹配:直接返回
   b. 红黑树:调用getTreeNode,O(log n)
   c. 链表:遍历查找,O(n)
4. 未找到返回null

5、扩容过程(resize)

1. 计算新容量:旧容量×2(保持2的幂)
2. 创建新数组
3. 重新哈希(rehash)所有元素
   - JDK 1.8优化:元素在新数组的位置只有两种可能
     * 保持原索引 j
     * 新索引 = j + 旧容量
   - 判断依据:hash & 旧容量 == 0 ?
     原理:由于新容量是旧容量×2,只需看hash值在旧容量位上的值

6、原理追问

  1. 为什么HashMap线程不安全?
  • 多线程put可能导致死循环(JDK1.7头插法,多线程扩容可能形成循环链表)或数据覆盖

  • JDK1.8 尾插法,解决了循环链表问题,依然存在数据丢失等线程安全问题

  1. 为什么用红黑树?
  • 链表查询O(n),红黑树O(log n),性能提升

7、面试问题

  • HashMap底层数据结构演变

  • 数组 → 链表 → 红黑树

  • 为什么链表长度超过8才树化

  • 泊松分布,概率极低

  • 性能平衡,较低阈值树化导致不必要的内存消耗和损失,红黑树还是适用于大表

  • 哈希冲突解决方法

  • 拉链法

  • 红黑树优化

  • 扩容机制和重新哈希过程

  • 元素数量超过负载因子0.75所允许的上限时触发扩容

  • 参照 5、扩容过程(resize)

  • 为什么重写equals必须重写hashCode

  • 一致性要求:Java 的约定,如果两个对象通过 equals 方法相等,那么这两个对象的 hashCode 方法也必须返回相同的整数值。

  • 哈希表的完整性:只重写 equals 而不重写 hashCode,可能会导致在哈希表中相等的对象被分配到不同的位置,从而无法找到这些对象,最终破坏哈希表的完整性。

  • JDK 1.7和1.8的主要区别

  • 处理冲突的方法

    JDK 1.7:使用链表处理冲突,所有冲突的元素都存储在同一桶的链表中。

    JDK 1.8:在链表长度超过 8 时,链表会转化为红黑树,以提高查找性能。

  • 扩容机制变化

    JDK 1.7:在扩容时,所有旧元素都会被重新计算并分散到新的桶中,扩容过程可能比较耗时。

    JDK 1.8:在扩容时,使用了新的逻辑来处理元素的移动,保持了扩容的效率。

  • 性能优化

    JDK 1.8:进行了许多性能优化,比如使用 Node 和 TreeNode 类来表示不同的数据结构,使得在链表和红黑树之间的转换更加高效。