小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。
这篇文章会非常干货,让你看完直呼哇塞!我会罗列一些关于 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 有什么不同?
- HashMap 是懒惰创建数组的,首次使用的时候才创建数组,也就是在初始化不给定容量的时候,Map 的长度为 0,只有在 put 值的时候才会扩容到默认值 16。
- 计算索引
- 如果桶下标没有被占用,创建 Node 占位返回,如果已经被占用,分两种情况,已经是 TreeNode 走红黑树的添加或更新逻辑,是普通的 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
- 返回前检查容器是否超过阈值,一旦超过进行扩容
不同点
- 在插入时,1.7 是头插入,1.8 是尾插法。
- 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就会扩容。
- 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 的猫腻。