在上篇文章中我们大致介绍了HashMap原理,本文主要围绕Java8HashMap做了哪些优化.
简述
在上文提到jdk1.7中HashMap采用数组+链表实现,虽然使用链表处理冲突,同一hash值的元素都存储在一个链表中,但当同一链表上的元素较多又想要查询最先插入的元素时,通过key依次寻找显然效率较低.所以java8HashMap采用数组+链表+红黑树方式实现,当链表长度超过阈值8时,会将链表转换为红黑树.
数据结构
红黑树相关的重要字段
/**
* 链表最大长度,若超过8,桶中链表转成红黑树
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* 桶中结点小于该长度,红黑树转成链表.中间有2个缓冲值的原因是避免频繁的切换浪费计算机资源
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* 哈希表的最小树形化容量,只有键值对数量大于64才会发生转换,将链式结构转化成树型结构,
* 否则采用扩容来避免冲突,至少4*TREEIFY_THRESHOLD来避免扩容和树形结构之间的冲突
*/
static final int MIN_TREEIFY_CAPACITY = 64;
/**
* 相对于java7,8用node替换了Entry类,它们的结构大体相同.一个显著的区别
* node有派生类TreeNode,通过这种继承关系,链表很容易转换成树
*/
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
Node(int hash, K key, V value, Node next) {...}
public final K getKey() {...}
public final V getValue() {...}
public final String toString() {...}
public final int hashCode() {...}
public final V setValue(V newValue) {...}
public final boolean equals(Object o) {...}
}
static final class TreeNode extends LinkedHashMap.Entry {
TreeNode parent; //父结点
TreeNode left; //结点的左孩子
TreeNode right; //结点的右孩子
TreeNode prev; //前一个元素结点
boolean red; //true表示红结点,false表示黑结点
TreeNode(int hash, K key, V val, Node next) {...}
final TreeNode root() {...}
static void moveRootToFront(Node[] tab, TreeNode root) {...}
final TreeNode find(int h, Object k, Class kc) {...}
final TreeNode getTreeNode(int h, Object k) {...}
static int tieBreakOrder(Object a, Object b) {...}
final void treeify(Node[] tab) {...}
final Node untreeify(HashMap map) {...}
final TreeNode putTreeVal(HashMap map, Node[] tab,
int h, K k, V v) {...}
final void removeTreeNode(HashMap map, Node[] tab,
boolean movable) {...}
final void split(HashMap map, Node[] tab, int index, int bit) {...}
/* ------------------------------------------------------------ */
// Red-black tree methods, all adapted from CLR
static TreeNode rotateLeft(TreeNode root,
TreeNode p) {...}
static TreeNode rotateRight(TreeNode root,
TreeNode p) {...}
static TreeNode balanceInsertion(TreeNode root,
TreeNode x) {...}
static TreeNode balanceDeletion(TreeNode root,
TreeNode x) {...}
static boolean checkInvariants(TreeNode t) {...}
}
复制代码
TreeNode继承关系图:
1.8 HashMap方法
put方法
put操作进行如下步骤:
①.通过hash算法计算key的hash值 ②.判断哈希表是否为空或为null,为空或为null时调用resize方法进行扩容
③.根据key计算hash值得到桶索引,若没有碰撞即桶中无结点直接添加
④.若发生碰撞,判断该桶中首个元素是否与key一样,若相同记录该结点
⑤.若不同,判断该桶结构是否是红黑树,若是在树中插入键值对
⑥.若不是红黑树,即为链表.遍历链表,判断链表长度是否大于8,若大于8将链表转成红黑树,在红黑树中插入键值对;
⑦.若未超过8且找到key相同的结点记录此结点,若没有找到则尾插结点
⑧.若记录的结点不为null且onlyIfAbsent为false或旧值为null进行替换返回旧值,否则不能替换
⑨.插入成功后size+1,校验是否超过阈值threshold,若过载则扩容
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
/**
* @param hash key经过hash计算后的hash值
* @param key 键
* @param value 值
* @param onlyIfAbsent 若为true,不会替换value
* @param evict 若为false,哈希表在创建模式中
* @return 返回被替换值.返回null可能被替换的就是null,或不存在key键对象
* 没有进行替换操作
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node[] tab; Node p; int n, i;
// 若哈希表为空或为null,调用resize方法创建一个
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 若没有发生冲突,直接创建结点
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node e; K k;
// 若桶中第一个结点的hash值相同并且equals方法返回true时进行替换
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 判断是否为红黑树
else if (p instanceof TreeNode)
e = ((TreeNode)p).putTreeVal(this, tab, hash, key, value);
// 为链表时
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//若链表中无相应key进行尾插
p.next = newNode(hash, key, value, null);
// 链表长度大于8,将其结构换成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 若key存在跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 若结点不为null,将值进行替换,返回旧值
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
若超过容量进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
复制代码
java7HashMap的put操作与java8有如下区别:
①.java7HashMap采用链表处理冲突hash算法对key的hash值做了4扰动,而java8引入了红黑树来处理较多的哈希冲突,遍历的时间复杂度由O(n)→O(logn)所以其简化了他hash算法将hash值的高位与低位进行混合
②.java7桶中仅可能是链式结构,而java8还可能是红黑树,所以java8只能先查看桶中首个元素后需要判断桶的数据结构根据其结构采用不同的方式处理
③.java7新增结点先判断是否超过阈值threshold再添加,java8先添加后判断
④.java7采用头插,而java8若桶是链式结构采用尾插
树形化
当桶中链表长度超过8时,会调用此方法链表结点转红黑树结点进行如下操作:
①.判断其是否符合树形化条件,若不符合进行扩容解决更多冲突
②.若符合遍历桶中链表所有结点,将头结点设为红黑树的根结点,创建与链表结点内容一致的树形结点
final void treeifyBin(Node[] tab, int hash) {
int n, index; Node e;
//若当前哈希表为空或长度小于最小化树形容量进行扩容来解决更多冲突
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
//当前位置桶不为null
else if ((e = tab[index = (n - 1) & hash]) != null) {
//红黑树头结点,尾结点
TreeNode hd = null, tl = null;
do {
//声明树形节点,内容和当前链表节点e一致
TreeNode 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 replacementTreeNode(Node p, Node next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
复制代码
红黑树转换步骤:
①.桶中第一个结点作为红黑树根节点(黑色)
②.遍历其他结点,从根结点开始通过比较哈希值寻找其位置
③.若x结点hash值小于p结点hash值,往p结点左边寻找,否则往p结点右边寻找
④.一直按照步骤③寻找,直至寻找的位置为null即此位置为x的目标位置
⑤.因为红黑树性质,其插入删除都需要平衡调整
⑥.最后确保红黑树根结点为桶中第一个节点
final void treeify(Node[] tab) {
TreeNode root = null;
for (TreeNode x = this, next; x != null; x = next) {
next = (TreeNode)x.next;
x.left = x.right = null;
//第一个结点为根结点,必须黑色
if (root == null) {
x.parent = null;
x.red = false;
root = x;
}
else {
K k = x.key;
int h = x.hash;
Class kc = null;
// 若不是根结点从根节点遍历所有结点与结点x比较哈希值找到其位置
for (TreeNode p = root;;) {
int dir, ph;
K pk = p.key;
// p哈希值大于x哈希值,dir为-1,从p的左边找
if ((ph = p.hash) > h)
dir = -1;
// p哈希值小于x哈希值,dir为1,从p的右边找
else if (ph < h)
dir = 1;
//哈希值相等时
else if ((kc == null &&
//comparableClassFor方法若x的Key实现了Comparable接口
//返回Key的运行时类型,否则返回null
(kc = comparableClassFor(k)) == null) ||
//compareComparables方法,若pk与x的key类型相同
//返回k.compareTo(pk),否则返回0
(dir = compareComparables(kc, k, pk)) == 0)
//若pk与k哈希值相同无法比较,直接比较它们的引用地址
//红黑树不像avl树一样需要高度平衡,其允许局部很少的不完全平衡
//这样对于效率影响不大省去了很多没有必要的调平衡操作
dir = tieBreakOrder(k, pk);
//将p作为x的父节点,用于给下面的x父节点赋值
TreeNode xp = p;
//若dir不大于0则向p左边查找,否则向p右边查找
//如果为null则表示该位置为x的目标位置
if ((p = (dir <= 0) ? p.left : p.right) == null) {
x.parent = xp;
//dir不大于0,x为p的左节点
if (dir <= 0)
xp.left = x;
//dir大于0,x为p的右节点
else
xp.right = x;
//保证插入后平衡
root = balanceInsertion(root, x);
break;
}
}
}
}
//确保根节点是桶的第一个节点
moveRootToFront(tab, root);
}
复制代码
moveRootToFront方法:
static void moveRootToFront(Node[] tab, TreeNode root) {
int n;
//哈希表不为空且桶中有节点
if (root != null && tab != null && (n = tab.length) > 0) {
int index = (n - 1) & root.hash;
//记录桶中第一个元素
TreeNode first = (TreeNode)tab[index];
//若根节点不是桶中第一个节点
if (root != first) {
Node rn;
//将根节点设为桶中第一个节点
tab[index] = root;
//获取根节点的前驱节点
TreeNode rp = root.prev;
//若根节点后继不为空则将其前驱指向根节点前驱节点
if ((rn = root.next) != null)
((TreeNode)rn).prev = rp;
//若根节点前驱节点不为null则将其后继指向根节点的后驱节点
if (rp != null)
rp.next = rn;
//若原桶中第一个结点不为null则将其前驱指向根节点
if (first != null)
first.prev = root;
//根节点后继指向原桶中第一个结点
root.next = first;
//根节点前驱设为null
root.prev = null;
}
//断言检测其是否符合红黑树性质
assert checkInvariants(root);
}
}
复制代码
扩容机制
①.分支1:若原哈希表不为空,判断其容量是否超过最大容量,若超过则将其阈值设为int最大值返回原哈希表无法扩容,若没超过再判断原哈希表容量的2倍是否最大容量且不小于16,符合条件将阈值设为原阈值两倍
②.分支2:若原哈希表容量为0,阈值大于0(初始化容量0的HashMap),将新表容量设为原表的阈值
③.分支3:若原哈希表容量为0,阈值也为0(无参构造),将容量和阈值设为默认值
④.当分支2成立,计算新的resize上限(正常情况新阈值为新容量*负载因子)
⑤.将计算好的新阈值设为当前阈值,以计算好的新容量定义新表
⑥.若原哈希表不为空则将其结点转移到新table中
/**
* 对哈希表初始化或扩容
* 若哈希表为null则对其进行初始化
* 扩容后结点要么原位置,要么在原位置偏移旧容量的位置
*/
final Node[] resize() {
// 记录当前哈希表
Node[] oldTab = table;
// 记录当前哈希表长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
// 记录当前哈希表阈值
int oldThr = threshold;
int newCap, newThr = 0;
// 若当前哈希表不为空
if (oldCap > 0) {
//极值场景,将扩容阈值设置成最大,返回当前哈希表无法扩容
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//若当前容量的两倍小于最大容量且当前容量不小于默认初始容量(16)时
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//将新阈值设为原阈值两倍
newThr = oldThr << 1; // double threshold
}
//若当前容量为0且当前阈值大于0
else if (oldThr > 0) // initial capacity was placed in threshold
//将新容量设置成当前扩容阈值
newCap = oldThr;
//若当前容量为0且当前阈值为0
else { // zero initial threshold signifies using defaults
// 新容量设为默认初始化容量
newCap = DEFAULT_INITIAL_CAPACITY;
// 设置新阈值
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//计算新的resize上限
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
//若新容量 < 最大容量且ft < 最大容量,新阈值为ft,否则为int最大值
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//将阈值设为newThr
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//创建新数组
Node[] newTab = (Node[])new Node[newCap];
//将当前哈希表设为扩容后的newTab
table = newTab;
//若原哈希表不为空则将其结点转移到新table中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node e;
//若原桶中有结点
if ((e = oldTab[j]) != null) {
//将旧桶置空便于gc
oldTab[j] = null;
//若桶中只有一个结点,重新计算位置添加
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//若桶结构为红黑树
else if (e instanceof TreeNode)
//分割树中结点
((TreeNode)e).split(this, newTab, j, oldCap);
//链式优化重hash的代码块
else { // preserve order
Node loHead = null, loTail = null;
Node hiHead = null, hiTail = null;
Node next;
do {
next = e.next;
// 原索引
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
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;
}
// 原索引+oldCap放到桶中
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
复制代码
java8的resize方法具有了扩容和初始化功能相对于7,其没有重新计算hash值,只需要看新增的1bit是0还是1可以认为是随机的,因此resize过程,均匀地把之前的冲突结点分散到新的bucket,这块便是java8新增的优化点,并且java7rehash时新表的数组索引位置相同,链表元素会倒置也因为倒置多线程可能会出现死循环,而java8顺序一致不会出现此场景.
split方法
final void split(HashMap map, Node[] tab, int index, int bit) {
//获取调用此方法结点
TreeNode b = this;
//存储与原索引位置相同的结点
TreeNode loHead = null, loTail = null;
//存储原索引+oldCap的结点
TreeNode hiHead = null, hiTail = null;
int lc = 0, hc = 0;
for (TreeNode e = b, next; e != null; e = next) {
next = (TreeNode)e.next;
e.next = null;
//扩容后与原位置相同,尾插
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) {
//桶中结点数少于6个将红黑树转为链表
if (lc <= UNTREEIFY_THRESHOLD)
tab[index] = loHead.untreeify(map);
else {
tab[index] = loHead;
//重新构建红黑树
if (hiHead != null) // (else is already treeified)
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);
}
}
}
复制代码
untreeify方法:
/**
* 红黑树转为链表
*/
final Node untreeify(HashMap map) {
Node hd = null, tl = null;
//从调用此方法结点开始遍历,将所有结点转为链表结点
for (Node q = this; q != null; q = q.next) {
Node p = map.replacementNode(q, null);
//第一个结点为头结点,其余逐个尾插
if (tl == null)
hd = p;
else
tl.next = p;
tl = p;
}
return hd;
}
复制代码
小结
最主要讲了put方法、扩容机制以及链表与树间转换,java8中HashMap引入了红黑树,因为红黑树查询时间复杂度为O(logn)能解决较多哈希冲突问题,所以简化了其hash算法,采用尾插方式添加结点.其次扩容也不需要重新计算hash值,扩容后的结点位置(原位置/偏移旧容量)均匀地把之前的冲突结点分散到新的桶中,且不会出现死循环,但是其依旧线程不安全.
在本节中并没有提及get方法,因为当理解了put原理,get操作与之雷同.也未涉及太多红黑树相关东西,我想等介绍treemap再详细讨论.
问题
1.为什么转红黑树的阈值是8?
我们可以从HashMap源码中有一段注释说明,理想情况下使用随机的哈希码,容器中结点分布在hash桶中的频率遵循泊松分布(详情),按照泊松分布的计算公式计算出了桶中元素个数和概率的对照表可以看到链表中元素个数为8时的概率已经非常小,再多的就更少了,所以选择了8.
桶中元素个数和概率的关系如下:
数量 | 概率 |
0 | 0.60653066 |
1 | 0.30326533 |
2 | 0.07581633 |
3 | 0.01263606 |
4 | 0.00157952 |
5 | 0.00015795 |
6 | 0.00001316 |
7 | 0.00000094 |
8 | 0.00000006 |
参考
https://blog.csdn.net/v123411739/article/details/78996181
https://tech.meituan.com/java-hashmap.html
https://www.toutiao.com/a6542437571140518414/