王炸!!!万字图文说透HashMap

根据哈希值去存储对象,大多情况可以根据哈希值直接定位到元素存储的桶位置,因此具有很快的访问速度。

但由于存储的对象是根据自身的哈希值进行存储,所以HashMap并不是有序的集合,这导致了遍历HashMap的顺序不确定。

HashMap只允许一条记录的键为null,允许多条记录的值为null。

这里的允许有一条记录的键为null,这指的是在整个HashMap中,只能有一个键为null的记录。而不是一个桶中只允许一条记录的键为null。

将一个键为null的映射放入HashMap时,HashMap会将这个键值对存储在一个特定的桶中,通常是数组的第一个位置,因为null的哈希码被定义为0。如果你再次插入一个键为null的键值对,新的值将会替换掉旧的值,因为HashMap不允许重复的键。

HashMap并不是线程安全的,当并发场景下同时写HashMap,可能会导致数据的不一致。

HashMap的实现:

jdk1.7:

HashMap里面是一个数组,数组中的每个元素时单向链表。

HashMap的参数:

上图中,每个绿色的实体都是Entry类的实例,包含四个属性:

key value hash值 next指针

jdk1.8:

其数据结构和jdk1.7最大的不同就是使用了红黑树,所以其结构由数组+链表+红黑树组成。

在jdk1.7中,使用HashMap查找元素的时候,根据 哈希值我们能够快速定位到数组的具体下标,但是之后的话,需要顺着链表一个个比较下去才能找到我们需要的,时间复杂度取决于链表的长度,为 O ( n )。为了降低这部分的开销,在Java8中,当链表中的元素超过了8个以后,会将链表转换为红黑树,在这些位置进行查找的时候可以降低时间复杂度为 O ( logN )。

拉链发导致的链表过深问题为什么不用二叉查找树,而使用红黑树,为什么不一开始就是用红黑树:

之所以选择红黑树是为了解决二叉査找树的缺陷,二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。

而红黑树在插入新数据后可能需要通过左旋,右旋、变色这些操作来保持平衡,引入红黑树就是为了查找数据快,解决链表查询深度的问题。

我们知道红黑树属于平衡二叉树,但是为了保持“平衡”是需要付出代价的,但是该代价所损耗的资源要比遍历线性链表要少,所以当长度大于8的时候,会使用红黑树。

如果链表长度很短的话,根本不需要引入红黑树,引入会因为损耗的资源被放大反而会慢。

红黑树有哪些特点:

每个节点非红即黑

根节点总是黑色的

如果节点是红色的,则它的子节点必须是黑色的,反之不一定

从根节点到叶节点或空子节点的每条路径,必须包含相同数目的黑色节点(即相同的黑色高度)

HashMap的主要参数:

DEFAULT _ INITIAL _ CAPACITY :

默认的初始化容量,1<<4位运算的结果是16,也就是默认的初始化容量为16。当然如果对要存储的数据有一个估计值,最好在初始化的时候显示的指定容量大小,减少扩容时的数据搬移等带来的效率消耗。同时,容量大小需要是2的整数倍。

MAXIMUM _ CAPACITY :

容量的最大值,1<<30位,2的30次幂。

DEFAULT _ LOAD _ FACTOR :

默认的加载因子,设计者认为这个数值是基于时间和空间消耗上最好的数值。这个值和容量的乘积是一个很重要的数值,也就是阈值,当达到这个值时候会产生扩容,扩容的大小大约为原来的二倍。

TREEIFY _ THRESHOLD :

因为jdk8以后HashMap 底层的存储结构改为了数组+链表+红黑树的存储结构(之前是数组+链表),刚开始存储元素产生碰撞时会在碰撞的数组后面挂上一个链表,当链表长度大于这个参数时,链表就可能会转化为红黑树,为什么是可能后面还有一个参数,需要他们两个都满足的时候才会转化。

UNTREEIFY _ THRESHOLD :

介绍上面的参数时,我们知道当长度过大时可能会产生从链表到红黑树的转化,但是,元素不仅仅只能添加还可以删除,或者另一种情况,扩容后该数组槽位置上的元素数据不是很多了,还使用红黑树的结构就会很浪费,所以这时就可以把红黑树结构变回链表结构,什么时候变,就是元素数量等于这个值也就是6的时候变回来(元素数量指的是一个数组槽内的数量,不是 HashMap 中所有元素的数量)。

MIN _ TREEIFY _ CAPCITY :

