面试中关于 HashMap 的高频问题

233 阅读6分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

这篇文章会非常干货,让你看完直呼哇塞!我会罗列一些关于 HashMap 的高频问题,希望可以带你一起回顾一下我们熟悉的 HashMap。

JDK 7 和 JDK 8 中, HashMap 底层有什么不同?

JDK 7 的底层是数组加链表,而 JDK 8 的底层是数组加链表加红黑数。数组和链表没啥好说的,红黑树就是一种平衡二叉树,它的特点就是根节点比左边的元素大,比右边的元素小。

这里要说一下,为什么要出现红黑树呢,是为了查询的优化,当发生极度 hash 冲突的时候,链表的长度可能会大于 8,而此时再查询一个链表末端的元素,需要从链表的头部开始遍历,一直找到最后,慢!有了树形结构,为的就是更快的查询出元素。

但是这里不得不说的就是出现了树是一种不好的表现,说明你的 hash 算法写的很垃圾或是你被黑客攻击了,让元素都跑到一个桶里面了。

为什么要使用红黑树,为何不直接使用数组加红黑树的组合?什么时候会转化为树?什么时候树又会退化为链表?

这个夺命三连问,真的让你崩溃啊,为什么使用树其实上面已经说了,防止被 dos 攻击或是链表过长的时候查询速度慢。直接使用树的话占用的空间更大,浪费空间。

OK,我们要使用树,但是我们又要避免出现树,这就是红黑树的尴尬,也是设计者的智慧体现!什么时候会转化为树呢,这里要和很多人想的不一样了,树化有两个触发条件,第一是链表的长度大于 8 ,第二是数组的容量大于等于 64 的时候。第二个条件大家都不知道吧。

为什么树化的阈值选为 8 呢,如果 hash 值如果足够随机,那么在 hash 表内就会按泊松分布,在负载因为 0.75 的情况下,长度超过 8 的链表出现的概率是 0.00000006,选择 8 就是为了让树化几率足够小。

树退化的情况有两个,在扩容时如果需要拆分树,且树元素的个数小于等于 6 ,则会退化为链表。第二是,remove 树节点时,若 root root.left root.right root.left.left 有一个为 null,也会退化为链表。这里是检测 remove 之前的状态。很纳闷为什么会这样是吧,我也很纳闷,估计就是时间和空间的折中吧。

static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;

介绍一下 put 方法流程,JDK 7 和 8 有什么不同?

  1. HashMap 是懒惰创建数组的,首次使用的时候才创建数组,也就是在初始化不给定容量的时候,Map 的长度为 0,只有在 put 值的时候才会扩容到默认值 16。
  1. 计算索引
  2. 如果桶下标没有被占用,创建 Node 占位返回,如果已经被占用,分两种情况,已经是 TreeNode 走红黑树的添加或更新逻辑,是普通的 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
  3. 返回前检查容器是否超过阈值,一旦超过进行扩容

不同点

  1. 在插入时,1.7 是头插入,1.8 是尾插法。
  1. 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就会扩容。
  2. 1.8 在扩容计算 Node 索引时,会优化,优化的逻辑就是 hash & capacity == 0 就放在原位置。这个优化有个前提,capacity 必须是 2 的 n 次方才行。

这就引出另外一个问题,为什么要二次 hash ,数组容量为何是 2 的 n 次幂?

另外,HashMap 的 key 可以为 null,作为 key 的对象,必须要实现 hashCode 和 equals 方法,并且 key 的内容不能变,因为这次得到一个 hash 值,下次得到另一个 hash 值,岂不乱套了。

额外说一说 get 的逻辑,根据 key 计算出索引值,找到,如果是链表是红黑树根据 key 的 equals 方法比较,得到对应的元素,这里也可以看到为什么作为 key 的对象要实现 hashCode 和 equals 方法,而且最好是不可变类了,所以我们常用 String 作为 key,因为 String 是不可变类哈。

为什么要二次 hash ,数组容量为何是 2 的 n 次幂?

索引的计算需要二次哈希,首先计算对象的 hashCode(), 再调用 HashMap 的 hash() 进行二次哈希,最后 & (capacity - 1) 得到索引。二次哈希是为了综合高位数组,让哈希分布的更为均匀。计算索引时,如果是 2 的 n 次幂,可以使用位与运算代替模运算,效率更高。扩容时 hash & oldCapacity == 0 的元素留在原位置,否则新位置 = 旧位置 + oldCapacity。

计算索引最后一步是 二次 hash 值 & (capacity -1) 也就等于二次 hash 值 % capacity ,你们说说设计 API 的这些人啊,怎么可能数学不好!

以上这些优化都需要容量是 2 的 n 次幂,不然无法使用位与运算。但是容量不是 2 的 n 次幂的 Hashtable 也活的好好的,就是没人使用……

capacity 是 2 的 n 次幂,也有一个缺点,当数组都是偶数时,奇数索引的位置没有数据……

负载因子为什么是 0.75

空间和时间的权衡之后的结果,大于这个值,空间节省了,链表变长了,查询慢。小于这个值,查询快了,扩容更频繁空间浪费了。

多线程下 HashMap 有啥问题?

首先,HashMap 是线程不安全的,在 1.7 时可能会发生扩容死链问题,1.8 时可能会发生数据错乱问题。

在 1.7 时,例如,在扩容前后,A,B 都本应该在一个桶里的链表上,且 A 在上 B 在下,A.next = B 此时发生了扩容,在新桶中 A 先入桶,B 再入桶,因为是头插法,B 在 A 的上面了,B.next = A,此时就发生了死链。

在 1.8 时,线程 1 找到索引,准备 put 值进去,此时卡住,线程 2 找到和线程 1 相同的索引,put 进去了,然后线程 1 开动了,啊偶,线程 2 的值被覆盖了。

正是因为有了问题,所以衍生出了 ConcurrentHashMap ,你看,面试就是这么进行下去的。持续关注,后续分享 ConcurrentHashMap 的猫腻。