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、为什么重写 equals() 方法,一定要重写 hashCode() 呢?

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

5、rehashing的时机

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

 

版权声明:本文为extjs4原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://www.cnblogs.com/extjs4/p/12778458.html