转载自:https://huanglei.rocks/coding/inside-jdk-hashmap.html (该个人博客十分geek)
基于 OpenJDK1.8
1 综述
1.1 内部类和字段
1.1.1 Node<K,V>
实现了Map.Entry
接口,代表链表状态下 HashMap 里面存放的一个元素。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
//...
}
Node
当中有一个next
字段,指向另一个Node
实例。也就是说Node
是链表的一个元素。
1.1.2 TreeNode<K,V>
TreeNode<K,V>
是Node<K,V>
的一个子类,用于当链表升级为红黑树时存储 entry。
1.1.3 table:Node<K,V>[]
节点元素的数组,即所谓的 bucket
1.1.4 modCount:int
HashMap 被修改的次数。多线程条件下可能某个线程迭代 map 的时候另一个线程修改了 map 的元素,可能导致数据的不一致。而迭代的线程可以通过在迭代开始和结束的时候的modCount
来判断是否有另一个线程在这个过程中修改了数据,如果修改了则抛出ConcurrentModifictionException
(详见java.util.HashMap#forEach
)。
1.2 底层数据结构
HashMap 会根据当前 table 的大小和冲突情况,逐渐升级存储的数据结构,数组->链表->RBT。
一开始 HashMap 由一个 Entry 数组支撑,也就是table:Map.Entry<K,V>[]
。table
当中的每一个元素都是某个链表的头结点,结构如下:
- 如果两个 key 的 hashCode 不同,那么这两个元素被放在
table:Node<K,V>[]
数组的不同位置上 - 如果两个 key 的 hashCode 相同,也就是发生了 hash 冲突,则两个元素被 append 到
table:Node<K,V>[]
的相同位置的链表尾部
显然,hashCode 的设计直接关系到 HashMap 的查询效率。如果 hashcode 没有冲突,那么 HashMap 的查询效率是O(1)
,如果出现了 hash 冲突,那么 HashMap 的查询效率下降到O(N)
。
2 插入
2.1 流程综述
通过 key 的hashCode
方法获取 hashCode,对 hashCode 调用HashMap.hash()
方法使高位的 bits 分散到低位来,然后通过hash()
的返回值决定当前的 entry 到底放在哪一个 bucket 当中(即table:Node<K,V>[]
的哪一个位置上)。
- 如果算出来的槽位上本来没有元素,那么直接把这个 entry 放在这个位置上即可
- 如果槽位上面已经是一个红黑树节点,那么把用 entry 构造新的 rbt 节点插入到这棵树上
- 如果槽位上面是一个普通链表节点,那么把当前 entry append 到链表尾部。判断链表是否达到需要升级的阈值,如果达到阈值则将链表转换为红黑树
- 如果插入之后的 map 的容量已经达到扩容阈值(capacity*loadfactor),那么对 map 进行扩容(
HashMap#resize()
)
2.2 hash()
HashMap#put()
方法实际上是对HashMap#putVal()
方法的调用。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
首先通过HashMap.hash()
方法来计算 hash 值(整数)。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash()
方法读取 key 的 hashCode,并且把高 16 位与第 16 位 XOR。
这里 XOR 主要是为了解决这种场景:如果某些 hashCode 只有高 16 位不同而低 16 位全部一致,那么这些 hashCode 永远都会冲突从而降低效率。典型的例子就是以一组连续近似相同的
float
为 key 的数据。比如下面这个例子:
2.123111112f: 0x4007E10D
3.123111113f: 0x4047E10D
4.123111114f: 0x4083F087
5.123111115f: 0x40A3F087
6.123111116f: 0x40C3F087
7.123111117f: 0x40E3F087
可见低 16 位冲突比较严重。移位+XOR 是速度、实现难度等的一种折中实现。
2.3 putVal()
putVal()
是一个插入数据的多功能实现,执行具体的插入操作,具有很多可选参数,这里不进行解释。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node<K,V>[] tab; //bucket
Node<K,V> p; //待插入的 entry
int n, i; //
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;//当前为空,则创建 table
if ((p = tab[i = (n - 1) & hash]) == null)//bucket 没有元素占用(无冲突)
tab[i] = newNode(hash, key, value, null);//直接占用当前 bucket
else {//出现冲突,p 为 bucket 上面已有的元素
Node<K,V> e; K k;
//待插入的数据和原来的数据的 key 完全一致
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)//如果原来 bucket 里面就是 rbt 的根节点
//那么构造 rbt 节点插入到 rbt 当中
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);
//判断是否到升级为 rbt 的阈值
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);//将链表升级为 rbt
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;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;//增加修改计数(modification count)用于检测并发修改
if (++size > threshold)//如果达到扩容阈值
resize();//进行扩容
afterNodeInsertion(evict);
return null;
}
3 性能:容量和负载因子
3.1 容量 Capacity
Capacity 是table:Node<K,V>[]
数组的长度。
HashMap 会根据存放的数据数量进行扩容,但是不管怎么扩容其容量都是 2^n。这个设计是为了计算通过 hashCode 计算 bucket 槽位的时候方便。
HashMap 是通过tab[i = (n - 1) & hash
来计算当前 Entry 到底是放在table:Node<K,V>[]
的哪一个 bucket 当中的,而老版本的 JDK 使用tab[i = hash % n
,只有当 n 为 2 的整数次幂的时候,这两个计算((n - 1) & hash
和hash%n
)才能等价。使用按位与&
取代取模%
主要是因为&
一般是单周期指令而%
需要用到除法器,速度相差好几倍。
3.2 负载因子 LoadFactor
负载因子是 HashMap 元素总数与 capacity 的比值,用于衡量当前的 bucket table 装了多满。
高负载因子可以增大空间利用率,但是会降低查询和插入的效率;低负载因子浪费空间而查询插入效率高。
初始情况下负载因子为 0.75。
当 HashMap 的容量大于 Capacity*LoadFactor 的时候,就会对table:Node<K,V>[]
进行扩容:
//OpenJDK1.8 HashMap.java:662
if (++size > threshold)//threshold 即为 capacity*loadfactor,每次 resize 的时候重新计算
resize();
负载因子本身就是在控件和时间之间的折衷。当我使用较小的负载因子时,虽然降低了冲突的可能性,使得单个链表的长度减小了,加快了访问和更新的速度,但是它占用了更多的控件,使得数组中的大部分控件没有得到利用,元素分布比较稀疏,同时由于Map频繁的调整大小,可能会降低性能。但是如果负载因子过大,会使得元素分布比较紧凑,导致产生冲突的可能性加大,从而访问、更新速度较慢。所以我们一般推荐不更改负载因子的值,采用默认值0.75.
4 resize()
的实现
由于table:Node<K,V>[]
的长度永远都是 2 的整数次幂,因此 resize 之后的元素所在的槽位要么是在原地,要么是在移动 2^k 的位置上。
比如一开始容量为 16(2^4),也就是只有hash()
(h^(h>>>16)
)运算之后的值的最低 4 位决定到底将 Entry 放在table:Node<K,V>[]
数组的哪一个位置,即掩码为0x0000000F
扩容之后,容量变为16>>1
=32,hash()
后的值的低 5 位参与计算槽位,掩码为0x0000001F
。
- 如果某个 key 的 hash 之后的值为
0xAABBCC0A
,则扩容之前的槽位为0xAABBCC0A & 0x0000000F = 0x0000000A=10(dec)
,扩容之后的槽位为0xAABBCC0A & 0x0000001F = 0x0000000A=10(dec)
,可见对于这个 key,扩容前后没有变化; - 相反的,如果某个 key 的 hash 之后的值为
0xAABBCCDA
则扩容之前的槽位为0xAABBCCDA & 0x0000000F = 0x0000000A=10(dec)
,扩容之后的槽位为0xAABBCCDA & 0x0000001F = 0x0000001A = 26(dec)
,即原来的槽位偏移了 16。
这个设计的特点在于,扩容的时候,不需要重新计算槽位,只需要知道对于原来的table:Node<K,V>[]
里面的每一个 Entry(Node),它的 hash 值(即hash()
运算之后的值)按位与上扩容之后的 mask 上面新增的那一个 bit(这个 bit 用 16 进制表示出来正好就是旧的table
的 capacity,比如 16 扩容到 32,mask 新增的那一个 bit 即是0x00000010(dec 16)
),其值为 0 还是 1。如果按位与的值为 0,那么这个 entry 在新的table
里的下标与原来相同;如果按位与的结果为 1,那么其在新table
的下标等于原来的下标+扩容前的 capacity。
for each Node in oldTable:
if Node.hash & oldCapacity == 0:
newTable[Node 在 oldTable 当中的下标]= Node //保留在 newTable 的原位
else:
newTable[Node 在 oldTable 当中的下标 + oldCapacity] = Node
5 其他:entrySet
一个 HashMap 可以有两种视图,一种是 map 视图,也就是键值对;另一种是 set 视图,即把 hashmap 看做若干个Entry<K,V>
元素组成的 set。HashMap
的entrySet
成员变量就是用来缓存当前 hashmap 的 set 视图的。entrySet
并不存储任何数据,它只是返回一个迭代器(java.util.HashMap.EntrySet#iterator
) 或者对 HashMap 的每一个元素执行某个Consumer<? super Map.Entry<K,V>>
操作而已(java.util.HashMap.EntrySet#forEach
)