HashMap的底层原理

本文深入探讨了HashMap的实现原理,包括其数组+链表+红黑树的数据结构,以及HashMap和HashTable的区别。重点阐述了HashMap的哈希算法、扩容机制和线程安全性,并给出了操作HashMap时的注意事项,如初始化容量的选择和负载因子的考量。此外,还介绍了JDK1.8引入红黑树如何优化性能。
摘要由CSDN通过智能技术生成


HashMap概述

  1. HashMap 是 Map 接口的实现,HashMap 允许空的 key-value 键值对,HashMap 被认为是 Hashtable 的增强版,HashMap 是一个非线程安全的容器,如果想构造线程安全的 Map 考虑使用 ConcurrentHashMapHashMap 是无序的,因为 HashMap 无法保证内部存储的键值对的有序性。

  2. HashMap 的底层数据结构是数组 + 链表的集合体,数组在 HashMap 中又被称为桶(bucket)。遍历 HashMap 需要的时间损耗为 HashMap 实例桶的数量 + (key - value 映射) 的数量。因此,如果遍历元素很重要的话,不要把初始容量设置的太高或者负载因子设置的太低。

  3. HashMap 实例有两个很重要的因素,初始容量和负载因子,初始容量指的就是 hash 表桶的数量,负载因子是一种衡量哈希表填充程度的标准,当哈希表中存在足够数量的 entry,以至于超过了负载因子和当前容量,这个哈希表会进行 rehash 操作,内部的数据结构重新 rebuilt。

  4. 注意 HashMap 不是线程安全的,如果多个线程同时影响了 HashMap ,并且至少一个线程修改了 HashMap 的结构,那么必须对 HashMap 进行同步操作。可以使用 Collections.synchronizedMap(new HashMap) 来创建一个线程安全的 Map。

  5. HashMap 会导致除了迭代器本身的 remove 外,外部 remove 方法都可能会导致 fail-fast 机制,因此尽量要用迭代器自己的 remove 方法。如果在迭代器创建的过程中修改了 map 的结构,就会抛出 ConcurrentModificationException 异常。

HashMap的实现原理

HashMap基于Hash算法实现的,我们通过put(key,value)存储,get(key)来获取。 当传入key时,
HashMap会根据key. hashCode()计算出hash值,根据hash值将value保存在bucket里。当计算出
的hash值相同时,我们称之为hash冲突,HashMap的做法是用链表和红黑树存储相同hash值的
value。当hash冲突的个数比较少时,使用链表否则使用红黑树。

HashMap的底层实现原理?

从结构实现来讲,HashMap是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下如所示。
在这里插入图片描述
这里需要知道两个问题:

  1. 数据底层具体存储的是什么?
  2. 这样的存储方式有什么优点呢?

(1)首先从源码可知,HashMap类中有一个非常重要的字段,就是Node[] table,即哈希桶数组,明显它是一个Node的数组。我们来看Node[JDK1.8]是何物。
在这里插入图片描述
Node是HashMap的一个内部类,实现了Map.Entry接口,本质是就是一个映射(键值对)。上图中的每个黑色圆点就是一个Node对象。

(2)HashMap就是使用哈希表来存储的。哈希表为解决冲突,可以采用开放地址法和链地址法等来解决
问题,Java中HashMap采用了链地址法。链地址法,简单来说,就是数组加链表的结合。在每个数组元素上都一个链表结构,当数据被Hash后,得到数组下标,把数据放在对应下标元素的链表上。例如程序执行下面代码:

