【Java集合框架】3 - 12 HashMap 源码分析
§3-12 HashMap
源码分析
3-12.1 HashMap
中定义的字段与类
HashMap
通过以下的类记录表中的数据结点:
-
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; } ... }
类中有字段分别用于记录结点的哈希值、键、值和后继结点。
-
TreeNode
:HashMap
的内部类,继承自LinkedHashMap
中的内部类Entry
,用于记录红黑树的结点;static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> { TreeNode<K,V> parent; // red-black tree links TreeNode<K,V> left; TreeNode<K,V> right; TreeNode<K,V> prev; // needed to unlink next upon deletion boolean red; TreeNode(int hash, K key, V val, Node<K,V> next) { super(hash, key, val, next); } ... }
类中有字段分别用于记录树结点的父结点、左右子结点以及颜色等。
-
LinkedHashMap.Entry
:LinkedHashMap
的内部类,继承自HashMap.Node
,用于普通LinkedHashMap
的条目;static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, next); } }
该类无特殊字段与方法,直接继承父类的对应成员。
除此之外,HashMap
还具有以下字段:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始容量,即 16
static final float DEFAULT_LOAD_FACTOR = 0.75f; //默认加载因子
static final int MAXIMUM_CAPACITY = 1 << 30; //最大容量
static final int TREEIFY_THRESHOLD = 8; //树形化阈值
transient Node<K,V>[] table; //数组
解释:
-
为什么选择以 \(2^n\) 为数组长度?
以 \(2^n\) 作为容量,减少哈希冲突概率,实现键的均匀存放。若指定了初始容量,则实际容量将会调整为最接近指定容量的 2 的幂。
对于哈希值以及索引计算等有关解释见后文(
putVal
处)。 -
为什么默认加载因子为 0.75?
加载因子就是哈希表中实际存储元素个数 \(n\) 与哈希表长度 \(m\) 的比,即 \(\alpha = {n\over m}\)。
加载因子越小,空间利用率越小,哈希冲突概率更小;加载因子越大,空间利用率越大,哈希冲突概率越大。实际一般使得 \(\alpha \in [0.6, 0.9]\)。
默认加载因子取
0.75
是因为查询在时间和空间上的平衡点为0.75
。
3-12.2 无参构造与 put
添加元素
首先,我们调用无参构造器创建 HashMap
对象:
HashMap<String, Integer> hm = new HashMap<>();
该方法的代码实现为:
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
方法内部仅仅只是将数组的加载因子设置为默认加载因子,甚至并未初始化数组,此时 table == null
。
创建完成后,往表中添加第一个元素,put
方法的实现为:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
方法在内部调用了 putVal
方法,在解释这一方法前,先看看这些形参的意义。
putVal
方法的声明以及对这些形参的解释如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
int hash
:键的哈希值,通过hash
方法获得,该方法的解释见下文;K key, V value
:数据元素的键与值;boolean onlyIfAbsent
:若为true
,则不改变现存值;此处传递false
,导致了put
方法的覆盖行为;boolean evict
:暂且忽略;put
方法返回值是被覆盖的值;
3-12.3 putVal
底层实现
请记住,HashMap
在底层使用了哈希表这种数据结构,并通过单链表和红黑树解决哈希冲突的问题。在阅读源码时,应当时刻将这种数据结构记于心中,并能够通过源码呼应数据结构中的有关运算。
putVal
方法实现了数据元素的添加,其实现(内容较长)如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab;
Node<K,V> p;
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e;
K k;
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);
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;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
在正式开始前,请牢记,数组当中的元素应当是情况讨论,可能是链表的头结点(Node
),也可能是红黑树的头结点(TreeNode
)。
接下来将逐句分析 putVal
的执行过程。
3-12.4 putVal
完整注释
此处将实现细节以单行注释的形式逐句表示在完整方法中,提高可读性。
//由 put 在内部调用 putVal,传入参数 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; //尚未初始化,n 将表示数组长度,i 将表示索引
//若为首次添加元素,空参构造并未初始化数组,此时数组为空,进入该分支内部
if ((tab = table) == null || (n = tab.length) == 0)
//调用 resize 方法调整数组大小,并将新数组长度赋予 n
n = (tab = resize()).length;
/*
resize 方法的执行逻辑:
1. 若为首次往空数组中添加元素,则将创建一个具有默认容量(16)和默认加载因子(0.75)的数组;
2. 若数组不为空,则判断是否达到扩容阈值(阈值 = 数组容量 * 加载因子,当数组内元素个数达到该数目时扩容);
3. 若达到扩容条件,则将数组扩容为原来的 2 倍,并将原数组的内容复制到新数组中;
4. 若未达到扩容条件,则无需进行任何操作;
*/
//计算待添加结点(数据元素)的哈希地址,并让 p 指向该地址
//判断数组中该地址的元素是否为空
if ((p = tab[i = (n - 1) & hash]) == null)
//若该地址为空,则直接添加,newNode 底层实际上就是调用了 Node 的构造器
tab[i] = newNode(hash, key, value, null);
else {
//数组中该地址不为空,已有元素占用
Node<K,V> e; //临时结点变量,尚未初始化,为 null
K k; //临时键变量,存储键内容,尚未初始化,为 null
//判断哈希表中的 p 地址处元素是否重复(仅与键有关)
if (p.hash == hash && //判断哈希值是否相同
//再调用 equals 比较内部成员属性值(需要重写),尽可能避免哈希冲突
((k = p.key) == key || (key != null && key.equals(k))))
//若发现重复元素,则让 e 指向该重复元素
e = p;
//若数组中元素不重复,则应进入链表或红黑树中进一步判断
//判断是否位于红黑树根结点
else if (p instanceof TreeNode)
//若为红黑树根结点,则按照红黑规则遍历数组并用相同方法比较是否重复(仅与键有关)
//若不重复,则添加到红黑树中,e 仍为 null
//若重复,e 指向重复元素
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
//若为链表头结点,则遍历链表
for (int binCount = 0; ; ++binCount) {
//若未发现重复元素,则在表尾添加
if ((e = p.next) == null) {
//e 和 p.next 在添加完成后不一致,e 为 null
p.next = newNode(hash, key, value, null);
//判断是否达到转换为红黑树的条件,此处先判断链表长度是否 大于8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//treeifyBin 内部还会进一步判断数组长度是否大于等于64
//二者同时满足才发生转换
treeifyBin(tab, hash);
//添加、转换完成,退出循环,e = null
break;
}
//检查是否重复
if (e.hash == hash &&
//再调用 equals 比较内部成员属性值(需要重写),尽可能避免哈希冲突
((k = e.key) == key || (key != null && key.equals(k))))
break;
//依赖这一语句实现循环的迭代,不断遍历链表
p = e;
}
}
//若 e = null,则未找到重复元素,无需覆盖
//否则,则应当考虑是否覆盖
if (e != null) { // existing mapping for key
//找到重复元素
V oldValue = e.value; //记录重复项的旧值
if (!onlyIfAbsent || oldValue == null) //方法传入 onlyIfAbsent = false,应当覆盖
e.value = value; //修改重复项的值
afterNodeAccess(e); //方法具有空实现,忽略
return oldValue; //返回旧值
}
}
//未找到重复元素,则方法执行到此处,为首次添加该元素
++modCount; //修改结构性修改次数
if (++size > threshold) //更新哈希表元素个数,并判断是否需要扩容
resize();
afterNodeInsertion(evict); //方法具有空实现,忽略
return null; //没有发生修改(覆盖),则返回空
}
解释:
-
为什么要在链表长度大于等于 8、数组长度大于 64 时改用红黑树?
红黑树插入的维护开销非常大,而每一次插入元素都有可能破坏其平衡,就需要在每次插入后进行维护,每时每刻都需要再次恢复平衡(改变结点颜色以及旋转,一般优先选择改变颜色)。当
HashMap
的put
操作非常多时,极有可能影响插入性能。 -
为什么哈希地址(哈希表中的数组索引)要如此计算(
index = (arr.length - 1) & hash
)?此部分内容引用自狂神的博客(现已删除),有修改。
引用的内容截取自视频JavaSE总结_哔哩哔哩_bilibili。
一般而言构建哈希函数的方法有直接定址法和除留余数法等。而除留余数法计算简单,适用范围广,是经常使用的一种哈希函数。
除留余数法的计算方式是用关键字 \(k\) 对一个不大于哈希表长度的整数 \(p\) 取余,即 \(h(k) = k \mod{p}\),一般而言,\(p\) 取质数效果较好。但在
HashMap
中,采用的是hash % length
。但这种运算不如位运算快,因此,源码做了优化,得到(length - 1) & hash
。计算机做数值运算时,计算效率加减法 > 乘法 > 除法 > 取模,驱魔效率最低,因此要极力避免。而哈希地址是通过取模运算获得,考虑到
HashMap
在不停地扩容,扩容有会涉及到数组移动,每一次移动都要重新计算索引,迁移大量元素,就会大大影响效率。直接使用与运算,效率是远高于取模运算的。而采用以 2 为底的指数
n
作为数组长度,该整数转换为二进制后就是 1 其后跟上多个 0,在此基础上 -1,就得到了n-1
个 1。例如数组长度为 8 时,
3 & (8-1) = 3
,5 & (8-1) = 5
无哈希冲突。但若长度为 9 的时候,3 & (9-1) = 0
,5 & (9-1) = 0
,发生了哈希冲突。因此,保证容量(数组长度)为 2 的幂,是为了保证做length - 1
时,每一位都能& 1
(掩码)。 -
HashMap
的扩容死循环问题是如何产生,又是如何解决的?简而言之,JDK 7 及以前采用头插法实现扩容,在高并发场景下扩容可能发生死循环问题。JDK 8 及以后采用尾插法解决了此问题。
3-12.5 hash
方法计算哈希值
putVal
的第一个参数就是键的哈希值,调用的是 hash
方法,实现如下:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
//允许键为空,若为空则哈希值为 0
}
数据元素的哈希值完全仅基于键计算,与值毫无关系。内部还调用了 hashCode
方法,并基于 hashCode
方法所得值计算键最终的哈希值。
当键不为空时,将会执行后半部分的异或运算,其本质是高 16 位异或低 16 位,这样做是为了减少哈希冲突概率;
下文内容源自于狂神说的博客(现已删除),截取自视频JavaSE总结_哔哩哔哩_bilibili
hashCode: 1954974080 | 111 0100 1000 0110 1000 1001 1000 0000 |
---|---|
2^4 - 1 = 15 (length -1) |
000 0000 0000 0000 0000 0000 0000 1111 |
& 与运算 |
000 0000 0000 0000 0000 0000 0000 0000 |
而使用高低 16 位的异或运算后:
原 hashCode | 1954974080 | 111 0100 1000 0110 1000 1001 1000 0000 |
---|---|---|
>>> 16 无符号右移 16 位 |
29830 | 000 0000 0000 0000 0111 0100 1000 0110 |
^ 异或运算 |
1955003654 | 111 0100 1000 0110 1111 1101 0000 0110 |
2^4 - 1 = 15 (length - 1) |
15 | 000 0000 0000 0000 0000 0000 0000 1111 |
& 与运算 |
6 | 000 0000 0000 0000 0000 0000 0000 0110 |
两种情况所计算出来的哈希地址明显不同,前者直接使用 hashCode & (length - 1)
得到 0
,后者使用异或运算后得到 6
,减少了碰撞概率。
3-12.6 resize
扩容
resize
用于扩容数组,其实现为:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table; //记录旧数组
int oldCap = (oldTab == null) ? 0 : oldTab.length; //记录旧容量
int oldThr = threshold; //记录旧阈值
int newCap, newThr = 0; //新容量、新阈值
//若旧数组不为空,即长度不为零
if (oldCap > 0) {
//若数组已达到最大限制,不扩容,修改阈值为整型最大值返回旧数组
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//数组未达到最大限制,初始化新容量为旧的 2 倍
//若新容量位于最大容量和默认容量间
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // 加倍阈值
}
//若数组为空,即长度为零
else if (oldThr > 0) //初始容量存储在阈值中,则将新容量初始化为原有的阈值
newCap = oldThr;
else { //若有零阈值(旧的),则使用默认配置
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//若尚未初始化新的阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor; //计算新的阈值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//更新哈希表的阈值为新阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; //具有新容量的新数组
table = newTab; //让哈希表数组指向新数组
//若旧数组不为空
if (oldTab != null) {
//遍历旧数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e; //临时变量,用于记录数组中结点
//初始化临时变量,指向所遍历到的数组中结点
//若不为空
if ((e = oldTab[j]) != null) {
oldTab[j] = null; //旧数组中的指针与结点对象脱离
//判断是否存在链表
if (e.next == null)
//不存在链表,重新计算哈希地址放进新数组中
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
//存在红黑树(属于树的根结点),将整棵树移动到新数组中
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 保留顺序
//存在链表
Node<K,V> loHead = null, loTail = null; //低链表
Node<K,V> hiHead = null, hiTail = null; //高链表
Node<K,V> next;
do {
next = e.next; //指向链表的下一个结点,获取下一个元素
if ((e.hash & oldCap) == 0) { //低链表
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { //为 1,高链表
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null); //遍历链表
if (loTail != null) {
loTail.next = null; //将低链表的尾结点指针设为空
newTab[j] = loHead; //将低链表放在新数组中原数组的位置
}
if (hiTail != null) {
hiTail.next = null; //将高链表的尾结点指针设为空
newTab[j + oldCap] = hiHead; //将高链表放在新数组中原数组的位置 + 旧数组容量处
}
}
}
}
}
//返回新数组
return newTab;
}
3-12.7 指定初始容量的构造方法
可以往构造器中传入一个整型参数指定映射表的初始容量,该方法的实现为:
public HashMap(int initialCapacity) {
//调用了指定初始容量和加载因子的构造方法
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
方法调用了另一个双参数的重载,该重载的实现为:
public HashMap(int initialCapacity, float loadFactor) {
//判断初始容量是否非法
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//若超出最大容量限制,则将初始容量设为最大容量
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//判断加载因子是否非法
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor; //设置加载因子
this.threshold = tableSizeFor(initialCapacity); //设置扩容阈值
}
设置阈值时,调用了 tableSizeFor
,追踪该方法:
/**
* Returns a power of two size for the given target capacity.
* 返回一个接近目标容量的 2 的幂。
*/
static final int tableSizeFor(int cap) {
//计算二进制表示下,指定数字的开头零个数
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
计算开头零个数的方法实现如下:
public static int numberOfLeadingZeros(int i) {
// HD, Count leading 0's
if (i <= 0)
//为零则直接返回 32(整型位数),负数返回 0
return i == 0 ? 32 : 0;
//对于一个正数
int n = 31; //至多有 31 位个 0
//通过不断缩小范围,减去自首个非零位开始的位数,确认个数
if (i >= 1 << 16) { n -= 16; i >>>= 16; }
if (i >= 1 << 8) { n -= 8; i >>>= 8; }
if (i >= 1 << 4) { n -= 4; i >>>= 4; }
if (i >= 1 << 2) { n -= 2; i >>>= 2; }
//最后在减去 1,即得到开头零的个数
return n - (i >>> 1);
}
注意,方法传入的实际为 cap - 1
,实际上回到 tableSizeFor
时,目标就是让找到的目标值大于等于原来指定的值。
至此,对象创建完毕。首次添加元素时会调用 resize
方法对空数组扩容,原数组为空,则用原数组的阈值视为新数组的容量。
因此,若指定了容量,实际容量会调整为最接近该值的 2 的幂,使得数组长度始终保持为 2 的幂。