杰瑞科技汇

Java HashMap底层原理如何实现?

  1. 核心思想:数组 + 链表/红黑树
  2. 关键概念:哈希、哈希冲突
  3. 核心操作:put 方法详解
  4. 核心操作:get 方法详解
  5. 扩容机制
  6. Java 8 的重要优化:引入红黑树
  7. 总结与特性

核心思想:数组 + 链表/红黑树

HashMap 的本质是一个 “数组的链表”(或者更准确地说是“数组的链表/红黑树”),它内部维护了一个数组,我们称之为 “桶数组” (Bucket Array)

Java HashMap底层原理如何实现?-图1
(图片来源网络,侵删)
  • 数组:提供了快速访问(通过索引)的能力,查找效率为 O(1)。
  • 链表/红黑树:当多个元素的哈希值冲突时,它们会被放在同一个“桶”中,以链表或红黑树的形式组织起来。

这个结构完美地结合了数组的快速定位和链表的动态扩容特性。

简单比喻: 想象一个有 10 个格子的储物柜(数组),每个格子都有一个编号(索引)。

  • 你有一个 key 为 "Apple" 的物品,通过某种计算(哈希函数)得出它应该放在第 3 号格子,你直接把 "Apple" 放进去。
  • 后来你又有一个 key 为 "Banana" 的物品,计算后发现它也应该放在第 3 号格子,这时,你不能把 "Banana" 直接扔进去,而是把它挂在 "Apple" 后面,形成一个链表(链表)。
  • 当第 3 号格子的物品越来越多时(比如超过 8 个),为了提高查找效率,这个链表就会被转换成一个更高效的 红黑树 结构。

关键概念:哈希、哈希冲突

哈希

HashMap 中的每个元素都是一个键值对,要快速找到这个元素,不能通过遍历,必须通过 key 来定位。

哈希 就是一个将任意长度的输入(key)通过一个算法(key.hashCode())转换成一个固定长度的输出(一个整数)的过程,这个整数就是 哈希码

Java HashMap底层原理如何实现?-图2
(图片来源网络,侵删)

在 Java 中,Object 类提供了 hashCode() 方法,所有 Java 对象都继承了这个方法。

哈希冲突

理想情况下,不同的 key 应该计算出不同的哈希码,但由于哈希码的范围是固定的(32 位整数),而 key 的数量可能是无限的,不同的 key 可能计算出相同的哈希码,这种现象就是 哈希冲突

HashMap 的核心任务就是如何优雅地处理哈希冲突。


核心操作:put 方法详解

当你调用 map.put(key, value) 时,内部发生了以下步骤:

Java HashMap底层原理如何实现?-图3
(图片来源网络,侵删)

步骤 1:计算 key 的哈希值

  • 调用 key.hashCode() 方法得到一个原始的哈希码。
  • 对这个哈希码进行一次“扰动处理”(hash() 方法),目的是为了减少哈希冲突的概率,这个处理包括高 16 位异或低 16 位,使得哈希值分布更均匀。
    // 简化的扰动处理逻辑
    static final int hash(Object key) {
        int h;
        // (h = key.hashCode()) ^ (h >>> 16) 就是扰动处理
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

步骤 2:计算数组索引(桶的位置)

  • 使用 & 运算符将上一步得到的哈希值与 (数组长度 - 1) 进行位运算,得到在数组中的存储位置(索引)。
    // index = (n - 1) & hash
    // n 是数组的长度
    • 为什么用 & 而不是 ?因为 & 运算符的效率远高于 取模运算。
    • 为什么是 (n - 1)?因为数组的索引是从 0 开始的,n-1 是最大的有效索引,这要求数组的长度 n 必须是 2 的幂次方,这样才能保证 (n-1) 的二进制形式全是 1,从而保证 & 运算后能均匀地分布在所有索引上。

步骤 3:处理桶中的元素 根据计算出的索引 i,去桶数组中查找:

  • 情况 1:桶为空

    • table[i]null,说明这个位置还没有元素。
    • 直接创建一个新的 Node 对象(包含 key, value, hash, next),并放入 table[i] 中。
  • 情况 2:桶不为空(发生哈希冲突)

    • table[i] 上已经有元素了,需要遍历这个桶里的链表或红黑树。
    • 遍历链表/树:
      • 如果遍历过程中发现某个节点的 key 和你正在插入的 key “相等”(通过 equals() 方法比较),则说明是更新操作。
        • 将该节点的 value 更新为新值,并返回旧值。
      • 如果遍历完整个链表/树都没有找到相等的 key,说明这是一个全新的键值对。
    • 插入新节点:
      • 如果桶的结构是 链表:将新创建的 Node 对象插入到链表的 末尾(Java 8 之前是头插法,之后改为尾插法,防止在扩容时链表出现环)。
      • 如果桶的结构是 红黑树:调用树的插入方法,将新节点插入到树中,并进行平衡调整。

步骤 4:检查是否需要转换结构或扩容

  • 在插入节点后,会检查当前桶中(链表或树)的元素数量 size
  • size 超过 8TREEIFY_THRESHOLD),并且数组的长度 大于等于 64,就会将这个链表 转换成红黑树,以提高后续的查找效率。
  • size 超过 8,但数组长度 小于 64,说明数组太“挤”了,此时不会转换成树,而是先进行 扩容,让元素更分散。
  • 每次插入元素后,都会检查 HashMap 中元素的总数量 map.size() 是否 超过扩容阈值capacity * loadFactor),如果超过,则进行 整体扩容

