HashMap 详解
基于 哈希表 的 Map<K, V> 接口的实现。
此实现提供所有可选的 映射 操作,并允许使用 null 值和 null 键。
是非线程安全的实现。
1、JDK7 和 JDK8 的 HashMap 的区别?
JDK7:
1. 底层实现
基于 数组 + 链表 来实现,它的底层维护一个 Entry 数组,它会根据计算的 hashCode 将对应的 KV 键值对 存储到该数组中。
2. 解决 Hash 冲突的方法
将该 KV 键值对 放到对应的已有元素的后面,形成了一个 链表式 的存储结构。
缺点:当 Hash 冲突 严重时,在桶上形成的链表会变得越来越长,这样在查询时的效率就会越来越低,其时间复杂度为O(N)。
JDK8:
1. 底层实现
是基于 数组 + 链表 + 红黑树 来实现的,它的底层维护一个 Node 数组,当链表的存储的数据个数 大于等于8 的时候,不再采用 链表 存储,而采用了 红黑树 存储结构。
2. 解决 Hash 冲突的方法
当链表长度到达一个阈值时,会将 单向链表 转换成 红黑树 提高性能。而当链表长度缩小到另一个阈值时,又会将 红黑树 转换回 单向链表 提高性能。
优点:大大的提高了 查找 性能,链表 为 O(N),而 红黑树 是 O(logN)。
2、HashMap 底层的实现原理
它基于 hash 算法,通过 put 方法 和 get方法 存储 和 获取对象。
存储对象 :我们将 K/V 传给 put 方法 时,它调用 K 的 hashCode 方法 计算 hash 值 从而得到 bucket位置,进一步存储,HashMap 会根据当前 bucket 的占用情况自动调整容量(超过 Load Facotr 则resize为原来的2倍)。
获取对象: 我们将 K 传给 get 方法 时,它调用 K 的 hashCode 方法 计算 hash 值 从而得到 bucket 位置,并进一步调用 equals() 方法 确定键值对。
如果发生碰撞的时候,HashMap 通过 链表 将产生碰撞冲突的元素组织起来。在 JDK8 中,如果一个 bucket 中碰撞冲突的元素超过某个限制(默认是8),则使用 红黑树 来替换 链表,从而提高速度。
bucket 是什么?
bucket 即 哈希桶数组:Node<K,V>[] table,Node 是 HashMap 的一个 内部类,实现了Map.Entry 接口。
源码:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
}
transient Node<K,V>[] table;
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
3、HashMap 为什么使用 红黑树 而不用 B树 ?
B/B+ 树 多用于 外存 上,B/B+ 树 也成为了一个磁盘友好的数据结构。(MySQL 底层就是用 B+ 树实现的)
如果用 B/B+ 树 的话,在数据量不是很多的情况下,数据都会 “挤在” 一个结点里面,这个时候遍历效率就退化成了 链表。
什么是 B树 ?
B树 是一种 平衡的多路查找树。一个节点可以存入多个值(当数据量较多时,可以提高遍历效率;但当数据量比较少时,遍历效率反而比较低)。
4、HashMap 为什么 线程不安全?
HashMap 在并发执行 put 操作时,可能会导致形成 循环链表,从而引起死循环。
5、HashMap 的 循环链表 是怎么产生的?
导致死循环的根本原因是 JDK7 扩容采用的是 “头插法”,会导致同一索引位置的节点在扩容后顺序反掉。而 JDK8 之后采用的是 “尾插法”,扩容后节点顺序不会反掉,理论上不存在死循环问题,但实际上依然存在死循环问题。
6、HashMap 是怎么 扩容 的?
- 数组的 初始容量为 16,而容量是以 2 的次方 扩充的,一是为了提高性能使用足够大的数组,二是为了能使用 位运算 代替 取模预算(据说提升了5~8倍)。
- 数组是否需要扩充 是通过 负载因子 判断的,如果当前元素个数为数组容量的 0.75 时,就会扩充数组。这个 0.75 就是默认的负载因子,可由构造器传入。我们也可以设置大于 1 的负载因子,这样数组就不会扩充,牺牲性能,节省内存。
- 为了解决碰撞,数组中的元素是 单向链表 类型。当链表长度到达一个阈值时(7 或 8),会将链表 转换成 红黑树 提高性能。而当链表长度缩小到另一个阈值时(6),又会将 红黑树 转换回 单向链表 提高性能。
- 对于第三点补充说明,检查 链表长度 转换成 红黑树 之前,还会先检测 当前数组容量 是否到达一个阈值(64),如果没有到达这个容量,会放弃转换,先去 扩充数组。所以上面也说了链表长度的阈值是 7 或 8,因为会有一次放弃转换的操作。
补充:扩容阈值(threshold) = 容量 * 负载因子(loadFactor)
事例:
例如:容量从 16 扩容到 32 ,具体的变化如下所示:
因此元素在重新计算 hash 之后,因为 n 变为 2 倍,那么 n-1 的 mask 范围在高位多 1bit(红色),因此新的 index 就会发生这样的变化:
因此,我们在扩充 HashMap 的时候,不需要重新计算 hash,只需要看看原来的 hash 值 新增 的那个 bit 是 1 还是 0 就好了,是 0 的话 索引没变,是 1 的话 索引变成 “原索引 + oldCap”。可以看看下图为 16 扩充为 32 的 resize 示意图:
这个设计确实非常的巧妙,既省去了重新计算 hash 值 的时间,而且同时,由于新增的 1bit 是 0 还是1 可以认为是随机的,因此 resize 的过程,均匀的把之前的冲突的节点分散到新的 bucket 了。
部分资料来源:
https://www.nowcoder.com/tutorial/94