1. HashMap概述
本文基于JDK11的HashMap
撰写。
1.1 数据结构
HashMap
的数据结构由数组链表构成。
JDK1.8中加入了红黑树(优化查找效率)
1.2 类结构
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {}
-
AbstractMap
:Map集合的可重用代码 -
Cloneable
:标识此类可以拷贝 -
Serializable
:标识此类可以序列化
1.3 线程安全性
HashMap
是线程不安全的,其源码中并无保证线程安全的相关代码。
1.4 效率
得益于多线程,使得HashMap
性能要比HashTable
高一点。
不过,需要注意的是HashTable
基本被淘汰,尽量避免使用它。
1.5 其它
-
HashMap
存储的元素是唯一性的。 -
HashMap
存储的元素是无序性的。 -
HashMap
键值对可以为null
,但为null
的键只能存在一个。
2. HashMap.Node
为便下文引用,HashMap.Node
在此章节中统一简写为Node
。
2.1 Node概述
Node
是HashMap
中的一个静态内部类,它的作用是封装存储元素。
如上1.1章节所述,HashMap
是以数组链表组成的,其数组,链表类型就是Node
。
2.2 Node类结构
static class Node<K,V> implements Map.Entry<K,V> {
// 元素哈希值
final int hash;
// 元素键
final K key;
// 元素值
V value;
// 链表结构,用于链接下一个元素
Node<K,V> next;
}
Map.Entry
接口,用于规范可迭代元素。
3. HashMap常用方法刨析
3.1 put
描述
描述:向HashMap
中添加一个映射,如果在此之前存在相同的键则会覆盖原值并返回原值,否则返回null
。
执行流程图
源码分析
public V put(K key, V value) {
// 元素在存储数组中的索引是通过特定的hash相关运算取得
return putVal(hash(key), key, value, false, true);
}
// 取键的hash
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 此方法是HashMap存储元素的核心方法
// hash:hash地址
// key:键
// value:值
// onlyIfAbsent:如果存在相同键,传入true则不会覆盖原值,反之
// evict:此参数大多用于HashMap的实现类
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
// tab:存储数组
// p:标识当前链表节点
// n:标识当前存储数组的长度
// i:标识链表在存储数组中的索引
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 第一次调用put方法 或 存储数组中没有元素时
if ((tab = table) == null || (n = tab.length) == 0)
// resize方法用来给数组扩容
// 此处调用resize是给数组初始化容量
n = (tab = resize()).length;
// 存储数组中i索引的值为null,则创建链表(头节点)
// so important!
// 存储数组的长度 - 1 & 键的哈希值 = 元素在数组中的索引
if ((p = tab[i = (n - 1) & hash]) == null)
// newNode方法用来创建节点(链表头节点)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
// 链表头节点相等(对应的键不为null)
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 转换成红黑树后,添加元素时进入此if代码块
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 遍历链表
for (int binCount = 0; ; ++binCount) {
// 链表的下一个节点为null
if ((e = p.next) == null) {
// 追加一个新的链表节点
p.next = newNode(hash, key, value, null);
// 链表节点大于或等于转换红黑树的阈值时,则将链表转换成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 查找相同键
// 判断当前链表节点的键是否等于新增的键
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 参阅onlyIfAbsent参数
if (!onlyIfAbsent || oldValue == null)
// 修改当前链表节点的值
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 防止并发修改
++modCount;
// 新增后的长度大于下一次准备扩容的长度时,则扩容。
// threshold:临界值,下一次的扩容长度
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
3.2 get
描述
根据key从HashMap
中获取对应value。
执行流程图
源码分析
public V get(Object key) {
Node<K,V> e;
// 如果存在此键则返回对应的value,否则返回null
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 执行条件
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果头节点匹配则立即返回值
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
// 如果已经转换成红黑树,则使用树查找
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 查找链表节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 先决条件不通过或不存在该键则返回null
return null;
}
3.3 remove
描述
根据key从HashMap
中删除对应value。
执行流程图
源码分析
public V remove(Object key) {
Node<K,V> e;
// 如果HashMap中存在此键,则删除并返回key对应值,否则返回null。
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 先决条件,不满足直接返回null
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 匹配头节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 节点是否有子节点
else if ((e = p.next) != null) {
// 如果链表已经转换成红黑树,则进入此if代码块
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 遍历所有节点,查找相同键
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 删除元素的先决条件
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 红黑树的删除
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// 链表头节点的删除
else if (node == p)
tab[index] = node.next;
else
// 链表其它节点的删除
p.next = node.next;
// 删除后的相关操作
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}
4. HashMap要点解惑
4.1 (n - 1) & hash 分析
n 是存储数组的默认长度(容量), HashMap
源码中可得知其初始值为 1 << 4
。
其 n - 1
= 15,二进制为 1111。
令一个数与 n - 1
做与运算,可以省去 hash 位级表示的第四位以上数字
例子
hash: 10110010
n-1: 00001111
y&(n-1):00000010
其特性与hash
对 n - 1
取模效果一致
hash: 10110010
n: 00010000
n%hash: 00000010
1 << 4,而不是写16。
这样写主要是直观,无论左位移几位,它都是2的幂,也就是2的n次方。
位运算的代价比取模运算性能高,资源消耗小很多。
HashMap作为最常用的集合,一点性能损失将会被放无限放大。
4.2 为什么 null 键位于第一位?
因HashMap
中存储元素是将其存放在数组中,其数组索引是经过特定hash运算得出。
而null
值经过hash运算得出来的值固定为0,那么null
值始终排在数组第一位。
其实HashMap严格来讲并不是无序的,因为它按照hash规则生成索引。
4.3 为什么要转换成红黑树
因链表结构的特性,查找最坏的情况下时间复杂度是O(n)
。
如果是查找一个不存在的值,那么将会把整个链表遍历一遍,这是非常不合适的!
由此引申出红黑树,使用其优化查找效率,时间复杂度为O(log2n)
。
之所以转换成红黑树,主要原因是其它特性的二叉树会在极端情况下会导致二叉树不平衡。
不平衡的二叉树与链表几乎是同样的时间复杂度
O(n)
。例如:二叉树插入元素(1, 2, 3, 4)
为何不直接使用红黑树优化,而是链表转红黑树,主要原因是其左旋,右旋性能损耗太大。
这也是为什么链表长度大于等于8时会判断是否转换为红黑树,只有元素个数超出64时才会将链表转换成红黑树。
4.4 负载因子的作用
负载因子的作用主要是减少哈希碰撞,决定存储数组的空间利用率。
4.5 负载因子的值为什么是 0.75
负载因子值取值范围在0 ~ 1之间,但JDK默认为0.75主要原因如下。
取值范围必须合理,如果太大则会造成大量的哈希冲突,如果太小则空间利用率太低。
当负载因子为1时,则存储元素的个数达到了存储数组的长度则会进行扩容,那么这样哈希碰撞的可能极高,因为每个数组索引在最坏情况下都会被使用。
HashMap
扩容时采用的公式:临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)
根据 HashMap
的扩容机制,它会保证 capacity 的值永远都是2的幂。
只有当负载因子为0.75时和任何2的幂乘积结果都是整数。
哈希碰撞解决参见 4.7 章节
4.6 为什么自定义类型做Key需要重写hashCode与equals
自定义类型当作HashMap
的Key时,不重写hashCode与equals时是无法正常使用HashMap
的。
因为HashMap
的内部存储使用key的hash存储的,而hash是通过hashCode
求得而来的,当hash相同的时候就会判断equals
是否也相同,否则会当作哈希冲突而跳过当前键。
因为hashCode
比较地址而不是逐个比较属性值,所以性能比equals
高出很多。这也便是优先使用hashCode
查找,后而使用equals
二次比较的原因。
4.7 哈希碰撞是如何解决的
HashMap
解决哈希碰撞用的是链地址法,也就是前文所说的数组链表。
但是,Hash碰撞太高的话,数组链表就会悄然的转换成链表。
所以为了提高查找效率就必须得减少哈希碰撞的几率,而减少哈希碰撞几率的方法就是4.5章节所述的负载因子。
4.8 为什么建议初始化长度是2的幂
主要原因是4.1章节的(n - 1) & hash
,其运算的先决条件就是2的幂。
HashMap
内部确保容量始终是2的幂。
static final int tableSizeFor(int cap) {
// 获取二进制的cap前导0个数
// 例子:cap = 16,则前导0个数为28
// 28个0 + 4个1(15) = 32位(int的bit数)
// 无符号右移高位统统补0,n + 1 决定了值始终为2的幂
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
4.9 头插入,尾插入
JDK8后采用尾插入
头插入就是插入的新元素总是放在链表的头部位置。
尾插入就是安装正常顺序放置的元素,总是放置在末尾节点。
为何JDK8之后改用尾插入?
此问题主要是因为HashMap
扩容时,元素会重新进行hash,分配元素到新的存储数组中。此操作可能会导致原先的头节点会被分配至子节点。
所以头插入会导致无限循环,也就是死链
。
而尾插入并没有指向的问题,并不会出现无限循环(死链)现象。
4.11 扩容机制
-
创建一个新的空数组,长度为原数组长度的两倍。
-
遍历原数组,把所有的元素重新hash到新数组中。
此处为何不直接复制,而是重新hash?
如4.1章节中所述,(n - 1) & hash
的值取决于数组长度。
数组扩容后长度改变,取得数组索引结果也是不同的。
所以必须得重新hash后,才能放置正确合理的索引位中。
4.12 HashMap为什么线程不安全
HashMap
中并无维护线程安全相关的代码,例如synchronized
,JUC
。
那么没有维护线程安全的代码,则在多线程情况下操作就有可能会造成如下情况。
如果其它线程put(1)时,另一个线程正好同时get(1),则可能获取到的值还是原值。
5. 引用
撰写本文时,查阅了部分文章。