目录
3.5.6、treeifyBin(): 将链表节点转为红黑树节点
1、HashMap简介
- Map是一个接口,他是key-value的键值对,一个map不能包含重复的key,并且每一个key只能映射一个value;
- Map接口提供了三个集合视图:key的集合keySet,value的集合values,key-value的集合entrySet;
- Map内元素的顺序取决于Iterator的具体实现逻辑,获取集合内的元素实际上是获取一个迭代器,实现对其中元素的遍历;
- Map接口的具体实现中存在三种Map结构,其中HashMap和TreeMap都允许存在null值,而HashTable的key不允许为空,但是HashMap不能保证遍历元素的顺序,TreeMap能够保证遍历元素的顺序。
HashMap是基于哈希表的Map接口的实现,提供所有可选的映射操作,允许使用null值和null键。JDK 1.8 对 HashMap 进行了比较大的优化,底层实现由之前的 “数组+链表” 改为 “数组+链表+红黑树”。
2、HashMap数据结构
当链表节点较少时仍然是以链表存在,当链表节点较多时(大于8)会转为红黑树
3、HashMap源码分析
3.1、HashMap继承结构和层次关系
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
}
3.2、类中属性
transient Node<K,V>[] table:这是一个Node类型的数组(也有称作Hash桶),可以从下面源码中看到静态内部类Node在这边可以看做就是一个节点,多个Node节点构成链表,当链表长度大于8的时候转换为红黑树。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
// 默认容量16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
final float loadFactor;//负载因子,用于扩容
// 默认负载因子0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 链表节点转换红黑树节点的阈值, 9个节点转
static final int TREEIFY_THRESHOLD = 8;
// 红黑树节点转换链表节点的阈值, 6个节点转
static final int UNTREEIFY_THRESHOLD = 6;
// 转红黑树时, table的最小长度
static final int MIN_TREEIFY_CAPACITY = 64;
transient Node<K,V>[] table;
// 链表节点, 继承自Entry
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
// ... ...
}
// 红黑树节点
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;
// ...
}
}
3.3、构造方法
总共有4个构造方法,与初始容量(16)和负载因子(0.75)有关。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
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;
this.threshold = tableSizeFor(initialCapacity);
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
3.4、定位哈希桶数组索引位置
不管增加、删除、查找键值对,定位到哈希桶数组的位置都是很关键的第一步。因为HashMap 的数据结构是“数组+链表+红黑树”的结合,所以我们当然希望这个 HashMap 里面的元素位置尽量分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用 hash 算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,不用遍历链表/红黑树,大大优化了查询的效率。HashMap 定位数组索引位置,直接决定了 hash 方法的离散性能。下面是定位哈希桶数组的源码:
// 代码1
static final int hash(Object key) { // 计算key的hash值
int h;
// 1.先拿到key的hashCode值; 2.将hashCode的高16位参与运算
//高16位与16位0值异或,低16位与高16位异或
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 代码2
int n = tab.length;
// 将(tab.length - 1) 与 hash值进行&运算
int index = (n - 1) & hash;
整个过程本质上就是三步:
- 拿到 key 的 hashCode 值
- 将 hashCode 的高位进行 按位与 运算,重新计算 hash 值
- 将计算出来的 hash 值与 (table.length - 1) 进行 & 运算
方法解读:
1、& 比 % 具有更高的效率:对于任意给定的对象,只要它的 hashCode() 返回值相同,那么计算得到的 hash 值总是相同的。我们首先想到的就是把 hash 值对 table 长度取模运算,这样一来,元素的分布相对来说是比较均匀的。
但是模运算消耗还是比较大的,我们知道计算机比较快的运算为位运算,因此 JDK 团队对取模运算进行了优化,使用上面代码2的位与运算来代替模运算。这个方法非常巧妙,它通过 “(table.length -1) & h” 来得到该对象的索引位置,这个优化是基于以下公式:x mod 2^n = x & (2^n - 1)。我们知道 HashMap 底层数组的长度总是 2 的 n 次方,并且取模运算为 “h mod table.length”,对应上面的公式,可以得到该运算等同于“h & (table.length - 1)”。这是 HashMap 在速度上的优化,因为 & 比 % 具有更高的效率。
2、让高位也参与运算增强散列:在 JDK1.8 的实现中,还优化了高位运算的算法,将 hashCode 的高 16 位与 hashCode 进行异或运算,主要是为了在 table 的 length 较小的时候,让高位也参与运算,并且不会有太大的开销。
3.5、常用方法
3.5.1、get()
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 1.对table进行校验:table不为空 && table长度大于0 &&
// table索引位置(使用table.length - 1和hash值进行位与运算)的节点不为空
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 2.检查first节点的hash值和key是否和入参的一样,如果一样则first即为目标节点,直接返回first节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 3.如果first不是目标节点,并且first的next节点不为空则继续遍历
if ((e = first.next) != null) {
if (first instanceof TreeNode)//判断当前节点不是下一个节点
// 4.如果是红黑树节点,则调用红黑树的查找目标节点方法getTreeNode
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 5.执行链表节点的查找,向下遍历链表, 直至找到节点的key和入参的key相等时,返回该节点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 6.找不到符合的返回空
return null;
}
get过程总结:由于map底层存储节点内部都是(hash、key、value、next),故首先获取key的hash,得到(hash、key)。然后根据(hash、key)获取node,最后取值。
获取node:首先确保node数组不为空、有值且索引处有值。然后根据(hash、key)判断。接着判断下一个值时红黑树则走红黑树,是链表则循环链表。
3.5.2、红黑树获取节点
如果是红黑树节点,则调用红黑树的查找目标节点方法 getTreeNode
final TreeNode<K,V> getTreeNode(int h, Object k) {
// 1.首先找到红黑树的根节点;2.使用根节点调用find方法
return ((parent != null) ? root() : this).find(h, k, null);
}
//this代表调用getTreeNode的对象,即node数组中的first对象
final TreeNode<K,V> root() {
for (TreeNode<K,V> r = this, p;;) {
if ((p = r.parent) == null)
return r;
r = p;
}
}
使用根节点调用find()
将 p 节点与 k 进行比较。如果传入的 key(即代码中的参数 k)所属的类实现了 Comparable 接口(kc 不为空),则将 k 跟 p 节点的 key 进行比较(kc 实现了 Comparable 接口,因此通过 kc 的比较方法进行比较),并将比较结果赋值给 dir,如果 dir<0 则代表 k<pk,则向 p 节点的左边遍历(pl);否则,向 p 节点的右边遍历(pr)。
/**
* 从调用此方法的节点开始查找, 通过hash值和key找到对应的节点
* 此方法是红黑树节点的查找, 红黑树是特殊的自平衡二叉查找树
* 平衡二叉查找树的特点:左节点<根节点<右节点
*/
final TreeNode<K,V> find(int h, Object k, Class<?> kc) {
// 1.将p节点赋值为调用此方法的节点,即为红黑树根节点
TreeNode<K,V> p = this;
// 2.从p节点开始向下遍历
do {
int ph, dir; K pk;
TreeNode<K,V> pl = p.left, pr = p.right, q;
// 3.如果传入的hash值小于p节点的hash值,则往p节点的左边遍历
if ((ph = p.hash) > h)
p = pl;
else if (ph < h) // 4.如果传入的hash值大于p节点的hash值,则往p节点的右边遍历
p = pr;
// 5.如果传入的hash值和key值等于p节点的hash值和key值,则p节点为目标节点,返回p节点
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
else if (pl == null) // 6.p节点的左节点为空则将向右遍历
p = pr;
else if (pr == null) // 7.p节点的右节点为空则向左遍历
p = pl;
// 8.将p节点与k进行比较
else if ((kc != null ||
(kc = comparableClassFor(k)) != null) && // 8.1 kc不为空代表k实现了Comparable
(dir = compareComparables(kc, k, pk)) != 0)// 8.2 k<pk则dir<0, k>pk则dir>0
// 8.3 k<pk则向左遍历(p赋值为p的左节点), 否则向右遍历
p = (dir < 0) ? pl : pr;
// 9.代码走到此处, 代表key所属类没有实现Comparable, 直接指定向p的右边遍历
else if ((q = pr.find(h, k, kc)) != null)
return q;
// 10.代码走到此处代表“pr.find(h, k, kc)”为空, 因此直接向左遍历
else
p = pl;
} while (p != null);
return null;
}
find()过程总结:从红黑树根节点开始遍历,先比较hash值,依据树的左子树<根<右子树原则向下遍历,如果hash值相等则比较key,key比较时需要判断是否可比(是否实现Comparable<X>),可比较时判断为null和类型不同的情况。一直向左或右遍历,直到匹配到或不存在为止。
comparableClassFor详解见:Java集合(五二): comparableClassFor(Object x)方法解读_mingyuli的博客-CSDN博客
3.5.3、链表获取节点
直接遍历链表比较hash和key即可。
do {
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
3.5.4、put 方法
- 1、首先看table数组,不存在(null或len=0)则初始化(扩容)。存在继续
- 2、看索引处,不存在则新增。存在继续
- 3、判断索引处的值,相等则记录。不等继续
- 4、判断是红黑树的话,走红黑树
- 5、判断是链表的话,走链表
- 6、判断超阈值,则扩容
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 1.校验table是否为空或者length等于0,如果是则调用resize方法进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 2.通过hash值计算索引位置,将该索引位置的头节点赋值给p,如果p为空则直接在该索引位置新增一个节点即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// table表该索引位置不为空,则进行查找
Node<K,V> e; K k;
// 3.判断p节点的key和hash值是否跟传入的相等,如果相等, 则p节点即为要查找的目标节点,将p节点赋值给e节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 4.判断p节点是否为TreeNode, 如果是则调用红黑树的putTreeVal方法查找目标节点
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 5.走到这代表p节点为普通链表节点,则调用普通的链表方法进行查找,使用binCount统计链表的节点数
for (int binCount = 0; ; ++binCount) {
// 6.如果p的next节点为空时,则代表找不到目标节点,则新增一个节点并插入链表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 7.校验节点数是否超过8个,如果超过则调用treeifyBin方法将链表节点转为红黑树节点,
// 减一是因为循环是从p节点的下一个节点开始的
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, hash);
break;
}
// 8.如果e节点存在hash值和key值都与传入的相同,则e节点即为目标节点,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e; // 将p指向下一个节点
}
}
// 9.如果e节点不为空,则代表目标节点存在,使用传入的value覆盖该节点的value,并返回oldValue
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e); // 用于LinkedHashMap
return oldValue;
}
}
++modCount;
// 10.如果插入节点后节点数超过阈值,则调用resize方法进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict); // 用于LinkedHashMap
return null;
}
3.5.5、红黑树put值,putTreeVal()
红黑树操作版的putVal。那么它的逻辑应该与putVal函数的里的for (int binCount = 0; ; ++binCount)差不多,即:如果能找到相同节点,那么返回该节点,不会做替换处理;如果不能找到相同节点,那么new一个新的TreeNode实例,把它放在应该放置的位置,并返回null(返回null代表没有找到相同节点)。
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;//一定需要这个变量?
TreeNode<K,V> root = (parent != null) ? root() : this;//parent是被调动putTreeVal成员函数的那个节点的父节点(成员)
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk; //dir代表传入节点与当前循环节点的比较大小
if ((ph = p.hash) > h) //如果传入节点的hash比当前处理节点小
dir = -1;
else if (ph < h) //如果传入节点的hash比当前处理节点大
dir = 1;
//如果传入节点的hash跟当前处理节点一样大,传入key与当前key判定相等,那么返回相同节点
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
//如果传入节点的hash跟当前处理节点一样大,传入key与当前key判定不相等,但又必须比出个大小来
//所以要么使用comparable接口,要么使用tieBreakOrder,最终dir会被赋值一个非0值
//分支进入条件1:如果类型参数K的类定义不是comparable接口的自限定
//分支进入条件2:虽然类型参数K的类定义是comparable接口的自限定,但传入节点和当前节点比较后为0
//总之这两种情况都没有比出大小
else if ((kc == null && (kc = comparableClassFor(k)) == null) ||
(dir = compareComparables(kc, k, pk)) == 0) {
if (!searched) {//这个分支只会进入一次,因为searched马上被置为true了
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null &&
(q = ch.find(h, k, kc)) != null) || //先去左子树找
((ch = p.right) != null &&
(q = ch.find(h, k, kc)) != null)) //先去右子树找
//如果能找到就返回相同节点,如果不能找到那说明整个红黑树里面没有相同节点,因为都找遍了
//所以if (!searched)只能进入一次,当然if (!searched)分支也可能一次都不进入,
//当传入节点与红黑树内所有节点都能比较出大小时
return q;
}
//执行到这里说明if (!searched)分支已经执行过了,且没有整棵树都没有找到相同节点
//但还是必须从左右子树挑一条路走,那么使用tieBreakOrder
dir = tieBreakOrder(k, pk); //为保持一致,它与treeify里tieBreakOrder的实参顺序一样
}
//至此,dir已得到一个非0值
TreeNode<K,V> xp = p; //xp暂存p引用,以备不时之需
//p根据dir更新为它自己的左孩子或右孩子,更新后不为null则继续循环,为null则代表找到了最终插入位置
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next; //注意还需要维护双链表结构
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); //new一个TreeNode实例
//赋值父节点的孩子指针
if (dir <= 0)
xp.left = x;
else
xp.right = x;
xp.next = x; //赋值链表结构的后继指针
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
//执行到这里,只是按照二叉查找的过程暂时放置了传入节点,为保持红黑树性质,
//需调用balanceInsertion,调用后返回新root(如果root发生了改变),
//再调用moveRootToFront,使得数组下标能指向红黑树的root节点
moveRootToFront(tab, balanceInsertion(root, x));
return null;//既然已经新增了节点,所以返回null
}
}
}
整个流程在for循环里,将p节点按照二叉查找的过程往下移动(每次循环会更新p),移动的方向根据变量dir的正负来往下走,如果整棵树里都没有与传入节点相同的既存节点,那么最终肯定会找到新节点的插入位置(if ((p = (dir <= 0) ? p.left : p.right) == null)分支):进入分支后,先new出一个TreeNode实例,再与周围节点重塑好双链表结构和红黑树结构,最后再做红黑树的自我调整以保持保持红黑树性质。
- 当传入节点与当前比较节点p使用comparable接口都比较不出大小来时(else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0)分支),就会进入if (!searched) {分支,这个分支只会进入一次,但进入后会把p的左右子树都搜索一遍,如果整棵树里面都没有相同节点,那么说明这个传入节点肯定会被插入,但还是需要先找到个插入位置,所以调用tieBreakOrder来获得默认顺序。
- 这里解释一下为什么if (!searched) {分支里,p的左右子树都搜索一遍:
- 虽然tieBreakOrder会给无法比较出大小的两个节点以默认顺序,但是由于还有左旋右旋操作,所以两个无法比较出大小的节点在红黑树中的顺序可能会被破坏。
- 默认顺序是新插入节点即使比较不出大小,也会把新插入节点放在左孩子(即认为新插入的更小),但由于之后还有红黑树的自我调整,可能会用到旋转操作,那么两个节点的孩子关系可能会变成右孩子的关系。
- 而且这种比较不出大小的情况可能会多次发生,那么干脆在遇到这种情况时,把当前p的左右子树都去找一遍得了(因为find函数会递归查找树上的所有节点),但这样肯定比较耗时,所以要留到第一次遇到比较不出大小的情况才执行这种操作。
- 当然,if (!searched) {分支也可能一次都不会进入,如果传入节点在二叉查找的过程中,与路径上的所有节点都能比较出大小(不管是用hash值,还是用comparable接口)。因为这种情况,二叉查找路径一直都是明确的(该往左子树走,还是该往右子树走)。
tieBreakOrder
如果两者不具有compare的资格,或者compare之后仍然没有比较出大小。那么就要通过一个决胜局再比一次,这个决胜局就是tieBreakOrder方法。
/**
* 用这个方法来比较两个对象,返回值要么大于0,要么小于0,不会为0
* 也就是说这一步一定能确定要插入的节点要么是树的左节点,要么是右节点,不然就无法继续满足二叉树结构了
*
* 先比较两个对象的类名,类名是字符串对象,就按字符串的比较规则
* 如果两个对象是同一个类型,那么调用本地方法为两个对象生成hashCode值,再进行比较,hashCode相等的* 话返回-1
* 用于不可比较或者hashCode相同时进行比较的方法, 只是一个一致的插入规则,用来维护重定位的等价性。
* /
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.5.6、treeifyBin(): 将链表节点转为红黑树节点
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//如果size是小于MIN_TREEIFY_CAPACITY,那么不会进行树化,只是resize
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {//e指向首元素
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);//用e来new一个TreeNode实例
if (tl == null)//如果head和tail都还没有赋值过
hd = p;
else {//如果head和tail已经赋值过,那么把p和tail相互连起来(p作为新tail放在后面)
p.prev = tl;
tl.next = p;
}
tl = p;//更新tail
} while ((e = e.next) != null); //如果e的后继不为空,让e往后移动
//现在执行完,只是让原链表元素保持原来顺序全部替换为TreeNode实例,然后将它们之间的前驱后继连起来,成为双向链表
if ((tab[index] = hd) != null)
hd.treeify(tab); //调用首元素的treeify方法
}
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
return new TreeNode<>(p.hash, p.key, p.value, next);
}
- 函数用来树化参数hash代表的那个哈希桶,把其结构变成哈希桶。
- 1、如果size小于MIN_TREEIFY_CAPACITY,则不能树化,只能再次resize。因为这种情况被认为是容量太小才导致哈希桶太挤(哈希桶节点超过8个)的。
- 2、树化过程是用Node实例的信息来new出新的TreeNode实例,利用循环将各个TreeNode的双链表结构连接起来。循环完毕后,执行tab[index] = hd,将数组下标指向双向链表头节点,这样,以前的Node实例的单链表就丢掉了。
- 3、TreeNode实例的双链表结构有了,就可以遍历它们了。
- 4、最后再调用TreeNode的treeify方法,将红黑树结构建立起来。
总结:首先判断是否可以树化;然后将单链表转为以TreeNode为节点的双链表,最后转为红黑树。
3.5.7、 treeify()树化,构建红黑树
final void treeify(Node<K,V>[] tab) {
TreeNode<K,V> root = null;
for (TreeNode<K,V> x = this, next; x != null; x = next) { //循环最后会让x指向next,然后在下一次循环里处理新x
next = (TreeNode<K,V>)x.next; //先把x的后继保存到next,这里转型是因为Node父类的next引用为Node,所以要转回来
x.left = x.right = null;
//x就是当前循环处理的节点。如果此时根节点还没分配,那么将x作为root
if (root == null) {
x.parent = null;
x.red = false; //root为黑色
root = x;
}
// 进入else说明root已经有了
// 下面的x肯定是第二个元素或更后面的元素
else {
K k = x.key;
int h = x.hash;
Class<?> kc = null; //key的Class对象
for (TreeNode<K,V> p = root;;) { //初始让p指向root
int dir, ph;
K pk = p.key;
if ((ph = p.hash) > h)
dir = -1; //代表当前处理节点x,比p小
else if (ph < h)
dir = 1; //代表当前处理节点x,比p大
// 如果hash值一样,则只能通过别的方式分出个大小
else if ((kc == null && //如果kc之前没有被赋值过,注意它与下一排在一个括号里
(kc = comparableClassFor(k)) == null) || //如果的k的类定义是Comparable的泛型自限定,那么赋值给kc
(dir = compareComparables(kc, k, pk)) == 0) //执行到这里说明k的类定义是Comparable的泛型自限定,且kc已经赋值为其Class对象
//如果pk与k类型一样,那么返回k.compareTo(x);否则返回0,所以返回0既可能代表k和pk一样大,也可能是二者没法比
dir = tieBreakOrder(k, pk); //返回-1或者1
TreeNode<K,V> xp = p;//让xp指向p,因为p引用马上要被替换掉了
// 根据dir的正负让p指向p的左孩子或右孩子,如果孩子不为null,继续下一次循环;否则进入分支
// 只有进入if分支才能退出循环(里面有break),整个循环过程就是二叉查找的过程,直到找到叶子节点
// 循环过程中,p会根据与x的比较结果一直向下移动
if ((p = (dir <= 0) ? p.left : p.right) == null) {
//通过二叉查找找到了x的最终位置,然后将xp(x的parent)与x连接起来
x.parent = xp;
if (dir <= 0)
xp.left = x;
else
xp.right = x;
//注意x并没有给red变量赋过值
root = balanceInsertion(root, x);
break;
}
}
}
}
moveRootToFront(tab, root);
}
- 会在TreeNode对象形成的双向链表的头节点上,调用这个成员方法。既然有了头节点,那么就能获取到该双向链表的所有节点,该函数遍历节点,通过改变TreeNode对象的红黑树相关成员,使得各个节点形成红黑树结构。
- 整个函数由双层循环构成。外层循环负责遍历双向链表(x为当前处理节点,循环的运作靠next = (TreeNode<K,V>)x.next和x = next),如果root节点还没有分配,那么分配root节点,否则把活交给内层循环。
- 进入内层循环说明红黑树中已经至少有一个节点了,此时要放置个新节点进去。首先要二叉查找树的过程,找到新节点的放置位置,整个过程为:
- dir会得到一个非0值(-1代表当前处理节点x更小,所以要进入左子树,+1反之,则进入右子树),(dir <= 0) ? p.left : p.right这里根据非0值把左孩子或右孩子赋值给p,继续下次循环;
- 重复这个下降过程;
- 当找到放置的位置时,if ((p = (dir <= 0) ? p.left : p.right) == null)分支会发现p会被赋值为null,因为p经过若干次循环后已经到达了一个叶子节点。此时每次用来保存原p引用的xp终于派上了用场(因为p会被置为null,不提前保存,这个引用就丢了);
- 但现在只是按照二叉查找的过程找到了一个位置而已,这个位置是暂时的,因为新节点放在这里不一定符合红黑树结构,所以需要调用balanceInsertion,调用完毕,才算完成。
- 当双层循环执行完毕后,需要调用moveRootToFront来使得root节点作为双向链表的头结点。
总结:就是将双向链表转为红黑树的过程:首先确定根节点,然后遍历下一个节点与根节点比较hash/key确定插入位置,最后做结构调整满足红黑树结构。
3.5.8、moveRootToFront
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
int n;
if (root != null && tab != null && (n = tab.length) > 0) {
int index = (n - 1) & root.hash;
TreeNode<K,V> first = (TreeNode<K,V>)tab[index];//得到当前table下标指向的节点
if (root != first) {//如果该table下标的第一个节点都已经是入参root了,就不用进分支了
Node<K,V> rn;//r的next
tab[index] = root;//将table下标指向root
TreeNode<K,V> rp = root.prev; //因为root不是first,所以它的prev肯定不为空
if ((rn = root.next) != null) //那么root也不是双向链表的尾节点
((TreeNode<K,V>)rn).prev = rp;
if (rp != null)
rp.next = rn;
if (first != null)
first.prev = root;
root.next = first;
root.prev = null;
}
assert checkInvariants(root);
}
}
- TreeNode有parent、left、right成员,所以它可以作为红黑树节点。TreeNode有prev、next成员,所以它也可以作为双向链表节点。正常状态下,作为红黑树存在的哈希桶,它的第一个节点肯定也是该红黑树的root节点。但是在经过结构化修改后(增加或删除节点),红黑树会自我调整以至于哈希桶的第一个节点不再是root了。此时,需要调用moveRootToFront函数。
- 想象作为红黑树存在的哈希桶,我们观察它时,既可以用红黑树的视图来看,也可以用双向链表的视图。而此函数的作用就是,改变该哈希桶的双向链表,使得root变成双向链表的头结点。注意,双向链表的头结点的prev为null,尾结点的next为null。这个函数干的事其实大概就是下图(只是为了理解,实际执行顺序请看代码,画图我偷懒了,双向箭头其实应该画成两个箭头):
3.5.9、 checkInvariants
static <K,V> boolean checkInvariants(TreeNode<K,V> t) {
TreeNode<K,V> tp = t.parent, tl = t.left, tr = t.right,
tb = t.prev, tn = (TreeNode<K,V>)t.next;
//检查双向链表的结构是否正确
if (tb != null && tb.next != t)
return false;
if (tn != null && tn.prev != t)
return false;
//检查红黑树的结构是否正确
if (tp != null && t != tp.left && t != tp.right)
return false;
if (tl != null && (tl.parent != t || tl.hash > t.hash))
return false;
if (tr != null && (tr.parent != t || tr.hash < t.hash))
return false;
//检查红黑树的颜色是否正确
if (t.red && tl != null && tl.red && tr != null && tr.red)
return false;
//递归调用自身,检查左右孩子
if (tl != null && !checkInvariants(tl))
return false;
if (tr != null && !checkInvariants(tr))
return false;
return true;
}
- 这是一个递归函数。整个执行过程类似于二叉树,叶子节点返回的bool值给上一层,以此类推,一直到了root。
- 返回上一层后,if (tl != null && !checkInvariants(tl))和if (tr != null && !checkInvariants(tr))会分别判断两个底层返回来的bool值,如果返回的bool值都是true,那么将执行终点return true。
3.5.10、resize
JDK8 HashMap源码行级解析 史上最全最详细解析_anlian523的博客-CSDN博客
4、相关介绍
4.1、负载因子
负载因子(loadFactor):源码中有个公式为 threshold = loadFactor * 容量。HashMap和HashSet都允许你指定负载因子的构造器,表示当负载情况达到负载因子水平的时候,容器会自动扩容,HashMap默认使用的负载因子值为0.75f(当容量达到四分之三进行再散列(扩容)),即表示不会等到容器满时才扩容。
当负载因子越大的时候能够容纳的键值对就越多但是查找的代价也会越高。所以如果知道将要在HashMap中存储多少数据,那么你可以创建一个具有恰当大小的初始容量这可以减少扩容时候的开销(优化手段)。但是大多数情况下0.75在时间跟空间代价上达到了平衡所以不建议修改。
4.2、如何确定元素在数组中的索引下标?
为什么不直接返回key.hashCode()呢?还要与 (h >>> 16)异或?
一共有两个步骤
1、先使用hash方法
hash方法中将key的hashcode与他的高16位进行一次亦或运算(hashcode本身的低16位与高16位进行 ^ 运算)。
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
为什么获取key的hashcode之后还需要和hashcode右移16位进行亦或运算?
0000 0100 1011 0011 1101 1111 1110 0001
>>> 16 (无符号右移16位)
0000 0000 0000 0000 0000 0100 1011 0011
hashcode为int类型,4个字节32位,为了确保散列性,肯定是32位都能进行散列算法计算最好。
1.1、为什么用亦或计算?
二进制位计算,a 只可能为0、1,b只可能为0、1。a中0出现几率为1/2,1也是1/2,b同理。
平时用的运算符有三种,或(|),与(&),异或(^)。
a、b进行位运算,有4种可能 00,01,10,11
- a或b计算 结果为1的几率为3/4,0的几率为1/4;
- a与b计算 结果为0的几率为3/4,1的几率为1/4;
- a亦或b计算 结果为1的几率为1/2,0的几率为1/2。
所以,进行异或(^)计算,得到的结果肯定更为平均,不会偏向0或者偏向1,更为散列。
1.2、右移16位进行亦或计算
我将其拆分为两部分,前16位的亦或运算,和后16位的亦或运算。
- 后16位的亦或运算,即原hashcode后16位与原hashcode前16位进行亦或计算,得出的结果,前16位和后16位都有参与其中,保证了 32位全部进行计算。
- 前16位的亦或运算,即原hasecode前16位与0000 0000 0000 0000进行亦或计算,结果只与前16位hashcode有关,同时异或计算,保证 结果为0和1的几率各为1/2,也是平均的。
1.3、结论:
- 结果的后16位保证了hashcode32位全部参与计算,也保证了0,1平均,散列性;
- 结果的前16位保证hashcode前16位了0,1平均散列性,附带hashcode前16位参与计算;
- 16与16位数相同,利于计算,不需要补齐,移去位数数据。
2. 索引下标取值为:(n - 1) & hash
jdk1.8中,hashMap获取某个key值,通过
(n - 1) & hash
去得到索引下标,其中,n为当前map容量,hash为key通过hash方法得到的值。
5、总结
- HashMap 的底层是个 Node 数组(Node<K,V>[] table),在数组的具体索引位置,如果存在多个节点,则可能是以链表或红黑树的形式存在。
- 增加、删除、查找键值对时,定位到哈希桶数组的位置是很关键的一步,源码中是通过下面3个操作来完成这一步:1)拿到 key 的 hashCode 值;2)将 hashCode 的高位参与运算,重新计算 hash 值;3)将计算出来的 hash 值与 “table.length - 1” 进行 & 运算。
- HashMap 的默认初始容量(capacity)是 16,capacity 必须为 2 的幂次方;默认负载因子(load factor)是 0.75;实际能存放的节点个数(threshold,即触发扩容的阈值)= capacity * load factor。
- HashMap 在触发扩容后,阈值会变为原来的 2 倍,并且会对所有节点进行重 hash 分布,重 hash 分布后节点的新分布位置只可能有两个:“原索引位置” 或 “原索引+oldCap位置”。例如 capacity 为16,索引位置 5 的节点扩容后,只可能分布在新表 “索引位置5” 和 “索引位置21(5+16)”。
- 导致 HashMap 扩容后,同一个索引位置的节点重 hash 最多分布在两个位置的根本原因是:1)table的长度始终为 2 的 n 次方;2)索引位置的计算方法为 “(table.length - 1) & hash”。HashMap 扩容是一个比较耗时的操作,定义 HashMap 时尽量给个接近的初始容量值。
- HashMap 有 threshold 属性和 loadFactor 属性,但是没有 capacity 属性。初始化时,如果传了初始化容量值,该值是存在 threshold 变量,并且 Node 数组是在第一次 put 时才会进行初始化,初始化时会将此时的 threshold 值作为新表的 capacity 值,然后用 capacity 和 loadFactor 计算新表的真正 threshold 值。
- 当同一个索引位置的节点在增加后达到 9 个时,并且此时数组的长度大于等于 64,则会触发链表节点(Node)转红黑树节点(TreeNode),转成红黑树节点后,其实链表的结构还存在,通过 next 属性维持。链表节点转红黑树节点的具体方法为源码中的 treeifyBin 方法。而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容。
- 当同一个索引位置的节点在移除后达到 6 个时,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点。红黑树节点转链表节点的具体方法为源码中的 untreeify 方法。
- HashMap 在 JDK 1.8 之后不再有死循环的问题,JDK 1.8 之前存在死循环的根本原因是在扩容后同一索引位置的节点顺序会反掉。
- HashMap 是非线程安全的,在并发场景下使用 ConcurrentHashMap 来代替。
参考:
https://blog.csdn.net/v123411739/article/details/78996181
HashMap中hash(Object key)原理,为什么(hashcode >>> 16)。_杨涛的博客的博客-CSDN博客
hashMap1.8 resize()个人解读_io.ke的博客-CSDN博客
原文:史上最详细的 JDK 1.8 HashMap 源码解析_程序员囧辉-CSDN博客_hashmap详解
JDK8 HashMap源码行级解析 红黑树操作 史上最全最详细图解_anlian523的博客-CSDN博客treeifyBinJDK8 HashMap源码行级解析 红黑树操作 史上最全最详细图解_anlian523的博客-CSDN博客