JDK 8 HashMap源码解析
前言
HashMap
作为日常开发中最常使用的一个数据结构了,同时个人也认为这是最重要的一个数据结构,因此涉及到数组、链表、Hash
算法、红黑树等等,基本HashMap
搞懂了,数据结构这块没啥问题了。
概览
先来总览一下HashMap
的继承关系吧
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
...............
}
HashMap
继承自AbstractMap
类,实现了Map
、Serializable
和cloneable
接口。
下面以一个常见场景引入今天的分析。
public class HashMapTest {
public static void main(String[] args) {
Map<Integer, String> map = new HashMap<>();//1
map.put(1, "1");//2
System.out.println(map.get(1));//3
}
}
构造方法
先从构造方法1开始看起吧,HashMap
提供了三种构造方法,其中涉及到的变量分别如下所示:
//默认加载因子 实验所得 时间和空间的折中
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//无参构造
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
//容量入参的构造方法
public HashMap(int initialCapacity) {
//底层调用的是双参数构造方法
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// HashMap容量上限 2的30次方
static final int MAXIMUM_CAPACITY = 1 << 30;
public HashMap(int initialCapacity, float loadFactor) {
//入参校验
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
// 如果传入的initialCapacity大于MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
//则将initialCapacity设为MAXIMUM_CAPACITY
initialCapacity = MAXIMUM_CAPACITY;
// 负载因子参数校验
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
//计算阈值 此时数组table还没有初始化 会在put方法中重新进行赋值
this.threshold = tableSizeFor(initialCapacity);
}
这里重点讲一下tableSizeFor
方法,这个方法将返回大于等于给定的initialCapacity
的最小的2的n
次幂,什么意思?打个比方,如果initialCapacity
为10的话,大于等于10,且是2的n
次幂的有很多,16,32,64...等等,但只有16是与10的差值最小的那个,所以tableSizeFor
最后返回的就是16。下面看一下这个方法是如何实现的。
/**
返回大于等于目标容量的最小的2次幂
*/
static final int tableSizeFor(int cap) {
//先减去1
int n = cap - 1;//1
// 先无符号右移 再 | 运算
n |= n >>> 1;//2
n |= n >>> 2;//3
n |= n >>> 4;//4
n |= n >>> 8;//5
n |= n >>> 16;//6
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;//7
}
就以10为例,运行完第1行后,n
=9,然后下面开始进行右移以及位运算,如下所示:
n=10
n-1=9
0000 1001
>>> 1 0000 0100
| 0000 1101
-----------------------
0000 1101
>>> 2 0000 0011
| 0000 1111
-----------------------
0000 1111
>>> 4 0000 0000
| 0000 1111
-----------------------
0000 1111
>>> 8 0000 0000
| 0000 1111
-----------------------
0000 1111
>>> 16 0000 0000
| 0000 1111
最后所得的n为15,且满足由于 n
>0 && n
<MAXIMUM_CAPACITY
,因而最后返回的为n+1
即为16。需要注意的是,这里为什么一定要先进行n=cap-1这个操作。假设此时,传入的是2的n
次幂,如果不先减去1的话,此时cap
为2^n形式,最后进行计算的结果一定是2*2^n
这种形式,但tableSizeFor
方法要求的是返回大于等于cap
的最小的2^n
,结果应该就是2^n
而不是2*2^n
。举个例子,传入的cap
为16,已经是2^4
,如果不先减去1的话,返回结果是32,违背了tableSizeFor
的含义,读者可自行验证一下。
接下来,再来思考几个问题:
- 为什么这里要进行1、2、4、8、16这样的运算呢?
-
- 对于局部来说,其实就是为了把高位移到低位(对于4位来说,前两位是高位,后两位是低位)这样之后再进行
|
操作,那么就可以将局部得到全1。
- 对于局部来说,其实就是为了把高位移到低位(对于4位来说,前两位是高位,后两位是低位)这样之后再进行
- 为什么这里只是到16就结束了呢?
-
- 因为我们这里针对的数值都是
int
类型,在Java当中int
类型占到4个字节,也就是32位。为什么不进行32位右移呢,这是因为32位右移之后就变成全0了,|
操作就没有什么意义,也不会影响结果,只是多余的操作**。
- 因为我们这里针对的数值都是
常用方法
put方法
接下来就是常用的put
方法,其底层实际调用的是putVal
方法。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
hash
方法用来计算key
的hashcode
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);// 右移16位
}
put
方法
//存放数据的数组
transient Node<K,V>[] table;
// 入参 hash:通过 hash 算法计算出来的值。
// 入参 onlyIfAbsent:false 表示即使 key 已经存在了,仍然会用新值覆盖原来的值,默认为 false
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//数组tab
Node<K,V>[] tab;
// n 表示数组的长度,i 为数组索引下标,
int n, i;
// p 为 i 下标位置的 Node 值
Node<K,V> p;
//1.1若数组为空的话
if ((tab = table) == null || (n = tab.length) == 0)
//1.2 使用resize方法进行初始化
n = (tab = resize()).length;
//如果当前索引位置tab[i]是空的
// (n-1)&hash 为了使key分散的更均匀
if ((p = tab[i = (n - 1) & hash]) == null)
//直接生成新的节点在当前索引位置上
tab[i] = newNode(hash, key, value, null);
else {
// 否则的话 则说明此处产生了hash冲突
// e 当前节点的临时变量
Node<K,V> e;
// key的临时变量
K k;
// 如果 key 的 hash 和值都相等 即相同的key val可能相同,可能不同 此时直接覆盖即可
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//直接把当前下标位置的 Node 值赋值给临时变量
e = p;
// 如果是红黑树,使用红黑树的方式新增
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 如果是个链表,把新节点放到链表的尾端
else {
//自旋
for (int binCount = 0; ; ++binCount) {
// e = p.next 表示从头开始,遍历链表
// p.next == null 表示p后面没有节点,即是链表的尾节点
if ((e = p.next) == null) {
// 把新节点放到链表的尾部
p.next = newNode(hash, key, value, null);
// 当链表的长度大于等于 8 时,链表转红黑树
// static final int TREEIFY_THRESHOLD = 8; 树化的阈值
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=p.next 用于在遍历过程中 p一直向后移动
p = e;
}
}
// 此时已经插入成功
if (e != null) {
//记录一下旧值
V oldValue = e.value;
//当 onlyIfAbsent 为 false 时,才会覆盖值 此值默认为false
if (!onlyIfAbsent || oldValue == null)
//进行赋值操作
e.value = value;
// hashMap中这个方法无用
afterNodeAccess(e);
//返回旧值
return oldValue;
}
}
// 记录 HashMap 的数据结构发生了变化 增删改都可以算作 数据结构变化
++modCount;
// 根据size大小判断是否要开始扩容
if (++size > threshold)
// 扩容
resize();
// hashMap中这个方法无用
afterNodeInsertion(evict);
return null;
}
总结一下,HashMap
的put
过程:
- 保存数据的数组是否为空,若为空则直接初始化;
- 如果数组下标所在位置为空,则直接进行赋值操作;
- 如果此时数组下班不为空,即产生了hash冲突,则使用链地址法进行解决;
- 如果此时链表中存在相同的key,则直接进行覆盖;
- 如果不同,此时如果是链表的话,则直接插入到链表尾部;
- 如果是红黑树的话,则直接插入到红黑树中;
- 插入成功后,根据onlyIfAbsent来判断是否直接覆盖旧值
- 返回旧值
put
方法的流程图如下所示:
resize方法
resize
方法一般有两个场景会触发:
- 一个是调用
put
方法时,若是此时HashMap
尚未初始化,则会调用resize
方法进行初始化; - 另一个是当目前
HashMap
中元素个数大于阈值threshold
时,调用resize
方法进行扩容。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//判断此时hashmap是否已经初始化了
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 1.根据oldCap是否大于0来判断是初始化还是扩容
//旧容量大于0 说明是扩容
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {//MAXIMUM_CAPACITY=2^30
//如果此时hashmap中元素个数已经超过最大容量 直接退出
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 不是的话 新容量为旧容量的2倍 且小于最大容量
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新阈值也是之前旧阈值的两倍
newThr = oldThr << 1; // double threshold
}
//2.若是初始化 在使用了带有capacity构造函数时,threshold就是此时hashmap的容量大小
else if (oldThr > 0) // initial capacity was placed in threshold
//新容量就等于旧阈值
newCap = oldThr;
//2.若是初始化,但使用了无参构造,则容量和阈值都使用默认的参数
else { // zero initial threshold signifies using defaults
//新容量等于默认容量
newCap = DEFAULT_INITIAL_CAPACITY;
//新阈值就等于默认负载因子与默认容量的乘积
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//用户自定义了map的初始化操作
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//更新threshold字段等于新阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//实例化新的数组 容量为newCap
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;
//如果是树的话 调用split方法进行处理
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
//如果没超过8个 是链表
//两条链表 高位和低位 分别用来存储同一个链表上的数据
// 后面根据分配分别插入到新数组中不同的位置
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
//下一个节点
next = e.next;
//当前元素的hash值 & 旧数组容量==0 使用低位链表来进行记录
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//如果 hash值 & 旧数组容量==1 使用高位链表来进行记录
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
//开始将这两条链表移动到新数组中
// lowHead 与旧数组的index保持一致 然后放到新数组中的index位置上
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//highHead 在旧数组index的基础上+旧数组的容量,然后放到新数组的
//index+oldCap位置处
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
// 返回新的hashMap
return newTab;
}
总结一下resize
的流程:
-
判断当前数组是否已经初始化了,如果没有则进行初始化
- 如果使用的无参构造:
- 则数组容量=16;
- 阈值=容量*阈值=12;
- 如果使用带有capacity的构造方法:
- 则数组容量就是此时的阈值大小;
- 如果使用的无参构造:
-
如果已经初始化过了,则是扩容操作
- 旧数组容量是否已经达到最大容量,2^30:
- 是,新阈值直接设为Integer.MAX_VALUE,直接退出;
- 否,新数组容量为旧数组容量的2倍,新阈值也是旧阈值的两倍;
- 旧数组容量是否已经达到最大容量,2^30:
-
创建新数组,开始遍历旧数组中的元素移动到新数组中:
-
当前元素是单节点元素,则直接计算在新数组中的index位置,然后移动到新数组中;
-
当前元素是红黑树类型,则调用 split方法进行处理;
-
当前元素是一个链表,则开始遍历这个链表,使用两条新链表来存储元素:
-
链表上单个节点 e.hash & oldCap ==0 则移动到lowHead这个链表上
-
链表上单个节点 e.hash & oldCap==1 则移动到highHead这个链表上;
-
lowHead这个链表,直接使用旧数组中的索引index,放入到新数组中;
-
highHead这个链表,在新数组中的索引为 index+oldCap;
-
-
与JDK7中的resize方法的区别
- JDK7中的
resize
方法只有扩容这一个功能;JDK8中的resize方法兼具初始化(懒加载,执行put的时候才去初始化数组)和扩容两个功能; - JDK7中
resize
时,会重新计算每个元素在新数组中的位置;JDK8中的resize
方法,在移动链表时,利用了元素的hash
值,使用e.hash & oldCap
这个值来判断这个元素是在新数组中保持与旧数组相同的索引index
,还是index+oldCap
; - JDK7中,
resize
方法在移动链表上的元素时,会改变链表元素的相对顺序,如a—>b
就会变成b—> a
,又因为使用的头插法,所以导致在多线程环境下进行扩容时可能导致链表成环,这样在调用get方法时陷入死循环;JDK8中采用尾插法,插入到新链表的时候不会改变链表中元素的相对位置,解决了死循环问题;
jdk8中resize方法中的链表移动示意图:
为什么使用 e.hash & oldCap
计算在新数组中的索引位置使用的e.hash & (newCap-1)
,由于newCap
是oldCap
的两倍,这就会导致参与&
运算时, newCap-1
将会比oldCap-1
多了一位参加运算。如果这个需要新判断的位置上为0,那么index
不变,否则变为需要迁移到oldIndex + oldCap
这个位置上去。
get方法
public V get(Object key) {
Node<K,V> e;
//调用getNode方法
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;
//判断索引位置处是否有值 否则直接return null
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// hash值和key值都相等 则表明命中 直接返回
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)
//是红黑树的话,调用getTreeNode方法查找
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);
}
}
return null;
}
get
方法的流程如下:
-
根据元素的
hash
值和hash(key)
方法定位到数组中索引所在位置; -
判断该索引位置是否存在值:
-
不存在的话,直接返回
null
; -
比较
hash
值和key
,若是相等,则表明命中,返回该索引位置所在的元素; -
若不相等,判断此处是否存在红黑树或者链表结构:
- 若是红黑树,则调用
getTreeNode
方法进行查找; - 若是链表,则遍历链表进行查找;
- 若是红黑树,则调用
-
流程图如下所示:
JDK8中HashMap新增的getOrDefault方法
JDK8
中新增一个getOrDefault
方法,该方法若是根据key
查找不到对应的值value
则返回传入的默认值。源码如下:
public V getOrDefault(Object key, V defaultValue) {
Node<K,V> e;
//可以看出与 get方法的不同 若是查找不到 默认返回defaultValue 其余与get方法一致
return (e = getNode(hash(key), key)) == null ? defaultValue : e.value;
}
remove方法
public boolean remove(Object key, Object value) {
return removeNode(hash(key), key, value, true, true) != null;
}
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;
//先定位
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 (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);
}
}
//node不为null 说明命中了要删除的节点
// 如果不需要对比value 或者是需要对比value 但value也相等 则开始进行删除
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//如果是红黑树的话 调用removeTreeNode进行删除
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//如果是首节点的话 直接指向下一个节点 一个的话可以看做是只有一个节点的链表 这样也可以置
//为null
else if (node == p)
tab[index] = node.next;
else
//否则的话,进行链表的移动
p.next = node.next;
//更新modCount
++modCount;
//更新size
--size;
afterNodeRemoval(node);
//返回删除的节点
return node;
}
}
return null;
}
remove
方法流程梳理如下:
-
利用元素的
hash
和hash(key)
方法进行定位 -
若是当前索引位置处为
null
,直接返回null
; -
比较
hash
和key
,若相等,则表明找到了待删除的节点 -
若不是,则判断该位置是红黑树还是链表:
- 若是红黑树,则调用
getTreeNode
方法进行查找; - 若是链表,则遍历链表进行查找;
- 若是红黑树,则调用
-
若是找到了待删除节点,则开始进行删除:
- 若该索引位置处为红黑树,则调用
removeTreeNode
方法进行删除; - 若是链表,则执行链表相关的操作进行删除;
- 若该索引位置处为红黑树,则调用
-
最后更新
modCount
和size
等属性的值;
扩展
为什么数组长度都是2的倍数?
- 当数组都是2的倍数时,
2^n-1
的二进制表示中所有位置都是1,这样与一个全部都是1的二进制数进行 & 操作时,速度会大大提升; - 计算元素的索引位置时,一般采用的是
%
操作,但是如果数组长度都是2的倍数的话,hash & (length-1)
等价于hash % length
,但是&
操作的效率更高,因为%
在操作系统会进行转换,&
操作不用; - 数组长度为2的倍数时,不同
key
计算出相同的index
的概率较小,减少hash
碰撞;
为了减少hash碰撞,hashMap做了哪些操作?
hash
方法中,hashCode ^ hashCode >>>16
,这样所得的hash
值可以将hashCode
的高位和低位都利用上,降低不同key
通过hash
方法获得相同hash
值的概率,减少hash
冲突;- 计算索引位置时,
hash & (length-1)
,由于length
始终是2的倍数,length-1
的二进制表示中各位都是1,一个数与各个位都是1的数进行&
操作,进一步降低hash
冲突;
总结
工作三年后的我再看HashMap
的源码,有一番新的感受,HashMap
这个数据结构对Java
程序员来说是必须掌握的知识点,希望大家消化下,争取化为己用。(PS:写出HashMap
的程序员们真的🐮)