核心操作:get 方法详解

当你调用 map.get(key) 时,过程与 put 类似,但更简单:

步骤 1:计算 key 的哈希值

  • put 方法一样,先对 key.hashCode() 进行扰动处理,得到最终的 hash 值。

步骤 2:计算数组索引

  • 使用 (n - 1) & hash 计算出在桶数组中的索引 i

步骤 3:查找元素

  • 根据 i 找到对应的桶 table[i]
  • 如果桶为空,直接返回 null
  • 如果桶不为空:
    • 如果桶是 链表:遍历链表,逐个比较节点的 key 是否与传入的 key 相等(使用 equals()),如果找到,返回对应的 value;如果遍历完没找到,返回 null
    • 如果桶是 红黑树:调用树的查找方法,在树中查找 key,如果找到返回对应的 value,否则返回 null

扩容机制

HashMap 中的元素数量 size 超过 *容量 加载因子** 时,HashMap 就需要进行扩容。

  • 容量:桶数组的长度,即 table.length,默认初始容量是 16
  • 加载因子:一个 float 类型的值,用于决定何时扩容,默认是 75
    • 默认扩容阈值16 * 0.75 = 12,当 HashMap 中存入第 13 个元素时,就会触发扩容。

扩容过程:

  1. 创建一个新的桶数组,新数组的容量是 旧数组的两倍
  2. 遍历旧数组中的每一个元素。
  3. 对于每个元素(可能是一个链表或一棵红黑树),需要重新计算它们在新数组中的位置。
    • 这里的一个巧妙之处是:因为新数组的长度是旧数组的两倍,(n-1) 的二进制表示只是多了一个最高位的 1
    • 一个元素的 hash 值在新旧数组中的位置,要么不变,要么在原位置的基础上加上旧数组的长度
    • 不需要重新哈希,只需要判断 hash 值的新增的最高位是 0 还是 1 即可,这大大提高了扩容的效率。

Java 8 的重要优化:引入红黑树

在 Java 7 及之前,HashMap 处理冲突的方式是纯链表,当某个桶中的链表过长时,查找效率会下降到 O(n)。

为了解决这个问题,Java 8 引入了 红黑树

  • 触发条件:当桶中链表的长度 超过 8 (TREEIFY_THRESHOLD) 并且数组总长度 超过 64 时,链表会转换为红黑树。
  • 优势:红黑树是一种自平衡的二叉查找树,其增、删、改、查的时间复杂度都是 O(log n),这极大地提高了在哈希冲突严重情况下的性能。
  • 退化条件:当进行扩容时,如果某个桶中的红黑树的节点数量 减少到 6 以下 (UNTREEIFY_THRESHOLD),红黑树会退化回链表,以节省空间。

这个优化使得 HashMap 在各种数据分布下都能保持非常好的性能。


总结与特性

特性 描述
数据结构 数组 + 链表 + 红黑树
核心原理 通过哈希函数将 key 映射到数组的索引上,实现快速存取。
哈希冲突处理 链地址法,冲突的元素以链表或红黑树的形式组织在同一个桶中。
时间复杂度 - 平均情况O(1)
- 最坏情况:如果所有 key 的哈希值都相同,会退化为链表,为 O(n),但在良好的哈希函数和扩容机制下,这种情况极少发生。
线程安全 非线程安全,如果在多线程环境下使用 HashMap,可能会导致数据不一致、死循环等问题,在多线程环境下,应使用 ConcurrentHashMapHashtable
key 的要求 key 必须实现 hashCode()equals() 方法,并且两个方法要遵循约定:a.equals(b)true,则 a.hashCode() 必须等于 b.hashCode()
null 允许存在 一个 null 键。null 键的哈希值被固定为 0,所以它总是存放在数组的第一个位置。
性能关键 - 哈希函数的质量:决定了哈希冲突的概率。
- 加载因子:权衡了空间和时间成本,值越大,空间利用率越高,但冲突概率也越大。

希望这个详细的解释能帮助你彻底理解 HashMap 的工作原理!

分享:
扫描分享到社交APP
上一篇
下一篇