HashMap 底层原理浅析

HashMap 底层原理

HashMap 的底层数据结构

JDK 1.7 中的实现

在 JDK 1.7 中,HashMap 采用数组 + 链表作为底层的数据结构,其中使用链表是为了解决哈希冲突。当发生哈希冲突时,即多个键被映射到了同一个哈希桶(数组的位置),HashMap 会将这些键值对存储在同一个哈希桶对应的链表中。具体来说,在 JDK 1.7 中,HashMap 的每个哈希桶(数组的位置)实际上是一个链表,每个链表存储了哈希值相同的键值对。当执行 Put 操作时,HashMap 首先会计算键的哈希值,然后确定该键应该存储在数组的哪个位置。如果该位置已经存在了链表,HashMap 就会遍历该链表,检查是否已经存在相同键的键值对。如果存在相同的键,则 HashMap 会更新相应的值;如果不存在相同的键,则 HashMap 会将新的键值对添加到链表的末尾。链地址法的优点是它能够处理哈希冲突,并且在一定程度上保持了 HashMap 的性能。然而,在负载因子较高的情况下,即链表较长的情况下,查询键值对的效率可能会降低,因为需要遍历链表来找到目标键值对。

JDK 1.8 中的实现

在 JDK 1.8 之后,HashMap 采用数组 + 链表 + 红黑树作为底层的数据结构,之所以引入红黑树来替代链表,是为了改善在负载因子较高时的性能,这种结构称为 “链表与红黑树混合实现”。当哈希冲突发生时,如果链表的长度超过一定阈值(默认为 8),HashMap 会将链表转换为红黑树。这样做的目的是为了在链表长度较长时提高查询、修改和删除操作的效率,因为红黑树的时间复杂度更稳定,为 O (log n)。而当链表长度较短时,仍然保持使用链表结构,因为在较短的链表中,链表的遍历效率更高。值得一提的是,当红黑树中元素个数小于一定数量时,会转换回原来的链表结构,JDK 设置这个默认数量为 6 个。

JDK 11+ 中的实现

在 JDK 11、JDK 17 等高版本的 JDK 中,HashMap 的实现仍然基于数组 + 链表 + 红黑树混合实现(如下图所示)。因此,处理哈希冲突的方式与 JDK 1.8 中相似,只是可能会对一些细节进行了优化或改进,比如会引入树化优化、移位优化等。比如树化优化指的是在进行树化操作时,会先判断当前链表长度是否大于等于 8,如果不是,则不会进行树化操作,以节省资源。这个优化主要是为了解决在一些场景下,链表长度虽然超过了阈值,但树化操作并不能带来性能提升的问题。

 提示

无论在哪个版本的 JDK 里面,HashMap 使用的链表都是单向链表。

HashMap 的特点如何实现

HashMap 是一种可以快速存储和快速存储的键值对容器,那么 JDK 是如何实现 HashMap 的快速存储和快速查找呢?

三种常见的数据结构

这里首先从数组、链表、二叉查找树这三种常见的数据结构说起。

数组

数组是连续的内存空间,利用二分查找法,数组的时间复杂度最低可以低到 O (1),可见数组的查询效率是非常高的。但是由于数组的内存必须是连续的,空间复杂度很高,所以数组的插入、删除效率都非常低。

链表

链表的内存空间比较分散,空间复杂度较低,执行插入和删除操作的效率较高。但是由于链表的内存空间过于分散,导致查询效率大大降低。

二叉查找树

二叉查找树的别名是二叉搜索树,或者叫二叉排序树,在查询效率上和排序后数组的二分查找法效率完全相同,从根节点开始到下面的分支节点,左边的节点永远比父节点的要小,右边的节点永远比父节点大,如下图所示:

上图中一共 12 个元素,如果按顺序查找,可能最多需要查找 12 次才能查到目标元素;但是通过使用二叉查找树后,查找 43 这个元素,只需要判断 4 次就可以。由此可以看出二叉查找树在查询效率上和排序后的数组二分查找法效率是一样的。但是由于二叉树的元素过于分散,导致空间复杂度过大,执行插入和删除操作时会非常低效。为了解决这个问题,JDK 使用了红黑树这种数据结构,而红黑树在时间复杂度上可以做到 O (log n) 的高效率。

HashMap 实现快速存储

快速存储是链表和红黑树的优势。HashMap 中数组的索引是通过哈希值(hashCode)与其右移 16 位后的结果进行异或运算,然后按位与运算(取模)来获得的,底层源码如下:

