文章目录
HashMap数据结构概括
在Java8后,HashMap的数据结构如下所示:
在表中,最顶层是一个数组,每个数组元素下可以抽象为一个桶,桶中元素可能为链表,也可能为树节点,在源码中分别用Node和TreeNode表示。
元素属性
分析源码,HashMap的具体属性元素包括:
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小大小
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 临界值 当实际大小(容量*填充因子)超过临界值时,会进行扩容
int threshold;
// 填充因子
final float loadFactor;
}
每一个节点元素都以Node或TreeNode的形式存在,从源码中看到,顶层数组的数据结构是Node<k,v>[] table
这是一个Node数组,数组大小根据threshold而定,在插入新元素超过threshold大小后,会触发扩容,每次扩容总是扩容到2的幂次方倍,如2,4,8……
其中Node和TreeNode的基础定义如下:
/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
static class Node<K,V> implements Map.Entry<K,V> {
// key的哈希值
final int hash;
// 节点key
final K key;
// 节点值
V value;
// 链表的下一个节点
Node<K,V> next;
}
/**
* Entry for Tree bins. Extends LinkedHashMap.Entry (which in turn
* extends Node) so can be used as extension of either regular or
* linked node.
*/
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继承自LinkedHashMap.Entry<K,V>而LinkedHashMap.Entry<K,V>又继承自Node。
构造函数、容量算法和哈希碰撞优化
HashMap存在多个重载构造函数,大多最后会调用:
/**
* 根据初始容量和负载因子构造一个空的HashMap
* @param initialCapacity 初始容量,最后容量会是比初始容量大的最小2幂次方值
* @param loadFactor 填充因子,当Map内元素量 >= threshold*loadFactor,会触发扩容操作
* @throws IllegalArgumentException if the initial capacity is negative
* or the load factor is nonpositive
*/
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函数获取规整的table时,会乘以loadFactor,得到实际的扩容阈值
this.threshold = tableSizeFor(initialCapacity);
}
实际的容量计算是基于tableSizeFor(int cap)
方法,具体实现为:
/**
* Returns a power of two size for the given target capacity.
*/
static final int tableSizeFor(int cap) {
// 防止如果cap已经是2的幂次方,最终计算会是cap的两倍
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
// n在[1,MAXIMUM_CAPACITY]之间,如果在区间内,需要加1来凑足2的幂次方。
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
>>>
表示无符号右移,高位全部用0填充,和>>
的区别是如果操作对象是负数,>>
高位用1填充,>>>
高位用0填充。|是位或,任何二进制位和1位或都等于1。
注意n是一个32位整型,这个函数的具体实现原理是将最高位1之前的所有位都变成1,以1xxxxxxx为例,其中x表示0或1,来看每步操作结果:
// n = 1xxxxxxxx
n |= n >>> 1;
// n = 11xxxxxxx
n |= n >>> 2;
// n = 1111xxxxx
n |= n >>> 4;
// n = 11111111x
n |= n >>> 8;
// n = 111111111
n |= n >>> 16;
// // n = 111111111
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
// n = 1000000000
从上面看到,n |= n >>> a,表示从n的最高位1往后的前a/2~a位都置为1。最后得到的是一个2的幂次方,最开始int n = cap - 1
,考虑cap=0b100,如果不-1,将得到0b1000,但实际0b100即为我们需要的结果
看到这里可能有疑问,为什么要将HashMap的容量控制在2的幂次方,这主要便于定位key在table中的位置,源码中抽象的算法如下::
index = (table.length - 1) & hash(key);
index表示key应在table的位置,table.length - 1相当于是一个低位掩码,假设table.length=10000,-1后变成01111,和哈希值取与,即最终取的是哈希值的低4位,假设哈希的低4位是分布均匀的,则可以保证key在数组容量内是分布均匀的,同时经过位运算得到,整体算法效率极高。
这里还有问题,散列值分布再松散,要是只取最后几位的话,碰撞也会很严重。而如果散列本身做得不好,分布上成等差数列的漏洞,恰好使最后几个低位呈现规律性重复,就会出现部分不均匀的情况,这里的关注点主要在key的hash碰撞优化,先看key的hash算法的实现:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
从上面的代码可以看到key的hash值的计算方法。key的hash值高16位不变,低16位与高16位异或作为key的最终hash值。h >>> 16,表示无符号右移16位,高位补0,任何数跟0异或都是其本身,因此key的hash值高16位不变。
这里考虑是假设我们直接用key的hashCode作为哈希值,经过(table.length - 1)取与,最多利用的仅仅hashCode的低16位,而hash()函数对hashCode高半区和低半区做异或,可以混合原始哈希码的高位和低位,以此来加大低位的随机性。而且混合后的低位掺杂了高位的部分特征,这样高位的信息也被变相保留下来,同时还降低了冲突的可能性。
最后看Peter Lawley的一篇专栏文章《An introduction to optimising a hashing strategy》里的的一个实验:他随机选取了352个字符串,在他们散列值完全没有冲突的前提下,对它们做低位掩码,取数组下标。
结果显示,当HashMap数组长度为512的时候,也就是用掩码取低9位的时候,在没有扰动函数的情况下,发生了103次碰撞,接近30%。而在使用了扰动函数之后只有92次碰撞。碰撞减少了将近10%。看来扰动函数确实还是有功效的。
HashMap有几个基础操作:put插入、get获取、remove移除、entrySet遍历。下面对这4个操作进行分析
put插入或更新Map元素
先看put的源码实现:
/**
* Associates the specified value with the specified key in this map.
* If the map previously contained a mapping for the key, the old
* value is replaced.
*
* @param key key with which the specified value is to be associated
* @param value value to be associated with the specified key
* @return 上一个key对应的value,可能为null,表示放的是null或不存在对应key-value
*/
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
putVal实现
put方法底层调用了核心基础方法putVal,大致实现过程如下:
- 先获取table长度,必要时进行扩容
- 根据key的hash定位key应在table数组的索引位i和首节点p
- 如果首节点位空,直接初始化一个新节点
- 判断key如果应为首节点,则记录为e
- 如果p instanceof TreeNode,则调用putTreeVal转换为树节点插入
- 否则p instanceof Node,遍历链表尝试插入key
- 遍历如果到了尾节点,则在尾部插入节点,并判断节点数是否达到TREEIFY_THRESHOLD阈值,如果达到,调用treeifyBin将链表转换为红黑树
- 如果在遍历过程找到替换节点,记录为e,并退出循环
- 如果e存在,则进行替换操作,并返回旧值
- 如果e不存在,说明进行了结构性变更(新增节点或红黑树化),则记录modCount,同时判定如果实际大小大于阈值则扩容。
具体实现源码如下:
/**
* Implements Map.put and related methods
*
* @param hash hash for key
* @param key the key
* @param value the value to put
* @param onlyIfAbsent if true, don't change existing value
* @param evict if false, the table is in creation mode.
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab是table的本地副本,p是根据hash计算的key在table中的所属桶的首节点
// n是table长度,i是key在table的索引位置
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table未初始化或者长度为0,通过resize进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// (n - 1) & hash 确定元素存放在哪个桶中
if ((p = tab[i = (n - 1) & hash]) == null)
// 桶为空,新生成结点放入桶中(此时,这个结点是放在数组中)
tab[i] = newNode(hash, key, value, null);
else {
// 桶中已经存在元素
Node<K,V> e; K k;
// 比较桶中第一个元素(数组中的结点)和传入key的hash值、引用地址或equals方法是否相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给e,用e来记录
e = p;
// hash值不相等,即key不相等;
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;
}
// 判断链表中结点的key值与插入的元素的key值是否相等
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
// 相等,跳出循环
break;
// 用于遍历桶中的链表,与前面的e = p.next组合,可以遍历链表
p = e;
}
}
// 表示在桶中找到key值、hash值与插入元素相等的结点
if (e != null) {
// 记录e的value
V oldValue = e.value;
// onlyIfAbsent为false或者旧值为null
if (!onlyIfAbsent || oldValue == null)
//用新值替换旧值
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 结构性修改
++modCount;
// 实际大小大于阈值则扩容
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
中间涉及到节点创建替换实现非常简单:
// Create a regular (non-tree) node
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
return new Node<>(hash, key, value, next);
}
// Create a tree bin node
TreeNode<K,V> newTreeNode(int hash, K key, V value, Node<K,V> next) {
return new TreeNode<>(hash, key, value, next);
}
resize获取或调整Map容量
在第一次触发初始化HashMap或扩容时都会触发resize函数调整。在进行扩容前,会根据不同触发条件,计算扩容后的阈值和容量,初始化新table,而后将旧table的元素迁移到新table中。整个扩容的核心算法在元素迁移部分。
迁移算法实现原理:
迁移算法是:
- 遍历table,过滤出不为null的桶首节点。
- 如果没有后续节点,直接计算新的索引位置存放在新的table中
- 否则如果是树节点,则拆分树节点再进行重新映射:
- 最后是普通链表节点,则先分组再映射,具体实现是根据(e.hash & oldCap)是否为0分成两条链表,如果为0则直接存入新链表相应位置,不为0需要重新计算索引,这里考虑以下实例:
从以上发现,每次扩容,会将同一个桶内的元素,根据(e.hash & oldCap)是否为0分到新表的两个桶中oldCap=1000,newCap=10000, hash1=11101,hash2=10101 1. 不满足hash1&oldCap==0:A=hash1&(oldCap-1)=00101,B=hash1&(newCap-1)=01101,A!=B,需要迁移到新table的其他索引位 2. 满足hash2&oldCap==0:A=hash2&(oldCap-1)=00101,B=hash2&(newCap-1)=00101,A=B,直接迁移到新table的相同索引位
final Node<K,V>[] resize() {
// 当前table保存
Node<K,V>[] oldTab = table;
// 保存table大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 保存当前阈值
int oldThr = threshold;
// 阈值=容量*loadFactor
int newCap, newThr = 0;
// 如果旧容量大于0,在没有超过最大容量,会对旧容量oldCab和阈值oldThr进行翻倍,存储在newCap和newThr中
if (oldCap > 0) {
// 如果超过最大容量,规整为最大容量
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 容量翻倍,使用左移,效率更高
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
// 阈值翻倍,如果能进来证明此map是扩容而不是初始化
newThr = oldThr << 1; // double threshold
}
// 旧容量为0,阈值大于0
else if (oldThr > 0)
//创建map时用的带参构造:public HashMap(int initialCapacity)或 public HashMap(int initialCapacity, float loadFactor) 进入此if
//注:带参的构造中initialCapacity(初始容量值)不管是输入几都会通过 “this.threshold = tableSizeFor(initialCapacity);”此方法计算出接近initialCapacity参数的2^n来作为初始化容量(初始化容量==oldThr)
newCap = oldThr;
// oldCap = 0并且oldThr = 0
else {
// 创建map时用的无参构造进入此if:
// 使用缺省值(如使用HashMap()构造函数,之后再插入一个元素会调用resize函数,会进入这一步)
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 新阈值为0
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"})
// 初始化table
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 之前的table已经初始化过
if (oldTab != null) {
// 复制元素,重新进行hash,遍历旧table
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 数组元素首节点不为空才继续操作
if ((e = oldTab[j]) != null) {
// 先设为空
oldTab[j] = null;
// 每个桶仅有首节点,没有后续节点
if (e.next == null)
// 根据hash计算在新表的索引位置,存入新表中
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
// 有子节点,且为树,进行树迁移操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 将同一桶中的元素根据(e.hash & oldCap)是否为0进行分割,分成两个不同的链表,完成rehash
// 为0放在lo链表,不为0放在hi链表
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null) // 尾为空,设置头部,第一次进入循环触发
loHead = e;
else // 接尾部
loTail.next = e;
loTail = e; // 更新尾部
}
else {//类似上面操作
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;
}
从代码中我们可以看到,对于每个桶:
- 如果是链表节点,在经过扩容后,会将同一个桶内的元素,根据(e.hash & oldCap)是否为0分到新表的两个桶中,但新表中两个桶的节点顺序并未发生改变。
- 如果是树节点,会对树进行拆分,再重新映射到新table中,具体实现在下一节分析
每次进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的,但经过一次扩容处理后,元素会更加均匀的分布在各个桶中,会提升访问效率。
链表树化、红黑树链化与拆分
在putVal()和resize()函数中,都有一些相关的红黑树操作,包括:putTreeVal,treeifyBin,split,untreeify。
红黑树特性
在分析这些操作前,先回顾下红黑树一些相关特性:
- 节点是红色或黑色。
- 根是黑色。
- 所有叶子都是黑色(叶子是NIL节点)。
- 每个红色节点必须有两个黑色的子节点。(从每个叶子到根的所有路径上不能有两个连续的红色节点。)
- 从任一节点到其每个叶子的所有简单路径都包含相同数目的黑色节点(简称黑高)。
红黑树的应用比较广泛,主要是用它来存储有序的数据,它的时间复杂度是O(lgn),效率非常之高
treeifyBin树化
/**
* 将普通节点链表转换成树形节点链表
*/
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 桶数组容量小于 MIN_TREEIFY_CAPACITY,优先进行扩容而不是树化
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) { // e为table中尝试树化的首节点
// hd 为头节点(head),tl 为尾节点(tail)
TreeNode<K,V> hd = null, tl = null;
do {
// 将普通节点替换成树形节点,简单地拷贝值并初始化赋值到一个TreeNode对象
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null) // 第一次循环会进入这个分支,赋值头节点
hd = p;
else {// 双向链表赋值
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null); // 将普通链表转成由树形节点链表
if ((tab[index] = hd) != null) // 赋值新的头节点
// 将树形链表转换成红黑树
hd.treeify(tab);
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
在扩容过程中,树化要满足两个条件:
- 链表长度大于等于 TREEIFY_THRESHOLD
- 桶数组容量大于等于 MIN_TREEIFY_CAPACITY
根据参考文章的观点,加入第二个条件的原因在于:
当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。毕竟高碰撞率是因为桶数组容量较小引起的,这个是主因。容量小时,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,将长链表转成红黑树是一件吃力不讨好的事。
针对红黑树的具体树化算法,这里不仔细分析,这里值得讨论的是树化后,如果决定每个key-value的存储顺序,源码中的实现如下:
- 比较键与键之间 hash 的大小,如果 hash 相同,继续往下比较
- 检测键类是否实现了 Comparable 接口,如果实现调用 compareTo 方法进行比较
- 如果仍未比较出大小,就需要进行仲裁了,仲裁方法为 tieBreakOrder,实现是先比较类名,再比较System.identityHashCode:
/** * Tie-breaking utility for ordering insertions when equal * hashCodes and non-comparable. We don't require a total * order, just a consistent insertion rule to maintain * equivalence across rebalancings. Tie-breaking further than * necessary simplifies testing a bit. */ static int tieBreakOrder(Object a, Object b) { int d; if (a == null || b == null || (d = a.getClass().getName(). compareTo(b.getClass().getName())) == 0) d = (System.identityHashCode(a) <= System.identityHashCode(b) ? -1 : 1); return d; }
通过上述3步得出大小顺序后就可以开始构建红黑树了。根据源码,在树化后,链表的pre/next顺序是保留的,这也是TreeNode继承自Node类的必要性,也方便后续红黑树转化回链表结构。
putTreeVal 插入红黑树元素
分析了树化过程,putTreeVal的实现就简单了,也是先根据以上key的顺序算法计算出顺序,再根据红黑树的插入算法完成插入,这里不再仔细分析
split 红黑树拆分
在上面提到,红黑树树化后,HashMap 通过两个额外的引用 next 和 prev 保留了原链表的节点顺序。这样再对红黑树进行重新映射时,完全可以按照映射链表的方式进行。这样就避免了将红黑树转成链表后再进行映射,无形中提高了效率。
下面来看具体实现:
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
TreeNode<K,V> b = this;
// Relink into lo and hi lists, preserving order
TreeNode<K,V> loHead = null, loTail = null;
TreeNode<K,V> hiHead = null, hiTail = null;
int lc = 0, hc = 0;
/*
* 红黑树节点仍然保留了 next 引用,故仍可以按链表方式遍历红黑树。
* 下面的循环是对红黑树节点进行分组,与上面类似
*/
for (TreeNode<K,V> e = b, next; e != null; e = next) {
next = (TreeNode<K,V>)e.next;
e.next = null;
// bit是旧table容量,这里同样根据是否(e.hash & bit) == 0切分到两个桶,切分同时对链表长度进行计数
if ((e.hash & bit) == 0) {
if ((e.prev = loTail) == null)
loHead = e;
else
loTail.next = e;
loTail = e;
++lc;
}
else {
if ((e.prev = hiTail) == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
++hc;
}
}
if (loHead != null) {
// 如果 loHead 不为空,且链表长度小于等于 6,则将红黑树转成链表
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
/*
* hiHead == null 时,表明扩容后,
* 所有节点仍在原位置,树结构不变,无需重新树化
*/
if (hiHead != null)
loHead.treeify(tab);
}
}
// 与上面类似
if (hiHead != null) {
if (hc <= UNTREEIFY_THRESHOLD)
tab[index + bit] = hiHead.untreeify(map);
else {
tab[index + bit] = hiHead;
if (loHead != null)
hiHead.treeify(tab);
}
}
}
从源码上可以看得出,重新映射红黑树的逻辑和重新映射链表的逻辑基本一致。不同的地方在于,重新映射后,会将红黑树拆分成两条由 TreeNode 组成的链表。如果链表长度小于 UNTREEIFY_THRESHOLD,则将链表转换成普通链表。否则根据条件重新将 TreeNode 链表树化。
untreeify 红黑树链化
前面说过,红黑树中仍然保留了原链表节点顺序。有了这个前提,再将红黑树转成链表就简单多了,仅需将 TreeNode 链表转成 Node 类型的链表即可:
final Node<K,V> untreeify(HashMap<K,V> map) {
Node<K,V> hd = null, tl = null;
// 遍历 TreeNode 链表,并用 Node 替换
for (Node<K,V> q = this; q != null; q = q.next) {
// 替换节点类型
Node<K,V> p = map.replacementNode(q, null);
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
Node<K,V> replacementNode(Node<K,V> p, Node<K,V> next) {
return new Node<>(p.hash, p.key, p.value, next);
}
get获取Map元素
get底层实际调用的是getNode方法:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
getNode 根据key 哈希值和key对象获取节点
查找过程插入实现简单很多,先定位键值对所在的桶的位置,然后再对链表或红黑树进行查找。通过这两步即可完成查找。
final Node<K,V> getNode(int hash, Object key) {
// tab是table的本地引用,first记录table对应key哈希的桶首节点,e作为引用辅助循环遍历,K为首节点的key
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 定位键值对所在桶的位置,初始化tab,n,first
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) {
// 如果 first 是 TreeNode 类型,则调用黑红树查找方法
if (first instanceof TreeNode)
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;
}
getTreeNode的实现依赖于红黑树的查找算法,这里不再分析。
remove移除Map元素
remove底层依赖于removeNode方法:
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
removeNode方法实现分成4个步骤:
- 定位桶位置
- 遍历链表并找到键相等的节点
- 判断若matchValue为true,需要比对值相等才进行下一步操作
- 删除节点,删除时有3种情况:
- 如果待删除节点父节点为树节点,调用树删除算法,同时对红黑树进行必要调整
- 如果删除的是首节点,直接下一节点作为首节点
- 删除的是普通链表非首节点,假设p是待删除节点的上一节点,设置p下一节点为待删除节点的下一节点
源码实现:
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 &&
// 1. 定位桶位置
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 如果键的值与链表第一个节点相等,则将 node 指向该节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
else if ((e = p.next) != null) {
// 如果是 TreeNode 类型,调用红黑树的查找逻辑定位待删除节点
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 2. 遍历链表,找到待删除节点
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 3. 删除节点,并修复链表或红黑树
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
if (node instanceof TreeNode)
// 调用树删除算法,同时对红黑树进行必要调整
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
// 如果删除的是首节点,直接下一节点作为首节点
tab[index] = node.next;
else
// 根据上面的算法,p是待删除节点的上一节点,设置p下一节点为待删除节点的下一节点。
p.next = node.next;
// 记录结构修改次数
++modCount;
--size;
// 删除回调
afterNodeRemoval(node);
return node;
}
}
return null;
}
entrySet遍历Map元素与并发更新fail fast原理
遍历除了使用entrySet()方法外,还可以使用ketSet()或values(),这里以entrySet作为代表分析。如果HashMap结构未发生变更,每次遍历的顺序都是一致,但都不是插入的顺序。下面来看遍历的相关核心实现:
public final void forEach(Consumer<? super Map.Entry<K,V>> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
// 缓存modCount
int mc = modCount;
// 遍历tbale
for (int i = 0; i < tab.length; ++i) {
// 对每个桶根据next顺序进行遍历
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e);
}
// 如果遍历过程,Map被修改过,则抛ConcurrentModificationException
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
如果我们需要在遍历过程删除元素,需要使用Map的迭代器来进行遍历,否则在遍历过程检测到modCount发生变化,会抛出异常。来看下相关实现:
final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
// ……省略其他代码
public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();
}
// ……省略其他代码
}
final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
从以上看到HashMap遍历是通过EntryIterator实现的,EntryIterator的核心实现代码在HashIterator中:
abstract class HashIterator {
Node<K,V> next; // next entry to return
Node<K,V> current; // current entry
int expectedModCount; // 用于快速失败,调用remove方法时会同步修改
int index; // current slot
HashIterator() {
expectedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
// 从0开始,遍历找到table中第一个首节点不为空的索引
if (t != null && size > 0) { // advance to first entry
do {} while (index < t.length && (next = t[index++]) == null);
}
}
public final boolean hasNext() {
return next != null;
}
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
// 遍历过程并发修改过(且不是通过本类的remove方法),抛异常快速失败
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
// next存储下一个待遍历节点,current=e存储当前要返回的节点,如果next为空,则进入if条件。
if ((next = (current = e).next) == null && (t = table) != null) {
// 继续遍历找到table中下一个首节点不为空的索引
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}
public final void remove() {
Node<K,V> p = current;
if (p == null)
throw new IllegalStateException();
// 删除时发现并发修改过(且不是通过本类的remove方法),抛异常快速失败
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
current = null;
K key = p.key;
// 删除节点
removeNode(hash(key), key, null, false, false);
// 更新并发修改数,避免后续遍历或删除操作出现快速失败。
expectedModCount = modCount;
}
}
并发操作死循环问题
在jdk1.8之前,resize操作时,当两个线程同时触发resize操作,基于头插法可能会导致链表节点的循环引用,在下次调用get操作查找一个不存在的key时,会在循环链表中出现死循环。
在jdk1.8中,从上述分析可以看到,在插入时,声明两对指针,维护两个连链表,依次在末端添加新的元素。(在多线程操作的情况下,无非是第二个线程重复第一个线程一模一样的操作),因而不再有多线程put导致死循环,但是依然有其他的弊端,比如数据丢失(与内存可见性有关)或size不准确。因此多线程情况下还是建议使用concurrenthashmap。