链表树化的一个标准,前面说过当数组槽内的元素数量大于8时可能会转化为红黑树,之所以说是可能就是因为这个值,当数组的长度小于这个值的时候,会先去进行扩容,扩容之后就有很大的可能让数组槽内的数据可以更分散一些了,也就不用转化数组槽后的存储结构了。当然,长度大于这个值并且槽内数据大于8时,那就转化为红黑树吧。

HashMap是如何计算hashCode的:

要将某个对象存入HashMap中,需要先计算对象的哈希值。才能确定它所存入的位置。

最简单的方法是将对象的哈希值去和HashMap的容量进行取模运算,但是“取模运算”的效率其实是偏低的。需要有一个更快速,消耗更小的方法。

在真实的源码中,具体实现是使用了两个方法

jdk1.7:

int hash(Object k) 方法

用于计算对象的哈希值(并没有使用JAVA默认的计算哈希值的方法)

该计算哈希值的方法能够减少哈希冲突并且能够更好地均匀地将键值对分布在桶之间,源码分析:

  1. h ^= (h >>> 20) ^ (h >>> 12);
    • 这行代码使用了位运算符 ^ (XOR,异或) 和无符号右移运算符 >>>。
    • h >>> 20 和 h >>> 12 是将原始 hashCode 向右无符号移动 20 位和 12 位。这样做的目的是将原始 hashCode 的高位信息也参与到低位的运算中,提高了低位的随机性。
    • 异或操作可以将高位和低位的信息混合,增加哈希值的随机性,减少冲突。
  1. return h ^ (h >>> 7) ^ (h >>> 4);
    • 这一行继续对 h 进行位运算,进一步增加随机性。
    • h >>> 7 和 h >>> 4 是将 h 向右无符号移动 7 位和 4 位,这样做可以让 h 的中间位也参与到最终哈希值的计算中。
    • 最终返回的哈希值是原始 hashCode 的不同位段的混合,这样可以使得哈希值在桶中更加均匀分布。

通过扰乱和重新分配 hashCode 的位,HashMap 的 hash() 方法能够使得即使是质量不高的原始 hashCode 也能够在 HashMap 中更加均匀地分布,从而减少冲突,提高查找效率。这种方法特别在大型数据集中表现出显著的性能提升。

int indexFor(int h,int length)方法(即扰动函数):

根据对象的哈希值h和数组长度length计算该对象在HashMap数组中的存储索引。

源码分析:

  1. return h & (length-1);
    • 这行代码使用了位运算符 & (AND,与)。
    • length 在HashMap中总是2的幂次方,这是在HashMap内部通过扩容机制保证的。
    • 当我们对一个数取模2^n时,可以简化为与2^n-1进行位与运算(h % 2^n 等价于 h & (2^n - 1)),这是因为2^n在二进制下是1后面跟着n个0,2^n-1就是n个1,与运算就相当于取h的低n位。
    • 这种位运算比起普通的取模运算效率要高得多,因为取模运算是CPU中较慢的操作之一。

indexFor方法通过位运算代替了取模运算,这在计算上更为高效。在普通的取模运算中,当模数不是2的幂次时,计算过程涉及到除法,这在计算机中是一个相对较慢的操作。而HashMap的设计中,数组的长度总是2的幂次,这样就可以用简单的位运算来快速地计算索引,大大提高了计算效率。

jdk1.8:

indexFor方法在JDK 1.7中被使用,到了JDK 1.8已经不存在,其功能被内嵌在其他方法中,但计算索引的基本原理仍然相同,确定元素存储位置的代码片段是这样:

即使用哈希值与数组长度减一的结果进行位与运算来确定元素的存储位置。

对于哈希值的计算,jdk1.8对hash()方法又进行了优化:

  1. 这段代码首先检查键是否为null,如果是,则直接返回哈希值0。这是因为HashMap允许键为null。
  2. 如果键不是null,则调用key.hashCode()获取原始哈希码h。
  3. 然后将h与h无符号右移16位后的结果进行异或运算(^)。这一步的目的是将高位和低位的信息混合,进一步降低哈希冲突的概率。

与JDK 1.7相比,JDK 1.8中的这些改进使得HashMap在处理大量数据时更加高效。通过混合高位和低位的哈希码信息,可以进一步降低哈希冲突的概率。此外,引入红黑树以代替长链表不仅减少了查找时间,也使得HashMap的性能在极端情况下更加稳定。这些优化共同提高了HashMap在不同情况下的性能,使其成为Java中使用最广泛的数据结构之一。

