转载自:https://huanglei.rocks/coding/inside-jdk-hashmap.html (该个人博客十分geek)
基于 OpenJDK1.8
Node
实现了Map.Entry
接口,代表链表状态下 HashMap 里面存放的一个元素。
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
//...
}
Node
当中有一个next
字段,指向另一个Node
实例。也就是说Node
是链表的一个元素。
TreeNode
TreeNode
是Node
的一个子类,用于当链表升级为红黑树时存储 entry。
table:Node[]
节点元素的数组,即所谓的 bucket
modCount:int
HashMap 被修改的次数。多线程条件下可能某个线程迭代 map 的时候另一个线程修改了 map 的元素,可能导致数据的不一致。而迭代的线程可以通过在迭代开始和结束的时候的modCount
来判断是否有另一个线程在这个过程中修改了数据,如果修改了则抛出ConcurrentModifictionException
(详见java.util.HashMap#forEach
)。
HashMap 会根据当前 table 的大小和冲突情况,逐渐升级存储的数据结构,数组->链表->RBT。
一开始 HashMap 由一个 Entry 数组支撑,也就是table:Map.Entry
。table
当中的每一个元素都是某个链表的头结点,结构如下:
table:Node[]
数组的不同位置上table:Node[]
的相同位置的链表尾部显然,hashCode 的设计直接关系到 HashMap 的查询效率。如果 hashcode 没有冲突,那么 HashMap 的查询效率是O(1)
,如果出现了 hash 冲突,那么 HashMap 的查询效率下降到O(N)
。
通过 key 的hashCode
方法获取 hashCode,对 hashCode 调用HashMap.hash()
方法使高位的 bits 分散到低位来,然后通过hash()
的返回值决定当前的 entry 到底放在哪一个 bucket 当中(即table:Node
的哪一个位置上)。
HashMap#resize()
)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 是速度、实现难度等的一种折中实现。
putVal()
putVal()
是一个插入数据的多功能实现,执行具体的插入操作,具有很多可选参数,这里不进行解释。
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
Node[] tab; //bucket
Node 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 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)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;
}
Capacity 是table:Node
数组的长度。
HashMap 会根据存放的数据数量进行扩容,但是不管怎么扩容其容量都是 2^n。这个设计是为了计算通过 hashCode 计算 bucket 槽位的时候方便。
HashMap 是通过tab[i = (n - 1) & hash
来计算当前 Entry 到底是放在table:Node
的哪一个 bucket 当中的,而老版本的 JDK 使用tab[i = hash % n
,只有当 n 为 2 的整数次幂的时候,这两个计算((n - 1) & hash
和hash%n
)才能等价。使用按位与&
取代取模%
主要是因为&
一般是单周期指令而%
需要用到除法器,速度相差好几倍。
负载因子是 HashMap 元素总数与 capacity 的比值,用于衡量当前的 bucket table 装了多满。
高负载因子可以增大空间利用率,但是会降低查询和插入的效率;低负载因子浪费空间而查询插入效率高。
初始情况下负载因子为 0.75。
当 HashMap 的容量大于 Capacity*LoadFactor 的时候,就会对table:Node
进行扩容:
//OpenJDK1.8 HashMap.java:662
if (++size > threshold)//threshold 即为 capacity*loadfactor,每次 resize 的时候重新计算
resize();
负载因子本身就是在控件和时间之间的折衷。当我使用较小的负载因子时,虽然降低了冲突的可能性,使得单个链表的长度减小了,加快了访问和更新的速度,但是它占用了更多的控件,使得数组中的大部分控件没有得到利用,元素分布比较稀疏,同时由于Map频繁的调整大小,可能会降低性能。但是如果负载因子过大,会使得元素分布比较紧凑,导致产生冲突的可能性加大,从而访问、更新速度较慢。所以我们一般推荐不更改负载因子的值,采用默认值0.75.
resize()
的实现由于table:Node
的长度永远都是 2 的整数次幂,因此 resize 之后的元素所在的槽位要么是在原地,要么是在移动 2^k 的位置上。
比如一开始容量为 16(2^4),也就是只有hash()
(h^(h>>>16)
)运算之后的值的最低 4 位决定到底将 Entry 放在table:Node
数组的哪一个位置,即掩码为0x0000000F
扩容之后,容量变为16>>1
=32,hash()
后的值的低 5 位参与计算槽位,掩码为0x0000001F
。
0xAABBCC0A
,则扩容之前的槽位为0xAABBCC0A & 0x0000000F = 0x0000000A=10(dec)
,扩容之后的槽位为0xAABBCC0A & 0x0000001F = 0x0000000A=10(dec)
,可见对于这个 key,扩容前后没有变化;0xAABBCCDA
则扩容之前的槽位为0xAABBCCDA & 0x0000000F = 0x0000000A=10(dec)
,扩容之后的槽位为0xAABBCCDA & 0x0000001F = 0x0000001A = 26(dec)
,即原来的槽位偏移了 16。这个设计的特点在于,扩容的时候,不需要重新计算槽位,只需要知道对于原来的table:Node
里面的每一个 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
entrySet
一个 HashMap 可以有两种视图,一种是 map 视图,也就是键值对;另一种是 set 视图,即把 hashmap 看做若干个Entry
元素组成的 set。HashMap
的entrySet
成员变量就是用来缓存当前 hashmap 的 set 视图的。entrySet
并不存储任何数据,它只是返回一个迭代器(java.util.HashMap.EntrySet#iterator
) 或者对 HashMap 的每一个元素执行某个Consumer super Map.Entry
操作而已(java.util.HashMap.EntrySet#forEach
)