一.HashMap的数据结构
jdk1.8版本是数组+ 链表/红黑树
二.元素属性
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;
}
HashMap的容量控制在2的幂次方,这主要便于定位key在table中的位置
index = (table.length - 1) & hash(key);
table.length - 1相当于一个低位的掩码,和哈希值取与,可以得到最终的index,因为这其中运用到了位运算,所有效率较高
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash()函数对hashCode高半区和低半区做异或,可以混合原始哈希码的高位和低位,以此来加大低位的随机性。
HashMap的基础操作:
put插入、get获取、remove移除、entrySet遍历。
三.put方法
put插入:
1.先获取table长度,必要时进行扩容
2.根据key的hash定位key应在table数组的索引位i和首节点p
3.如果首节点位空,直接初始化一个新节点
4.判断key如果应为首节点,则记录为e
5.如果p instanceof TreeNode,则调用putTreeVal转换为树节点插入
6.否则p instanceof Node,遍历链表尝试插入key
遍历如果到了尾节点,则在尾部插入节点,并判断节点数是否达到TREEIFY_THRESHOLD阈值,如果达到,调用treeifyBin将链表转换为红黑树
7.如果在遍历过程找到替换节点,记录为e,并退出循环
8.如果e存在,则进行替换操作,并返回旧值
9.如果e不存在,说明进行了结构性变更(新增节点或红黑树化),则记录modCount,同时判定如果实际大小大于阈值则扩容。
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中。整个扩容的核心算法在元素迁移部分。
迁移算法原理
迁移算法是:
1.遍历table,过滤出不为null的桶首节点。
2.如果没有后续节点,直接计算新的索引位置存放在新的table中
3.否则如果是树节点,则拆分树节点再进行重新映射:
4.最后是普通链表节点,则先分组再映射,具体实现是根据(e.hash & oldCap)是否为0分成两条链表,
如果为0则直接存入新链表相应位置,不为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的相同索引位
从以上发现,每次扩容,会将同一个桶内的元素,根据(e.hash & oldCap)是否为0分到新表的两个桶中
从以上发现,每次扩容,会将同一个桶内的元素,根据(e.hash & oldCap)是否为0分到新表的两个桶中
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。
treeifyBin树化
在扩容的过程中,满足树化的要求
1.链表长度大于等于 TREEIFY_THRESHOLD
2.桶数组容量大于等于 MIN_TREEIFY_CAPACITY
加入第二个条件的原因在于:
1.当桶数组容量比较小时,键值对节点 hash 的碰撞率可能会比较高,进而导致链表长度较长。这个时候应该优先扩容,而不是立马树化。毕竟高碰撞率是因为桶数组容量较小引起的,这个是主因。
2.容量小时,优先扩容可以避免一些列的不必要的树化过程。同时,桶容量较小时,扩容会比较频繁,扩容时需要拆分红黑树并重新映射。所以在桶容量比较小的情况下,将长链表转成红黑树是一件吃力不讨好的事。
这里值得讨论的是树化后,如果决定每个key-value的存储顺序,源码中的实现如下:
1.比较键与键之间 hash 的大小,如果 hash 相同,继续往下比较
2.检测键类是否实现了 Comparable 接口,如果实现调用 compareTo 方法进行比较
3.如果仍未比较出大小,就需要进行仲裁了,仲裁方法为 tieBreakOrder,实现是先比较类名,再比较System.identityHashCode:
在源码中,链表的pre/next顺序是保留的,这也是TreeNode继承自Node类的必要性,也方便后续红黑树转化回链表结构。
split 红黑树拆分
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 红黑树链化
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);
}
再将红黑树转成链表就简单多了,仅需将 TreeNode 链表转成 Node 类型的链表
六.remove移除Map元素
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
1.定位桶位置
2.遍历链表并找到键相等的节点
3.判断若matchValue为true,需要比对值相等才进行下一步操作
删除节点,删除时有3种情况:
i.如果待删除节点父节点为树节点,调用树删除算法,同时对红黑树进行必要调整
ii.如果删除的是首节点,直接下一节点作为首节点
iii.删除的是普通链表非首节点,假设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(); }
}
八.并发操作死循环问题
在jdk1.8之前,resize操作时,当两个线程同时触发resize操作,基于头插法可能会导致链表节点的循环引用,在下次调用get操作查找一个不存在的key时,会在循环链表中出现死循环。
在jdk1.8中,从上述分析可以看到,在插入时,声明两对指针,维护两个连链表,依次在末端添加新的元素。(在多线程操作的情况下,无非是第二个线程重复第一个线程一模一样的操作),因而不再有多线程put导致死循环,但是依然有其他的弊端,比如数据丢失(与内存可见性有关)或size不准确。因此多线程情况下还是建议使用concurrenthashmap。