HashMap初始化设置长度时,容量会如何初始化:

默认情况下,HashMap初始的容量为16,但是,如果用户通过构造函数指定了一个数字作为容量,如23,35等,那么HashMap会选择大于该数字的第一个2的幂作为容量(用户设置23-->初始化为32、用户设置35-->初始化为64)

在jdk1.7和jdk1.8中,HashMap初始化这个容量的时机不同。jdk1.8中,在调用HashMap的构造函数定义HashMap时就会进行容量的设定。而在jdk1.7中,要等到第一次put操作时才进行这一初始化容量操作。

为什么HashMap要进行扩容的:

首先要明确为什么会发生扩容。哈希表的存在就是一个目的:便于表内元素快速地访问,插入和删除。

假设现在HashMap中的元素已经很多了,但是现在每一个桶中的链化已经比较严重了,哪怕是已经树化了,时间复杂度也没有O(1)好。因为桶内累积过多的键值对,导致在该桶中的操作变慢。为了维持HashMap的操作效率,需要控制桶的负载,即桶中元素的数量与桶总数的比例。这个比例被称为负载因子。一旦负载因子超过预定的阈值(例如,Java中默认为0.75),就需要进行扩容操作。

扩容包括以下几个目的:

  1. 减少哈希碰撞: 通过增加桶的数量,扩容可以降低哈希碰撞的概率,从而减少链表长度,这对于基于链表的桶来说尤其重要。
  2. 保持时间复杂度: 扩容后,理想情况下,HashMap的常见操作(如get和put)可以保持接近常数时间复杂度(O(1)),即使在最坏的情况下也能保持较好的性能。
  3. 空间换时间: 扩容是一个典型的空间换时间的策略,通过使用更多的内存空间来创建更大的哈希表,以期望获得更快的操作速度。
  4. 优化数据分布: 通过重新计算现有键的哈希值,并在新的、更大的哈希表中重新分配位置,可以优化键值对在桶中的分布,使其更加均匀。
  5. 提高性能: 在长期运行的系统中,数据量持续增长是很常见的。扩容可以提前预防性能瓶颈的出现,确保系统即便在数据量大幅增加时仍能保持良好的性能。

HashMap是如何进行扩容的:

HashMap扩容是一个涉及到重新分配内部数据结构的过程,以便容纳更多元素而不会显著降低性能。这个过程通常涉及以下步骤:

  1. 触发条件:
    扩容通常在插入操作中被触发,当HashMap中的元素数量超过当前容量与负载因子的乘积时。例如,如果负载因子是0.75,而数组长度是16,那么当我们尝试插入第13个元素时就会触发扩容。
  2. 创建新数组:
    创建一个新的内部数组,其容量通常是原数组容量的两倍。这是为了保持扩容的指数增长,从而保持哈希表的性能。
  3. 重新哈希:
    对于原数组中的每一个元素,使用新的数组长度重新计算它们的索引。这通常涉及到重新应用哈希函数或者对哈希值进行调整,以便于在新的数组中找到它们的位置。
  4. 元素迁移:
    将所有旧数组中的元素根据新计算出的索引放入新数组中。如果HashMap使用链表来解决哈希冲突,那么需要将链表中的每个元素都重新哈希并插入到新的桶中。
  5. 更新内部状态:
    更新HashMap的内部状态,包括新的容量、阈值和任何其他必要的元数据,以反映新的内部数组。
  6. 释放旧数组:
    一旦元素迁移完成,旧的数组将不再被使用,可以被垃圾收集器回收。

在这个过程中,最关键的步骤是重新哈希和元素迁移,因为它们决定了新数组中元素的分布。如果这两步不恰当,可能会导致性能问题,比如哈希碰撞的增加。

扩容是一个代价较高的操作,因为它涉及到遍历所有元素并重新计算它们的位置。因此,HashMap的初始化容量和负载因子的选择非常重要,它们可以帮助减少扩容的频率,从而优化整体性能。

为什么HashMap扩容时,容量都一定会变为2的幂次:

这个设计选择主要是为了优化哈希表的性能,具体原因涉及到如何高效地定位元素在数组中的位置(即计算索引)以及如何均匀地分布这些元素以减少哈希冲突。

高效的索引计算

在HashMap中,通过哈希值来确定元素在数组中的位置。如果数组的长度是2的幂次方,那么计算索引的过程可以简化为使用位运算,而不是取模运算。位运算比取模运算要快得多,因为取模运算(除法)在硬件层面上是更加复杂和耗时的。

