Java面试系列第3篇-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,则继续执行循环体,执行后的引用关系
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。