HashMap的实现原理

1,数据结构

      JDK1.8后添加红黑树,底层是数组+链表红黑树实现。

      链表长度>8 & 数组长度>=64 ====>  红黑树

      红黑树节点数<6  =====>链表

2,HashMap的插入原理

1,判断数组为空--->初始化,初始化,默认初始化容量(capactiy)16,必须是2的幂,最大值为int最大值,默认初始化加载因子(factor)是0.75f,同时设置临界值为16*0.75f, 如果自己传入初始大小k,初始化大小为 大于k的 2的整数次方,例如如果传10,大小为16

2,数组不为空 ==> 计算key的hash值,若key为null,则放在table[0]位置,不为空,则先计算hash,然后通过hash与table.length取模计算index的值,然后将key放到table[index]位置,

3,当table[index]已存在其它元素时,说明发生了hash冲突(存在二个节点key的hash值一样),

4,继续判断key是否相等,相等,用新的value替换原数据

5,若key不相等,判断当前节点类型是不是树型节点,如果是树型节点,创造树型节点插入红黑树中;

6,如果不是树型节点,创建普通Node加入链表中;判断链表长度是否大于 8, 大于的话链表转换为红黑树;

7,若小于8,会在table[index]位置形成一个链表,将新添加的元素放在table[index],原来的元素通过Entry的next进行链接,这样以链表形式解决hash冲突问题,

8,插入完成之后判断当前节点数是否大于阈值,如果大于开始扩容为原数组的二倍。

    这个阈值是8,红黑树转链表的阈值是6;因为只有长度N>=7的时候,红黑树的平均查找长度lgN才会小于链表的平均查找长度N/2,这个可以画函数图来确定,lgN与N/2的交点处N约为6.64。设置成8是为了防止出现频繁的链表与红黑树的转换,当大于8的时候链表转红黑树,小于6的时候红黑树转链表,中间这一段作为缓冲

3,HashMap的哈希函数怎么设计?

hash函数是拿到key的hashCode值,是一个32位的int值,然后将hashcode的高16位和低16位进行异或操作

这样设计的原因:

      1,尽可能降低hash碰撞,因此采用了位运算

      2,算法一定要高效,因为这是高频操作,因此采用位运算

注意:异或运算:如果a、b两个值不相同,则异或结果为1。如果a、b两个值相同,异或结果为0

4,为什么采用hashcode的高16位和低16位异或就能降低hash碰撞,hash函数能不能直接用key的hashcode?

       因为key.hashcode()方法调用的是key键值类型自带的hash函数,返回int类型散列值。int值的范围是前后加起来大约是40亿的映射空间。只要是hash函数映射的比较均匀松散,一般应用是很难出现碰撞的。但是40亿长度的数组,内存肯定放不下。如果HashMap数组的初始大小才16,用之前还需要先对数组的长度进行模运算,得到的余数才能用来访问数组下标。所以用位运算,位运算比模运算快。这里也刚好解释了为什么HashMap的数组长度要取2的整数幂。因为这样,(数组长度-1)正好相当于一个“低位掩码”。“与”操作的结果就是散列值的高位全部归零,只保留低位值,用来做数组下标访问。

5,为什么它使用红黑树不使用二叉树?
     红黑树牺牲了一些查找性能,但是本身并不是完全平衡的二叉树。因此插入删除效率高,二叉树是相反的。

6,你之前提到了负载因子,你知道它有什么作用吗?
      负载因子表示HashMap的拥挤程度,影响到hash操作到同一个数组位置的概率。默认的负载因子是0.75,当hashmap里容纳的元素达到数组长度的75%时,就会扩容,在HashMap的构造器中可以定制负载因子。

7,你现在可以给我讲讲get方法的过程吗?
      计算出key的hashcode,根据hashcode定位该元素所在桶的下标,若桶为空,则返回null,不为空,遍历Entry对象链表,直到找到元素,没找到就返回null。

