HashMap实现原理

HashMap在实际开发中用到的频率非常高,面试中也是热点。所以决定写一篇文章进行分析,希望对想看源码的人起到一些帮助,看之前需要对链表比较熟悉。
以下都是我自己的理解,欢迎讨论,写的不好轻喷。


一、为什么需要散列表

HashMap中的数据结构为散列表,又名哈希表。在这里我会对散列表进行一个简单的介绍,在此之前我们需要先回顾一下 数组链表 的优缺点。

  • 数组数组删除、插入性能不佳,寻址性能极优
  • 链表链表查询性能不佳,删除、插入性能极优

数组和链表的优缺点取决于他们各自在内存中存储的模式,也就是直接使用顺序存储链式存储导致的。无论是数组还是链表,都有明显的缺点。而在实际业务中,我们想要的往往是寻址、删除、插入性能都很好的数据结构,散列表就是这样一种结构,它巧妙的结合了数组与链表的优点,并将其缺点弱化(并不是完全消除)


二、散列表原理

  • 散列表:散列表是一个根据key来访问value的存储结构,HashMap中实现的散列表是一个链表类型的数组,即数组+链表,用来存储key-value数据对。Ps:在1.8及以后版本的jdk中,HashMap中还加入了红黑树,关于这点后文会细说。

哈希函数与下标的计算

散列表的做法是将key映射到数组的某个下标,存取的时候通过key获取到下标(index)然后通过下标直接存取。速度极快,而将key映射到下标需要使用散列函数,又名哈希函数。说到哈希函数可能有人已经想到了,如何将key映射到数组的下标。

key-index映射过程

图中计算下标使用到了以下两个函数:

  • hash(key):对key进行hash计算,获得一个int类型的hash值 效果参考Object.hashCode()
  • index(hash, N+1):对上面得到的hash值进行计算,获得一个不超过数组大小的下标 效果参考 hash%(N+1)

值得注意的是,下标并不是通过hash函数直接得到的,计算下标还要对hash值做index()处理。
Ps:在散列表中,数组的格子叫做,下标叫做桶号,桶可以包含一个key-value对,为了方便理解,后文不会使用这两个名词。

哈希碰撞与下标冲突

以下是哈希碰撞相关的说明:

  • 哈希碰撞的定义:有a,b两条数据,且a != b,对于这组数据,如果有hash(a) == hash(b),则哈希发生碰撞
  • 原因:hash函数的返回值是一个int类型的数据,int的取值是有范围的,而散列表的key是没有范围的,可以是任何值。将多数key映射到少数hashCode,必然会有多个key对应同一个hashCode的情况。
  • 可能性:许多人认为int的取值范围够大,在实际使用中很少会发生碰撞。其实不然,可以参见生日悖论,简单解释一下生日悖论:23人里有两人同一天生日的概率超过50%。(更加具体的说明请自行搜索“生日悖论”)同理,哈希碰撞的可能性是很大的。
哈希碰撞示意图

以下是下标冲突相关的说明:

  • 下标冲突:与哈希的碰撞类似,在将hashCode映射到数组下标时也是有可能重复的。在往数组的某个下标插入节点的时候发现该下标已经有其他节点,即为下标冲突。
下标冲突示意图

很多人认为哈希值的碰撞和下标冲突是同一个东西,其实不是的,它们的正确关系是这样的,hashCode发生碰撞,则下标一定冲突;而下标冲突,hashCode并不一定碰撞

如何解决碰撞和冲突

上文提到,在jdk1.8以前HashMap的实现是散列表 = 数组 + 链表 ,但是到目前为止我们还没有看到链表起到的作用。事实上,HashMap引入链表的用意就是解决下标冲突。

下图是引入链表后的散列表:

图片来源于百度百科-哈希表词条

