嗨,小米的技术小窝又迎来了大家!今天我们要聊的话题是大家在日常开发中经常接触到的数据结构之一——HashMap。这个看似简单的键值对存储结构,背后却蕴含了许多设计和优化的奥秘。废话不多说,让我们一起来揭开HashMap的神秘面纱!

HashMap的实现结构

在HashMap的实现中,我们首先面临的挑战是哈希冲突的问题。当不同的键经过哈希算法得到相同的哈希值时,我们需要一种机制来有效地处理这种冲突,确保数据的正确性和高效性。

为了解决哈希冲突,HashMap采用了链地址法,也称为拉链法。这种方法的核心思想是,在哈希表的每个槽(数组的一个位置)上维护一个链表,当发生哈希冲突时,将具有相同哈希值的键值对存储在同一个链表中。这样,即便发生冲突,我们仍然能够通过遍历链表找到目标元素。

然而,为了进一步提高性能,HashMap的设计并没有仅仅停留在链地址法上。除了数组+链表的结构,当链表的长度达到一定阈值(默认为8)时,HashMap会将该链表转换为红黑树,从而提高查找效率。这种结合了数组和链表(或红黑树)的设计,既利用了链表结构解决哈希冲突的问题,又在特定情况下采用了更高效的红黑树结构。

这样的设计使得HashMap在不同场景下都能够表现出色。链地址法解决了一般情况下的哈希冲突,而数组+链表(红黑树)的结构进一步优化了在链表过长时的性能,保证了HashMap在各种情况下都能够高效地存储和检索键值对。

HashMap的重要属性

在深入了解HashMap的设计和优化之前,我们必须认识到其关键属性对于性能的至关重要性。这里我们将进一步讨论HashMap的重要属性,并解释为什么加载因子被设置为0.75以及阈值默认为12。

首先,初始容量(Initial Capacity)是HashMap创建时数组的大小。选择适当的初始容量对于减少哈希冲突、提高性能至关重要。通常情况下,初始容量选择为2的幂次方,以便在哈希函数计算时能够更均匀地分布在数组中。

其次,加载因子(Load Factor)是用于衡量HashMap容量利用率的参数。为什么加载因子被设置为0.75呢?这是一种平衡性能和内存占用的取舍。当加载因子为0.75时,数组中的桶被使用的情况大致在75%左右,这时进行扩容操作的概率相对较低,从而降低了哈希冲突的发生,提高了整体性能。

阈值(Threshold)是在哈希表中桶的数量超过一定阈值时触发扩容的动态计算值。为什么阈值默认为12呢?这也是一个性能和内存占用的平衡。在JDK中,当哈希表的容量达到12时,就会触发扩容操作。这个值是通过初始容量和加载因子的乘积来计算的。选择12是为了在大多数情况下实现较好的性能,避免频繁的扩容操作。

关于时间复杂度分析,理想情况下,HashMap的增、删、查操作的时间复杂度均为O(1)。然而,在哈希冲突发生时,链表长度过长可能导致查找性能下降至O(n),这时Java8引入了红黑树,将链表长度较长的桶转化为红黑树,将查找的时间复杂度优化为O(log n)。这种动态适应的设计使得HashMap在各种情况下都能够保持高效性能。

HashMap如何添加元素

让我们逐步拆解HashMap源码,深入了解其添加元素的优化过程。下面是HashMap源码中的关键方法 putVal 的拆解。

性能篇,Hashmap的设计与优化?_数组

