zoukankan      html  css  js  c++  java
  • Java面试系列第4篇-HashMap相关面试题

    HashMap是非线程安全的,如果想要用线程安全的map,可使用同步的HashTable或通过Collections.synchronizeMap(hashMap)让HashMap变的同步,或者使用并发集合ConcurrentHashMap。下面来介绍一些常见的HashMap面试题目。

     1、为何HashMap的数组长度一定是2的次幂?

    我们知道,HashMap的存储对于JDK1.7来说,是通过数组加链表的方式实现的。通过hash值获取数组下标存储索引,通过链表来解决冲突。下面看一下调用hash()方法获取hash值的源代码实现,如下:

    final int hash(Object k) {
          int h = hashSeed;
          if (0 != h && k instanceof String) {
              return sun.misc.Hashing.stringHash32((String) k);
          }
          h ^= k.hashCode();
    
          h ^= (h >>> 20) ^ (h >>> 12);
          return h ^ (h >>> 7) ^ (h >>> 4);
    }

    在HashMap中得到hash值后会调用indexFor()计算数组中的索引位置,实现如下:

    static int indexFor(int h, int length) {
        return h & (length-1); //  保证获取的索引是数组的合法索引
    }
    

    通过hashcode与length-1进行与运算,之前已经说过,数组长度length一定是2的幂次,所以减去1之后,结果用二进制表示时,其右边的log2(length)个二进制位都为1,也就是说,hashcode的log2(length)个低二进制位决定最终的存储索引位置,如果数组长度length不为2的幂次,那么length-1的结果用二进制表示时,右边的log2(length)个二进制位会含有0,导致hashcode的log2(length)个低二进制位中的部分数据不起作用,会增加索引位置冲突的机率。  

    2、为什么Map桶中个数超过8才转为红黑树

    在 JDK 1.7 中,是用链表存储的,这样如果碰撞很多的话,就变成了在链表上的查找;在 JDK 1.8 进行了优化,当链表长度较大时(超过 8),会采用红黑树来存储,这样大大提高了查找效率。针对JDK 1.8版本的冲突解决,经常会被问到为什么是超过8才用红黑树的问题。

    当桶中个数达到8就转成红黑树,当长度降到6就转成普通链表存储,而7就是为了防止链表和树频繁转换。至于选择8的原因,根据源代码的解析,是因为一个桶中存储8个或以上的概率非常小,这样小的事件都发生了,说明产生了严重的冲突,需要更高效的查找方式。 

    3、为什么HashMap链表会形成死循环

    JDK1.7 的 HashMap 链表会有死循环的可能,因为JDK1.7是采用的头插法,在多线程环境下有可能会使链表形成环状,从而导致死循环。JDK1.8做了改进,用的是尾插法,不会产生死循环。 

    因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生,那么就死造成死循环。简书中https://www.jianshu.com/p/1e9cf0ac07f4给了一个非常生动的例子,如下:

    假设HashMap初始化大小为4,插入个3节点,不巧的是,这3个节点都hash到同一个位置,如果按照默认的负载因子的话,插入第3个节点就会扩容,为了验证效果,假设负载因子是1。

    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;
        for (Entry<K,V> e : table) {
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }
    

    下面进行图示说明。

     

    插入第4个节点时,发生rehash,假设现在有两个线程同时进行,线程1和线程2,两个线程都会新建新的数组。

    假设 线程2 在执行到Entry<K,V> next = e.next;之后,cpu时间片用完了,这时变量e指向节点a,变量next指向节点b。

    线程1继续执行,很不巧,a、b、c节点rehash之后又是在同一个位置7,开始移动节点

    第一步,移动节点a

    第二步,移动节点b 

    注意,这里的顺序是反过来的,继续移动节点c

     

    这个时候 线程1 的时间片用完,内部的table还没有设置成新的newTable, 线程2 开始执行,这时内部的引用关系如下: 

     这时,在 线程2 中,变量e指向节点a,变量next指向节点b,开始执行循环体的剩余逻辑。 

    Entry<K,V> next = e.next;
    int i = indexFor(e.hash, newCapacity);
    e.next = newTable[i];
    newTable[i] = e;
    e = next;
    

    执行之后的引用关系如下图。  

    执行后,变量e指向节点b,因为e不是null,则继续执行循环体,执行后的引用关系

     
    变量e又重新指回节点a,只能继续执行循环体,这里仔细分析下:

    1、执行完Entry<K,V> next = e.next;,目前节点a没有next,所以变量next指向null;
    2、e.next = newTable[i]; 其中 newTable[i] 指向节点b,那就是把a的next指向了节点b,这样a和b就相互引用了,形成了一个环;
    3、newTable[i] = e 把节点a放到了数组i位置;
    4、e = next; 把变量e赋值为null,因为第一步中变量next就是指向null;

    所以最终的引用关系是这样的:

    节点a和b互相引用,形成了一个环,当在数组该位置get寻找对应的key时,就发生了死循环。

    另外,如果线程2把newTable设置成到内部的table,节点c的数据就丢了,看来还有数据遗失的问题。

    4、HashMap在并发环境下为什么会丢失数据?

    在多线程下put操作时,执行addEntry(hash, key, value, i),如果有产生哈希碰撞,导致两个线程得到同样的bucketIndex去存储,就可能会出现覆盖丢失的情况,源代码实现如下:

    void addEntry(int hash, K key, V value, int bucketIndex) {
        //多个线程操作数组的同一个位置
        Entry<K,V> e = table[bucketIndex];
        table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
        if (size++ >= threshold)
              resize(2 * table.length);
     }

    5、为什么重写 equals() 方法,一定要重写 hashCode() 呢?

    一般情况下,任何两个Object的hashCode在默认情况下都是不同的,如果有两个Object相等,在不重写 hashCode()方法的情况下,那么很可能存储到数组中不同的索引位置,而Java为了加快查找速度,通常对以散列表做为存储结构的集合进行查找时,首先要通过hash来定位查询的索引位置,如果存储到其它索引位置,则没有办法查找。

    6、rehashing的时机

    通过负载因子来判断,即用元素数量除以索引的数量,也就是平均每个索引处存储多少个元素。Java 中默认值是 0.75f,如果超过了这个值就会 rehashing。

    7、HashMap 1.7与1.8的区别

    分别有以下几点:

    • 数组+链表改成了数组+链表或红黑树,防止发生hash冲突,链表长度过长,将时间复杂度由O(n)降为O(logn);
    • 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后。因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环;
    • 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
    • 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;

    8、 为什么要用扰动函数?

      

    扰动函数就是解决碰撞问题。若不使用扰动函数,则直接将key.hashCode()和下面的步骤2做与运算,则会有以下情景。 

    以初始长度16为例,16-1=15。2进制表示是00000000 00000000 00001111。和某散列值做“与”操作如下,结果就是截取了最低的四位值。

      

    这样就算散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,则碰撞会更严重。

    由扰动函数源码可知,会有以下步骤:

    ①使用key.hashCode()计算hash值并赋值给变量h;

    ②将h向右移动16位;

    ③将变量h和向右移16位的h做异或运算(二进制位相同为0,不同为1)。此时得到经过扰动函数后的hansh值。

      

    右移16位正好为32bit的一半,自己的高半区和低半区做异或,是为了混合原始哈希吗的高位和低位,来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,使高位的信息也被保留下来。

      

    若直接使用key.hashCode()计算出hash值,则范围为:-2147483648到2147483648,大约40亿的映射空间。若映射得比较均匀,是很难出现碰撞的。但是这么大范围无法放入内存中,况且HashMap的 初始容量为16。所以必须要进行与运算取模。

  • 相关阅读:
    MongoDB:数据库管理
    MongoDB:用户管理
    MongoDB:入门
    彻底透析SpringBoot jar可执行原理
    轻松了解Spring中的控制反转和依赖注入(一)
    领域驱动最佳实践--用代码来告诉你来如何进行领域驱动设计
    血的教训--如何正确使用线程池submit和execute方法
    领域驱动设计之实战权限系统微服务
    为什么我们需要领域驱动设计
    【Go入门学习】golang自定义路由控制实现(二)-流式注册接口以及支持RESTFUL
  • 原文地址:https://www.cnblogs.com/extjs4/p/12778458.html
Copyright © 2011-2022 走看看