面试准备:基于JDK1.8的 hashMap源码

在jdk1.8中hashMap进行了较大优化,具体可以回答以下几点:

1.  hashMap内部由 数组+链表转变为数组+链表+红黑树实现,当链表节点大于8时,存储结构由链表转为红黑树。

2.  HashMap有两个参数影响其性能:初始容量加载因子。默认初始容量是16(桶的数量),加载因子是0.75。容量是哈希表中桶(Entry数组)的数量,初始容量只是哈希表在创建时的容量。加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度。当哈希表中的条目数超出了加载因子与当前容量的乘积时,通过调用 rehash 方法将容量翻倍,即当HashMap里面存了16*0.75=12个元素的时候,就会扩容,扩到32。

3. 如何定位key的数组索引值?

不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。前面说过HashMap的数据结构是“数组+链表+红黑树”的结合,所以我们当然希望这个HashMap里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率。HashMap定位数组索引位置,直接决定了hash方法的离散性能。下面是定位哈希桶数组的源码:

// 代码1
static final int hash(Object key) { // 计算key的hash值
    int h;
    // 1.先拿到key的hashCode值; 2.将hashCode的高16位参与运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 代码2
int n = tab.length;
// 将(tab.length - 1) 与 hash值进行&运算
int index = (n - 1) & hash;

答案:1.首先计算key的hashCode值 ; 2.将hashCode的高16位参与运算,重新计算hash值;3.将hash值与table.length-1进行&(与)运算,得出最后的key对应的数组索引值

对于2的解释:在JDK1.8的实现中,优化了高位运算的算法,将hashCode的高16位与hashCode进行^(异或)运算,主要是为了在table的length较小的时候,让高位也参与运算,并且不会有太大的开销。

对于3的解释:为了尽可能散列开来使元素均匀分配,我们首先想到的就是把hash值对table长度取模运算,但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此JDK团队对取模运算进行了优化,使用上面代码2的位与运算来代替模运算。这个方法非常巧妙,它通过 “(table.length -1) & h” 来得到该对象的索引位置,这个优化是基于以下公式:x mod 2^n = x & (2^n - 1)。我们知道HashMap底层数组的长度总是2的n次方,并且取模运算为“h mod table.length”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)”。这是HashMap在速度上的优化,因为&比%具有更高的效率。

4. 为什么HashMap的容量一定为2的n次幂?

为了使元素能够均匀分布,减少碰撞。

HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;这个算法实际就是取模,hash%length,计算机中直接求余效率不如位移运算,源码中做了优化hash&(length-1),hash%length==hash&(length-1)的前提是length是2的n次方;

为什么这样能均匀分布减少碰撞呢?2的n次方实际就是1后面n个0,2的n次方-1  实际就是n个1;
例如长度为9时候,3&(9-1)=0  2&(9-1)=0 ,都在0上,碰撞了;
例如长度为8时候,3&(8-1)=3  2&(8-1)=2 ,不同位置上,不碰撞;

其实就是按位“与”的时候,每一位都能  &1  ,也就是和1111……1111111进行与运算

5.扩容后,节点重hash为什么只可能分布在原索引位置与原索引+oldCap位置?

扩容代码中,使用节点的hash值跟oldCap进行位与运算,以此决定将节点分布到原索引位置或者原索引+oldCap位置上,这是为什么了?

假设老表的容量为16,即oldCap=16,则新表容量为16*2=32,假设节点1的hash值为0000 0000 0000 0000 0000 1111 0000 1010,节点2的hash值为0000 0000 0000 0000 0000 1111 0001 1010,则节点1和节点2在老表的索引位置计算如下图计算1,由于老表的长度限制,节点1和节点2的索引位置只取决于节点hash值的最后4位。再看计算2,计算2为新表的索引计算,可以知道如果两个节点在老表的索引位置相同,则新表的索引位置只取决于节点hash值倒数第5位的值,而此位置的值刚好为老表的容量值16,此时节点在新表的索引位置只有两种情况:原索引位置和原索引+oldCap位置(在此例中即为10和10+16=26)。由于结果只取决于节点hash值的倒数第5位,而此位置的值刚好为老表的容量值16,因此此时新表的索引位置的计算可以替换为计算3,直接使用节点的hash值与老表的容量16进行位于运算,如果结果为0则该节点在新表的索引位置为原索引位置,否则该节点在新表的索引位置为原索引+oldCap位置。

面试准备:基于JDK1.8的 hashMap源码_第1张图片

6.HashMap的存取机制

   如何getValue?

