1、HashMap 了解吗?平时在什么地方使用过它呢?
我们需要根据键值获取到元素值时就选用 Map
接口下的集合,需要排序时选择 TreeMap
,不需要排序时就选择 HashMap
,需要保证线程安全就选用 ConcurrentHashMap。
HashMap
底层利用数组支持下标随机访问数据的特性,快速的对键值对进行增删改查操作。
2、HashMap 底层数据结构说一下?(直接说最新的即可,无需对比以前的版本,但要强调介绍的版本号,保证严谨)
在最新的 JDK 1.8 中,HashMap 的底层数据结构为 “哈希表 + 链表 + 红黑树”。当哈希表中出现哈希冲突时,HashMap 采用 “链地址法” 来解决,也就是哈希表中的每个槽位,都会对应一个链表,所有哈希值相同的元素都会被放到同一个槽位对应的链表中。但随着链表长度的增加,元素的读取效率会下降,直到达到某个阈值时(目前JDK是8),HashMap 会将链表转化为红黑树,进一步提升性能。
将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树。
3、为什么用红黑树呢?用平衡二叉树不可以吗?或者你讲一讲他们各自的优缺点吗?
- 红黑树是弱平衡二叉树,整棵树可以有局部的不平衡。
- AVL 树是强平衡二叉树,它严格要求整棵树的平衡性。
也就是说,虽然两者的插入,删除复杂度都为 O(logn),实际中 AVL 树需要执行更多的旋转操作来保证强平衡性,效率要低于红黑树。但红黑树也有缺点,它需要额外的字段来记录每个节点的颜色,因此会占用更多的存储空间。
4、为什么选择 8 之后转为红黑树呢?另外链表转为红黑树之后,还会继续转为链表吗?
这个在源码的注释中有解释,大致意思为:如果元素的哈希值足够随机,理想情况下链表的长度对应的概率符合泊松分布,达到 8 的概率小于千万分之一。也就是说,一般情况下并不会发生链表到红黑树的转化,更多是一种防止自己选取的哈希算法不好的保底策略,在极端情况下仍会有较好的效率。
但是,当红黑树的节点小于 6 时,红黑树又会转回链表,原因是数据量很小的情况下,空间和时间上链表都要比红黑树优秀。至于为什么要把这个阈值定为 6,而不同样定为 8,主要是而为了防止元素数量在 8 附近导致两种数据结构的频繁转换。
5、简单描述下 put 的流程?可以说一下JDK位了效率更快,在 put 的时候,做了哪些优化不?
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,进行扩容
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中,桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
// 桶中已经存在元素(处理hash冲突)
else {
Node<K,V> e; K k;
//快速判断第一个节点table[i]的key是否与插入的key一样,若相同就直接使用插入的值p替换掉旧的值e。
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判断插入的是否是红黑树节点
else if (p instanceof TreeNode)
// 放入树中
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 不是红黑树节点则说明为链表结点
else {
// 在链表最末插入结点
for (int binCount = 0; ; ++binCount) {
// 到达链表的尾部
if ((e = p.next) == null) {
// 在尾部插入新结点
p.next = newNode(hash, key, value, null);
// 结点数量达到阈值(默认为 8 ),执行 treeifyBin 方法
// 这个方法会根据 HashMap 数组来决定是否转换为红黑树。
// 只有当数组长度大于或者等于 64 的情况下,才会执行转换红黑树操作,以减少搜索时间。否则,就是只是对数组扩容。
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
// 跳出循环
break;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++modCount;
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
“首先 put( ) 会计算出要插入 key 的哈希值,通过哈希值计算出其在数组中的索引位置,如果该位置上没有元素则直接插入,有元素则需要遍历这个位置上的所有元素。如果能找到与当前键相等的键值对,则将其更新为当前值并返回旧值,如果找不到与当前键相等的键值对,则需要执行真正的插入操作,将其插入到链表或者红黑树中,最后判断插入后是否需要扩容。”
put( ) 的优化我印象深的是计算 key 哈希值的 hash( ),主要有两个优化的点:
- 使用位运算代替取模运算和对 hashCode 进行搅动计算。具体来说,可以用x这个公式将取模转变为位运算来提升性能,但是同时也需要底层数组的长度是 2 的倍数,这个在 HashMap 的初始化和扩容方法中做了保证。
- 除此之外,为了进一步降低哈希冲突的概率,hash( ) 又通过多个与运算将哈希值的高位和低位进行搅动,尽可能的做到在不同 key 中哪怕有一个位的不同,都会对最终产生的哈希值造成影响。
6、多线程情况下,put 是线程安全的吗?可以简单举个例子,说一下哪里不安全吗?
不是,在 JDK 1.7 中多线程同时进行 put( ) 会出现数据覆盖问题,在需要扩容时也可能会出现链死循环问题。JDK 1.8 修复了链死循环,但数据覆盖问题依然存在。
JDK 1.7 的 HashMap 底层为数组 + 链表,扩容的 transfer( ) 会遍历原链表中的每个节点,采用头插法将其转移到新哈希表槽位的链表中,这个过程在多线程下会导致新链表中出现环路,并造成某些元素丢失。
JDK 1.8 采用的是尾插法,保证了元素在扩容前后的顺序一致,避免了死循环问题,但还会造成数据覆盖。比如两个线程同时执行 put( ),且两个线程都同时判断槽位为空,则后插入的数据会覆盖先插入的数据。
但是还是不建议在多线程下使用 HashMap
,因为多线程下使用 HashMap
还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap
。
7、如果我想要让 hashmap 变成线程安全的,你觉得可以怎么做?(有时候会扯到 concurrentHashMap,不过咱们这里先不追击这个)
想要解决 HashMap 的线程不安全问题,首先我们不能修改源码,那就要么使用一些 “辅助” 操作,让它变得安全,要么就寻找替代品。首先说的 “辅助” 操作是指,使用 Collections 类的 synchronizedMap 方法包装一下,它返回由指定映射支持的同步映射,是线程安全的。换替代品的话,可以考虑 HashTable,HashTable 通过将整个表上锁来实现线程安全,某些情况下效率很低。还可以使用 ConcurrentHashMap,它使用分段锁或者 CAS 操作来保证线程安全。
7.5、ConcurrentHashMap和HashTable线程安全的具体实现方式
- 底层数据结构: JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树。Hashtable
和 JDK1.8 之前的HashMap
的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; - 实现线程安全的方式(重要):
- 在 JDK1.7 的时候,
ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 - 到了 JDK1.8 的时候,
ConcurrentHashMap
已经摒弃了Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作。
- 在 JDK1.7 的时候,
HashTable(上) 和 ConcurrentHashMap(下)
8、头插法会导致死循环,那你觉得在以前的版本中,为啥会使用头插法呢?
采用头插法的话,最新插入的数据就会在链表的最前边,根据程序的局部性原理,最近被访问的数据很可能不久之后会再次访问,那么此时可以在 O(1) 时间返回。
9、那我们再说一说 HashMap 的扩容吧,什么时候会扩容呢?你觉得为啥负载因子为啥选择 0.75 呢?
HashMap 需要扩容时,可以分为几种情况来考虑。
- 首先是在无参的构造函数中。在第一次进行 put 操作之前,HashMap 内部数组为 null,第一次 put 后才会开始第一次初始化扩容,默认为 16 。
- 其次是指定了初始容量的构造参数,也是在第一次 put 操作之后才开始初始化扩容,但此时的容量是第一个不小于指定容量的 2 的幂数,阈值为计算后容量乘负载因子。
- 其它情况就是,非首次 put,导致容量大于阈值,需要扩容。容量和阈值都变为原来的 2 倍,负载因子不变。
负载因子为 0.75 的原因,简单来说是 “哈希冲突” 和 “空间利用率“ 矛盾的一个折中。原因是,扩容因子是用来计算阈值的,阈值为底层 table 长度乘负载因子,当 HashMap 容量大于阈值时会触发扩容。所以如果负载因子过小,table 中还没填几个元素就要扩容,虽然哈希冲突概率很小,但空间浪费太多。相反,如果负载因子过大,空间利用率是高,但哈希冲突的概率也大大增加。那就取个折中吧,为 0.75。
10、频繁扩容会导致效率比较低下,那你觉得在平时,在实际的开发场景中,可以怎么优化来避免频繁扩容呢?
容易想到的就是,提前预估业务的存储量,设置一个较大的初始容量。这时不用考虑它是否是 2 的次幂,HashMap 自己会计算出第一个大于等于给定容量的 2 次幂来作为初始容量。除此之外,可以自定义负载因子的大小,对哈希函数优化等等。
11、一个场景题:只存60个键值对,需要设置初始化容量吗?设置的话设置多少初始化容量比较好呢?
HashMap 默认的初始容量大小为 16。如果不设置初始容量的话,根据规则 size > threshold 时会触发扩容,且 threshold = loadFactor *capacitry,最终 capacity 会经历 16 – 32 – 64 – 128 三次扩容操作。考虑到HashMap 自己会计算出第一个大于等于给定容量的 2 次幂来作为初始容量,所以随机选一个 65 – 128 之间的数作为初始容量即可。