08、Map源码面试题

1、 Map 数据结构类问题

1、 说一说 HashMap 底层数据结构

HashMap的底层数据结构是数组+链表+红黑树,之所以采用这种结构,是因为数组通过下标查询的速度很快,方便快速查找,时间复杂度是O(1),数组的默认大小是16;当向HashMap中put()元素时,会先根据key计算数组的下标index(计算方式是(n-1) & hash,n是数组的长度,hash是key的hashcode),通过index获取数组的值,如果值不为null就说明hash冲突了,会接着寻找以该数组元素为头节点的链表,链表查询的时间复杂度是O(n),当链表长度等于8并且数组长度大于64时,链表会转换成红黑树,红黑树的时间复杂度是O(log(n)),当红黑树的节点数量等于6时,红黑树又会转换成链表。

2、HashMap、TreeMap、LinkedHashMap的相同点与不同点

相同点:

  1. 三者存放的都是k-v键值对;
  2. 都可以使用迭代器的方式遍历,当使用迭代器遍历时,如果集合被改变,都会快速失败;
  3. 都是线程不安全的,当作为共享变量时,存在线程安全问题;

不同点:

  1. 底层数据结构不同,HashMap的底层数据结构是数组+链表+红黑树;TreeMap底层数据结构是红黑树;LinkedHashMap继承了HashMap,所以底层数据结构也是数组+链表+红黑树;

  2. HashMap中的节点是无序的;TreeMap中的节点是根据key自动排序(可以自定义排序规则);LinkedHashMap中的节点也是有序的,可以按照元素存放顺序和访问最少删除进行访问;

  3. 使用场景不同,HashMap由于使用了数组,查询效率很快,但是里面的节点是无序的;TreeMap的底层数据结构是红黑树,元素在加进来的时候就会进行排序,查询速度也很快,但是新增和删除元素时需要不断的自旋来维护节点的顺序,效率相对较低;LinkedHashMap主要用于需要根据存放顺序进行访问的场景;

3、说一下Map的hash算法

HashMap 的hash 算法源码如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
key 在数组中的位置公式:tab[(n - 1) & hash]

首先计算出 key 的 hashcode,因为 key 是 Object,所以会根据 key 的不同类型进行 hashcode 的计算,接着计算 h ^ (h >>> 16) ,这么做的好处是使大多数场景下,算出来的 hash 值比较分散。

一般来说,hash 值算出来之后,要计算当前 key 在数组中的索引下标位置时,可以采用取模的方式,就是索引下标位置 = hash 值 % 数组大小,这样做的好处,就是可以保证计算出来的索引下标值可以均匀的分布在数组的各个索引位置上,但取模操作对于处理器的计算是比较慢的,数学上有个公式,当 b 是 2 的幂次方时,a % b = (b-1)& a,所以此处索引位置的计算公式我们可以更换为: (n-1) & hash。

int hash = "hello".hashCode();
System.out.println("hash: " + hash);
System.out.println("hash % 16 : " +  (hash % 16));
System.out.println("15 & hash : " + (15 & hash));

/**
* 运行结果:
* hash: 99162322
* hash % 16 : 2
* 15 & hash : 2
*/

此问题可以延伸出三个小问题:

  1. 为什么不用 key % 数组大小,而是需要用 key 的 hash 值 % 数组大小?

    如果 key 是数字,直接用 key % 数组大小是完全没有问题的,但我们的 key 还有可能是字符串,是复杂对象,这时候用 字符串或复杂对象 % 数组大小是不行的,所以需要先计算出 key 的 hash 值。

  2. 计算 hash 值时,为什么需要右移 16 位?

    hash 算法是 h ^ (h >>> 16),为了使计算出的 hash 值更分散,所以选择先将 h 无符号右移 16 位,然后再于 h 异或时,就能达到 h 的高 16 位和低 16 位都能参与计算,减少了碰撞的可能性。

  3. 为什么把取模操作换成了 & 操作?

    key.hashCode() 算出来的 hash 值还不是数组的索引下标,为了随机的计算出索引的下表位置,我们还会用 hash 值和数组大小进行取模,这样子计算出来的索引下标比较均匀分布。取模操作处理器计算比较慢,处理器对 & 操作就比较擅长,换成了 & 操作,是有数学上证明的支撑,为了提高了处理器处理的速度。

  4. 为什么提倡HashMap的数组大小是 2 的幂次方?

    主要原因有两点:

    1. 因为只有大小是 2 的幂次方时,才能使 hash 值 % n(数组大小) == (n-1) & hash 公式成立;

    2. 使数组扩容是成本更低,当数组扩容时,会重新计算原来的HashMap中元素在数组中的下标,进行数据迁移,当数组扩容后的大小等于原来的数组大小的两倍时,链表中只会有一半的元素需要进行迁移,原理参考博文:

4、HashMap是如何解决hash冲突的