get(key)方法得到key的hash值,然后计算hash&(table.length-1)得到桶的位置,然后判断桶的key是否与参数key相等,不等就遍历后面的元素找到相同key值,返回对应的value值即可.

  如何put(key,value)?

1、hash(key),取key的hashcode进行高位运算,返回hash值 
2、如果hash数组为空,直接resize() 
3、对hash进行取模运算计算,得到key-value在数组中的存储位置i 
(1)如果table[i] == null,直接插入Node 
(2)如果table[i] != null,判断是否为红黑树p instanceof TreeNode。 
(3)如果是红黑树,则判断TreeNode是否已存在,如果存在则直接返回oldnode并更新;不存在则直接插入红黑树,++size,超出threshold容量就扩容 
(4)如果是链表,则判断Node是否已存在,如果存在则直接返回oldnode并更新;不存在则直接插入链表尾部,判断链表长度,如果大于8则转为红黑树存储,++size,超出threshold容量就扩容

6. HashMap在jdk1.8之前的多线程扩容时的死循环问题

HashMap在1.7时,当进行扩容后,节点的顺序会反掉,所以,在多线程扩容后容易造成死循环(无限循环)问题。在JDK1.8后,扩容后,节点A和节点C的先后顺序跟扩容前是一样的。因此,即使此时有多个线程并发扩容,也不会出现死循环的情况。当然,这仍然改变不了HashMap仍是非并发安全,在并发下,还是要使用ConcurrentHashMap来代替。

7.HashMap和Hashtable的区别:

  1. HashMap允许key和value为null,Hashtable不允许。
  2. HashMap的默认初始容量为16,Hashtable为11。
  3. HashMap的扩容为原来的2倍,Hashtable的扩容为原来的2倍加1。
  4. HashMap是非线程安全的,Hashtable是线程安全的。
  5. HashMap的hash值重新计算过,Hashtable直接使用hashCode。
  6. HashMap去掉了Hashtable中的contains方法。
  7. HashMap继承自AbstractMap类,Hashtable继承自Dictionary类。
  8. 总结:

    1. HashMap的底层是个Node数组(Node[] table),在数组的具体索引位置,如果存在多个节点,则可能是以链表或红黑树的形式存在。
    2. 增加、删除、查找键值对时,定位到哈希桶数组的位置是很关键的一步,源码中是通过下面3个操作来完成这一步:1)拿到key的hashCode值;2)将hashCode的高位参与运算,重新计算hash值;3)将计算出来的hash值与(table.length - 1)进行&运算。
    3. HashMap的默认初始容量(capacity)是16,capacity必须为2的幂次方;默认负载因子(load factor)是0.75;实际能存放的节点个数(threshold,即触发扩容的阈值)= capacity * load factor。
    4. HashMap在触发扩容后,阈值会变为原来的2倍,并且会进行重hash,重hash后索引位置index的节点的新分布位置最多只有两个:原索引位置或原索引+oldCap位置。例如capacity为16,索引位置5的节点扩容后,只可能分布在新报索引位置5和索引位置21(5+16)。
    5. 导致HashMap扩容后,同一个索引位置的节点重hash最多分布在两个位置的根本原因是:1)table的长度始终为2的n次方;2)索引位置的计算方法为“(table.length - 1) & hash”。HashMap扩容是一个比较耗时的操作,定义HashMap时尽量给个接近的初始容量值。
    6. HashMap有threshold属性和loadFactor属性,但是没有capacity属性。初始化时,如果传了初始化容量值,该值是存在threshold变量,并且Node数组是在第一次put时才会进行初始化,初始化时会将此时的threshold值作为新表的capacity值,然后用capacity和loadFactor计算新表的真正threshold值。
    7. 当同一个索引位置的节点在增加后达到9个时,会触发链表节点(Node)转红黑树节点(TreeNode,间接继承Node),转成红黑树节点后,其实链表的结构还存在,通过next属性维持。链表节点转红黑树节点的具体方法为源码中的treeifyBin(Node[] tab, int hash)方法。
    8. 当同一个索引位置的节点在移除后达到6个时,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点。红黑树节点转链表节点的具体方法为源码中的untreeify(HashMap map)方法。
    9. HashMap在JDK1.8之后不再有死循环的问题,JDK1.8之前存在死循环的根本原因是在扩容后同一索引位置的节点顺序会反掉。
    10. HashMap是非线程安全的,在并发场景下使用ConcurrentHashMap来代替。


你可能感兴趣的:(面试)