均匀的元素分布

为了减少哈希冲突,HashMap需要尽可能均匀地将元素分布在数组中。当数组的长度是2的幂次方时,通过上述的位与运算得到的索引可以保证如果哈希值均匀分布,那么元素在数组中的位置也会尽可能均匀。这是因为这种方法实际上是使用哈希值的低位来决定元素的位置,如果哈希函数设计得好(即能够产生均匀分布的哈希值),那么元素的分布也会比较均匀。

扩容过程的数据迁移

在HashMap进行扩容时,会创建一个新的数组,其长度是原数组长度的两倍。为了将旧数组中的元素迁移到新数组,每个元素的新索引位置要么与在旧数组中的位置相同,要么在旧位置的基础上加上旧数组的长度。这种计算新索引的方法非常高效,因为它利用了长度为2的幂次方的特性,只需要检查哈希值的某一位即可决定是保持原位置还是移动到新位置。

HashMap的容量设置为多少合适:

参考Hollis的八股文:

其他场景和对应的建议:

1. 预估数据量

在使用HashMap之前,首先需要估计将存储在其中的元素数量。这是因为HashMap的性能大部分依赖于哈希表的负载因子和容量。

  • 小到中等数据量:如果数据量不是特别大(例如几百到几千条记录),可以使用默认的初始化容量(如Java中默认是16)和默认负载因子(如Java中默认是0.75)。这种情况下,HashMap可能会进行少量的扩容,但对性能的影响不大。
  • 大数据量:如果预计要存储大量元素(例如数十万或更多),则应该提前计算并设置一个较大的初始容量。这可以减少扩容操作的次数,每次扩容都需要重新计算所有键的哈希值并重新分配内存,这是一个成本较高的操作。

2. 内存与性能权衡

HashMap的容量和负载因子是内存使用和性能之间权衡的两个主要参数。

  • 高负载因子:较高的负载因子(如0.75或更高)意味着更高的空间效率,但增加了冲突的可能性,可能会导致性能下降。适用于内存使用受限的环境。
  • 低负载因子:较低的负载因子(如0.5)会使用更多的内存空间,但减少了哈希冲突,通常可以提供更快的查找性能。适用于对性能要求较高的应用。

3. 应用场景

不同的应用场景对HashMap的配置要求不同:

  • 读多写少:如果应用主要是读操作,可以适当增大初始容量,减少运行时的扩容需求,从而优化读取性能。
  • 写多读少:如果频繁更新HashMap,则应关注写入性能,适当调整负载因子,避免频繁扩容。
  • 内存敏感型应用:如果应用对内存使用非常敏感,可以选择较高的负载因子,以减少内存占用,但需要注意这可能会牺牲一些性能。

如果HashMap的大小超过了负载因子(load factor)定义的容量怎么办:

比如说当前的容器容量是16,负载因子是0.75,16*0.75=12,也就是说,当容量达到了12的时候就会进行扩容操作。当负载因子是1.0的时候,也就意味着,只有当数组的8个值(这个图表示了8个)全部填充了,才会发生扩容。这就带来了很大的问题,因为 Hash 冲突时避免不了的。当负载因子是1.0的时候,意味着会出现大量的 Hash 的冲突,底层的红黑树变得异常复杂。对于查询效率极其不利。这种情况就是牺牲了时间来保证空间的利用率。因此一句话总结就是负载因子过大,虽然空间利用率上去了,但是时间效率降低了。

负载因子是0.5的时候,这也就意味着,当数组中的元素达到了一半就开始扩容,既然填充的元素少了, Hash 冲突也会减少,那么底层的链表长度或者是红黑树的高度就会降低。查询效率就会增加。

但是,兄弟们,这时候空间利用率就会大大的降低,原本存储1M的数据,现在就意味着需要2M的空间。

一句话总结就是负载因子太小,虽然时间效率提升了,但是空间利用率降低了。大致意思就是说负载因子是0.75的时候,空间利用率比较高,而且避免了相当多的 Hash 冲突,使得底层的链表或者是红黑树的高度比较低,提升了空间效率。

HashMap中的remove方法如何实现

1.首先, remove 方法会计算键的哈希值,并通过哈希值计算出在数组中的索引位置。

2.如果该位置上的元素为空,说明没有找到对应的键值对,直接返回 null 。

3.如果该位置上的元素不为空,检查是否与当前键相等,如果相等,那么将该键值对删除,并返回该键值对的值。

4.如果该位置上的元素不为空,但也与当前键不相等,那么就需要在链表或红黑树中继续查找。