1
2
3
4
5
static final int hash(Object key) {
int h;
// 调用 key.hashCode() 生成初步哈希值,然后对哈希值进行扰动处理
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
1
2
// 使用按位与运算替代取模运算(这样更高效),n 是 HashMap 底层数组的长度(总是 2 的幂),hash 是经过扰动处理后的哈希值
int index = (n - 1) & hash

通过这样的计算可以保证数组索引的分散。但是分散并不代表不会出现相同的索引,也就是索引冲突(哈希冲突)。在遇到哈希冲突的时候,HashMap 会在该索引的位置生成一个单向链表,将元素放置到尾部。虽然链表这种数据结构的插入效率比较高,但是在查询上效率却非常低,所以 HashMap 在链表元素数量大于 8 的时候,会自动将链表转成红黑树,以达到查询高效,插入也高效的目的。当然,在红黑树中元素个数小于一定数量时,会转换回原来的链表结构,JDK 设置这个默认数量为 6 个。这样不管是在外围 “数组” 上,还是在 “链表” 上,以及转换成 “红黑树” 这种数据结构,HashMap 都能做到快速存储。

总结

HashMap 底层的数组在查找效率上绝对有保证。当发生哈希冲突时,会使用链表存储冲突的元素,元素数量仅仅只有 8 个的链表,查询效率不需要考虑。大于 8 个元素后,链表会转换成红黑树,而红黑树的查询效率与数组相当,这点也不需要质疑。综合考虑,当发生哈希冲突时,HashMap 在查询方面也做到了快速查找的特性。

HashMap 什么时候会扩容

HashMap 在内部会维护一个负载因子(Load Factor),默认的负载因子是 0.75,也就是当 HashMap 中的元素数量达到容量的 75% 时,会触发扩容操作。扩容操作会创建一个新的更大的数组,通常是 当前容量的两倍,然后将原有的元素重新散列到新的数组中。这个过程需要重新计算每个元素的哈希值,并将其放置到新的数组中。由于这个过程涉及到重新哈希,可能会导致元素的重新排列,因此在扩容过程中,HashMap 的性能可能会受到影响。

提示

HashMap 的默认初始容量是 16,最大容量为 2^30,每次扩容后的容量都是之前容量的两倍。

HashMap 底层的哈希算法

在 HashMap 中,哈希算法的核心是调用对象(键对象)的 hashCode() 方法生成一个初步的哈希值,然后通过一系列处理(扰动处理)使这个哈希值分布得更加均匀,以减少哈希冲突。下面是 Java 8 中 HashMap 底层源码中用于计算哈希值的代码:

1
2
3
4
5
static final int hash(Object key) {
int h;
// 调用 key.hashCode() 生成初步哈希值,然后进行扰动处理
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
  • key.hashCode():调用键对象的 hashCode() 方法,生成初步的哈希值 h
  • h >>> 16:将初步哈希值 h 向右无符号移动 16 位,目的是为了引入哈希值的高位信息。
  • h ^ (h >>> 16):将初步哈希值 h 和其右移 16 位后的结果进行异或操作。这样做是为了混合哈希值的高位和低位,以生成一个更加随机和均匀分布的哈希值。

上述步骤生成的哈希值,将用于确定键在 HashMap 中存储的位置,即哈希桶的位置(数组的位置)。具体位置的计算方式如下:

1
2
// 使用按位与运算替代取模运算,这样更高效
int index = (n - 1) & hash

其中,n 是 HashMap 底层数组的长度(总是 2 的幂),hash 是经过扰动处理后的哈希值。(n - 1) & hash 通过按位与运算将哈希值限制在数组的索引范围内。

总结

HashMap 底层的哈希算法通过键对象的 hashCode() 方法生成初步哈希值,并通过扰动处理(即将哈希值与其右移 16 位后的结果进行异或运算)使哈希值分布更加均匀,从而有效减少哈希冲突,提升整体性能。这是 HashMap 在 Java 中实现高效键值对存储和检索的关键机制。

HashTable 底层原理

HashTable 如何实现线程安全

HashTable 使用同步方法来保证线程安全,也就是所有关键操作方法(如 get()put()remove() 等)都使用了 synchronized 关键字进行同步,确保同一时刻只有一个线程可以执行这些方法,这也就保证了线程安全。

ConcurrentHashMap 底层原理

为什么需要 ConcurrentHashMap

思考

为什么已经有 HashTable 了,JDK 还会提供 ConcurrentHashMap 这个线程安全的类呢?

首先 HashTable 本身是个容器,这也就说明了 HashTable 本身可以不断的变大,试想一下,HashTable 如果本身存储 1000 个元素,那么在调用 get() 方法时,就会将这 1000 个元素完全锁住,期间其他任何线程都得等待。这样就会造成容器越大,对容器数据操作的效率就越低。为了解决这个问题,JDK 提供了 ConcurrentHashMap 类,其实 ConcurrentHashMap 的底层也是通过 synchronized 关键字来实现线程安全的,不同于 HashTable 的是 ConcurrentHashMap 在线程同步上更加细分化,它不会像 HashTable 那样一次性将所有数据都锁住,而是采用 “分段锁” 思想。

ConcurrentHashMap 如何实现线程安全

思考

这里需要知道,ConcurrentHashMap 底层数据结构的实现其实和 HashMap 没有多大区别,都是数组 + 链表 + 红黑树。那怎么将 HashMap 的数据结构使用分段锁的思想使其在线程同步上更加细分化呢?

JDK 1.7 中的实现

在 JDK 1.7 及以前,是这样实现的。比如容器 HashMap 中存在 1000 个元素,各个元素都放置到 HashMap 数组的链表或者红黑树中,最后得到的数组大小可能只有 128。ConcurrentHashMap 会根据这 128 个数组元素对其分段,比如以 16 个数组元素为一段,可以分为 8 段。在实际获取和添加元素时,首先会根据哈希算法计算得到元素的索引,然后找到该元素所处的段位,然后只将该段位锁住,并不影响其他段位的数据操作。这样,如果按照 HashTable 的效率为基本单位来计算,ConcurrentHashMap 在 JDK 1.7 及以前的效率会提高 8 倍,当然数据量越大,提高的效率将越多。

JDK 1.8 中的实现

在 JDK 1.8 及以后,ConcurrentHashMap 依旧使用分段锁的思想来实现线程安全,但不同于 JDK 1.7 及以前的实现,JDK 1.8 将锁的粒度更加细分化,以每个数组索引为锁来进行实现。比如 HashMap 中数组的长度为 128,那么就会存在 128 个锁将每个数组元素锁住,这样在并发效率上有了很大的提升。