Java8 HashMap主要方法解读
前言
为了加强对Map的理解,以及方便自己日后回顾和别人参考学习,就尝试写这种源码解读的文章。Map相关的源码解读文章之前看了好多篇,java6,java7,java8每个版本都有变动,这篇文章写的是基于java8的版本。自我感觉这个版本的源码比较复杂,变动比较大,但是万变不离其宗吧。
如果文章有哪里写的不对的,希望各位看官能帮忙指出,谢谢。
resize方法解读
解读
/**
* The table, initialized on first use, and resized as necessary.
* 略去后面的注释
*/
transient Node<K,V>[] table;
如上面table的注释所写,table变量是在第一次使用的时候才进行初始化。
那第一次使用是指什么时候呢,指的是当我们往容器put元素的时候,这可以是执行构造方法时传递一个map对象,也可以是执行put方法,还可以是执行putAll方法…不管怎样,最终它们调用的都是putVal方法。
那么问题来了,初始化table是在putVal方法中吗?并不是的,其实在putVal方法中是通过调用resize方法对table进行初始化的。下面代码是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;
//...省略后面的代码
}
由上面的代码可知,如果table为空或者长度为0,那么将执行resize方法来初始化table。
因此,table的初始化,是在resize方法中进行的。如果不介绍这些,我们将很难理解resize方法前20几行代码存在的意义。
下面开始分析resize方法,
final Node<K,V>[] resize() {
//将table赋给oldTab
Node<K,V>[] oldTab = table;
//判断oldTab是否为空,为空说明table还没初始化所有需要初始化table
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//oldCap大于0的情况,即非第一次调用resize方法,即table已经初始化过,说明执行的是扩容操作
if (oldCap > 0) {
//如果oldCap大于等于MAXIMUM_CAPACITY,则将阈值设置为Integer.MAX_VALUE,即最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//否则正常扩容
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//else if成立,对应的情况是map通过HashMap(int initialCapacity, float loadFactor)或HashMap(int initialCapacity)来进行实例化,这两种初始化方式会设置阈值threshold(在构造方法中会通过tableSizeFor方法保证得到的threshold是2的n次幂,这里不展开),注意!该情况是将旧的阈值作为新的容量值!为什么不写成newCap = oldCap呢,因为oldCap表示的是容量,而容量是通过table.length得到的,而此时table为空,因此其容量为0
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//执行该else说明oldCap和oldThr都为0,即对应的情况是使用HashMap()进行实例化,该实例化为设置加载因子为0.75,那么newCap设置为默认值16,阈值为16*0.75,即12
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//我的理解是,该if对应的是上面else if的情况。其目的是设置newThr值。但为什么不把该方法体放到上面else if的尾部呢。
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"})
//开辟一块新的空间
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
///将新的空间赋值给容器的table属性
table = newTab;
//接下来,如果只是初始化,那么oldTab为null,将不执行if,直接返回table。如果是扩容操作,那么oldTab不为空,将将旧table的数据转到新的table中,oldTab始终指向旧的容器
if (oldTab != null) {
//遍历table数组
for (int j = 0; j < oldCap; ++j) {
//创建一个引用e,将用于指向从数组中取出的第一个节点,也就是指向该链表(也可能是红黑树)
Node<K,V> e;
//e指向数组下标j下对应的节点,如果为空,则进行下一轮循环;如果不为空,则准备对该链表开始转移工作
if ((e = oldTab[j]) != null) {
//先释放oldTab[j]指向的节点,我们还有一个引用e指向该节点
oldTab[j] = null;
//如果e.next为空,说明该链表就只有一个节点
if (e.next == null)
//根据节点的hash值和新数组的长度减一进行"&"位运算从而计算出该节点在新数组中的位置,并将节点存入到此位置。
newTab[e.hash & (newCap - 1)] = e;
//如果该节点具体属于TreeNode类型,则说明e指向的是一颗红黑树
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//否则,说明e指向的是链表而不是红黑树
else { // preserve order
//假设引用e在旧数组中的位置为下标i的位置上,那么下面前两个指针表示在新数组中位置i上的链表的头指针和尾指针,后两个指针表示在新数组中位置(i+oldCap)上的链表的头指针和尾指针
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
//通过do while循环将引用e指向的链表的每个节点进行转移
do {
next = e.next;
//如果成立,那么表示该节点在新数组中的下标位置与在旧数组中的下标位置一样
if ((e.hash & oldCap) == 0) {
//如果尾指针为空,说明链表为空,头指针也为空,那么将头指针指向节点e
if (loTail == null)
loHead = e;
//否则将节点e接到链表尾部
else
loTail.next = e;
//最后重新调整尾指针
loTail = e;
}
//如果不成立,表示该节点在新数组中的下标位置是在旧数组中的下标位置加旧数组长度
else {
//如果尾指针为空,说明链表为空,头指针也为空,那么将头指针指向节点e
if (hiTail == null)
hiHead = e;
//否则将节点e接到链表尾部
else
hiTail.next = e;
//最后重新调整尾指针
hiTail = e;
}
} while ((e = next) != null);
//如果尾指针不为空,说明链表存在
if (loTail != null) {
//则让尾节点的next指向null,因为该节点在原数组中并不一定是链表的最后一个节点,因此next不一定为空,现在,它既然作为尾节点,那么它的next域必须为空
loTail.next = null;
//让数组对应位置的元素指向这条链表
newTab[j] = loHead;
}
//同上
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//最后返回这个新的数组,因此我们可以在其他方法中看到这样取新数组长度的写法:n = (tab = resize()).length
return newTab;
}
疑惑
问题:
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
为什么遇到只有一个节点的情况可以直接将该节点放到新数组中的指定位置,难道不存在新数组中该指定位置已经存在一个节点的情况吗?
解答:
确实不存在,扩前前处于不同链表的节点在扩容后也一定不可能串到同一个链表上。因为它们扩容后只可能存在两个位置:一是原数组对应下标位置,二是原数组对应下标位置加原数组长度。因此,如果只有一个节点,那么该节点转移到新数组后,存放的位置必然为空。
可结合下图进行更好的理解:
如图所示,table1表示扩容前,table2表示扩容后。那么扩容前处于下标i的元素扩容后必然处于下标i的位置或者处于下标i+table1.length。即table1中处于不同下标的节点在扩容后也不会处于同一条链表上。因此,如果扩容前该数组下标位置上的节点只有一个,那么扩容后,新的位置必然为空。
putVal方法解读
所有的增加元素方法,像put方法、putMapEntries方法、putAll方法、putIfAbsent方法,最终调用的都是putVal方法,因此这里来解读putVal方法的源码:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//tab指向的是table,p表示指向链表的指针,n表示的是数组长度,i表示数组下标
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果table属性为空或者长度为0,说明table还没初始化或者初始化时设置的容量为的话
if ((tab = table) == null || (n = tab.length) == 0)
//就执行扩容操作,并获取扩容后数组长度重新赋值给变量n
n = (tab = resize()).length;
//如果插入的键值对的key对应的hash值经计算后得到的值,即数组下标值,它在数组中对应的位置为空的话
if ((p = tab[i = (n - 1) & hash]) == null)
就直接将该键值对的信息存入到一个节点中并将节点放到该位置上
tab[i] = newNode(hash, key, value, null);
//如果计算出来的值对应在数组中的那个位置不为空,说明发生了碰撞,则要进行碰撞处理
else {
//e是一个引用,用来指向链表或红黑树的节点
Node<K,V> e; K k;
//判断第一个节点的key是否跟要插入的键值对的key相等,这里是特殊处理,即不考虑p指向的是链表还是红黑树,我们只对第一个节点进行判断
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//相等则让e指向该第一个节点,仅此(后面再来替换)
e = p;
//否则,再判断p指向的是否红黑树
else if (p instanceof TreeNode)
//是的话使用putTreeVal进行处理,返回值表示红黑树中节点的key与要插入的键值对的key相等的节点,可能为null
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//否则,说明p指向的是链表结构
else {
//如果发生的不是替代而是新增,那么当链表的节点个数从7变成8时,链表结构就会转换成红黑树结构,这就是for循环存在得以意义
for (int binCount = 0; ; ++binCount) {
//先执行e=p,next,再判断e是否为null,为null说明遍历完链表了
if ((e = p.next) == null) {
//则利用要插入的键值对的数据创建一个新的节点并接入到链表的尾部
p.next = newNode(hash, key, value, null);
//再判断是否结构是否需要改变(是否需要从链表结构变成红黑树)
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
//插入成功了,跳出for循环
break;
}
//如果if成立,说明e指向的节点的key值与要插入的键值对的key相等的节点,说明找到了被替换的节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
//既然找到了被替换的节点了,就可以跳出for循环了
break;
//如果两个if都没成立,那么让p指向e
p = e;
}
}
//如果e不为空,对应的是找到被替换的节点的情况,此情况HashMap的结构没有发生改变
if (e != null) { // existing mapping for key
//获取被替换的节点的key值,最后用于返回
V oldValue = e.value;
//如果设置了onlyIfAbsent为true,表示只有当value为null才能替代。默认设置为false,表示遇到key一样时替代处理
if (!onlyIfAbsent || oldValue == null)
//用新value值替代旧value值
e.value = value;
// afterNodeAccess方法:Callbacks to allow LinkedHashMap post-actions
afterNodeAccess(e);
//返回旧value值
return oldValue;
}
}
//如果e为空,对应的情况是插入了新的节点,此情况HashMap的结构发生了改变
//因为结构发生了改变,因此modCount加1
++modCount;
//size属性:The number of key-value mappings contained in this map.
//新增节点后,要判断总节点数是否大于阈值
if (++size > threshold)
//执行扩容操作
resize();
// afterNodeInsertion方法:Callbacks to allow LinkedHashMap post-actions
afterNodeInsertion(evict);
//这里对应的是新增了一个节点的情况,因此不存在新value值替代旧value值,故返回null
return null;
}
小细节
putVal方法中,在for循环for (int binCount = 0; ; ++binCount)中,第一个if写的比较简短,但不好理解:
for (int binCount = 0; ; ++binCount) {
//先执行e=p,next,再判断e是否为null,为null说明遍历完链表了
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;
}
其实就等价于:
for (int binCount = 0; ; ++binCount) {
//先执行e=p,next,再判断e是否为null,为null说明遍历完链表了
e = p.next;//让e指向链表下一个节点
if (e == 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提取出来,就容易看多了。
getNode方法解读
解读
所有的获取元素方法(包括get方法、getOrDefault方法)最终都是通过调用getNode方法。因此,这里来解读getNode方法:
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
//如果table为null或者table长度为0或者该hash值对应的数组下标位置上元素为null,说明目标节点不存在,则直接返回null(并让tab指向table,n表示数组长度,first指向第一个节点,若不存在第一个节点,first将指向null)
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//否则,判断第一个节点是否目标节点(特殊处理第一个节点)
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方法来搜索,若存在则返回该节点,若不存在,则返回null
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//否则,对应的是链表结果
//遍历链表的每个节点,根据hash值和key值对每个节点进行对比,查找到了,则返回该节点
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
//否则,说明不存在符合条件的节点,返回null
return null;
}
removeNode方法
解读
有增,有取,也必然要有删。而public V remove(Object key)方法和public boolean replace(K key, V oldValue, V newValue)方法都是通过调用removeNode方法来实现移除节点功能的。因此,这里来解读removeNode方法:
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;
//如果table为空,或者table长度为0,或者该hash值在数组中对应的下标的元素为空,则返回null
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;
//单独处理第一个节点,如果方法形参key与该节点的key相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//则让node指向该节点
node = p;
//如果第一个节点不符合要求,且下一个节点不为空
else if ((e = p.next) != null) {
//如果p指向的是一颗红黑树
if (p instanceof TreeNode)
//则使用getTreeNode方法去查找,并将查询结果赋给node
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
//否则说明p指向的是链表
else {
//使用do while来遍历链表
do {
//如果找到符合条件的节点
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
//则让node指向该节点,并跳出循环
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
//node为空说明找不到,则直接返回null。
//如果matchValue为true,说明移除节点不仅要匹配key,还要匹配value
//如果matchValue为false,则!matchValue为true,则不用考虑value值是否相等
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//如果该节点属于TreeNode类型
if (node instanceof TreeNode)
//那么使用removeTreeNode方法移除该节点
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//如果node==p,说明被移除的节点是第一个节点
else if (node == p)
//则直接让tab[index]指向被移除的节点的下一个节点
tab[index] = node.next;
//如果被移除的节点node不是第一个节点,此时p指向的是node节点的上一个节点
else
//直接让p指向node节点的下一个节点
p.next = node.next;
//结构改变了,modCount要加一
++modCount;
//移除了节点,size要减一
--size;
//afterNodeRemoval方法在HashMap中不起作用
afterNodeRemoval(node);
//返回被移除的节点
return node;
}
}
//此情况对饮的是table为空,或者table长度为0,或者该hash值在数组中对应的下标的元素为空,或者在table中找不到符合条件的节点
return null;
}
小细节
remove(Object key)方法和remove(Object key, Object value)都是通过调用removeNode(int hash, Object key, Object value,boolean matchValue, boolean movable)方法来实现移除节点的效果,其中前者在调用removeNode方法时传入的value为null,且matchValue为false,即不需要匹配value。而后者在调用removeNode方法时传入了value并且matchValue设置为true,因此不仅需要匹配key,还得在value也匹配时才能移除节点。这也正是removeNode方法中if判断中会存在(!matchValue || (v = node.value) == value ||(value != null && value.equals(v)))
这一块的原因。
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
...
}
containsKey方法与containsValue方法
解读
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
public boolean containsValue(Object value) {
Node<K,V>[] tab; V v;
//如果table为空或者长度为0,则直接返回null
if ((tab = table) != null && size > 0) {
//否则遍历table数组
for (int i = 0; i < tab.length; ++i) {
//遍历链表,如果找到符合条件的节点,则返回true
for (Node<K,V> e = tab[i]; e != null; e = e.next) {
if ((v = e.value) == value ||
(value != null && value.equals(v)))
return true;
}
}
}
//否则返回false
return false;
}
分析
TreeNode继承自LinkedHashMap.Entry,而LinkedHashMap.Entry继承自HashMap.Node,因此TreeNode也拥有Node的各个属性,包括next属性。
所以,如果引用指向的是红黑树结构的节点,也可以像链表那样用next来遍历吗?
是的,当实例化一个TreeNode节点的时候,调用的是父类LinkedHashMap的构造方法,而LinkedHashMap又是调用了它的父类HashMap的构造方法,因此最终调用的方法是:
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
也就是说,当在同一数组下标发生冲突的节点超过8个时,这些冲突的节点不是从链表结构转换成红黑树结构,而是扩充了红黑树结构,也就是说链表结构依然是存在的。
而由于这里只是查找容器中是否包含符合条件的value值,只是查找而没有改变内容,因此可以统一使用链表结构来进行访问。当然,也可以像getNode方法一样进行分类讨论。
我想containsValue方法之所以不像getNode方法一样,对节点进行分类别处理,是因为containsValue不像getNode方法那样被频繁调用,所以可以直接以链表结构进行遍历吧,毕竟分类别处理代码量会大一些。
而对于containsKey方法,由于可以利用现成的getNode方法,因此就没必要重写一遍方法体了。