8,HashMap如何解决冲突?

  •  链地址法:将冲突的元素存入数组后面的链表中,hashMap中使用的方法就是链地址法,也就是数组+单链表
  •  再哈希:同时构造多个不同的hash函数,第一个冲突就使用第二个,以此类推
  •  建立公共溢出区:将冲突的元素存入数组后面的链表中,hashMap中使用的方法就是链地址法,也就是数组+单链表。
  •  开放地址法:从发生冲突的单元起,按照一定的顺序从哈希表中找出一个空白单元,然后把冲突元素存入该单元的方法

9,JDK1.8对HashMap还做了哪些优化?

  • 数组+链表改成了数组+链表或红黑树;
  • 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;
  • 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
  • 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;

10,为什么要做这种优化?

  1. 1.8使用红黑树:防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);
  2. 1.8使用尾插法:因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;A线程在插入节点B,B线程也在插入,遇到容量不够开始扩容,重新hash,放置元素,采用头插法,后遍历到的B节点放入了头部,这样形成了环
  3. 这是由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1,怎么理解呢?扩容前长度为16,用于计算(n-1) & hash 的二进制n-1为0000 1111,扩容为32后的二进制就高位多了1,为0001 1111。因为是& 运算,1和任何数 & 都是它本身,那就分二种情况,如下图:原数据hashcode高位第4位为0和高位为1的情况;第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)

 

 11,那HashMap是线程安全的吗?

       不是,在多线程环境下,1.7 会产生死循环、数据丢失、数据覆盖的问题,1.8 中会有数据覆盖的问题,以1.8为例,当A线程判断index位置为空后正好挂起,B线程开始往index位置的写入节点数据,这时A线程恢复现场,执行赋值操作,就把A线程的数据给覆盖了;还有++size这个地方也会造成多线程同时扩容等问题。

怎么解决线程不安全?

  • Java中有HashTable、Collections.synchronizedMap、以及ConcurrentHashMap可以实现线程安全的Map。
  • HashTable是直接在操作方法上加synchronized关键字,锁住整个数组,锁粒度比较大,
  • Collections.synchronizedMap是使用Collections集合工具的内部类,通过传入Map封装出一个SynchronizedMap对象,内部定义了一个对象锁,方法通过对象锁实现线程安全;
  • ConcurrentHashMap使用分段锁,降低了锁粒度,让并发度大大提高。

12,ConcurrentHashMap的分段锁的实现原理?

       ConcurrentHashMap成员变量使用volatile 修饰,免除了指令重排序,同时保证内存可见性,另外使用CAS操作和synchronized结合实现赋值操作,多线程操作只会锁住当前操作索引的节点。(线程A锁住A节点所在链表,线程B锁住B节点所在链表,操作互不干涉)

13,链表转红黑树是链表长度达到阈值,这个阈值是多少?为什么是8,不是16,32甚至是7 ?又为什么红黑树转链表的阈值是  6,不是8了呢?

阈值是8,红黑树转链表阈值为6,

因为经过计算,在hash函数设计合理的情况下,发生hash碰撞8次的机率为百万分之6,概率说话。。因为8够用了,至于为什么转回来是6,因为如果hash碰撞次数在8附近徘徊,会一直发生链表和红黑树的互相转化,为了预防这种情况的发生,设置为6

14,HashMap内部节点是有序的吗?

        是无序的,根据hash值随机插入

那有没有有序的Map?

         LinkedHashMap 和 TreeMap

跟我讲讲LinkedHashMap怎么实现有序的?

         LinkedHashMap内部维护了一个单链表,有头尾节点,同时LinkedHashMap节点Entry内部除了继承HashMap的Node属性,还有before 和 after用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。

TreeMap怎么实现有序的?

          TreeMap是按照Key的自然顺序或者实现的Comprator接口的比较函数的顺序进行排序,内部是通过红黑树来实现。所以要么key所属的类实现Comparable接口,或者自定义一个实现了Comparator接口的比较器,传给TreeMap用于key的比较

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章