Map 在面试中,占据了很大一部分的面试题目,其中以 HashMap 为主,这些面试题目有的可以说得清楚,有的很难说清楚,如果是面对面面试的话,建议画一画。
答:HashMap 底层是数组 + 链表 + 红黑树的数据结构,数组的主要作用是方便快速查找,时间复杂度是 O(1),默认大小是 16,数组的下标索引是通过 key 的 hashcode 计算出来的,数组元素叫做 Node,当多个 key 的 hashcode 一致,但 key 值不同时,单个 Node 就会转化成链表,链表的查询复杂度是 O(n),当链表的长度大于等于 8 并且数组的大小超过 64 时,链表就会转化成红黑树,红黑树的查询复杂度是 O(log(n)),简单来说,最坏的查询次数相当于红黑树的最大深度。
答:相同点:
不同点:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key 在数组中的位置公式:tab[(n - 1) & hash]
如上代码是 HashMap 的hash 算法。
这其实是一个数学问题,源码中就是通过以上代码来计算 hash 的,首先计算出 key 的 hashcode,因为 key 是 Object,所以会根据 key 的不同类型进行 hashcode 的计算,接着计算 h ^ (h >>> 16)
,这么做的好处是使大多数场景下,算出来的 hash 值比较分散。
一般来说,hash 值算出来之后,要计算当前 key 在数组中的索引下标位置时,可以采用取模的方式,就是索引下标位置 = hash 值 % 数组大小,这样做的好处,就是可以保证计算出来的索引下标值可以均匀的分布在数组的各个索引位置上,但取模操作对于处理器的计算是比较慢的,数学上有个公式,当 b 是 2 的幂次方时,a % b = a &(b-1)
,所以此处索引位置的计算公式我们可以更换为: (n-1) & hash。
此问题可以延伸出三个小问题:
答:如果 key 是数字,直接用 key % 数组大小是完全没有问题的,但我们的 key 还有可能是字符串,是复杂对象,这时候用 字符串或复杂对象 % 数组大小是不行的,所以需要先计算出 key 的 hash 值。
答:hash 算法是 h ^ (h >>> 16)
,为了使计算出的 hash 值更分散,所以选择先将 h 无符号右移 16 位,然后再于 h 异或时,就能达到 h 的高 16 位和低 16 位都能参与计算,减少了碰撞的可能性。
答:key.hashCode()
算出来的 hash 值还不是数组的索引下标,为了随机的计算出索引的下表位置,我们还会用 hash 值和数组大小进行取模,这样子计算出来的索引下标比较均匀分布。
取模操作处理器计算比较慢,处理器对 & 操作就比较擅长,换成了 & 操作,是有数学上证明的支撑,为了提高了处理器处理的速度。
答:因为只有大小是 2 的幂次方时,才能使 hash 值 % n(数组大小) == (n-1) & hash 公式成立。
答:
1:好的 hash 算法,细问的话复述一下上题的 hash 算法;
2:自动扩容,当数组大小快满的时候,采取自动扩容,可以减少 hash 冲突;
3:hash 冲突发生时,采用链表来解决;
4:hash 冲突严重时,链表会自动转化成红黑树,提高遍历速度。
网上列举的一些其它办法,如开放定址法,尽量不要说,因为这些方法资料很少,实战用过的人更少,如果你没有深入研究的话,面试官让你深入描述一下很难说清楚,反而留下不好的印象,说 HashMap 现有的措施就足够了。
答:扩容的时机:
put 时,发现数组为空,进行初始化扩容,默认扩容大小为 16;
put 成功后,发现现有数组大小大于扩容的门阀值时,进行扩容,扩容为老数组大小的 2 倍;
扩容的门阀是 threshold,每次扩容时 threshold 都会被重新计算,门阀值等于数组的大小 * 影响因子(0.75)。
新数组初始化之后,需要将老数组的值拷贝到新数组上,链表和红黑树都有自己拷贝的方法。
答:hash 冲突指的是 key 值的 hashcode 计算相同,但 key 值不同的情况。
如果桶中元素原本只有一个或已经是链表了,新增元素直接追加到链表尾部;
如果桶中元素已经是链表,并且链表个数大于等于 8 时,此时有两种情况:
如果此时数组大小小于 64,数组再次扩容,链表不会转化成红黑树;
如果数组大小大于 64 时,链表就会转化成红黑树。
这里不仅仅判断链表个数大于等于 8,还判断了数组大小,数组容量小于 64 没有立即转化的原因,猜测主要是因为红黑树占用的空间比链表大很多,转化也比较耗时,所以数组容量小的情况下冲突严重,我们可以先尝试扩容,看看能否通过扩容来解决冲突的问题。
答:当链表个数太多了,遍历可能比较耗时,转化成红黑树,可以使遍历的时间复杂度降低,但转化成红黑树,有空间和转化耗时的成本,我们通过泊松分布公式计算,正常情况下,链表个数出现 8 的概念不到千万分之一,所以说正常情况下,链表都不会转化成红黑树,这样设计的目的,是为了防止非正常情况下,比如 hash 算法出了问题时,导致链表个数轻易大于等于 8 时,仍然能够快速遍历。
延伸问题:红黑树什么时候转变成链表。
答:当节点的个数小于等于 6 时,红黑树会自动转化成链表,主要还是考虑红黑树的空间成本问题,当节点个数小于等于 6 时,遍历链表也很快,所以红黑树会重新变成链表。
答:如果数组有了 key,但不想覆盖 value ,可以选择 putIfAbsent 方法,这个方法有个内置变量 onlyIfAbsent,内置是 true ,就不会覆盖,我们平时使用的 put 方法,内置 onlyIfAbsent 为 false,是允许覆盖的。
取值时,如果为空,想返回默认值,可以使用 getOrDefault 方法,方法第一参数为 key,第二个参数为你想返回的默认值,如 map.getOrDefault(“2”,“0”)
,当 map 中没有 key 为 2 的值时,会默认返回 0,而不是空。
HashMap<String,String > map = Maps.newHashMap();
map.put("1","1");
map.put("2","2");
map.forEach((s, s2) -> map.remove("1"));
答:不行,会报错误 ConcurrentModificationException,原因如下图:
图片描述
建议使用迭代器的方式进行删除,原理同 ArrayList 迭代器原理,我们在《List 源码会问那些面试题》中有说到。
答:我们在源码解析中有说,可以详细描述下源码的实现路径,说不清楚的话,可以画一画。
答:DTO 就是一个数据载体,可以看做拥有很多属性的 Java 类,我们可以对这些属性进行 get、set 操作。
看是什么类型的 Map,如果是 HashMap 的话,一定需要覆写 equals 和 hashCode 方法,因为在 get 和 put 的时候,需要通过 equals 方法进行相等的判断;如果是 TreeMap 的话,DTO 需要实现 Comparable 接口,因为 TreeMap 会使用 Comparable 接口进行判断 key 的大小;如果是 LinkedHashMap 的话,和 HashMap 一样的。
答:LRU ,英文全称:Least recently used,中文叫做最近最少访问,在 LinkedHashMap 中,也叫做最少访问删除策略,我们可以通过 removeEldestEntry 方法设定一定的策略,使最少被访问的元素,在适当的时机被删除,原理是在 put 方法执行的最后,LinkedHashMap 会去检查这种策略,如果满足策略,就删除头节点。
保证头节点就是最少访问的元素的原理是:LinkedHashMap 在 get 的时候,都会把当前访问的节点,移动到链表的尾部,慢慢的,就会使头部的节点都是最少被访问的元素。
答:因为 TreeMap 的底层就是通过排序来比较两个 key 的大小的,所以推荐 key 实现 Comparable 接口,是为了往你希望的排序顺序上发展, 而 String 本身已经实现了 Comparable 接口,所以使用 String 时,我们不需要额外的工作,不仅仅是 String ,其他包装类型也都实现了 Comparable 接口,如 Long、Double、Short 等等。
Map 的面试题主要是 HashMap 为主,会问很多源码方面的东西,TreeMap 和 LinkedHashMap 主要以功能和场景为主,作为加分项。
Map 的面试题型很多,但只要弄懂原理,题目再多变化,回答起来都会比较简单。