JDK源码那些事儿之我眼中的HashMap
Posted Orange技术那些事儿
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JDK源码那些事儿之我眼中的HashMap相关的知识,希望对你有一定的参考价值。
源码部分从HashMap说起是因为笔者看了很多遍这个类的源码部分,同时感觉网上很多都是粗略的介绍,有些可能还不正确,最后只能自己看源码来验证理解,写下这篇文章一方面是为了促使自己能深入,另一方面也是给一些新人一些指导,不求有功,但求无过。有错误的地方请在评论中指出,我会及时验证修改,谢谢。
接下来就来说下我眼中的HashMap。
jdk版本:1.8
在深入源码之前,了解HashMap的整体结构是非常重要的事情,结构也体现出了源码中一些对HashMap的操作,结构大致如下:
从上边的结构图大家应该也能看出来HashMap的实现结构: 数组+链表+红黑树
。
看下类注释,直接看源码部分最好,可能大多数都看不明白,这里可以看下别人的翻译:
https://blog.csdn.net/fan2012huan/article/details/51085924
本文中笔者不打算对红黑树部分进行讲解说明,插入和删除操作会引发各种状态,需要做对应的调整,之后会单独写一篇红黑树基础,结合TreeNode来做讲解。
先总结一些名词概念方便初学者理解:
1.桶(bucket):数组中存储元素的位置,参考结构图,实际上是数组中的某个索引下的元素,这个元素有可能是树的根节点或者链表的首节点,当然,理解上还是一个链表或红黑树整体当成桶
2.bin:桶中的每个元素,即红黑树中的某个元素或者是链表中的某个元素。
https://www.cnblogs.com/yangecnu/p/Introduce-Hashtable.html
HashMap也是对哈希表的一种实现,简单理解,可以类比数学中的求余操作,对范围进行固定,将大量的数据放入一个有界的范围中,求余放置,这种操作算是哈希表的一种实现方式。
下面进行源码部分的说明:
类定义
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable
继承AbstractMap 实现Cloneable接口,提供克隆功能 实现Serializable接口,支持序列化,方便序列化传输
这里有个有意思的问题:为什么HashMap继承了AbstractMap还要实现Map接口?有兴趣的可以去看下stackoverflow上的回答:
https://stackoverflow.com/questions/2165204/why-does-linkedhashsete-extend-hashsete-and-implement-sete
变量说明
/**
* Node数组的默认长度,16
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* Node数组的最大长度,最大扩容长度
*/
/**
* 默认负载因子
* 这个是干嘛的呢?
* 负载因子是哈希表在自动扩容之前能承受容量的一种尺度。
* 当哈希表的数目超出了负载因子与当前容量的乘积时,则要对该哈希表进行rehash操作(扩容操作)。
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 链表转换为树的阈值,超过这个长度的链表会被转换为红黑树,
* 当然,不止这一个条件,在下面的源码部分会看到。
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 当进行resize操作时,小于这个长度的树会被转换为链表
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 链表被转换成树形的最小容量,
* 如果没有达到这个容量只会执行resize进行扩容
* 可以理解成一种计算规则
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
*
* 第一次使用的时候进行初始化,put操作才会初始化对象
* 调用构造函数时不会初始化,后面源码可参考
*/
transient Node<K,V>[] table;
/**
*
* entrySet保存key和value 用于迭代
*/
transient Set<Map.Entry<K,V>> entrySet;
/**
*
* 存放元素的个数,但不等于数组的长度
*/
transient int size;
/**
*
* 计数器,fail-fast机制相关,不详细介绍,有兴趣的自己google下
* 你可以当成一个在高并发读写操作时的判断,举个例子:
* 一个线程A迭代遍历a,modCount=expectedModCount值为1,执行过程中,一个线程B修改了a,modCount=2,线程A在遍历时判断了modCount<>expectedModCount,抛错
* 当然,这个只是简单的检查,并不能得到保证
*/
transient int modCount;
/**
*
* 阈值,当实际大小超过阈值(容量*负载因子)的时候,会进行扩容
*/
int threshold;
/**
*
* 负载因子
*/
final float loadFactor;
在看方法之前先看下Node实现:
/**
* Node的实现
* 看出来是最多实现单向链表 仅有一个next引用
* 比较简单明了,应该都能看明白
*/
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;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
/**
* Map.Entry 判断类型
* 键值对进行比较 判断是否相等
*/
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
重点说明
在注释中我会添加一些标记帮助理清流程,同时方便我后边总结对照和参考(例如A1,A2是同一级)。
/**
* 负载因子设置成默认值 0.75f
* A1
*/
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
/**
* 初始数组长度设置,负载因子默认值
* A2
*/
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
/**
* 初始长度和负载因子设置
* A2
*/
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;
// 根据初始容量设置阈值
// 二进制操作,比较绕,需要自己好好理解下
// 这值在resize有用,resize代码可以注意下,主要是为了区分是否是有参构造函数还是无参构造函数以便之后的操作
// 可以参考文章:https://www.cnblogs.com/liujinhong/p/6576543.html
// 是否有更深层次的考虑笔者还未想到,有大神可以在评论区告知我
this.threshold = tableSizeFor(initialCapacity);
}
/**
* 将m存入当前map中
*/
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
/**
* evict参数相当于占位符,是为了扩展性,可以追溯到afterNodeInsertion(evict),方法是空的
* 在LinkedHashMap中有实现,有兴趣可以去看看
*/
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
/**
* 判断table是否已经被初始化
*/
if (table == null) { // pre-size
// 未被初始化,判断m中元素的个数放入当前map中是否会超出最大容量的阈值
float ft = ((float)s / loadFactor) + 1.0F;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 计算得到的t大于阈值 阈值设置
if (t > threshold)
threshold = tableSizeFor(t);
}
else if (s > threshold)
/**
* 当前map已经初始化,并且添加的元素长度大于阈值,需要进行扩容操作
*/
resize();
/**
* 上边已经初始化并处理好阈值设置,下面使用entrySet循环putVal保存m中的Node对象的key和value
* 这里有个重要的地方,
* putVal的第一个参数,hash(key),map的put操作也是同样的调用方式
* 可以参考文章:https://www.cnblogs.com/liujinhong/p/6576543.html
* 顺便看下源码上的注释,主要是减少冲突和性能上的考虑
*/
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
putVal(hash(key), key, value, false, evict);
}
}
}
/**
* 扩容操作,重点部分
*
* 如果第一次带容量参数时,创建时阈值设置为对应容量的最小的2的N次方(大于等于传入容量参数),去看下上边HashMap(int initialCapacity),
* 如果添加一个元素,会执行resize将阈值设置为了阈值 * 负载因子,
* 比如设置1000 创建时阈值threshold=1024,负载因子默认,其他值都未进行操作,
* 添加一个元素 阈值变为1024 * 0.75 = 768,创建的Node数组长度为1024,size=1,
* 添加第769个元素时,进行resize操作,threshold=1536,Node数组长度为2048,数组拷贝到新数组中,
* 如果有确认的数据长度,不想让HashMap进行扩容操作,那么则需要在构造时填上计算好的数组容量
* 强烈建议自己写代码debug试试
*/
final Node<K,V>[] resize() {
//oldTab 保存扩容前的Node数组
Node<K,V>[] oldTab = table;
// oldCap null的话即为0,否则就是扩容前的Node数组的容量大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 扩容前的阈值
int oldThr = threshold;
// 扩容后的数组容量(长度),扩容后的阈值
int newCap, newThr = 0;
// 1.扩容前的数组不为空
// B1
if (oldCap > 0) {
// 扩容前的Node数组容量大于等于设置的最大容量,不会进行扩容,阈值设置为Integer.MAX_VALUE
if (oldCap >= MAXIMUM_CAPACITY) {
// C1
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 如果扩容前的数组容量扩大为2倍依然没有超过最大容量,
// 并且扩容前的Node数组容量大于等于数组的默认容量,
// 扩容后的数组容量值为扩容前的map的容量的2倍,并且扩容后的阈值同样设置为扩容前的两倍,
// 反之,则只设置扩容后的容量值为扩容前的map的容量的2倍
// 这里newCap已经在条件里赋值了
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// C2
newThr = oldThr << 1; // double threshold
}
// 2.扩容前的数组未初始化并且使用了有参构造函数构造
// 这里在oldCap = 0时执行,这里oldThr > 0说明初始化时是有参初始化构造的map
// 自己可以试下无参构造函数,threshold的值为0
// B2
else if (oldThr > 0) // initial capacity was placed in threshold
// 使用有参初始化构造函数并且在第一次put操作时会进入执行(去看下put源码)
// 扩容后的容量大小设置为原有阈值
// 例如我上边的注释中的例子,这里第一次添加键值对时容量设置为了1024
newCap = oldThr;
// 3.扩容前的数组未初始化并且使用了无参构造函数构造
// B3
else { // zero initial threshold signifies using defaults
// 扩容后的容量 = 默认容量,扩容后的阈值 = 默认容量 * 负载因子
// 扩容后的容量为16,阈值为12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
/**
* 上边设置了新容量和新的阈值,执行到这里,你应该发现只有newThr可能没被赋值,所以这里要继续进行一个操作,来对newThr进行赋值
* 新阈值等于0,照上边逻辑:
* 两种情况:
* 1.扩容前的node数组容量有值且扩容后容量超过最大值或者原node数组容量小于默认初始容量16
* 2.使用有参构造函数,第一次put操作时上边代码里没有设置newThr
* D1
*/
if (newThr == 0) {
// 应该得到的新阈值ft = 新容量 * 负载因子
float ft = (float)newCap * loadFactor;
// 假如新容量小于最大容量并且ft小于最大容量则新的阈值设置为ft,否则设置成int最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 执行到这,扩容后的容量和阈值都计算完毕
// 阈值设置为新阈值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 创建扩容后的Node数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
// 切换为扩容后的Node数组,此时还未进行将旧数组拷贝到新数组
table = newTab;
// E1
if (oldTab != null) {
// 原有数组不为空,将原有数组数据拷贝到新数组中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 非空元素才进行赋值
if ((e = oldTab[j]) != null) {
// 原有数值引用置空,方便GC
oldTab[j] = null;
if (e.next == null)
// 桶对应的Node只有当前一个节点,链表长度为1
// 中括号中计算原有数组元素在新数组中存放的位置,
// 为什么这么计算?
// 正常的想,添加了一个键值对,键的hash值(当然,这里在HashMap的hash(key)进行了统一处理)
// 那么长度是有限的,在这个有限长度下如何放置,类比整数取余操作,
// &操作表明只取e.hash的低n位,n是newCap - 1转换成二进制的有效位数
// 这里记得初始不设长度时默认16,二进制为10000,减一为1111,低4位
// 设置长度时tableSizeFor重新设置了长度和16处理类似
// 通过&操作所有添加的键值对都分配到了数组中,当然,分配到数组中同一个位置时会扩展成链表或红黑树
// 添加详细操作看后边putVal源码,这里先不用纠结
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 到此说明e.next不为空,那么需要判断了,
// 因为有两种结构,一种是链表,一种为红黑树
// 这里先进行红黑树处理,树的具体处理后边有时间单独做一章进行说明讲解,
// 这里先简单了解,扩容之后,需要对原有的树进行处理,使得数据分散比较均匀。
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
/**
* 到这里结合HashMap的结构,
* 排除上边两个条件,这里就进行链表结构的处理
* 进行链表复制操作
* 复制的时候就有个问题了,举个例子,原来我是16,现在扩容成了32(原数组两倍,我上边分析里有说明)
* 那么我复制时怎么办?
* 不移动原来的链表?
* 这里就要想到了我扩容之后访问的时候不能影响
* 那么就需要看下put操作时是怎样存的,这里先说下,putVal里也可以看到
* (n - 1) & hash 和上边newTab[e.hash & (newCap - 1)] 分析是一样的
* 这里不知道你想到了吗?扩容之后有什么不同?
* 如果还没什么想法,请继续往下看,我等下会说明
* 新扩容部分头尾节点(hi可以理解成高位)设置为hiHead,hiTail
* 原有部分头尾节点(lo可以理解成低位)设置为loHead,loTail
* 这里什么意思呢?
* 往下看就好,我下面的注释详细说明了为什么定义了两个链表头尾节点
*/
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 这里循环获取链表元素进行处理
do {
next = e.next;
/**
* e.hash & oldCap = 0
* 位与操作,这里初学者要自己写下多理解下
* 举个例子:
* oldCap=32=100000(二进制),newCap=64=1000000(二进制)
* 在未扩容之前计算元素所处位置是(oldCap-1) & hash
* 全1位与操作,取值范围落在0~oldCap-1
* e.hash & oldCap 只判断了最高位的那个1位置是否相同
* 相同则非0,不同则为0
* 为什么要判断这一位呢?
* 我们想一想,扩容之后,计算bucket(桶)位置(即元素落在数组那个索引位置)时
* (newCap-1) & hash和(oldCap-1) & hash两者对比,只有一位不同
* 比如32和64,最高位是1不同,其他位相同
* 如果扩容之后最高位为0,则扩容前后得到的bucket位置相同,不需要调整位置
* 如果非0,则是1,则需要将桶位置调整到更高的索引位置
* 而且这里也应该明白,同一个bucket下的链表(非单一元素)在扩容后
* 因为只有一位二进制不同,不是1就是0
* 最多分到两个bucket中,一个是扩容前的bucket(当前所在的bucket),
* 一个是扩容后的bucket(新的bucket),
* 这里也说明了上边为什么设置了两组头尾节点,一组低位链表,一组高位链表
* 扩容前后两个bucket位置之间差值为原数组容量值
* 上边32和64,差值为63-31=32=oldCap=10000(二进制)
* 所以这下面使用的是oldCap
*/
if ((e.hash & oldCap) == 0) {
// 说明当前Node元素位置 = 原数组中的位置
// 放入loHead,loTail这一组中,低位链表
if (loTail == null)
// 链表还未放元素,链表头赋值
loHead = e;
else
// 链表存在元素,新元素放置在链表尾部,next指向新元素
loTail.next = e;
// 尾节点指向改变,变成了新添加的节点
loTail = e;
}
else {
// 类似上边
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 上面已经处理完了,分成了高低位两个链表,下面就是将这两个链表放置扩容后的新数组中
if (loTail != null) {
// 低位链表不为空,添加到新数组,尾节点next指向置空,因为原有节点可能还存在next指向
loTail.next = null;
// 新数组j处就是原有数组j处,这里直接将低位首节点引用赋值给新数组节点
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
// 这里和我上边注释分析是一致的,相差的值即为oldCap,即原数组的容量
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
/**
* put操作方法主体
* hash,key的hash值,上边讲过,HashMap自己处理过的
* onlyIfAbsent,是否覆盖原有值,true,不覆盖原有值
* evict,LinkedHashMap实现afterNodeInsertion方法时调用,这里相当于占位符的作用
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// F1
if ((tab = table) == null || (n = tab.length) == 0)
// table为空或长度为0时,对table进行初始化,上边已经分析过了
// 这里也说明了第一次初始化是在这里,而不是使用构造方法,排除putMapEntries方式
n = (tab = resize()).length;
// 判断当前需要存储的键值对存放到数组中的位置是否已经存在值(链表或者红黑树)
// 即是否已经有对应key
// G1
if ((p = tab[i = (n - 1) & hash]) == null)
// 不存在,则创建一个新节点保存
tab[i] = newNode(hash, key, value, null);
// G2
else {
// 将桶上的值进行匹配,判断是否存在
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 链表头节点(或红黑树根节点)与当前需要保存的hash值相等
// 并且key值相等,e和p是同一个,说明添加了相同的key
// e指向p对应的节点
e = p;
else if (p instanceof TreeNode)
// 红黑树添加节点处理,本文不详细将红黑树部分,后面有空会单独抽出讲解
// 返回值可以理解成如果有相同key,则返回对应Node,否则返回null(创建了新的Node)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 这里说明非头节点(数组中对应的桶的第一个节点),非红黑树结构,
// 说明需要匹配链表,判断链表中对应的key是否已存在
// 设置binCount计算当前桶中bin的数量,即链表长度
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// next 为空 无下一个元素 不再继续查找 直接新创建直接赋值next
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 判断是否树化,这里就是链表树化条件,在treeifyBin还有个数组容量判断,方法也可能只进行扩容操作
// 总结下,即桶中bin数量大于等于TREEIFY_THRESHOLD=8,数组容量不能小于MIN_TREEIFY_CAPACITY=64时进行树化转化
// 怎么转成红黑树结构这里也不做深入,后续会进行说明
treeifyBin(tab, hash);
break;
}
// 不为空 且节点为寻找的节点 终止循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 上边已经检查完map中是否存在对应key的Node节点,不存在的新创建节点,这里处理下存在对应key的节点数据
// H1
if (e != null) { // existing mapping for key
// 保存下原来的节点值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
// onlyIfAbsent 是否需要覆盖操作,是则覆盖
e.value = value;
// 子类实现方法的话可以进行对应的后置操作
afterNodeAccess(e);
// 返回原值
return oldValue;
}
}
++modCount;
// 实际元素长度,不是容量,是每次添加一个新的键值对会加1,覆盖不增加
// 判断是否大于阈值,进行扩容操作
// I1
if (++size > threshold)
resize();
// 同afterNodeAccess,子类实现方法的话可以进行对应的后置操作
afterNodeInsertion(evict);
return null;
}
重点的部分也就是在上面这几个方法,剩下的源码部分就不一一贴出来分析了,能看懂我上面说明的部分,基本上除了红黑树和jdk1.8的新特性相关部分,其余部分应该基本都能看懂,这里再补充一个序列化方面的问题:
为什么HashMap中的table变量要设置为transient?在理解这个问题之前,自行去看下序列化代码writeObject和readObject,然后参考以下链接来思考:
https://segmentfault.com/q/1010000000630486
HashMap中,由于Entry的存放位置是根据Key的Hash值来计算,然后存放到数组中的,对于同一个Key,在不同的JVM实现中计算得出的Hash值可能是不同的。这里不同意思是说我原来在window机器上A是放在Node数组中0的位置,在Mac上可能是放在Node数组中5的位置,但是不修改的话,反序列化之后Mac上也是0的位置,这样导致后续增加节点会错乱,不是我们想要的结果,故在序列化中HashMap对每个键值对的键和值序列化,而不是整体,反序列化一个一个取出来,不会造成位置错乱。
那么JDK1.8中HashMap在多线程环境下会造成死循环吗?
从上边结构以及处理过程的分析来看,应该是不会的,只不过数据丢失还是会发生,这一块我就不进行验证了,自行Google,手写代码来验证。同时想多说句,对于一般开发人员知道HashMap是非线程安全的,多线程情况下使用ConcurrentHashMap即可,后边有时间ConcurrentHashMap的分析我也会整理出来。
总结
在重点说明部分我已经详细解释了resize和put操作的过程,可能有些新人还是不能梳理清楚,我在这里结合下日常使用总结下整个过程,方便各位理解:
1.HashMap创建过程(正常状态):
2.HashMap resize过程(正常状态):
3.HashMap put过程(正常状态):
HashMap首先需要理解清楚其内部的实现结构: 数组+链表+红黑树
,在结构的基础之上来对源码进行深入,resize和put操作是最为重要的两部分,理解了这两块,基本上对HashMap的整体处理过程有了一定的认知,另外,一定要自己动手debug,理清数据的转换,对了解HashMap有很大的帮助。
文章先从基础部分说起,解释了一些名词,提及了哈希表,从实现结构开始来帮助各位更好的理解源码操作部分,对重点的几个部分做出详细的说明,resize和put操作难点部分也做了相应的解释,希望对各位有所帮助,后边有空我会将红黑树部分的理解分享出来,谢谢。
以上是关于JDK源码那些事儿之我眼中的HashMap的主要内容,如果未能解决你的问题,请参考以下文章