map.put("张三""狂徒")
  1. 系统将调用”张三”这个key的hashCode()方法得到其hashCode值(该方法适用于每个Java对象),然后再通过Hash算法的后两步运算(高位运算和取模运算)来定位该键值对的存储位置,有时两个key会定位到相同的位置,表示发生了Hash碰撞。当然Hash算法计算结果越分散均匀,Hash碰撞的概率就越小,map的存取效率就会越高。
  2. 如果哈希桶数组很大,即使较差的Hash算法也会比较分散,如果哈希桶数组数组很小,即使好的Hash算法也会出现较多碰撞,所以就需要在空间成本和时间成本之间权衡,其实就是在根据实际情况确定哈希桶数组的大小,并在此基础上设计好的hash算法减少Hash碰撞。
  3. 那么通过什么方式来控制map使得Hash碰撞的概率又小,哈希桶数组(Node[] table)占用空间又少呢?答案就是好的Hash算法和扩容机制。
  4. 在理解Hash和扩容流程之前,我们得先了解下HashMap的几个字段。从HashMap的默认构造函数源码可知,构造函数就是对下面几个字段进行初始化,源码如下:
    在这里插入图片描述
  • 首先,Node[] table的初始化长度length(默认值是16), Load factor为负载因子(默认值是0.75)
    threshold是HashMap所能容纳的最大数据量的Node(键值对)个数threshold = length * Load factor

  • 也就是说,在数组定义好长度之后,负载因子越大,所能容纳的键值对个数越多。
    结合负载因子的定义公式可知,threshold就是在此Load factorlength(数组长度)对应下允许的最大元素数目,超过这个数目就重新resize(扩容),扩容后的HashMap容量是之前容量的两倍。默认的负载因子0.75是对空间和时间效率的一一个平衡选择,建议大家不要修改,除非在时间和空间比较特殊的情况下,如果内存空间很多而又对时间效率要求很高,可以降低负载因子Load factor的值;相反,如果内存空间紧张而对时间效率要求不高,可以增加负载因子loadFactor的值,这个值可以大于1。

  • size这个字段其实很好理解,就是HashMap中实际存在的键值对数量。注意和table的长度length、容纳最大键值对数量threshold区别。而modCount字段主要用来记录HashMap内部结构发生变化的次数,主要用于迭代的快速失败。强调一点, 内部结构发生变化指的是结构发生变化,例如put新键值对,但是某个key对应的value值被覆盖不属于结构变化。

  • 在HashMap中,哈希桶数组table的长度length大小必须为2的n次方(-定是合数),这是一种非常规的设计,常规的设计是把桶的大小设计为素数。相对来说素数导致冲突的概率要小于合数,Hashtable 初始化桶大小为11,就是桶大小设计为素数的应用(Hashtable扩 容后不能保证还是素数)。HashMap采用这种非常规设计,主要是为了在取模和扩容时做优化同时为了减少冲突,HashMap定位哈希桶索引位置时,也加入了高位参与运算的过程。

  • 这里存在一个问题,即使负载因子和Hash算法设计的再合理,也免不了会出现拉链过长的情况,-旦出现拉链过长,则会严重影响HashMap的性能。

  • 于是,在DK1.8版本中,对数据结构做了进一步的优化,引入了红黑树。而当链表长度太长(默认超过8)时,链表就转换为红黑树,利用红黑树快速增删改查的特点提高HashMap的性能,其中会用到红黑树的插入、删除、查找等算法。

  • 功能实现-方法
    HashMap的内部功能实现很多,本次主要从根据key获取哈希桶数组索引位置、put方法的详细执行、扩容过程三个具有代表性的点深入展开讲解。

  1. 确定哈希桶数组索引位置
    不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap的数据结构是数组和链表的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表,大大优化了查询的效率。HashMap定位数组索引位置,直接决定了hash方法的离散性能。先看看源码的实现(方法一+方法二):
    在这里插入图片描述
  • 这里的Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算
  • 对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用方法一所计算得到的Hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用方法二来计算该对象应该保存在table数组的哪个索引处。
  • 这个方法非常巧妙,它通过h&(table.length-1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
  • 在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的 高16位异或低16位实现的:(h = k.hashCode() ^(h>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
    下面举例说明下,n为table的长度。
    在这里插入图片描述
    2.HashMap的put方法
    HashMap的put方法执行过程可以通过下图来理解。
    在这里插入图片描述
  1. ①.判断键值对数组table[i]是否为空或为null,否则执行resize()进行扩 容;
  2. ②.根据键值key计算hash值得到插入的数组索引i,如果table[i]== null,直接新建节点添加,转向⑥,如果table[i]不为空,转向③;
  3. ③.判断table[i]的首个元素是否和key-样,如果相同直接覆盖value,否则转向④,这里的相同指的是
    hashCode以及equals; .
  4. ④.判断table[i]是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对,否则转向⑤;
  5. ⑤.遍历table[i],判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现key已经存在直接覆盖value即可;
  6. ⑥.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,进行扩容。

JDK1.8HashMap的put方法源码如下:
在这里插入图片描述
在这里插入图片描述
3.扩容机制
扩容(resize)就是重新计算容量,向HashMap对象里不停的添加元素,而HashMap对象内部的数组无法装载更多的元素时,对象就需要扩大数组的长度,以便能装入更多的元素。当然Java里的数组是无法自动扩容的,方法是使用一个新的数组代替已有的容量小的数组,就像我们用一个小桶装水,如果想装更多的水,就得换大水桶。
我们分析下resize的源码,鉴于JDK1.8融入了红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,好理解一些,本质上区别不大。
在这里插入图片描述这里就是使用一个容量更大的数组来代替已有的容量小的数组,transfer()方法将原有Entry数组的元素拷贝到新的Entry数组里。
在这里插入图片描述

  • newTable[i]的引用赋给了e.next,也就是使用了单链表的头插入方式,同一位置上新元素总会被放在链表的头部位置;这样先放在一个索引上的元素终会被放到Entry链的尾部(如果发生了hash冲突的话),这一点和Jdk1.8有区别。在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。
  • 下面举个例子说明下扩容过程。假设了我们的hash算法就是简单的用key mod一下 表的大小(也就是数组的长度)。其中的哈希桶数组table的size=2,所以key=3、 7、5,put顺序依次为5、7、3。在mod2以后都冲突在table[1]这里了。这里假设负载因子loadFactor=1,即当键值对的实际大小size大于table的实际大小时进行扩容。接下来的三个步骤是哈希桶数组resize成4,然后所有的Node重新rehash的过程。
    在这里插入图片描述
    下面我们讲解下JDK1.8做了哪些优化。经过观测可以发现,我们使用的是2次幂的扩展(指长度扩为原来2倍),所以,元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。看下图可以明白这句话的意思,n为table的长度,图(a) 表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。
    在这里插入图片描述

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

HashMap 和 HashTable 的区别

HashMap及其他hash类

HashMap操作注意事项以及优化?

(1)扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个 大致的数值,避免map进行频繁的扩容。
(2)负载因子是可以修改的,也可以大于1,但是建议不要轻易修改,除非情况非常特殊。
(3) HashMap是线程不安全的,不要在并发的环境中同时操作HashMap,建议使ConcurrentHashMap。
(4) JDK1.8引入红黑树大程度优化了HashMap的性能。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值