实现原理
HashMap概述
HashMap是基于哈希表的Map接口的非同步实现。此实现提供所有可选的映射操作,并允许使用null值和null键。此类不保证它的顺序。
HashMap的数据结构
是一个“链表散列”的数据结构,即数组和链表的结合体。
HashMap 基于 Hash 算法实现的
当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标存储时,
如果出现hash值相同的key,此时有两种情况。
(1)如果key相同,则覆盖原始值;
(2)如果key不同(出现冲突),则将当前的key-value放入链表中获取时,直接找到hash值对应的下标,在进一步
判断key是否相同,从而找到对应值。
理解了以上过程就不难明白HashMap是如何解决hash冲突的问题,核心就是使用了数组的存储方式,然后将冲突
的key的对象放入链表中,一旦发现冲突就在链表中做进一步的对比。
HashMap的扩容操作
①.在jdk1.8中,resize方法是在HashMap中的键值对大于阀值时或者初始化时,就调用resize方法进行扩容;
②.每次扩展的时候,都是扩展2倍;
③.扩展后Node对象的位置要么在原位置,要么移动到原偏移量两倍的位置。
对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。
系统默认负载因子为0.75
在putVal()中,我们看到在这个函数里面使用到了2次resize()方法,resize()方法表示的在进行第一次初始化时会对其进行扩容,或者当该数组的实际大小大于其临界值值(第一次为12),这个时候在扩容的同时也会伴随的桶上面的元素进行重新分发,这也是JDK1.8版本的一个优化的地方,
在1.7中,扩容之后需要重新去计算其Hash值,根据Hash值对其进行分发。
但在1.8版本中,则是根据在同一个桶的位置中进行判断(e.hash & oldCap)是否为0,重新进行hash分配后,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
HashMap是怎么解决哈希冲突的
哈希:Hash,一般翻译为“散列”,也有直接音译为“哈希”的,这就是把任意长度的输入通过散列算法,变换成固定长度的输出,该输出就是散列值(哈希值);这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来唯一的确定输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
在Java中,保存数据有两种比较简单的数据结构:数组和链表。数组的特点是:寻址容易,插入和删除困难;链表的特点是:寻址困难,但插入和删除容易;所以我们将数组和链表结合在一起,发挥两者各自的优势,使用一种叫做拉链法的方式可以解决哈希冲突。
JDK1.7:无冲突时,存放数组;冲突时,存放链表。采用头插法。
JDK1.8:无冲突时,存放数组;冲突时,链表长度小于8:存放链表; 链表长度大于8时:树化并存放红黑树。采用尾插法。
HashMap相关问题
HashMap的特点
hashmap存取是无序的
键和值位置都可以是null,但是键位置只能是一个null
键的位置是唯一的,底层的数据结构是控制键的
jdk1.8前数据结构是:链表+数组
jdk1.8之后是:数组+链表+红黑树
阀值(边界值) >8并且数组长度大于64,才将链表转化成红黑树,变成红黑树的目的是提高搜索速度,高效查询
为什么要在数组长度大于64之后,链表才会进化红黑树
在数组较小时,红黑树会降低效率。并且红黑树需要进行左旋右旋、变色,操作来维持平衡,同时数组长度小于64时,搜索更快一些。
jdk1.8之前HashMap的实现是数组+链表,即使哈希函数取得再好,也很难达到百分百均匀分布。
当HashMap中大量的元素都存放在同一个桶中时,这个桶下有一天长长的链表,此时HashMap就相当于单链表,
假如单链表有n个元素,遍历的时间复杂度就从O(1)退化成O(n),失去了它的优势。为了解决这种情况引入了红黑树来优化这个问题。(查找时间复杂度为O(logn))
为什么加载因子设置为0.75,初始化临界值是12
HashMap中的threshold是HashMap所能容纳键值绝对的最大值。计算公式为
length*LoadFactory。也就是说,数组定义好长度之后,负债因子越大,所能容纳的键值对个数越大
LoadFactory(加载因子)越趋近于1,那么数组中存放的数据也就越多,数据就越密集,也就会有更多的链表长度处于更长的数值,我们查询效率就会降低,当我们添加数据时,产生hsah冲突的概率就会高。
默认的加载因子是0.75,加载因子越小,越趋近于0,数组中存放的数据也就越少,表现的更加稀疏
如果加载因子小一些比如是0.4,那么初始长度16*0.4=6,数组占满6个空间就进行扩容,很多的空间可能元素很少或没有,会造成大量空间浪费
加载因子设置为0.9,这样会导致扩容之前的查找元素的效率非常低
默认的加载因子是0.75是对空间 和时间的一种平衡选择
哈希表底层采用何种算法计算hash值?还有哪些算法可以计算hash值?
HashCode方法是Object中的方法,所有的类都可以对其进行使用,首先底层通过调用HashCode方法生成初始hash值h1,然后将h1无符号右移16位得到h2,之后将h1和h2进行按位异或(^)运算得到最终hash值h3,之后将h3与(length-1)进行按位于(&)运算得到hash表索引
平方取中位数
取余数
当两个对象的hashCode相等时会怎么样
HashCode相等时产生hash碰撞,HashCode相等会调用equals方法比较内容是否相等,内容相等则会进行覆盖,内容不相等则会连接到链表后方,链表长度超过8且数组长度超过64,会转变成红黑树结点。
HashMap的put方法流程
jdk1.8
首先根据Key的值计算hash值,找到该元素在数组中存储的下标
如果数组为空,则调用resize进行初始化;
如果没有哈希冲突直接放在对应的数组下标里
如果有冲突了Key已经存在,就覆盖掉Value
如果冲突后是链表结构,就判断该链表是否大于8 ,如果大于8并且数组容量小于64,就进行扩容;
如果大于8并且数组的容量大于64,则将这个结构转化为红黑树;否则,链表插入键值对,若Key存在,就覆盖Value
如果冲突后,发现该节点是红黑树,就将这个节点挂到树上
HashMap扩容方式
HashMap在容量超过负债因子所定义的容量之后,就扩容。java里的数组无法自己扩容的,将HashMap的大小扩为原来的数组的两倍
扩容之后原位置的节点只有两种调整
保持原位置不动(新bit为0时)
散列原索引+扩容大小的位置(新bit为1时)
一般用什么作为HashMap的Key?
一般用Integer、String这种不可变类当HashMap的Key
因为String是不可变的,当创建字符串时,它的HashCode被缓存下来,不需要再次计算,相等对于其他对象更快
hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这类很规范的重写了hashCode()以及equals()方法
为什么Map通中节点个数超过8才转为红黑树
从平均查找长度来看,红黑树的平均查找长度是logn,如果长度为8,则logn=3,而链表的平均查找长度为n/4,长度为8时,n/2=4,所以阈值8能大大提高搜索速度。
当长度为6是红黑树退化为链表是因为logn=log6约等于2.6.而n/2=6.2=3,两者相差不大,而红黑树节点占用更多的内存空间,所以此时转化最为友好。
HashMap为什么线程不安全
多线程下扩容死循环。JDK1.7中HashMap使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表问题。此问题在JDK1.7和JDK1.8都存在。
多线程的put可能导致元素的丢失。多线程同时执行put操作,如果计算出来的索引位置是相同的,那么会造成前一个Key被后一个Key覆盖,从而导致元素的丢失。
put和get并发时,可能导致get为NULL。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题,此问题在JDK1.7和JDK1.8都存在。
HashMap如何实现线程安全
方法一:使用hashtable
方法二:使用java.util.concurrent.concurentHashMap
方法三:使用java.util.collections.synchronizedMap()方法包装HashMap object,得到线程安全的Map,并在此Map上操作。
HashMap: 底层是数组+链表的形式,其中get、put方法等都是synchronized修饰的,因此,hashtable是线程安全的,但是执行效率低,一般不推荐使用。
synchronizedMap:synchronizedMap的线程安全和hashtable一样,都是使用synchronized方法,将方法上锁。
concurrentHashMap:一个concurrentHashMap包含多个segment,而segment类似与hashTable,是线程安全的,因此concurrentHashMap是一个segment的数组,当有线程操作时,一个线程会操作一个segment,因此是多线程的同时也是安全的,有几个segment就允许几个线程同时操作。一般操作最先都是将key映射到对segment上。