5.遍历链表或者红黑树,查找与当前键相等的键值对,找到则将该键值对删除,并返回该键值对的值,否则返回 null .

有哪些解决哈希碰撞的方法:

哈希碰撞,即两个不同的对象经过哈希函数处理后得到了相同的哈希值。

以下是几种解决哈希碰撞的常见方法:

  1. 链地址法(Separate Chaining)
    方法描述:链地址法是通过在每个哈希桶中维护一个链表来解决碰撞问题。如果两个元素的哈希值相同,它们将被存储在同一个哈希桶的链表中。

例子:假设有两个字符串键 "cat" 和 "dog" 经过哈希函数后都得到了哈希值 5。在使用链地址法的 HashMap 中,这两个键将被存储在哈希表索引为 5 的位置的链表中。如果我们首先插入 "cat",然后插入 "dog",那么 "dog" 将被添加到链表的头部或尾部,取决于具体实现。

  1. 开放地址法(Open Addressing)
    方法描述:开放地址法是另一种解决碰撞的方法,它在发生碰撞时寻找哈希表中的另一个空闲位置。这种方法包括线性探测、二次探测和双重散列等变体。

例子:以线性探测为例。如果 "cat" 和 "dog" 的哈希值都是 5,但位置 5 已经被 "cat" 占用,那么 "dog" 将尝试位置 6。如果位置 6 也被占用,那么接下来会尝试位置 7,以此类推,直到找到一个空位。

  1. 双重散列(Double Hashing)
    方法描述:双重散列是开放地址法的一种,它使用两个哈希函数来计算索引,当发生碰撞时,会使用第二个哈希函数来计算步长。

例子:如果 "cat" 和 "dog" 的哈希值都是 5,我们首先尝试将 "cat" 存储在位置 5。当我们尝试存储 "dog" 时,发现位置 5 已经被占用,我们将使用第二个哈希函数来计算一个步长,假设步长为 3,那么我们将尝试位置 5 + 3 = 8。如果位置 8 也被占用,我们再次加上步长,尝试位置 11,以此类推。

  1. 再散列(Rehashing)
    方法描述:再散列是在哈希表达到一定的负载因子后,增加哈希表的大小,并重新计算每个元素的哈希值,将它们存储到新的位置。

例子:假设我们的 HashMap 初始大小为 8,当我们不断添加元素,直到达到负载因子的阈值(例如 0.75),那么 HashMap 将增加到更大的大小,比如 16。这时,所有已存储的键将根据新的表大小重新计算哈希值并分配到新的位置。

  1. 使用良好的哈希函数
    方法描述:一个设计良好的哈希函数可以最大程度地减少哈希碰撞的可能性。这样的函数会尽量将输入均匀分布在哈希表的所有位置上。

例子:假设我们有一个简单的哈希函数,它只是返回字符串长度作为哈希值。这个函数会导致所有长度相同的字符串发生碰撞。一个更好的哈希函数会考虑字符串的每个字符,并根据所有字符生成一个近似均匀分布的哈希值。

HashMap使用的是什么方法缓解的哈希冲突:

1. 使用更好的哈希函数结合扰动函数使元素分布均匀,减少碰撞几率

2. 使用链表和红黑表的结合,数组的每个槽位(或称为“桶”)可以存储一个链表/红黑树的头节点。当多个元素哈希到同一个桶时,这些元素将会形成一个链表/红黑树存储在该桶中。

HashMap的并发场景存在的问题:

这个问题只会出现在1.7及之前的版本,1.8后这个问题便被修复了

如果两个线程都发现 HashMap 需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,若单个桶内的元素次序原本为A->B->C,扩容后会变成C->B->A。如下图:

扩容时使用头插法的原因是jdk的开发者认为后插入的数据被使用到的概率更高,容易成为热点数据,将后插入的数据放入队列头部,会使得热点数据查询效率更高。

正是这种头插法的操作,在多线程并发扩容时,会带来死循环的问题。后期会将图加上

除了并发死循环,HashMap在并发环境下还会存在的问题:

1. 多线程put的时候,size的个数和真正的个数不一样

2 .多线程put的时候,可能会把上一个put的值覆盖掉

3. 和其他不支持并发的集合一样,HashMap也采用了fail-fast操作,当多个线程同时put和get的时候,会抛出并发异常

4. 当既有get操作,又有扩容操作的时候,有可能数据刚好被扩容换了桶,导致get不到数据

  • 22
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值