步骤解析:

  1. 桶位置定位: 通过 (n - 1) & hash 的位运算方式,快速计算桶的位置。如果数组为空或长度为0,调用 resize() 进行初始化或扩容。
  2. 处理空桶: 如果定位的桶位置为空,则直接将新节点添加到该位置。
  3. 处理哈希冲突: 如果桶位置已经存在元素,需要进一步处理哈希冲突。首先检查当前桶的元素是否与待插入元素具有相同的键,如果是,则直接更新该元素的值。
  4. 树化: 当链表长度达到一定阈值(默认8),采用红黑树结构进行优化,提高查找效率。
  5. 链表插入: 如果不满足树化条件,通过循环遍历链表,将新元素插入到链表末尾。在插入时,判断是否达到树化阈值,若是,则调用 treeifyBin() 方法将链表转为红黑树。
  6. 元素更新: 如果发现相同键的元素已存在,根据需求更新元素的值。
  7. 扩容检查: 在插入元素后,检查元素数量是否超过了阈值。若超过,调用 resize() 进行扩容。
  8. 扩容操作: 扩容过程包括创建新数组、重新分配元素到新数组、替换原数组等步骤。保证扩容时能够并发执行,提高性能。
  9. 插入后处理: 在元素插入后,进行一些额外的处理,如更新修改次数,执行 afterNodeInsertion 方法等。

HashMap如何获取元素

让我们逐步拆解HashMap源码,深入了解其获取元素的优化过程。下面是HashMap源码中的 get 方法的拆解。

性能篇,Hashmap的设计与优化?_数组_02

步骤解析:

  1. 哈希计算: 通过 hash(key) 方法计算键的哈希值。
  2. 桶位置定位: 通过 (n - 1) & hash 的位运算方式,快速计算桶的位置。
  3. 处理空桶: 如果桶位置为空,直接返回null。
  4. 检查首节点: 检查桶的首节点是否匹配,如果匹配,则直接返回首节点。
  5. 处理红黑树: 如果首节点为红黑树,调用 getTreeNode 方法进行获取。
  6. 遍历链表: 如果不是红黑树,遍历链表,查找具有相同键的节点。
  7. 节点匹配: 匹配键时,返回对应节点的值。

HashMap如何扩容

在HashMap 1.7版本中,当发生哈希冲突时,即多个元素映射到相同的桶位置形成单向链表时,扩容操作会导致链表中元素的位置变换。具体来说,链表的尾部元素会变成新链表的头部元素。这是因为在扩容过程中,原数组中的每个桶位置都需要重新计算,而元素会被插入到新数组的不同桶位置。这种单向链表头尾变换的策略可能导致在并发操作中链表出现断裂,进而引发数据丢失或不一致性的问题。

HashMap 1.8版本对扩容过程进行了改进,采用了更为灵活的策略,以解决1.7版本中可能出现的问题。

  • 并发扩容机制: 引入了并发扩容机制,允许多个线程同时进行扩容操作,提高了并发性能。这是通过TreeNode的 MOVED 状态和sizing控制来实现的。
  • 链表转红黑树: 在1.8版本中,当链表长度达到一定阈值(默认为8)时,会将链表转化为红黑树。这一步骤是为了提高在链表过长时的查找效率,避免性能下降。
  • 元素重新分配: 在扩容过程中,1.8版本采用了更为灵活的元素重新分配策略。而不再强制将链表中的元素按原顺序重新分配,而是通过重新计算元素的哈希值,将元素重新分散到新的桶位置。这样,原链表中的元素在新数组中的位置不再呈现单向链表的头尾变换,而是根据新的哈希值重新随机分配到新数组的不同桶位置。

END

总结一下,HashMap的设计和优化涉及到多个方面,从初始容量、加载因子到哈希算法、扩容机制,每个方面都值得我们深入研究。在实际开发中,了解HashMap的底层实现原理,并根据具体场景进行适当的优化,将有助于提高程序的性能和效率。

希望今天的分享能够给大家带来一些启发,如果有任何疑问或想要深入了解的地方,欢迎在评论区留言,小米会竭诚为大家解答。感谢大家的支持,我们下期再见啦!

如有疑问或者更多的技术分享,欢迎关注我的微信公众号“知其然亦知其所以然”!

性能篇,Hashmap的设计与优化?_数组_03