如上图所示,左边的竖条,是一个大小为16的数组,其中存储的是链表的头结点,我们知道,拥有链表的头结点即可访问整个链表,所以认为这个数组中的每个下标都存储着一个链表。其具体做法是,如果发现下标冲突,则后插入的节点以链表的形式追加到前一个节点的后面

  • 举个例子,现在假设有节点B要添加到表中,那么遇到冲突后的整个流程是这样的:
    1、计算得到B.key对应的下标为 n
    2、判断 n 下标下是否有值
    3、发现有值,下标冲突,获取到该下标已有的节点A
    4、判断(B.key).equals(A.key)
    5、如果key相等,根据key唯一的特性,B节点覆盖A节点
    6、如果key不等,判断A.next是否有值
    7、如果A.next也有值,回到步骤4对A.next节点进行对比
    8、重复步骤4-7,直到发现key相等的节点,或遍历到链表的最后一个节点X,执行操作X.next = B
    Ps:读取节点的流程与添加类似,先计算下标,然后遍历链表,直到找到对应的key

这种使用链表解决冲突的方法叫做:拉链法(又叫链地址法)。HashMap使用的就是拉链法,拉链法是冲突发生以后的解决方案。

Q:有了拉链法,就不用担心发生冲突吗?
A:并不是!由于冲突的节点会不停的在链表上追加,大量的冲突会导致单个链表过长,使查询性能降低。所以一个好的散列表的实现应该从源头上减少冲突发生的可能性,冲突发生的概率和哈希函数返回值的均匀程度有直接关系,得到的哈希值越均匀,冲突发生的可能性越小。为了使哈希值更均匀,HashMap内部单独实现了hash()方法。


三、HashMap中散列表的实际运用

以上是散列表的存储结构,但是在被运用到HashMap中时还有其他需要注意的地方,这里会详细说明。

扩容与负载因子

现在我们清楚了散列表的存储结构,细心的人应该已经发现了一个问题:Java中数组的长度是固定的,无论哈希函数是否均匀,随着插入到散列表中数据的增多,在数组长度不变的情况下,链表的长度会不断增加。这会导致链表查询性能不佳的缺点出现在散列表上,从而使散列表失去原本的意义。为了解决这个问题,HashMap引入了扩容与负载因子。

以下是和扩容相关的一些概念和解释:

  • 默认容量:HashMap中数组的长度如果不指定,则默认为16
  • 2的n次幂:HashMap在new的时候可以指定数组长度,但不管如何指定,实际长度一定是2的n次幂(16、32、64、128等),举几个例子,指定长度为17或29,实际长度为32,指定为35、57或63,实际长度为64,以此类推。
  • 扩容:随着散列表存储节点的不断增加,散列表中数组的长度也应该增加,为了保证2的n次幂特性,每次扩容都是当前长度*2, 扩容的方法是,新建一个两倍的数组,然后遍历散列表中的所有节点,重新计算下标放入新数组。
  • 负载因子:由于散列存储下标具有不确定性,在数组即将被占满的时候,后续添加会发生大量冲突,为了避免,需要使数组在即将被占满前就扩容,而不是等待数组被占满。负载因子决定具体何时扩容。其默认值是0.75,可以在调用构造器的时候指定。0.75的意思是,在调用put方法时,算上被put的节点,如果当前数组被占用达到75%则进行扩容。

Ps:扩容要重新计算下标扩容要重新计算下标扩容要重新计算下标,因为下标的计算和数组长度有关,长度改变,下标也应当重新计算。

引入红黑树

在1.8及其以上的jdk版本中,HashMap又引入了红黑树。

  • 红黑树:红黑树是一个相对平衡的二叉查找树。平衡二叉树的查找原理和二分查找类似,时间复杂度也一样,都是从有序序列的中间开始查找。此处不做更详细的解释,只需要知道是一个查询效率很高的数据结构即可。

红黑树的引入被用于替换链表,上文说到,如果冲突过多,会导致链表过长,降低查询性能,均匀的hash函数能有效的缓解冲突过多,但是并不能完全避免。所以HashMap加入了另一种解决方案,在往链表后追加节点时,如果发现链表长度达到8,就会将链表转为红黑树,以此提升查询的性能。

你可能感兴趣的:(HashMap实现原理)