HashMap作为Java面试中高频出现的面试题,是面试官们最喜欢问的问题之一,通常会出现在前3道技术面试题中,主要是为了筛选不会Java的候选人,亦或者是考察候选人平时会不会看JDK源码,下面我们将从不同维度讲解在面试过程中,我们需要如何回答HashMap有关的面试题,以及对于面试而言,需要掌握到什么程度。
本文只是列举了一些常见且典型,并且需要花时间读源码的面试问题,并没有包括一些显而易见的简单的题,这样能让正在准备面试的你更快更全面的复习。
讲到HashMap的原理,大多数程序员都知道HashMap是基于数组加链表,这里我们简单讲一下原理。
我们先介绍一个HashMap内部的数据结构Node,结构很简单,hash字段存的是经过HashMap的hash()
方法得出的key
的hash值,key
和value
字段分别存的是键值对,next
字段存的是下一个Node对象.
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
// ...
}
我们知道HashMap内部会维护一个Node类型的数组tab,当我们用put(key, value)来存储一个键值对时候,首先会用HashMap内置的hash方法得到这个key的hashcode,然后用这个hash值和数组的长度作逻辑与运算,结果就是该键值对需要存放的数组位置,如下。
// 检查tab[pos]是否已经有node元素了
pos = (tab.length -1) & hash(key)
如果tab[pos]已经有node元素了,说明发生了哈希冲突,那么Node中的next字段就会发挥作用,将哈希值相同的元素连起来变成链表。从这里可以看出Java中的HashMap解决冲突是使用拉链法。常见的解决哈希冲突的三种方法
在不指定参数的情况下,初始大小会在第一次插入新元素时指定,Node数组大小为16,扩容阈值是总容量*0.75,初始时是12。
当发生下面2种情况是将会扩容,每次扩容到原数组大小的2倍
当链表的长度第一次大于等于8时,且总的node数组长度大于64时会将链表转成红黑树。当我们扩容后,如果此时红黑树小于等于6个节点,则会转回链表。
我们都知道链表的查询时间复杂度是O(1),而红黑树是O(logN),所以当哈希冲突非常严重时,红黑树的效率要明显好于链表,当然在冲突不严重的时候,比如HashMap中定义的小于8时,链表的空间复杂度要明显优于红黑树,下面的Java Doc也做了相关的解释。
那么为什么会在大于等于8的时候需要转成红黑树呢,Java Doc中给出了这样一段解释:
/**
* Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
**/
JDK解释说用红黑树所占用的空间几乎是链表的2倍,所以我们只在有很多哈希冲突的极端的情况下,那为什么当冲突数达到8时会被认为是极端情况呢,Java Doc解释到大多数的hash算法都是均匀分布的,会使用红黑树的场景非常少见。而理论上,一个随机分布的hash算法,元素在某个数组位置出现的概率是服从泊松分布,那么在0.75的扩容因子下,泊松分布的概率参数为0.5,此时发生8个相同hash值的概率为低于一千万分之一,所以一个设计良好的hash算法几乎是不会发生红黑树转换问题的。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
从这段code中可以发现一个有意思的逻辑,即让key的hashCode()
方法返回的hash值右移16位后再与hash作逻辑运算,我们知道hashCode()产生的是一个int值,而int值是32位的,当int值右移16位后,其实就是这个hashCode的高位和低位做了异或运算,为什么要这样呢,我们来看一下官方的Java Doc是怎么说的:
/**
* Computes key.hashCode() and spreads (XORs) higher bits of hash
* to lower. Because the table uses power-of-two masking, sets of
* hashes that vary only in bits above the current mask will
* always collide. (Among known examples are sets of Float keys
* holding consecutive whole numbers in small tables.) So we
* apply a transform that spreads the impact of higher bits
* downward. There is a tradeoff between speed, utility, and
* quality of bit-spreading. Because many common sets of hashes
* are already reasonably distributed (so don't benefit from
* spreading), and because we use trees to handle large sets of
* collisions in bins, we just XOR some shifted bits in the
* cheapest possible way to reduce systematic lossage, as well as
* to incorporate impact of the highest bits that would otherwise
* never be used in index calculations because of table bounds.
*/
翻译过来就是说,如果不同key的hashcode只在高位有差异的话,那么他们将会发生哈希冲突,为什么会这样呢,因为大多数情况下我们的HashMap的容量都不会非常大,而hash后的值是需要与HashMap的Node数组长度作逻辑与操作的,如果HashMap的数组长度很小,那么每次逻辑与操作都会丢失hashCode的高位值,从而导致哈希冲突。而高低位的异或运算是一种权衡之计,即解决了hash分布问题,又不太影响性能,在极端情况下还有红黑树来解决冲突。
pos = (tab.length -1) & hash(key)
因为我们决定放在数组的哪个位置是使用上面的逻辑与的方法,tab.length是2次幂转成二进制就是1后面n个0,tab.length-1就是n个1,那么在做完逻辑与后,不同hash值得key所在的位置就是hash值本身所代表的位置,不会因为这个逻辑与而导致不同的hash的key在相同的位置。
而扩容之后,由于tab.length只是从n个1变成了n+1个1,那么原来hash值在n+1位前都相同的key还会在同一位置,而n+1位不同的key将会移到原来的2倍位置。
synchronized
关键字实现,当我们只是需要数据一致性时可以使用这个实现从HashMap可以扩展出很多面试题,例如并发问题,集合问题,队列问题等,这些将在后面的相关专题中一一介绍。