Java HashMap面试须知

前言

HashMap作为Java面试中高频出现的面试题,是面试官们最喜欢问的问题之一,通常会出现在前3道技术面试题中,主要是为了筛选不会Java的候选人,亦或者是考察候选人平时会不会看JDK源码,下面我们将从不同维度讲解在面试过程中,我们需要如何回答HashMap有关的面试题,以及对于面试而言,需要掌握到什么程度。

本文只是列举了一些常见且典型,并且需要花时间读源码的面试问题,并没有包括一些显而易见的简单的题,这样能让正在准备面试的你更快更全面的复习。

问题1:HashMap的实现原理

讲到HashMap的原理,大多数程序员都知道HashMap是基于数组加链表,这里我们简单讲一下原理。

我们先介绍一个HashMap内部的数据结构Node,结构很简单,hash字段存的是经过HashMap的hash()方法得出的key的hash值,keyvalue字段分别存的是键值对,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解决冲突是使用拉链法。常见的解决哈希冲突的三种方法

  • 开放寻址法:当发生哈希冲突时,按照一定次序从哈希表中寻找一个空闲的单元
  • 拉链法:当发生哈希冲突时,将发生冲突的元素会以链表的方式存储
  • 再哈希法:当发生哈希冲突时,会再哈希一次

问题2:HashMap的初始大小是多少

在不指定参数的情况下,初始大小会在第一次插入新元素时指定,Node数组大小为16,扩容阈值是总容量*0.75,初始时是12。

问题3:什么时候需要扩容,每次扩容多少

当发生下面2种情况是将会扩容,每次扩容到原数组大小的2倍

  • 每次发生插入新元素操作时,都会检查HashMap的大小是不是超过了总容量*0.75
  • 当需要把链表转成红黑树时,会先检查总的node数组长度是否小于64,如果是,会先进行扩容而不是转红黑树

问题4:链表什么时候会转成红黑树,转成红黑树有什么好处

当链表的长度第一次大于等于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算法几乎是不会发生红黑树转换问题的。

问题5:HashMap中的Key是如何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分布问题,又不太影响性能,在极端情况下还有红黑树来解决冲突。

问题6:为什么HashMap总是以2次幂来进行扩容

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倍位置。

问题7:HashMap的线程安全版本有哪些,各自的使用场景

  • Collections.synchronizedmap:通过将所有线程不安全的方法加上synchronized关键字实现,当我们只是需要数据一致性时可以使用这个实现
  • ConcurrentHashMap:HashMap的线程安全版本,支持并发读,所以当我们需要频繁读写操作时,ConcurrentHashMap的性能优于Collections.synchronizedmap

结语

从HashMap可以扩展出很多面试题,例如并发问题,集合问题,队列问题等,这些将在后面的相关专题中一一介绍。

你可能感兴趣的:(java,面试,开发语言)