对于hash冲突,HashMap的解决方案如下:

  1. 设计好的hash算法,(h = key.hashCode()) ^ (h >>> 16)让hashcode的高16位和低16位都参与运算,使计算出来的hash更加分散,减小碰撞概率;
  2. 数组自动扩容,当存在链表长度大于等于8且数组长度小于64,或HashMap中的元素数量大于阈值threshold时,数组会自动扩容;
  3. 采用链表结构,当hash冲突时,使用链表来处理;
  4. 当hash冲突严重时,将链表转化成红黑树,保证查询性能(只有极端情况下或hash算法有问题时,才会出现红黑树)

2、 HashMap 源码细节类问题

1、HashMap是如何扩容的

扩容的时机:

  1. put 时,发现数组为空,进行初始化扩容,默认扩容大小为 16;
  2. put 成功后,发现HashMap元素总数量大于扩容的阀值时,进行扩容,扩容为老数组大小的 2 倍;
  3. hash冲突,put成功后,发现链表的长度大于等于8,且数组长度小于64,进行扩容,扩容为老数组大小的 2 倍;

扩容的阈值是 threshold,每次扩容时 threshold 都会被重新计算,阈值等于数组的大小 * 影响因子(0.75)。新数组初始化或扩容之后,需要将老数组的值拷贝到新数组上,链表和红黑树都有自己拷贝的方法

2、当链表长度大于等于8时,为什么要先判断数组容量是否小于64,再决定是否将链表转成红黑树

之所以要判断数组容量,主要是因为红黑树占用的空间比链表大很多,转化也比较耗时,所以数组容量小的情况下冲突严重,可以先尝试扩容,看看能否通过扩容来解决冲突的问题。

3、为什么链表长度大于等于 8 时,链表要转化成红黑树

链表查询的平均时间复杂度是O(n)/2,红黑树的时间复杂度是O(logn),当链表长度大于等于8时,链表的查询性能开始明显小于红黑树,为了提高查询性能,所以将链表转化成红黑树;同样的,当红黑树的节点数量小于等于6时,红黑树的查询性能并不大于链表,所以又将红黑树转化成了链表,之所以不使用7,是为了避免链表与红黑树之间的频繁转换(例如,重复的删除或新增同一个元素,如果阈值是7,会导致链表于红黑树之间频繁转换)。

4、为什么不直接使用数组+红黑树,而要使用数组+链表+红黑树

红黑树占用的空间比链表大很多,并且当新增或删除元素时,红黑树会不断的通过自旋来维护节点key的顺序,这个过程时很耗费时间的,事实上,基本不会出现链表转化成红黑树的情况。通过泊松分布公式计算,正常情况下,链表个数出现 8 的概率不到千万分之一(数组容量为8时,概率为1/8^8),所以说正常情况下,链表都不会转化成红黑树,这样设计的目的,是为了防止非正常情况下,比如 hash 算法出了问题时,导致链表个数轻易大于等于 8 时,仍然能够快速遍历。

5、Map 在 put 时,如果数组中已经有了这个 key,不想把 value 覆盖怎么办?取值时,如果得到的 value 是空时,想返回默认值怎么办?

如果数组有了 key,但不想覆盖 value ,可以选择 putIfAbsent 方法;取值时,如果为空,想返回默认值,可以使用 getOrDefault 方法。

这两个方法是在Map接口中定于的,HashMap、TreeMap、LinkedHashMap都有这两个方法。

3、其它 Map 面试题

1、DTO 作为 Map 的 key 时,有无需要注意的点?

看是什么类型的 Map,如果是 HashMap和LinkedHashMap,一定需要覆写 equals 和 hashCode 方法,因为在 get 和 put 的时候,需要通过 equals 方法进行相等的判断;如果是 TreeMap,DTO 需要实现 Comparable 接口,因为 TreeMap 会使用 Comparable 接口进行判断 key 的大小,从而保证红黑树中的元素左小右大。

2、LinkedHashMap 中的 LRU 是什么意思,是如何实现的

LRU ,英文全称:Least recently used,中文叫做最近最少访问,在 LinkedHashMap 中,也叫做最少访问删除策略,我们可以通过 removeEldestEntry 方法设定一定的策略,使最少被访问的元素,在适当的时机被删除,原理是在 put 方法执行的最后,LinkedHashMap 会去检查这种策略,如果满足策略,就删除头节点。保证头节点就是最少访问的元素的原理是:LinkedHashMap 在 get 的时候,都会把当前访问的节点,移动到链表的尾部,慢慢的,就会使头部的节点都是最少被访问的元素。

3、为什么推荐 TreeMap 的元素最好都实现 Comparable 接口?但 key 是 String 的时候,却没有额外的工作呢?

因为 TreeMap 的底层就是通过排序来比较两个 key 的大小的,所以推荐 key 实现 Comparable 接口,是为了往你希望的排序顺序上发展, 而 String 本身已经实现了 Comparable 接口,所以使用 String 时,我们不需要额外的工作,不仅仅是 String ,其他包装类型也都实现了 Comparable 接口,如 Long、Double、Short 等等。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值