序:
网上有很多关于HashMap源码相关的文章 ,也有很多的视频讲HashMap的实现原理,但是无论文章看多少遍,视频看多少次,总觉得那些知识不是自己的,被别人一忽悠,又变成了人云亦云,既然如此,不如自己切身实际的来研究一下HashMap的源码实现吧。我写的也只是作为一个参考,如果想将这些原理,思想变成自己的东西,还是建议读者自己去研究,并写出博客,这样,知识才能真正的转化为自己的东西 。好了,话不多说,直接上代码来看吧。
在看源码之前,先来说一个学习方法,因为历史的原因,新的东西一般比旧的东西好,比如 jdk8 的实现肯定比jdk7更加完善,Spring 5以上的版本肯定比Spring 3.0 的版本更加的完美,因为个人的认知,能力,时间有限,我们尽量避免一上来就研究那些高版本的源码,因为高版本的代码本身做了很多的优化,以及一些低版本bug的解决,我们可能会被高版本的实现逻辑和方式 绕晕, 而低版本的代码也更加接近于一个技术的核心思想,以及一些低版本出现的问题,如果能在高版本中得到解决,我们看了低版本的代码后,再去研究高版本,只是锦上添花的事情,同时更加深刻的理解代码的优化思想,就像你遇到一个好人,可能不知道珍惜,当你遇到烂人的时候,你才知道你之前遇到的人是多么的好一样的原理,所以在研究一些源码的时候,先去研究他奠定核心思想的版本,再去研究一些高的版本,再去看他们高版本的新的特性,这样更加有利于我们去真正的理解一门技术的演变过程,知道它的过去与未来,所以,在java集合框架,java并发框架的源码研究,主要是以JDK7为主,如果有时间,再去看JDK 8 做了哪些优化,言归正传 。
JDK 7 HashMap 的实现
下面是HashMap的一些属性和构造函数代码,这些属性什么意思呢?
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable { private static final long serialVersionUID = 362498820763181265L; // 默认初始容量 - 必须是 2 的幂次方。 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 // HashMap最大容量,自己指定的容量 必须是 2 <= 1<<30 的幂,如果自己通过HashMap的构造函数指定容量时, // 小于2 ,则默认指定为2 ,如果大于 1<<30,则用1<<30替换,也就是说HashMap的容量必须是 大于2 并小于 1<<30,并且还要是2的幂次方 // 从后面的构造函数的代码可以看出。 static final int MAXIMUM_CAPACITY = 1 << 30; // 负载因子,默认是0.75,举个例子,假如HashMap的初始容量为16 ,当HashMap中的元素超过16*0.75 = 12 时 // 为了避免Hash碰撞,需要对HashMap进行扩容,而负载因子,就是这个意思,这只是一个理论值,可以自己通过构造函数传参的方式设定。 // 不同的语言,默认负载因子都不相同 ,比如 python ,C # 等 static final float DEFAULT_LOAD_FACTOR = 0.75f; // 表未膨胀时要共享的空表实例 static final Entry<?,?>[] EMPTY_TABLE = {}; // 表,根据需要调整大小。长度必须始终是 2 的幂次方 transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE; // hash 表中key和个数,也就是hashmap中总Entry的个数 transient int size; // 要调整大小的下一个大小值(容量 * 负载因子)。 int threshold; // 哈希表的负载因子 final float loadFactor; // 此 HashMap 已在结构上修改的次数 结构修改是指更改 HashMap 中的映射数量或以其他方式修改其内部结构(例如,重新散列)的那些。该字段用于使 HashMap 的 Collection-views 上的迭代器快速失败。 (请参阅 ConcurrentModificationException)。 transient int modCount; // 映射容量的默认阈值,高于该阈值的替代散列用于字符串键。由于 String 键的散列码计算较弱,替代散列降低了冲突的发生率。 // 这个值可以通过定义系统属性jdk.map.althashing.threshold来覆盖。属性值为1会强制始终使用替代散列,而-1值确保永远不会使用替代散列。 static final int ALTERNATIVE_HASHING_THRESHOLD_DEFAULT = Integer.MAX_VALUE; // MAX_VALUE的值是 2147483647 // 与此实例关联的随机值,应用于键的哈希码,以使哈希冲突更难找到。如果为 0,则禁用替代散列。 transient int hashSeed = 0; public HashMap(int initialCapacity, float loadFactor) { // 如果HashMap初始容量小于0 ,则抛出异常 if (initialCapacity < 0) throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity); // 如果初始容量大于 1 << 30 ,用1 << 30 ,替换掉初始容量 if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal load factor: " + loadFactor); // 负载因子 this.loadFactor = loadFactor; // HashMap扩容时阈值 threshold = initialCapacity; // 默认为空实现,init()方法内部没有任何实现 init(); } public HashMap(int initialCapacity) { // 创建HashMap时,自己指定初始容量 , 使用默认负载因子 DEFAULT_LOAD_FACTOR = 0.75f this(initialCapacity, DEFAULT_LOAD_FACTOR); } public HashMap() { // 默认初始容量为16 , 负载因子 0.75f this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR); } // 可以自己传一个hashMap作为初始化参数,初始化HashMap public HashMap(Map<? extends K, ? extends V> m) { // 取传入参数的hashMap 的 size / 0.75 + 1 和默认初始容量 16 比较大小,取两者最大传作为initialCapacity的值 // 而负载因子仍然是0.75f this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1, DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR); inflateTable(threshold); putAllForCreate(m); } }
上面有一个1 << 30 是什么意思哦,我们来看一下下面的示例
System.out.println(1 << 1); // 1 = 2 ^ 1 = 0000 0000 0000 0000 0000 0000 0000 0010 System.out.println(1 << 2); // 4 = 2 ^ 2 = 0000 0000 0000 0000 0000 0000 0000 0100 System.out.println(1 << 3); // 8 = 2 ^ 3 = 0000 0000 0000 0000 0000 0000 0000 1000 System.out.println(1 << 4); // 16 = 2 ^ 4 = 0000 0000 0000 0000 0000 0000 0001 0000 System.out.println(1 << 5); // 32 = 2 ^ 5 = 0000 0000 0000 0000 0000 0000 0010 0000 System.out.println(1 << 30); // 1073741824 = 2 ^30 = 0100 0000 0000 0000 0000 0000 0000 0000
而1 << 30的意思就是 0000 0000 0000 0000 0000 0000 0000 0001左移30位变成 0100 0000 0000 0000 0000 0000 0000 0000。
put()
关于HashMap的基本属性和构造函数先看到这里,而以map作为构造参数,我们后面再来分析 。
我们来看最简单的put方法
public static void main(String[] args) { Map<String, Object> map = new HashMap<>(); map.put("zhangsan",1); }
那put方法内部实现逻辑是怎样子的呢?
public V put(K key, V value) { // table的默认值就EMPTY_TABLE,因此,当第一个元素put时,会先走inflateTable方法 if (table == EMPTY_TABLE) { // 我们知道HashMap是数组加链表的方式实现,因此下面方法就是初始化数组 inflateTable(threshold); } if (key == null) // 对于key为null的这种情况处理 return putForNullKey(value); // 计算key的哈希值 int hash = hash(key); // 计算key所在桶的位置,其实就是table数组下标 int i = indexFor(hash, table.length); // 定位出hash 表的位置,并遍历table[i]所在链表 for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; // 如果链表节点的hash值等于当前计算出的hash值,并且key值等于当前key的值,则说明HashMap中之前已经push过key if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); //默认是空实现 // 返回旧的值 return oldValue; } } // 如果HashMap中之前并没有put过该key ,则 modCount 值加一,从代码中可以看出,modCount也就是hashmap中put key 的个数 modCount++; // 向hashMap中加入新的结点 addEntry(hash, key, value, i); return null; }
初始化HashMap
private void inflateTable(int toSize) { // 数组容量修改为2的^次方 int capacity = roundUpToPowerOf2(toSize); // 重置数组扩容的阈值 ,取 capacity * 负载因子 和 1 << 30 + 1 两者最小值作为阈值 threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1); // 创建HashMap数组 table = new Entry[capacity]; // 初始化散列掩码值。我们推迟初始化,直到我们真正需要它。 initHashSeedAsNeeded(capacity); }
在上面代码中,大家可能比较迷惑的一点是roundUpToPowerOf2()方法的内部实现,下面,我们就来扣里面是如何 实现的。
private static int roundUpToPowerOf2(int number) { // 如果设置的容量大于 1 << 30 ,则以1 << 30作为最大容量,如果等于1 ,则容量为1(1 其实是2^0次方), // 如果大于1,则调用highestOneBit函数来处理。 return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1; } public static int highestOneBit(int i) { // HD, Figure 3-1 i |= (i >> 1); i |= (i >> 2); i |= (i >> 4); i |= (i >> 8); i |= (i >> 16); return i - (i >>> 1); } 光看代码有点晕,我们还是以举例的方式来看,我们来举个例子,如果HashMap构造函数传入的初始容量为11, int 值是32位 ========================================================================================= 看11 是如何计算的 Integer.highestOneBit((number - 1) << 1) ,先来看 (11 - 1) << 1 == 10 << 1 10 的二进制码为 10 = 2 ^ 3 + 2 ^ 1 = 0000 0000 0000 0000 0000 0000 0000 1010 而 10 << 1 的二进制码为 10 向左移1位, 0000 0000 0000 0000 0000 0000 0001 0100 = 2 ^ 4 + 2 ^ 2 = 20 i |= (i >> 1) = 0000 0000 0000 0000 0000 0000 0001 0100 | 0000 0000 0000 0000 0000 0000 0000 1010 = 0000 0000 0000 0000 0000 0000 0001 1110 = 2 ^ 4 + 2 ^3 + 2 ^ 2 + 2 ^ 1 = 16 + 8 + 4 + 2 = 30 ,此时i = 30 i |= (i >> 2) = 0000 0000 0000 0000 0000 0000 0001 1110 | 0000 0000 0000 0000 0000 0000 0000 0111 = 0000 0000 0000 0000 0000 0000 0001 1111 = 2 ^ 4 + 2^3 + 2 ^ 2 + 2 ^ 1 + 2 ^ 0 = 16 + 8 + 4 + 2 + 1 = 31 ,此时i = 31 i |= (i >> 4) = 0000 0000 0000 0000 0000 0000 0001 1111 | 0000 0000 0000 0000 0000 0000 0000 0001 = 0000 0000 0000 0000 0000 0000 0001 1111 = 31, 此时i还是 = 31 i |= (i >> 8) = 0000 0000 0000 0000 0000 0000 0001 1111 0000 0000 0000 0000 0000 0000 0000 0000 = 0000 0000 0000 0000 0000 0000 0001 1111 = 31 ,此时i还是 =31 i |= (i >> 16)= 0000 0000 0000 0000 0000 0000 0001 1111 0000 0000 0000 0000 0000 0000 0000 0000 = 0000 0000 0000 0000 0000 0000 0001 1111 = 31 ,此时i还是 =31 java中>>>是什么意思? >>>是java中的移位运算符,表示无符号右移。 i - (i >>> 1) = 31 - (0000 0000 0000 0000 0000 0000 0000 1111) = 31 - (2 ^ 3 + 2 ^ 2 + 2 ^ 1 + 2 ^ 0 ) = 31 - 15 = 16 因此,如果我们传入的初始容量为11,HashMap会帮我们默认转化为16 ,为什么呢?后面的内容来说明 =========================================================================================
下面函数一大堆代码,其实最终还是设置hashSeed的值,hashSeed这个值有什么用呢?看hash()
final boolean initHashSeedAsNeeded(int capacity) { //当我们初始化的时候hashSeed为0,0!=0 这时为false. boolean currentAltHashing = hashSeed != 0; //isBooted()这个方法里面返回了一个boolean值,也就是虚拟机是否启动,因为虚拟机启动时也需要用hashmap, // 如果虚拟机没有启动完成,isBooted()方法返回为false , 启动了,则返回true // Holder.ALTERNATIVE_HASHING_THRESHOLD默认等于 Integer.MAX_VALUE // 我们一般数组的容量不会大于Integer.MAX_VALUE,因此useAltHashing = false boolean useAltHashing = sun.misc.VM.isBooted() && (capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD); // 默认false ^ false =false ,所以switching= false boolean switching = currentAltHashing ^ useAltHashing; if (switching) { hashSeed = useAltHashing ? sun.misc.Hashing.randomHashSeed(this) : 0; } return switching; }
从上面代码分析,默认情况下hashSeed=0,但是有人肯定想,如果我就设置hash map的容量大于 等于Integer.MAX_VALUE时,会怎样呢?从代码分析来看, boolean useAltHashing = sun.misc.VM.isBooted() &&
(capacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD) = true && true = true ,因此useAltHashing =true ,
而 boolean switching = currentAltHashing ^ useAltHashing = false ^ true = true(currentAltHashing默认为false) ,因此会进入代码块 hashSeed = useAltHashing
? sun.misc.Hashing.randomHashSeed(this)
: 0 这一行执行,而useAltHashing = true ,则 hashSeed = sun.misc.Hashing.randomHashSeed(this) ,因此,如果我们数组容量大于Integer.MAX_VALUE时,hashSeed会等于 sun.misc.Hashing.randomHashSeed(this) ,从而影响hash()中hash值的计算,按常理我们也能理解,当数组中的容量大于等于Integer.MAX_VALUE时,为了减少hash碰撞,只能让散列码变得更加均匀,从而加入了hashSeed值参与hash值的计算,当然,从源码中可以看出,修改jdk.map.althashing.threshold的值也会影响到hashSeed值,因为我们hash map的容量很少超过Integer.MAX_VALUE,具体怎样去修改hashSeed的值,感兴趣的小伙伴可以自行去研究的哈。
对于key为null的情况处理
private V putForNullKey(V value) { for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } } modCount++; addEntry(0, null, value, 0); return null; }
其实关于key=null的情况,和非null的区别就在于,key=null的数据肯定存储于table的第0个位置的链表中。 如果遍历第table[0]的链表,如果没有对应的key=null的节点,则向table[0]链表中加一个key为null的节点 。
hash()
final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } // String的哈希Code计算方式 public int hashCode() { int h = hash; if (h == 0 && value.length > 0) { char val[] = value; for (int i = 0; i < value.length; i++) { h = 31 * h + val[i]; } hash = h; } return h; } 如果我们put一个key = "a" 的key ,那么看一下计算过程,首先,我们看String对象的hashCode计算,看hashCode的计算方法,如"a",h的默认值为0,将 字符串转化为 char val[] = {'a'} ,遍历 val ,第一次for循环为 h = 31 * 0 + 97 如果key= "ab" ,则先将字符串转化为char val[] = {'a','b'} 第一次for循环 h = 31 * 0 + 97 = 97 第二次for循环 h = 31 * 97 + 98 ,因为a ASC码对应的int值为97 ,b ASC码对应的int值为98 。 以key为"a"为例,"a"的hashCode = 31 * 0 + 97 = 97 , 此时我们看hash函数的 h ^= k.hashCode() 这一行代码 。 大家可能对 ^= 这个运算符比较陌生 , ^= 表示按位异或。比如 二进制 1001 ^ 1100 = 0101 0^0=0,1^1=0 ,1^0 = 1,0^1=1 ,所以 h ^= k.hashCode() = 0 = 0000 0000 0000 0000 0000 0000 0000 0000 ^ 97 = 0000 0000 0000 0000 0000 0000 0110 0001 = 2 ^ 6 + 2 ^ 5 + 1 = 64 + 32 + 1 经过h ^= k.hashCode()运算之后, h 还是等于 97 再来看 h ^= (h >>> 20) ^ (h >>> 12)这一行代码 (h >>> 20) = (97 >>> 20) = 0000 0000 0000 0000 0000 0000 0000 0000 (h >>> 12) = (97 >>> 12) = 0000 0000 0000 0000 0000 0000 0000 0000 因此(h >>> 20) ^ (h >>> 12) = 0000 0000 0000 0000 0000 0000 0000 0000 ^ 0000 0000 0000 0000 0000 0000 0000 0000 = 0000 0000 0000 0000 0000 0000 0000 0000 h ^= (h >>> 20) ^ (h >>> 12) = 97 ^ 0 = 0000 0000 0000 0000 0000 0000 0110 0001 ^ 0000 0000 0000 0000 0000 0000 0000 0000 = 0000 0000 0000 0000 0000 0000 0110 0001 此时再继续看h ^ (h >>> 7) ^ (h >>> 4)这一行代码的执行结果,而此时h的值依然是97 h ^ (h >>> 7) = h = 0000 0000 0000 0000 0000 0000 0110 0001 ^ h >>> 7 = 0000 0000 0000 0000 0000 0000 0000 0000 = 0000 0000 0000 0000 0000 0000 0110 0001 = 2 ^ 6 + 2 ^ 5 + 2 ^ 0 = 64 + 32 + 1 = 97 ,此时 h = 97 ,经过一番 操作之后,发现一顿操作猛如虎,定晴一看原地褚,值还是97 h ^ (h >>> 4) = h = 0000 0000 0000 0000 0000 0000 0110 0001 ^ (h >>> 4) = 0000 0000 0000 0000 0000 0000 0000 0110 = 0000 0000 0000 0000 0000 0000 0110 0111 = 2^6 + 2 ^ 5 + 2 ^ 2 + 2 ^ 1 + 2 ^ 0 = 64 + 32 + 4 + 2 + 1 = 103
我相信认真分析上面代码的小伙伴,对hash运算肯定清楚了,对于 ^= , >>> 这些运算符也不再害怕 ,接下来我们继续分析为什么获得了hashCode之后,还需要
h ^= (h >>> 20) ^ (h >>> 12);
h ^ (h >>> 7) ^ (h >>> 4); 调用这两行代码呢? 肯定有人会说会散列得更加均匀,那能举个例子吗?来看下面代码
public static void main(String[] args) { System.out.println(1 % 15 ); System.out.println(16 % 15 ); System.out.println(31 % 15 ); System.out.println("-------------------------"); System.out.println(hash(1) % 15 ); System.out.println(hash(16) % 15 ); System.out.println(hash(31) % 15 ); } public static int hash(int hashCode) { int h = 0; h ^= hashCode; h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); } 执行结果 1 1 1 ------------------------- 1 2 0
假如我们的hash table的长度是16,为什么是对15 取模,后面再来分析,先看如果hashCode分别是1 ,16 ,31 的话,对15取模,都会落在table[1]中,结构如下
而调用hash函数运行之后,hashCode为1,16,31会分别落在 table[1],table[2] ,table[0]中,如下图所示
我相信此时此刻,聪明的你应该理解hash()函数的计算过程以及意图了吧。接来下,我们来分析桶的计算。
static int indexFor(int h, int length) { // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2"; return h & (length-1); }
桶的计算逻辑也非常简单,就是用hash值&table表的长度-1,为什么不用取模的方式呢?我们知道,计算机最快的是位运算,而不是取模,h & (length-1)的计算逻辑,因此还是用"a"字符串的hash值 103(h的值) 来计算 ,hash表的长度为16(length为16) 。
h & (length-1) 103 = 0000 0000 0000 0000 0000 0000 0110 0111 & (16-1) = 0000 0000 0000 0000 0000 0000 0000 1111 = 0000 0000 0000 0000 0000 0000 0000 0111 = 2^2 + 2^1 + 2^0 = 4 + 2 + 1 = 7 也就计算出"a"这个key落在table[7]这个位置,h & (length-1) 相当于丢弃h大于length-1 的高位数据得到的一个值 也就是说,无论key计算出来的hash值是多大,通过h & (length-1)计算后,总能落到[0,length-1]区间,实现原理和取模类似, 但性能却得到大大提升。 当然,indexFor函数上注释了,length的长度必须为2的幂次方,为什么呢? 举个例子,假如 length的长度为 11 (length-1) = 10 = 0000 0000 0000 0000 0000 0000 0000 1010 & 一个值 ,看下面代码 for(int i = 0 ;i < 11 ;i ++){ System.out.println(i & 10); } 执行结果: 0 0 2 2 0 0 2 2 8 8 10 有没有发现问题,table[1],table[3],table[5],table[7] 都没有数据,而数据全部落到了 table[0],table[2],table[8],table[10] 中。 再来看如果table数组的长度是16 for(int i = 0 ;i < 11 ;i ++){ System.out.println(i & 15); } 0 1 2 3 4 5 6 7 8 9 10 从执行结果可以看出 , 均匀的落到了table中,这也就是为什么hash 表的长度一定要是2的n次方的原因 。 如果不信,可以对1 - 16 的table表的长度分别测试,肯定是table 表的长度为2 的幂次方的数据落得最均匀。
接下来,我们来看,当key之前没有put到hash表中,向hash表中添加新Entry的过程
void addEntry(int hash, K key, V value, int bucketIndex) { // 如果hash map中总元素的个数大于 threshold = 容量 * 负载因子,并且table[bucketIndex]的元素不为空,则进行hashMap扩容 if ((size >= threshold) && (null != table[bucketIndex])) { // hash 表的扩容与元素复制 resize(2 * table.length); // 重新计算key的hash,注意,如果key == null时,hash值为0 hash = (null != key) ? hash(key) : 0; // 重新计算桶的位置 bucketIndex = indexFor(hash, table.length); } // 如果不进行数组扩容 ,则创建新的节点 createEntry(hash, key, value, bucketIndex); } void createEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, e); size++; }
从源码中来看,如果别人问你,当hashmap的元素个数超过threshold时就扩容吗?我相信此时此刻的你已经有答案了,不一定,如果key对应桶的第一个位置为空时(也就是table[bucketIndex]为空时),依然不会扩容,hashmap是数组加链表数组成,那createEntry()方法中只是创建了一个新节点嘛,没有看到什么next之类的嘛 ,我们来看一下Entry的结构。
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next; int hash; Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; } }
当再结合createEntry()方法的代码来看,发现一目了解了,并没有刻意的去this.next = oldEntry 这种,而是在构造函数中悄无声息的实现了。
void createEntry(int hash, K key, V value, int bucketIndex) { Entry<K,V> e = table[bucketIndex]; table[bucketIndex] = new Entry<>(hash, key, value, null); table[bucketIndex].next = e ; }
定晴一看,不就是经典的头插法不。
接下来我们来看数组扩容 ,在addEntry()方法中,数组扩容之后重新计算hash值和bucketIndex的位置,bucketIndex的位置重新计算可以理解,因为h & (length-1)算法本身依赖于table数组的长度,但是hash值为什么需要重新计算呢?难道hash值会随着table的长度变化吗?看hash()方法,感觉hash值和hash 表的长度没有关系, 我猜可能是为了给继承了HashMap的类,如果重写了HashMap 的hash()方法,hash()算法和hash 表的length相关,这样,如果扩容了table数组的长度,需要再次调用hash()方法的。
resize()
接下来,我们来看hash map 的扩容逻辑。
void resize(int newCapacity) { Entry[] oldTable = table; int oldCapacity = oldTable.length; // 如果当前容量为 MAXIMUM_CAPACITY,此方法不会调整map大小,而是将阈值设置为 Integer.MAX_VALUE。 // 也就是hash map 的容量 达到了MAXIMUM_CAPACITY时,就再也不会扩容,以后put元素时,只会将元素插入相应的位置 if (oldCapacity == MAXIMUM_CAPACITY) { // 调整域值为Integer.MAX_VALUE threshold = Integer.MAX_VALUE; return; } // 创建新数组 ,容量为原来两倍 Entry[] newTable = new Entry[newCapacity]; // 重新初始化散列掩后将旧数组中的元素复制到新数组中 transfer(newTable, initHashSeedAsNeeded(newCapacity)); // 替换掉原来的旧数组 table = newTable; //重新计算扩容阈值 threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); }
MAXIMUM_CAPACITY = 2 ^ 30 次方 = 1073741824 , Integer.MAX_VALUE = 2147483647 ,刚好 Integer.MAX_VALUE = MAXIMUM_CAPACITY * 2 - 1 ,所以Integer.MAX_VALUE > MAXIMUM_CAPACITY,因此,如果hash 的旧数组长度为 MAXIMUM_CAPACITY(也就是1 << 30 )时,不再对数组再进行扩容,只是将阈值设置为Integer.MAX_VALUE,有人会想,如果hash map 中元素个数再次超过 Integer.MAX_VALUE时,那会怎样呢?我们回头看addEntry(),会发现下面代码会再次执行。也就是resize()方法会再次执行
if ((size >= threshold) && (null != table[bucketIndex])) { resize(2 * table.length); hash = (null != key) ? hash(key) : 0; bucketIndex = indexFor(hash, table.length); }
不过,聪明的小伙伴肯定会发现,你再次执行也没有关系,大不了,下面的代码再次被执行,
Entry[] oldTable = table; int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return; }
也就是说只要hash map 的元素个数超过Integer.MAX_VALUE时,上面的代码每次都会执行,但是每次,都不会扩容,最后还是执行createEntry(hash, key, value, bucketIndex)这个方法,进行元素的创建,所以当别人问你,hash map中元素个数超过MAXIMUM_CAPACITY时,hash map 不会再扩容,只是将阈值设置Integer.MAX_VALUE,当元素个数再次达到Integer.MAX_VALUE时,此时hash map 也不会做任何扩容的操作,只是默默的将元素插入到链表中而已,只是每次插入多了一次key的hash计算和桶的计算而已。
可能很多人对hash map的扩容很感兴趣,要么看视频,要么看博客,人云亦云,那接下来,我们就来对hash map 的扩容进行分析。
transfer()
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length; for (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; // 从initHashSeedAsNeeded()函数中可以看出 ,rehash默认情况下为false // 也就是不需要重新计算hash if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } // 重新计算桶的值 int i = indexFor(e.hash, newCapacity); // 下面就是经典的头插法了 e.next = newTable[i]; newTable[i] = e; e = next; } } }
从上面代码中可以看出先是对整个数组遍历,再是对整个链表遍历,重新计算每一个元素的桶的位置,再插入相应的数组对应的链表中,大家有没有发现一个有趣的事情,如下图所示 ,如果一个链表中的元素1,2,3,4 在旧的table中,当移动元素之后,如果还是落在一个桶中,则经过一次扩容之后,链表调转过来了,当然这也是一个有趣的小现象,过一会分析jdk8中如何实现hash map,再来对比 。
好像单线程下的扩容很简单嘛 ,不就是遍历一下 hash map ,重新计算一下hash map 的桶的位置嘛,再重新插入数据不就可以了不? 网络上说,多线程下,会导致hash map 产生循环链表,是怎样一个原因呢? 因为网上有小伙伴也写过相关博客,我就重复造轮子了 老生常谈,HashMap的死循环【基于JDK1.7】,感兴趣的小伙伴可以去研究一下这篇博客。 我觉得图做得非常的好,解释我想我还是补充一下。
两个线程,线程1时间片用完,内部的table还没有设置成新的newTable,此时线程2开始执行,这时的引用关系如下 。
这时,在 线程2 中,变量e指向节点a,变量next指向节点b,开始执行循环体的剩余逻辑。
while(null != e) { Entry<K,V> next = e.next; // 此时e = a节点,e.next = b 节点 e.next = newTable[i]; // a.next = newTable[i] newTable[i] = e; // newTable[i] = a节点 e = next; // e = b 节点 }
经过一轮循环之后,变成下面的引用关系。
此时此刻 ,e 节点并不为空,e 节点就是b 节点 ,因此while循环还需要继续执行。
while(null != e) { Entry<K,V> next = e.next; // e = b 节点, next = a ,因为线程1已经建立起 c 指向b节点,b 指向a节点的关系 e.next = newTable[i]; // e.next = a ,因为上一次循环时,newTable[i] = a 节点 newTable[i] = e; // newTable[i] = b 节点 e = next; // e = a 节点 }
此时此刻,e 节点等于a节点,e 节点并不为空,引用关系如下 。
e 节点等于a节点,e 节点并不为空,因此while循环还需要继续执行。
while(null != e) { Entry<K,V> next = e.next; // e = a 节点,e.next = null 因此 next = null e.next = newTable[i]; // newTable[i] 指向的是b 节点,也就是newTable[i] = b ,e.next = b ,也就是a.next = b ,循环关系建立,因为线程1中已经建立了b.next = a ,也就是b 节点指向a节点 newTable[i] = e; // newTable[i] = a 节点 e = next; // e节点= next ,而a.next = null,所以此时此刻 ,e ==null,再次循环时,退出while循环。 }
在原博客中其实已经说得很清楚了,注意的是,只线程2刚好第一次while循环,执行到Entry<K,V> next = e.next;这一行代码,此时线程1抢到CPU的时间片,并且将整个链表复制完成,而此时并没有退出transfer()方法,并执行resize()方法中的table = newTable; 这一行代码,此时线程2抢到了时间片,并执行了两次while循环,创建了循环链表 。当然创建循环链表,出现的问题就大了,落在table[i]这个桶的链表的数据无论是你插入还是查询数据,都会出现死循环,因此JDK 7 中的 HashMap在多线程的环境下慎用。
我相信大家对Hash Map 的put方法有了深入的了解了,下面我们年看看其他方法的使用。先来看get()方法吧。
get()
public V get(Object key) { // 如果key 为空时 if (key == null) return getForNullKey(); // 如果key 不为空时 Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }
上面分两种情况,如果key为空和key不等于空两种情况 ,我们先来看key为空的情况 。
private V getForNullKey() { // 如果hash map 中的元素个数为0时,直接返回null if (size == 0) { return null; } // 否则遍历table[0]的链表,获取相应的值,如果找不到key == null 的元素,直接返回null 。 for (Entry<K,V> e = table[0]; e != null; e = e.next) { if (e.key == null) return e.value; } return null; }
从上面的代码逻辑非常简单,需要注意的是,如果size > 0 时,为什么是从table[0]的链表中查找,我相信看过前面代码的小伙伴肯定会想到put()方法中的putForNullKey(value);一行代码,在putForNullKey()方法中就对key为null的情况做了处理,默认key为null的元素都会插入到table[0]的链表中,接下来,我们来看key不为null的情况。
final Entry<K,V> getEntry(Object key) { // 判断map中的元素个数是否为0,如果为0,则返回空,避免无用的空遍历 if (size == 0) { return null; } // 计算key hash 值 int hash = (key == null) ? 0 : hash(key); // 循环遍历table[i]中的链表 for (Entry<K,V> e = table[indexFor(hash, table.length)] ; e != null ; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) return e; } return null; }
上面查找元素的代码很简单,就是遍历整个链表,找到了呢?就直接返回元素,找不到呢?就返回空,但是细心的小伙伴有没有发现,进行了hash比较之后,还需要进行key的比较,为什么呢?有没有多此一举呢?当然,你去看put方法时,也有这样的比较,为什么呢? 心细的小伙伴肯定会知道,因为hash比较快,所以先进行hash比较,而为什么要进行key的比较呢?因为有可能出现 “xxx” 计算出的hash 码和"yyy"计算出的hash码一样,当出现这种情况时,就需要进行key值比较了,不然就会出现逻辑错误,也就是输入key="xxx"可能查询出key="yyy"的value。
接下来,我们再来分析我们常见的用法,先来看public HashMap(Map<? extends K, ? extends V> m) 这个构造函数。
public HashMap(Map<? extends K, ? extends V> m) { // 取m.size() / 0.75f 的最大值作为map的初始容量,负载因子默认为0.75f this(Math.max((int) (m.size() / 0.75f ) + 1,16), 0.75f); // 初始化容器 inflateTable(threshold); // 创建所有的节点 putAllForCreate(m); }
接下来,我们来看如何将m 容器中的元素复制到新map中来。
private void putAllForCreate(Map<? extends K, ? extends V> m) { // 遍历旧map中所有的元素 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) putForCreate(e.getKey(), e.getValue()); } private void putForCreate(K key, V value) { // 计算hash值 int hash = null == key ? 0 : hash(key); // 计算桶的位置 int i = indexFor(hash, table.length); /** * Look for preexisting entry for key. This will never happen for * clone or deserialize. It will only happen for construction if the * input Map is a sorted map whose ordering is inconsistent w/ equals. */ for (Entry<K,V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { e.value = value; return; } } // 创建新的Entry createEntry(hash, key, value, i); }
上面的代码很简单,无非是重新计算 key 的hash值,并加到相应的链表中,但是有一点需要注意的是,我们不是新new的map不,为什么还会出现遍历当前的map呢,发现key存在时,则替换掉key的值,这不是多此一举不?当然我们来看一下他的注释翻译成中文【查找密钥的预先存在的条目。克隆或反序列化永远不会发生这种情况。只有当输入 Map 是一个排序不一致的有序映射 w 等于时才会发生这种情况。】 ,好像看不懂额,但是后面这一句提醒我了,也就是说,如果实现了Map接口,并且能存储多个一样key的时,需要考虑这种情况 ,那我们来自己实现一个HashMap ,叫MyHashMap,而MyHashMap中,所有的代码都与HashMap一样,只有put方法做一些修改,代码如下。
public class MyHashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable { public V put(K key, V value) { if (table == EMPTY_TABLE) { inflateTable(threshold); } if (key == null) return putForNullKey(value); int hash = hash(key); int i = indexFor(hash, table.length); /* for (Entry<K, V> e = table[i]; e != null; e = e.next) { Object k; if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { V oldValue = e.value; e.value = value; e.recordAccess(this); return oldValue; } }*/ modCount++; addEntry(hash, key, value, i); return null; } }
上面的代码中,注释掉较验key是否在map中存在的代码。 下面我们来做测试 。
MyHashMap<String, Object> myHashMap = new MyHashMap<>(); myHashMap.put("c", 1); myHashMap.put("c", 2); System.out.println(JSON.toJSONString(myHashMap)); Map<String,Object> map = new HashMap<>(myHashMap); System.out.println(JSON.toJSONString(map)); 结果输出 : {"c":2,"c":1} {"c":1} 从结果来看,在MyHashMap中存在两个元素,而在HashMap中只存在一个元素,因此在putForCreate()方法中的较验key是否存在的逻辑已经生效了。
当然,如果还有小伙伴觉得为什么map中存储的元素是1,不是2 ,先来看myHashMap结构。
因为是头插法,所以myHashMap中是 2 指向1的,而在遍历时,先循环遍历时,先遍历到2,再遍历到1,而map中,后面遍历到的1会将2给覆盖掉,所以map中存储的只有value为1这个元素。
putAll()
我们再来看,日常中用得比较多的putAll()方法。
public void putAll(Map<? extends K, ? extends V> m) { // 获取传入m的元素个数 int numKeysToBeAdded = m.size(); // 如果传入的是一个空map,则直接返回 if (numKeysToBeAdded == 0) return; // 如果当前map没有被初始化,则先进行初始化 if (table == EMPTY_TABLE) { // 下面的设计也非常巧妙,保证当table 等于null时,初始化的新map,将所有的m中的元素插入到table中不需要扩容 // 感兴趣的小伙伴可以自己想想? inflateTable((int) Math.max(numKeysToBeAdded * loadFactor, threshold)); } // 当m中的元素个数大于阈值时 if (numKeysToBeAdded > threshold) { // 计算 扩容后的map 容量 int targetCapacity = (int)(numKeysToBeAdded / loadFactor + 1); // 如果出来的值大于 1 << 30 ,则用1 << 30替代 if (targetCapacity > (1 << 30)) targetCapacity = (1 << 30); // 当前被插入元素map 的容量 int newCapacity = table.length; // 假如 targetCapacity = 57 ,newCapacity= 16 ,则第一次循环 16 << 1 = 32 ,发现32 还是小于57 // 再次左移,32 << 1 ,32 再左移1位, 此时newCapacity等于64 ,此时64 > 57,退出循环 ,那么新的容量为64 ,其他情况以此类推 while (newCapacity < targetCapacity) newCapacity <<= 1; // 如果计算出来的map容量大于 原来的容量,则进行数据迁移。 if (newCapacity > table.length) // 数组扩容与数据迁移 resize(newCapacity); } // 遍历整个map,将每一个元素插入到当前map中 for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) put(e.getKey(), e.getValue()); }
上面标红的注释大家看到没有,难道 一个无符号整形 左移1 位 不大于 原来的值不?加这个判断是不是有点多此一举,聪明的小伙伴肯定想到了,从上面的计算结果来看 ,newCapacity map的最大值也只能是 2 ^ 30次方,如果此时数组table的长度达到了 2 ^ 30次方 这个值时,数组将不再进行扩容,和put()方法的逻辑是一致的。
remove()
接下来,我们再来看常用的remove()方法。
public V remove(Object key) { Entry<K, V> e = removeEntryForKey(key); //如果元素不为空,则返回元素的值 return (e == null ? null : e.value); } final Entry<K, V> removeEntryForKey(Object key) { if (size == 0) { return null; } int hash = (key == null) ? 0 : hash(key); int i = indexFor(hash, table.length); // 首先 pre和e都指向table[i]的第1个节点 Entry<K, V> prev = table[i]; Entry<K, V> e = prev; // 如果table[i]链表的第一个节点不为空 while (e != null) { Entry<K, V> next = e.next; Object k; if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { modCount++; size--; if (prev == e) //如果链表的第1个元素就是要移除的元素,直接用链表中第2个元素覆盖掉第0个元素 table[i] = next; else //如果找到的key不是链表中的第一个元素,则需要将被移除元素的上一个元素的next prev.next = next; e.recordRemoval(this); return e; } prev = e; e = next; } return e; }
从上面代码中,我们又得出几点有意思的事情。modCount这个变量是每操作一次,数量就加一,而size就是map的元素个数。在移除元素时,有两种情况,不知道大家看明白没有,第一种情况,就是当移除元素是队列中的第1个元素时。
第二种情况,如果要移除掉队列中第2个元素时,此时2会变成孤立的节点,2 将会被JVM回收掉。
可能还是有小伙伴比较迷惑,在被移除的节点e中,并没有将当前e节点的next节点置空,jvm会回收掉吗?
就像上图中A节点指向了B节点,但是没有任何节点指向A节点,此时A节点会被JVM回收掉,也就是说在hash map元素的代码中,并没有将被移除的节点的next置空的原因。
clear()
接下来我们再来看用得比较多的clear()方法
public void clear() { modCount++; Arrays.fill(table, null); size = 0; } public static void fill(Object[] a, Object val) { for (int i = 0, len = a.length; i < len; i++) a[i] = val; }
clear()方法的实现逻辑还是很简单,就是将table数组中的第一个元素置空,链表中的所有元素将被JVM回收掉,同时table也变成了全新的数组了。
此时此时,我相信大家对HashMap中大部分操作的源码都有所了解了,下面我们再来看一个Hash Map的遍历是怎样实现的。
在看源码之前,我们先来看一个例子。
public static void main(String[] args) { Map<Integer, String> map = new HashMap<Integer, String>(); map.put(1, "xiao"); map.put(2, "chao"); //方法一 for (Map.Entry<Integer, String> entry : map.entrySet()) { System.out.println("方法一:key =" + entry.getKey() + "---value=" + entry.getValue()); } }
我相信上面的代码,大家再熟悉不过了,但是HashMap遍历的源码是如何实现的呢?在看HashMap源码之前,我们先来看一个循环遍历的例子。
public class IteratorTest implements Set { public static String[] values = new String[]{"a", "b", "c", "d"}; private int index = 0; public static void main(String[] args) { IteratorTest iteratorTest = new IteratorTest(); for (Object a : iteratorTest) { System.out.println(a); } for (Object a : iteratorTest) { System.out.println(a); } } @Override public Iterator iterator() { System.out.println("iterator 方法执行"); return new Stringterator(); } private class Stringterator implements Iterator { public Stringterator() { index =0; } @Override public boolean hasNext() { System.out.println("hasNext 方法执行 index=" + index); if (index > values.length - 1) { return false; } return true; } @Override public String next() { String result = values[index]; index++; System.out.println("next 方法执行 index=" + index); return result; } @Override public void remove() { System.out.println("remove"); } } } iterator 方法执行 hasNext 方法执行 index=0 next 方法执行 index=1 a hasNext 方法执行 index=1 next 方法执行 index=2 b hasNext 方法执行 index=2 next 方法执行 index=3 c hasNext 方法执行 index=3 next 方法执行 index=4 d hasNext 方法执行 index=4 iterator 方法执行 hasNext 方法执行 index=0 next 方法执行 index=1 a hasNext 方法执行 index=1 next 方法执行 index=2 b hasNext 方法执行 index=2 next 方法执行 index=3 c hasNext 方法执行 index=3 next 方法执行 index=4 d hasNext 方法执行 index=4
上面这个函数的意图其实是很简单的,就是循环遍历values的值,IteratorTest实现了Set接口,重写了iterator()方法,从执行结果来看,for()循环的Iterator的原理也很简单,无非每次调用hasNext()方法是否有元素,如果有,则调用next()方法将结果作为for 循环中的临时值(上例中for循环中的a变量),并继续执行for循环内代码块。【注意】每一次循环遍历,都会调用iterator()方法,理解了上面的原理,我们再来看Hash Map如何实现for循环遍历所有元素的。从entrySet()方法进入。
private transient Set<Map.Entry<K,V>> entrySet = null; public Set<Map.Entry<K,V>> entrySet() { return entrySet0(); } private Set<Map.Entry<K,V>> entrySet0() { Set<Map.Entry<K,V>> es = entrySet; return es != null ? es : (entrySet = new EntrySet()); } private final class EntrySet extends AbstractSet<Map.Entry<K,V>> { public Iterator<Map.Entry<K,V>> iterator() { return newEntryIterator(); } public boolean contains(Object o) { if (!(o instanceof Map.Entry)) return false; Map.Entry<K,V> e = (Map.Entry<K,V>) o; Entry<K,V> candidate = getEntry(e.getKey()); return candidate != null && candidate.equals(e); } public boolean remove(Object o) { return removeMapping(o) != null; } public int size() { return size; } public void clear() { HashMap.this.clear(); } } Iterator<Map.Entry<K,V>> newEntryIterator() { return new EntryIterator(); }
上面代码,看上去一大堆,其实内容很简单,就是hashMap每次在for循环时调用entrySet()方法返回一个EntrySet对象,如果entrySet属性已经赋值了呢?就直接取entrySet属性值返回即可,如果没有赋值,则创建一个EntrySet对象并返回即可,但是需要注意的一点是,每次进行for循环时都会调用iterator()方法。也就是每次for循环都会调用newEntryIterator()方法,但是在看newEntryIterator()方法之前,我们还是来看一下EntrySet和我们的Set接口是什么关系。
聪明的读者现在知道了吧,因为EntrySet最终实现了Iterable接口,而Iterable是能被for循环遍历的,接下来我们进入newEntryIterator()方法分析 。
Iterator<Map.Entry<K,V>> newEntryIterator() { return new EntryIterator(); }
发现,newEntryIterator()方法只是创建了一个EntryIterator对象并返回,先来看一下EntryIterator对象的关系。
发现并没有什么特别的,只是实现了HashIterator接口,那么他们之间的关系是什么呢?请看下图
从上面关系来看,也就是EntryIterator肯定实现了hasNext和next方法,那我们来看代码 。
private abstract class HashIterator<E> implements Iterator<E> { Entry<K,V> next; // next entry to return int expectedModCount; // For fast-fail int index; // current slot Entry<K,V> current; // current entry HashIterator() { // 保存当前hash map 元素被增减的次数 expectedModCount = modCount; //hash map 的元素个数大于 0 if (size > 0) { // advance to first entry Entry[] t = table; // 遍历整个数组,找到数组中第一个不为空的元素,用next指向这个元素 while (index < t.length && (next = t[index++]) == null) ; } } // 判断next是否为空 public final boolean hasNext() { return next != null; } final Entry<K,V> nextEntry() { //如果在遍历的过程中对hash map中的元素进行增减,则会抛出异常 if (modCount != expectedModCount) throw new ConcurrentModificationException(); Entry<K,V> e = next; // 如果在遍历过程中,next被修改为null,也抛出异常 if (e == null) throw new NoSuchElementException(); // next 指向e.next节点 if ((next = e.next) == null) { //如果e所在的链表已经遍历完,则继续寻找一下不为空的元素节点 // 而寻找的过程就是继续遍历数组下标,直到table[i] 指向的链表的第一个节点节点不为空为止,找到了就用next指向他 Entry[] t = table; while (index < t.length && (next = t[index++]) == null) ; } current = e; return e; } ... } private final class EntryIterator extends HashIterator<Map.Entry<K,V>> { public Map.Entry<K,V> next() { return nextEntry(); } }
在上述过程中,我们有一点容易忽略的就是expectedModCount变量的使用,这个变量看起来不起眼,但是用来做什么的呢?比如我们在遍历Map的过程中,remove()了元素,会导致modCount和expectedModCount不相等,此时会抛出ConcurrentModificationException异常,那根据这个知识点,那我们可以出一道笔试题了。
public class HashMapTest { public static void main(String[] args) throws Exception { Map<String, Object> map = new HashMap<>(); map.put("a",1); map.put("b",2); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } map.put("c",3); } }).start(); int i = 0 ; for(Map.Entry<String,Object> m : map.entrySet()){ if(i == 0 ){ Thread.sleep(1000); } System.out.println(m.getKey() ); i ++; } } } A 程序会正常打打印出 a ,b ,c B 程序只会打印出a ,b C 程序会抛出异常 D 以上说法都不正确 a->1 Exception in thread "main" java.util.ConcurrentModificationException at java.util.HashMap$HashIterator.nextNode(HashMap.java:1442) at java.util.HashMap$EntryIterator.next(HashMap.java:1476) at java.util.HashMap$EntryIterator.next(HashMap.java:1474) at com.t2022.t01.t23.HashMapTest.main(HashMapTest.java:27) 我相信此时的你会毫不犹豫的说出,答案选C ,因为在遍历的过程中,修改了modCount的值和expectedModCount不一致,会导致遍历失败并抛出ConcurrentModificationException异常。
接下来,再来看看整个遍历过程图表是怎样的。
我相信JDK7中的HashMap的源码已经解析得差不多了,其他的方法使用,我相信此时的你也有能力去阅读和分析了,这里就不再赘述 。在JDK7 中,我们发现了Map的几个问题,在并发条件下扩容会导致循环链表的产生,每一次扩容时,都需要遍历整个链表并重新计算hash值,如果hashMap的链表过长时,也会带来较大的性能影响,那JDK 8中有没有对这些做优化呢?那我们就进入JDK 8 的HashMap的源码实现研究 。我相信有了JDK 7 的基础,现来研究JDK 8,你会轻松许多。那我们迫不及待的进入吧。
JDK 8 HashMap 的实现
在JDK 8 比JDK 7 大致多了这三个属性
//使用红黑树而不是链表的计数阈值。将元素添加到至少具有这么多节点的红黑树时,将链表转换为红黑树。该值必须大于 2 并且应该至少为 8,以便与红黑树移除中关于在收缩时转换回普通链表的假设相吻合。 static final int TREEIFY_THRESHOLD = 8; //如果红黑树的节点个数小于6时,将将红黑树转化为链表,6 这个值将是红黑树转化为链表的阈值 static final int UNTREEIFY_THRESHOLD = 6; // 进行树化的map中的最小容量,大体意思就是说,如果map 的元素个数没有达到64个,即使链表长度大于 8 , // 也不转化为红黑树,宁可对map 进行扩容 static final int MIN_TREEIFY_CAPACITY = 64;
大家有没有发现,从这几个参数中,就能看出JDK 8在性能优化安全方面肯定做了大量的优化,大家千万不要以为学习了JDK 7的Hash Map 的源码后,JDK 8 将会一马平川,说不定是一个全新的开始 。继续来看构造函数 。
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); }
首先我们来看第一个构造函数的this.threshold = tableSizeFor(initialCapacity); 这一行代码,计算阈值,大家有没有发现JDK 7 中的threshold阈值有点怪,就是先将初始容量initialCapacity设置为阈值,在进行初始化table时,根据阈值threshold计算出table的长度,再根据 table 的长度 * 负载因子 (0.75 )重新计算threshold,就感觉threshold这个的语义定义的得清晰,那来看JDK 8 是否做了优化呢?
static final int tableSizeFor(int cap) { int n = cap - 1; n |= n >>> 1; n |= n >>> 2; n |= n >>> 4; n |= n >>> 8; n |= n >>> 16; return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; } 我们还是像JDK 7 一样,传一个11作为初始容量,那调用tableSizeFor()计算出来的结果是多少呢? cap = 11 int n = cap -1 = 11 - 1 = 10 ; 对应的二进制码为 0000 0000 0000 0000 0000 0000 0000 1010 >>> 表示无符号右移 , n >>> 1 表示 n 无符号右移1位 n |= n >>> 1 = 0000 0000 0000 0000 0000 0000 0000 0101 0000 0000 0000 0000 0000 0000 0000 1111 = 2 ^ 3 + 2 ^ 2 + 2 ^ 1 + 2 ^ 0 = 8 + 4 + 2 + 1 = 15 n |= n >>> 2 = 0000 0000 0000 0000 0000 0000 0000 1111 | 0000 0000 0000 0000 0000 0000 0000 0011 = 0000 0000 0000 0000 0000 0000 0000 1111 = 15 n |= n >>> 4 = 0000 0000 0000 0000 0000 0000 0000 1111 | 0000 0000 0000 0000 0000 0000 0000 0000 = 0000 0000 0000 0000 0000 0000 0000 1111 = 15 n |= n >>> 8 = 0000 0000 0000 0000 0000 0000 0000 1111 | 0000 0000 0000 0000 0000 0000 0000 0000 = 0000 0000 0000 0000 0000 0000 0000 1111 = 15 n |= n >>> 16 = 0000 0000 0000 0000 0000 0000 0000 1111 0000 0000 0000 0000 0000 0000 0000 0000 = 0000 0000 0000 0000 0000 0000 0000 1111 = 15 (n < 0) ? 1 : (n >= 1 << 30 ) ? 1 << 30 : n + 1 显然 n = 15 < (1 << 30 ),此时threshold 等于15 + 1 = 16
从上面tableSizeFor()函数来看,将threshold的值提前算好了,而不是要初始化table时再计算了。putMapEntries()方法,我们留在后面再来看,先看put方法 。
//将指定的值与此映射中的指定键相关联。如果映射先前包含键的映射,则用新值替换旧值。 参数: key – 与指定值关联的键 value – 与指定键关联的值 返回值: 与key关联的前一个值,如果没有key映射,则返回 null 。 (返回null还可以指示映射先前将null与key关联。) public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
我觉得上面的注释已经写得非常清楚了,我就不解释了,下面来看hash()函数
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); } 在JDK7 中我们已经分析过 ,"a"字符串的hashCode = 97 ,在这个hash函数中计算出a的hash值是多少呢? 97 = 0000 0000 0000 0000 0000 0000 0110 0001 97 >>> 16 = 0000 0000 0000 0000 0000 0000 0000 0000 h = 97 ,所以 97 ^ (97 >>> 16 ) = 0000 0000 0000 0000 0000 0000 0110 0001 = 2 ^ 6 + 2^5 + 1 = 64 + 32 + 1 = 97 细心的读者有没有发现,在JDK 7中,散列key 时,hash()函数中用了好几次位运算与取反 ,在JDK 8中就只用了一次,这也是对hash函数的性能优化吧。 h ^= k.hashCode(); h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4);
接下来,我们来看putVal()函数的内部实现。
//实现 Map.put 和相关方法 参数: hash – 键的散列 key ——钥匙 value – 要放置的值 onlyIfAbsent – 如果为true,则不覆盖现有的key的值 evict – 如果为 false,则表处于创建模式。这个参数提供给子类用 返回值: 前一个值,如果没有,则为 null final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; //如果hashmap 没有被初始化,则调用resize()进行初始化,JDK7中是通过 // inflateTable()函数进行初始化的 if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; // 直接用(n -1 ) & hash 计算桶的位置 // 如果table[i]对应位置上的元素为空,直接创建新节点 // 这里值得注意的是,在JDK 7 存储元素用的是Entry,在JDK 8中用Node // 细心的读者会发现,其实Entry 和 Node结构类似 if ((p = tab[i = (n - 1) & hash] ) == null) // 创建新节点,指定节点的next 为null tab[i] = newNode(hash, key, value, null); else { // table[i]位置已经存在链表或红黑树了 Node<K,V> e; K k; // 如果链表的头节点或树的根节点的key就是我们传入的key // 也就是非常幸运,一次就找到了,table[i] 这个节点的key 和我们传入的key相等 if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) e = p; // 用e记录找到的节点 // 如果table[i]指向的是一颗树 else if (p instanceof TreeNode) // 将当前节点加入到红黑树中 e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value); // 排除掉上面所有可能后,table[i] 指向的是一个链表 else { // 遍历整个链表 for (int binCount = 0; ; ++binCount) { // 如果遍历完整个链表都没有找到节点的key 等于当前传入的key if ((e = p.next) == null) { // 链表中没有key 和传入的key 相等,则创建新的节点 p.next = newNode(hash, key, value, null); // 如果链表中的元素个数大于红黑树的阈值(默认为8 ) // TREEIFY_THRESHOLD 默认为8 ,则将链表为红黑树 if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st // 调用将链表转化为红黑树函数 treeifyBin(tab, hash); break; } // 如果链表中有key和方法传入的key 相等,则退出循环,同时将当前找到的节点记录在e if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; // 配合 e = p.next() 遍历链表 p = e; } } // 如果通过key找到对应的节点 if (e != null) { // existing mapping for key V oldValue = e.value; // onlyIfAbsent 为 false 或 e 节点的值为空,则用当前传入的value 替换掉旧的value。 if (!onlyIfAbsent || oldValue == null) e.value = value; // 提供给子类实现使用 afterNodeAccess(e); return oldValue; } } // hash map 修改的次数加1 ++modCount; // 插入元素后,如果map中的节点个数大于阈值,则进行map扩容 if (++size > threshold) // 进行map扩容 resize(); afterNodeInsertion(evict); return null; } Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) { return new Node<>(hash, key, value, next); } // 在JDK 7 中用的是Entry ,JDK 8中存储元素用Node static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } }
putVal()方法看上去一大堆代码,实际原理也很简单。
- 定位table[i]的第一个节点的key和当前传入的key是否相同,如果相同,先记录当前节点到e中,否则走下一步。
- 判断table[i]指向的是链表还是红黑树,如果是红黑树,则调用putTreeVal()方法,将key和value加入到树中。
- 如果1,2两种情况都不是,那么table[i]指向的肯定是链表,循环遍历整个链表,如果链表中有节点的key和传入的key相等,则记录节点到e中,否则创建新的节点,然后判断当前链表中的元素个数 + 1(为什么要加1呢?因为for循环的binCount并没有统计当前插入的元素 ,也就是TREEIFY_THRESHOLD -1 的原因),如果当前链表的元素个数> TREEIFY_THRESHOLD -1时,将链表转化为红黑树。
- 如果map中有key和传入的key相等,则用新有value替换旧的value,但是这里需要注意,在putVal()中多加了一个参数onlyIfAbsent,如果onlyIfAbsent为false,则用新的value值替换掉旧的value,如果onlyIfAbsent为true,不允许用新的value替换旧的value,但有一种情况除外,旧值为value为null时(即使设置了onlyIfAbsent为true),也用传入的value替换掉旧的value。
在putVal()中有两个重要的函数,一个是初始化和扩容函数resize(), 另外一个是将链表转化为红黑树的函数 treeifyBin(),
final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; // 获取旧的数组长度,如果没有初始化,则为0 int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { // 如果旧数组长度 大于 1 << 30 ,则旧的扩容阈值为Integer.MAX_VALUE ,并返回 // 不再进行扩容,这个和JDK 7的一样 if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } // newCap = oldCap * 2 ,如果 newCap < (1 << 30 ),并且 旧table.length > 16 , // 则newThr = oldThr * 2 // 这里需要注意的一点是好像oldCap = 2 ^ 29 次方时,没有考虑,后面再来看,还有优化的一点是,所以的 乘2 // 用左移来计算 else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) // 这一行也是需要注意的,为什么容量扩大为原来两倍,而阈值也扩大为两倍就可以了呢? // 请看 假如原来的 map 数组长度为16 ,阈值为 12 ,负载因子为 0.75 // 显然 16 * 0.75 = 12 ,那么数组长度扩容为原来了两倍 16 * 2 * 0.75 = 12 * 2 = 24 // 显然,数组容量扩容为原来两倍,此时阈值也扩容为原来两倍即可,就不再用 newCap * 负载因子 = 新阈值的计算了 // 可以看到jdk8 对hash map的优化远处不在的 newThr = oldThr << 1; // double threshold } // 下面这种情况就是table还没有初始化时,将newCap 等于阈值threshold else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; // 对于既没有设置oldCap ,也没有设置oldThr时,默认newCap = 16 , newThr = 16 * 0.75 = 12 else { newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } // 下面代码考虑了两种情况 // 第一种情况,就是table还没有初始化的情况,此时newThr等于0 // 第二种情况 ,就是旧表的长度为2 ^ 29次方时,此时newThr也是等于0 ,将进入下面代码执行 if (newThr == 0) { // 无论上面第一种情况还是第二种情况 , newCap总是有值的,此时ft = newCap * 负载因子 float ft = (float)newCap * loadFactor; // 如果newCap是第一次初始化,那么阈值就是 newCap * 负载因子 // 如果newCap 的长度是2 ^ 30 时,阈值等于Integer.MAX_VALUE ,大家可能有点迷惑, // 为什么此时newCap = 2 ^ 30 次方呢,没有地方为newCap 赋值啊? // 请看上面代码 (newCap = oldCap << 1) ,这行代码虽然写在 // else if ()条件里,但是还是为newCap赋值了,为 oldCap * 2 ,也就是2 ^ 29 * 2 = 2 ^ 30 次方 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } threshold = newThr; // 创建新表 @SuppressWarnings({"rawtypes","unchecked"}) Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; table = newTab; // 下面就是真正的扩容操作 if (oldTab != null) { for (int j = 0; j < oldCap; ++j) { Node<K,V> e; // 下面的if条件中e = oldTab[j] ,用e指向table[j]的第一个节点 if ((e = oldTab[j]) != null) { oldTab[j] = null; // 如果oldTab[j] == null ,也就是说旧表的table[j]指向的链表或树为空 if (e.next == null) newTab[e.hash & (newCap - 1)] = e; // 如果table[j] 指向的是一颗树 else if (e instanceof TreeNode) ((TreeNode<K,V>)e).split(this, newTab, j, oldCap); else { // preserve order // 进入当前代码的肯定table[j] 指向的是链表 // 初始化高低位节点指针,用于创建链表使用 Node<K,V> loHead = null, loTail = null; Node<K,V> hiHead = null, hiTail = null; Node<K,V> next; do { // 用next指向e的下一个节点 next = e.next; // 如果e的hash值与oldCap相& = 0 ,则走下面代码 // 假如现在有两个节点,hash值分别是3,19 ,旧表数组长度为16,新表为32,那么旧表hash值为3和19的元素 // 只会落在table[3] 的链表上,而新表会落到低位链表table[3] ,和高位链表table[19]上,下面来看计算过程 // 旧表计算 tab[i = (n - 1) & hash] // (n - 1) & hash = (16 - 1) & 3 // 0000 0000 0000 0000 0000 0000 0000 1111 & // 0000 0000 0000 0000 0000 0000 0000 0011 = // 0000 0000 0000 0000 0000 0000 0000 0011 = 2 ^ 1 + 2 ^ 0 = 3 // 而hash值为19的计算过程为 // (n - 1) & hash = (16 - 1) & 19 = // 0000 0000 0000 0000 0000 0000 0000 1111 & // 0000 0000 0000 0000 0000 0000 0001 0011 = // 0000 0000 0000 0000 0000 0000 0000 0011 = 2 ^ 1 + 2 ^ 0 = 3 // 这也就是为什么旧表中桶的计算过程 ,hash值为3,和19 的都会落到table[3]的链表中 // 新表计算 // e.hash & oldCap = 3 & 16 = 0 // 3 = 0000 0000 0000 0000 0000 0000 0000 0011 & // 16 = 0000 0000 0000 0000 0000 0000 0001 0000 = // 0000 0000 0000 0000 0000 0000 0000 0000 = 0 // hash值等于19的情况 // e.hash & oldCap = 19 & 16 = 16 // 19 = 0000 0000 0000 0000 0000 0000 0001 0011 & // 16 = 0000 0000 0000 0000 0000 0000 0001 0000 = // 0000 0000 0000 0000 0000 0000 0001 0000 = 2 ^ 4 = 16 = 16 // 如果e.hash & oldCap == 0 则落在低位链表中,否则落在单位链表中 // 当前例子中,新表中hash值为3和19的元素分别会放到table[3] 和table[3 + 16]的链表中 if ((e.hash & oldCap) == 0) { // 如果loTail为null,证明当前链表中为空 if (loTail == null) // 如果低位链表从来没有插入元素,则用低位链表的头节点指向当前e loHead = e; else //将当前e节点加到链表尾部 loTail.next = e; // e 节点作为链表尾部节点 loTail = e; } else { // 如果hiTail为null,证明当前链表中为空 if (hiTail == null) // 如果高位链表从来没有插入元素,则用高位链表的头节点指向当前e hiHead = e; else //将当前e节点加到链表尾部 hiTail.next = e; // e 节点作为链表尾部节点 hiTail = e; } } while ((e = next) != null); // 如果低位链表的尾节点不为空 if (loTail != null) { // 设置低位链表的next节点为空 loTail.next = null; // 新链表的低位j 指向低位头节点 newTab[j] = loHead; } // 如果高位链表的尾节点不为空 if (hiTail != null) { // 高位链表的next节点置空 hiTail.next = null; // 如上面分析的,hash值为19的节点放到高位,此时newTab[j + oldCap ] = newTable[3 + 16] = hiHead newTab[j + oldCap] = hiHead; } } } } } return newTab; }
对于上面这一块链表的操作,我相信看完注释后,有了一定的理解,map 迁移时,使用的是尾插法,记得在JDK 7中链表使用的是头插法,在putVal()方法中,如果table[i]指向的是一个链表时有所体现,使用尾插法实现map元素的添加,那JDK 8 中为什么要这么做呢? 不知道聪明的小伙伴想到没有 ?大家有没有注意到,无论插入元素key是什么,都需要遍历整个链表,看当前key在hashMap中是否存在,如果存在,则可能替换掉原来的值,而JDK7中也是这样实现的,在JDK8中,当遍历完整个链表以后,如果tail节点的next节点为空,则直接将当前节点插入到tail节点之后,不知道之前JDK 7 数组迁移时会导致产生循环链表的原因看明白没有,因为采用头插入法,会导致下图所示循环链表的产生
原因是线程2在扩容时刚刚执行到a.next = b 时,此时线程1抢到了时间片,并将整个链表复制完毕,当线程1执行完但并没有用newTable覆盖旧的table,形成了c -> b -> a的结构,此时链表结构是b.next= a ,同时线程2抢到了时间片,继续进行链表遍历操作,此时会建立b.next指向a的结构,再次循环时,a 节点作为线程2的table[i]的头节点插入,形成了循环链表。
采用尾插法,当线程2 a.next 指向 b , 线程1 抢到时间片,执行完复制,形成了 a ->b ->c的关系,此时线程2继续执行,因为采用的是尾插法,所以,即使a.next = b ,那么b.next = c,也不会是b.next = a,最终不会形成循环链表的结构,此时大家有没有发现JDK8 在JDK 7的基础上做了许多的性能优化以及问题的解决 。
接下来,我们还是继续来看链表数据迁移的过程 。
- table[j]只有1个元素,直接将table[i]的元素放到新链表的newTab[e.hash & (newCap - 1)] 这个位置。
- table[j]指向的是一个红黑树,则调用Node的split()方法,进行数据迁移。
- table[j]指向的是链表,如果(e.hash & oldCap) == 0 则放到newTab[i]的链表尾部,如果 ( e.hash & oldCap) != 0,则将当前Node加入到newTab[j + oldCap]的链表尾部。
- 最后将每个链表尾部的next设置为空,将新表的 newTable[j] 和newTable[j + oldCap] 指向分别指向链表头。
为什么最后要将loTail.next = null; 和 hiTail.next = null;置空呢?相信聪明的小伙伴会发现,在数据迁移完之后,可能会存在newTab[j] 的尾节点指向 newTab[j + oldCap] 中的节点 或 newTab[j + oldCap] 的尾节点指向newTab[j]中的节点,因此需要将两个链表next节点置空,在旧节点的整个链表遍历完,都没有将newTab[j]和newTab[j + oldCap]指向头节点,在结束时需要将 newTab[j]和newTab[j + oldCap]指向链表表头。
上图中旧map的数组长度为16,新map的数组容量为32,因此3,19,35,51,116 这些hash值 & 15 = 3
,而3和35&16 = 0,则只能放在低位,而 19和51和166 & 16 > 0 ,因此放到高位,这就是上图中所含义。
当然,在resize()方法中还有一块没有分析,当 oldTab[j]指向的是一个红黑树时,但是这一块比较麻烦,我们先来看一下,在putVal中没有分析过的代码,如果table[i]指向的是树节点时,怎样将当前节点加入到红黑树中。在看putTreeVal()方法之前,我们来看一篇别人的博客。 漫画:什么是红黑树? ,强烈建议大家去看原文章哦,因为我觉得写得太有趣了,为了避免将来文章被删除,我还是将原文章复制一份到我的博客吧,在后面的代码分析过程中也需要用到里面示例,因此在这里复制一份,希望原文章的作者不要见怪。
在了解红黑树之前,咱们需要先来理解二叉查找树(Binary Search Tree )。
二叉对(BST)具备什么特性呢?
- 左子树上所有的结点均小于或等于它的根结点的值。
- 右子树的所有结点的值大于或等于它的根结点的值。
- 左,右子树也分别为二叉排序树。
下图就是这棵树,这是一棵典型的二叉查找树。
这样的数据结构有什么好处呢? 我们来试着查找一下值为10的节点
1.查看根节点9:
4. 由于 10 > 9 ,因此查看右孩子 13
由于 10 < 13 ,因此查看左孩子 11 :
5. 由于10 < 11,因此查看左孩子10,发现10正是要查找的节点:
这种方式正是二分查找的思想,查找所需要的最大次数等同于二叉查找树的高度。
在插入节点的时候也是利用类似的方法,通过一层一层比较大小,找到新节点适合插入的位置 。
很遗憾,二叉查找树仍然存在它的缺陷。缺陷体现在插入新节点的时候,让我们来看看下面这种情形 :
假设初始的二叉查找树只有三个节点,根节点值为9 ,左孩子的值为8 ,右孩子的值为12 :
接下来我们依次插入如下5个节点,7,6,5,4,3 依照二叉查找树的特性,如果会变成什么样呢?
好好的二叉查找树变成了瘸子了, 正是因为如此,这样的形态虽然也符合二叉查找树的特性,但是查找的性能大打折扣,几乎变成了线性。
如何解决二叉查找树的多次插入新节点而导致的不平衡呢?我们的主角【红黑树】应运而生了。
红黑树(Red Black Tree)是一自平衡二叉查找树,除了符合二叉查找树的基本特性外,它还具有下列的附加特性。
- 节点是红色或黑色
- 根节点是黑色
- 每个叶子节点都是黑色的空节点(NIL节)
- 每个红色节点的两个节点都是黑色(从每个叶子到根的所有路径上不能有两个连续的红色节点)
- 从任意一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
下图就是这棵树,这就是一颗典型的红黑树。
天呐,这条条框框也太多了,正是因为这些规则,才保证了红黑树的平衡,红黑树从根节点到叶子节点的最长路径不会超过最短路径的2倍。
当插入或删除节点的时候,红黑树的规则有可能被打破,这时候就需要做出一些调整,来继续维持我们的规则 。
什么情况下不会破坏红黑树的规则,什么情况下不会破坏规则呢?我们举两个简单的例子。
-
向原红黑树中插入值为14的新节点
由于父亲节点是黑色节点,因此这种情况下并不会破坏红黑树的规则,无需做任何调整。 -
向原红黑树插入值为21的新节点
由于父节点是红色节点,因此这种情况打破了红黑树规则4(每个红色节点的两个子节点都是黑色),必须进行调整,使之重新符合红黑色的规则 。
小白:那么,我们需要做出怎样的调整,才能保证一颗红黑树始终是红黑树呢?
小强:调整有两种方法,[变色]和[旋转],而旋转又分成两种形式,[左旋转]和[右旋转]。
【变色 】:
为了重新符合红黑树的规则,尝试把红色节点变为黑色,或者把黑色节点变为红色 。
下图所表示的是红黑树的一部分,需要注意节点25并非根节点,因为节点21和节点22连续出现了红色,不符合规则4,所以把节点22从红色变为黑色 。
但这样还不算完,因为凭空多出了黑色节点打破了规则5,所以发生了链锁反应,需要继续把节点25从黑色变成了红色 :
此时仍然没有结束,因为节点25和节点27又形成了两个连续的红色节点,需要继续把节点27从红色变成了黑色。
左旋转:
逆时针旋转红黑树的两个节点,使得父节点被自己的右孩子取代,而自己成为自己的左孩子,说起来很诡异,大家看下图 。
图中,身为右孩子的Y取代了X的位置,而X变成了自己的左孩子,此为左旋转。
右旋转:
顺时针旋转红黑树的两个节点,使得父节点被自己的左孩子取代,而自己成为自己的右孩子 。 大家看下图 。
图中,身为左孩子的Y取代了X的位置,而X变成了自己在的右孩子,此为右旋转。
小白: 好复杂啊,究竟什么时候用到变色呢?什么时候用到旋转呢?
小强:确实有些复杂,红黑树的插入和删除包含很多种方式,每一种情况都不同的处理方式,在这里我们只是举一个例子,大家体会一下。
我们以刚才插入的节点21的情况为例子:
首先,我们需要做的是变色,把节点25及其下文的节点变色 :
此时节点17和节点25的连续的两个红色节点,那么把节点17变成黑色节点?恐怕不合适,这样一来不但打破了规则4,而且根据规则2(根节点是黑色),也不可能把节点13变成红色节点。
变色已经无法解决的问题,我们把节点13看作X,把节点17看作Y,像刚才示意图那样进行左旋转:
由于根节点必须是黑色,所以需要变色,变色结果如下:
这样就结束了吗?并没有,因为其中两条路径(17->8->6->NIL)的黑色节点个数是4,其他路径的黑色节点个数是3,不符合规则5。
这个时候,我们需要把节点13看作是X,节点8看作是Y,像刚才示意图那样进行右旋转。
最后根据规则来进行变色 。
如此一来,我们的红黑树变得重新符合规则,这一个例子的调整过程比较复杂,经历了如下步骤 :
变色->左旋转->变色->右旋转->变色
小白: 还真是绕啊,我慢慢的消化吧。
小白:最后再请教一个问题,红黑树在哪些地方被实际应用呢?
小强:红黑树的应用有很多,在JDK 集合类TreeMap和TreeSet底层就是红黑树实现的,在Java8 中连HashMap都用到了红黑树。
小强:关于红黑树,我们就介绍到这里我,感谢大家 。
几点说明 :
1.关于红黑树自平衡调整,插入和删除节点的时候都涉及到很多种情况,我们下面就看HashMap是如何实现红黑树的。
关于我将原博客大部分都复制过来了,可能有人会有看法,我也是通过 漫画:什么是红黑树?才真正的了解的,我自己也是一个学习的过程,如果想了解原理,还是建议大家去看原博客,因为写得太好,怕博主删除博客,我也只能复制过来了,另外一方面,我现在想分析HashMap的红黑树的实现过程,避免不了去理解红黑树原理,可能下面的源码解析和漫画:什么是红黑树?息息相关,所以在看下面解析过程务必去了解红黑树的原理再来看,不然被说得云里雾里了,下面我们就走进HashMap红黑树研究中。
在进入真实代码分析之前,我们先来看看神秘的TreeNode结构 。
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; Node(int hash, K key, V value, Node<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } } static class Entry<K,V> extends HashMap.Node<K,V> { Entry<K,V> before, after; Entry(int hash, K key, V value, Node<K,V> next) { super(hash, key, value, 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; }
总结上面的关系,发现HashMap有那么多的属性。
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 Entry<K,V> before, after; Node<K,V> next; boolean red; final int hash; final K key; V value; }
请看TreeNode属性图,发现他的顿时感觉他的七大姑,八大姨全出来了。
了解了红黑树的原理和TreeNode的属性后,我们进入putTreeVal()方法。
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; for (TreeNode<K,V> p = root;;) { int dir, ph; K pk; // dir < 0 ,则从当前节点的右子树查找 if ((ph = p.hash) > h) dir = -1; // dir > 0 ,则从当前节点的左子树查找 else if (ph < h) dir = 1; // 如果找到key相等的节点,则直接返回 else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; else if ((kc == null && // k 的Class 如果实现了Comparable接口,并且Comparable的泛型参数为k的 Class (kc = comparableClassFor(k)) == null) || // 因为上面的判断条件是 comparableClassFor(k)) != null 时才会执行下面的判断 // 因此 如果 kc !=null ,也就是k 的class 实现了Comparable 接口,那通过compareComparables()方法调用 // k 对象的compareTo() 并返回值。 (dir = compareComparables(kc, k, pk)) == 0) { // 凡是进入下面代码的都是那些hash值相等 , // (并没有实现Comparable 接口或 实现了Comparable接口,但调用对象的compareTo()方法返回为0的情况) if (!searched) { // 如果从来没有进行搜索过,则进行搜索,接着searched = true ,也就是整个遍历过程中,下面代码只会进入一次 TreeNode<K,V> q, ch; searched = true; // 如果当前节点的左子树不为空 if (((ch = p.left) != null && // 先从左子树中寻找是否有节点key与当前key相等的 (q = ch.find(h, k, kc)) != null) || // 如果当前节点的右子树不为空 ((ch = p.right) != null && // 左子树找不到符合条件的key,再从右子树中查找 (q = ch.find(h, k, kc)) != null)) // 如果从当前节点的左右子树中查找到key相同的节点,则返回 return q; } // 此时已经将树节点的左右子树都找遍了,并且出现极端情况,(k 的hash值和 当前树节点的hash值相等 并且 k 和当前树节点的key 值不相等 ) // 或 k 的hash值和 当前树节点的hash值相等 并且 k 和当前树节点的key 值不相等 ,并且 k 和当前树节点都实现了Comparable 并且 调用compareTo() // 方法返回0的极端情况 ,只能调用System.identityHashCode( obj ) 来比较了 dir = tieBreakOrder(k, pk); } TreeNode<K,V> xp = p; // 如果需要向当前树节点的左子树查找,但左子节点为空 ,此时只能将当前节点插入到树节点左边 或 // 如果需要向当前树节点的右子树查找,但右子节点为空 ,此时只能将key 插入到当前节点的右节点 if ((p = (dir <= 0) ? p.left : p.right) == null) { // 当程序执行到此,表明树中肯定不存在节点的key与当前k 相等的情况,只能将k 直接插入当前树节点,成为叶子节点 // 记录当前树节点的next节点 Node<K,V> xpn = xp.next; // 创建新的树节点,并且新节点的next 指向当前树节点的next节点 TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn); if (dir <= 0) // 如果新创建的节点的hash值 < 树节点,则将新节点插入当前树节点的左节点,新节点成为当前树节点的左子节点 xp.left = x; else // 如果新创建的节点的hash值 > 树节点,则将新节点插入当前树节点的右节点,新节点成为当前树节点的右子节点 xp.right = x; // 当前树节点的next指向新创建的节点 xp.next = x; // 新创建节点的parent 和prev 指向当前树节点 x.parent = x.prev = xp; if (xpn != null) // 如果当前树节点的之前的next 节点不为空,则用之前的树节点的pre 指向新创建的节点 ((TreeNode<K,V>)xpn).prev = x; // 变色->旋转->变色 平衡 等一系列操作操作了 ,先来看红黑树的平衡balanceInsertion()方法 ,接着看 // moveRootToFront()方法的实现逻辑 moveRootToFront(tab, balanceInsertion(root, x)); return null; } } }
查找当前节点的根节点并返回,如果自己是根节点,则返回自身
final TreeNode<K,V> root() { // 循环遍历当前节点的父节点,父节点为空的节点肯定是根节点 for (TreeNode<K,V> r = this, p;;) { if ((p = r.parent) == null) return r; r = p; } }
如果对象实现了Comparable,则返回对象的class ,否则为空。
static Class<?> comparableClassFor(Object x) { if (x instanceof Comparable) { Class<?> c; Type[] ts, as; Type t; ParameterizedType p; if ((c = x.getClass()) == String.class) // bypass checks return c; if ((ts = c.getGenericInterfaces()) != null) { for (int i = 0; i < ts.length; ++i) { if (((t = ts[i]) instanceof ParameterizedType) && ((p = (ParameterizedType)t).getRawType() == Comparable.class) && (as = p.getActualTypeArguments()) != null && as.length == 1 && as[0] == c) // type arg is c return c; } } } return null; } getGenericInterfaces()这个方法获取对象的所有实现接口类。 (ParameterizedType)t).getRawType() 表示获取接口的类型 p.getActualTypeArguments() 获取接口泛型类型参数 综合上述 comparableClassFor方法的目的就是判断方法有没有实现Comparable接口, 并且Comparable接口的泛型类型的参数只有一个,并且这个泛型类型与当前实现Comparable接口的类为同一个类。 public class CompareTestA implements Comparable<CompareTestA>{ } public class CompareTestB implements Comparable<User>{ } 在上面两个方法中,CompareTestA 的对象调用comparableClassFor()方法将返回CompareTestA对象, 而CompareTestB的对象调用comparableClassFor()方法将返回null,因为方法中as[0] == c 这个条件将返回false 。 我相信此时此刻,你对comparableClassFor()方法的实现原理有所理解了吧。 static int compareComparables(Class<?> kc, Object k, Object x) { return (x == null || x.getClass() != kc ? 0 : ((Comparable)k).compareTo(x)); } 对于compareComparables()方法的实现逻辑就简单了,就是实现了Comparable接口的类调用其compareTo()方法比较并返回结果
下面就是对于find()方法的理解。
final TreeNode<K,V> find(int h, Object k, Class<?> kc) { TreeNode<K,V> p = this; do { int ph, dir; K pk; // 记录当前节点的左子节点 TreeNode<K,V> pl = p.left, // 记录当前节点的右子节点 pr = p.right, q; // 如果当前树节点的hash值大于 key 的哈希值,则去当树节点的右子树中查找 if ((ph = p.hash) > h) p = pl; // 如果当前树节点的hash值小于 key的哈希值,则到当前树节点的左子树中查找 else if (ph < h) p = pr; // 如果当树节点的hash值等于key的hash值,并且key equals 当前树节点的key,则节点找到了,直接返回 else if ((pk = p.key) == k || (k != null && k.equals(pk))) return p; // 如果当树节点的hash值等于key的hash值,并且key 不等于当前树节点的key,恰好左子节点为空,则只能查找当前节点的右子树 else if (pl == null) p = pr; // 如果当树节点的hash值等于key的hash值,并且key 不等于当前树节点的key,恰好右子节点为空,则只能查找当前节点的左子树 else if (pr == null) p = pl; // 如果当树节点的hash值等于key的hash值,并且key 不等于当前树节点的key,以上条件都不满足,并且key Class 实现了Comparable接口 else if ((kc != null || // 如果实现了Comparable接口 (kc = comparableClassFor(k)) != null) && // 调用对象的compareTo()方法比较 (dir = compareComparables(kc, k, pk)) != 0) p = (dir < 0) ? pl : pr; // 如果key的hash值等于当前树节点的hash值,并且key对象没有实现Comparable接口 // 并且当前树节点的左右子树不为空,则递归查找当前树节点的右子树 else if ((q = pr.find(h, k, kc)) != null) return q; else // 如果key的hash值等于当前节点的hash值,并且没有实现Comparable接口,并且 左右子树不为空 // 并且从右子树中找不到当前key相等的节点,则递归查找左子树 p = pl; // 直到找到叶子节点为止 } while (p != null); return null; }
上面这个方法的实现原理其实很简单,如根节点是13 ,现在要查找元素hash值为6的元素,请看下图
其中蓝线为查找路径,那我们来走一下代码流程 ,find()方法进入,find方法中
find方法中 do while()中
第一次循环
h = 6
p = this = 8 因为先从左节点开始遍历 。
因为 p.hash = 8 > 6 ,所以 p = pl = 1 ,p !=null ,接着下一次循环
第二次循环
ph = p.hash < h ,也就是 1 < 6 ,所以,p = pr ,而pr = 6 ,p != null ,接着第三次循环
第三次循环
ph = p.hash == 6 ,现次判断 (pk = p.key) == k || (k != null && k.equals(pk)), 假如hash值相等,并且key也相等,则 等于6的p 节点就是我们要找的节点 。
对于find()方法还有一种情况需要考虑,对于重写了hashCode()方法并实现了Comparable接口的对象,对于这种情况,如果对象的hashCode()相等,则调用对象的compareTo()方法进行比较,如果当前key.compareTo(树节点)>0,则到树节点的右子树中查找,如果key.compareTo(树节点)<0 ,则到树的左子树中查找,直到查找叶子节点为止。
接下来,我们来看一种极端的情况 ,也就是不同对象的hashCode()值相等,compareTo()比较,还是相等的情况下,就会进入tieBreakOrder()方法来计算到底是查找左子树还是右子树,那我们先来看看什么情况下会导致 hashCode()值相等,compareTo()比较依然相等的情况呢?我们来看一个例子。
public class UserInfo implements Comparable<UserInfo> { private int age; private int length; public UserInfo() { } public UserInfo(int age, int length) { this.age = age; this.length = length; } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; UserInfo userInfo = (UserInfo) o; return age == userInfo.age && length == userInfo.length; } @Override public int hashCode() { return age + 31 * length; } @Override public int compareTo(UserInfo o) { if ((this.age * this.length * 31) > (o.age + o.length * 31)) { return 1; } else if ((this.age * this.length * 31) < (o.age + o.length * 31)) { return -1; } return 0; } public static void main(String[] args) { UserInfo userInfo = new UserInfo(31, 1); UserInfo userInfo1 = new UserInfo(62, 0); System.out.println(userInfo1.hashCode()); System.out.println(userInfo.hashCode()); int a = System.identityHashCode(userInfo); int b = System.identityHashCode(userInfo1); int c = System.identityHashCode(userInfo); System.out.println(a); System.out.println(b); System.out.println(c); } } 结果输出 : 491044090 644117698 491044090 UserInfo 有两个int类型的属性, age 和length 其实上面的原理很简单,我重写了hashCode,并且重写了compareTo()方法, hashCode 的计算 是通过 age + 31 * length 而compareTo方法也是通过 age + 31 * length 来比较的 ,创建两个对象,userInfo和userInfo1 UserInfo userInfo = new UserInfo(31, 1); UserInfo userInfo1 = new UserInfo(62, 0); userInfo 的hashCode = age + 31 * length = 31 + 31 * 1 = 62 userInfo1的hashCde = age + 31 * length = 62 + 31 * 0 = 62 ,userInfo和userInfo1的哈希值相等 再来看compareTo()方法,发现compareTo()方法也是通过哈希值来比较,这样两个对象比较compareTo()也是返回0, 而对于这种极端情况,hashMap 只能通过System.identityHashCode(userInfo) 来计算比较了,identityHashCode()方法有什么好处呢? 也就是同一个对象任何时候调用identityHashCode()返回的值总是相等的,如上 userInfo对象两次调用identityHashCode()方法返回的值 都是491044090,不同的对象调用identityHashCode()方法返回的值肯定是不一样,因此,可以通过此方法结果来比较到底是从当前树节点的左子树还是右子树查找了。
通过上面的分析 ,此时再来理解tieBreakOrder()方法的意图就简单了。
static int tieBreakOrder(Object a, Object b) { int d = 0 ; 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; } identityHashCode()这个方法还有一个有意思的事情,就是identityHashCode(null) = 0 ,当参数为null时,始终返回0
接下来,我们来分析之前在看红黑树的原理时 【变色->左旋转->变色->右旋转->变色 】是如何实现的。
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, TreeNode<K,V> x) { // 在红黑树的第三条规则中 每个叶子节点都是黑色的空节点(NIL节), // 因为叶子节点都黑色的黑色的空节点(NIL节),所以每个有值的叶子节点必须为红色 // 因为新节点肯定插入的是叶子节点,因此默认为红色 x.red = true; for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { // 如果新插入节点的parent节点为空,则表示当前树只有一个节点,也就是x节点是根节点,根节点必须为黑色节点,因此 x.red = false 。 if ((xp = x.parent) == null) { x.red = false; return x; } // 只要当前新插入的节点的父亲节点是黑色节点 或 // 当前新插入节点的爷爷节点为空,则也直接返回,不需要树的平衡,也就是当前树有值节点只有两层 // 如果只有两层,那么根节点肯定黑色节点,为什么呢?我想 (xpp = xp.parent) == null) 这个判断,应该就是解决并发情况下 // 当前树根节点还没有被设置为黑色节点时,此时第二个节点也开始执行balanceInsertion()方法时的考虑,如图图1.1 所示 else if (!xp.red || (xpp = xp.parent) == null) return root; if (xp == (xppl = xpp.left)) { // 请看图2 ,从之前的原理图中可以看出 // 如果新插入节点的父节点是爷爷节点的左节点,爷爷节点的右节点不为空且还是红色 if ((xppr = xpp.right) != null && xppr.red) { // 爷爷节点的右节点置为黑色,父亲节点置为黑色,爷爷节点设置为红色,x 等于爷爷节点 xppr.red = false; xp.red = false; xpp.red = true; x = xpp; // 原理,如果当前存在两个连续的红色节点,并且当前节点的爷爷节点的右节点为红色节点, // 那么爷爷节点的右节点肯定不存在子节点,并且爷爷节点肯定为黑色节点,且在没有插入当前节点之前, // 当前节点的父亲节点肯定不存在子节点, 有了上面的这些条件之后,如果要想树平衡,只需要将爷爷设置为红色节点, // 父亲节点设置为黑色节点,爷爷节点的右节点设置为黑色节点即可,经过此种方式变色之后,至少能保证爷爷节点 // 到左右子树经历的黑色节点数是一样的,初衷就是为了保证加入一个红色节点后减少对树的影响 。 // 因为爷爷节点到其左右叶子节点经历的黑色节点数一样,此时只需要将爷爷节点看作为新插入节点一样,看树是否平衡即可 。 // 因此为了最后的x = xpp } else { // 凡是进入此else代码肯定要符合以下2 个条件 // 1.当前x节点的父节点不为空,且为红色节点,且为爷爷节点的左节点 // 2.当前x节点的爷爷节点的右节点为空,且爷爷节点为黑色 if (x == xp.right) { // 如果当前x为父亲节点的右子节点 ,则需要进行左旋转 // 进行左旋转 ,旋转的基点为x节点的父亲节点 // 但是这里需要着重注意的是,旋转之后原来的xp节点变成了x节点,原来的x节点会变成xp节点 root = rotateLeft(root, x = xp); // 如果xp节点不为空,也就是原来的x节点不为空,则将当前xpp节点设置为xp.parent节点 xpp = (xp = x.parent) == null ? null : xp.parent; } // 如果父亲节点不为空 if (xp != null) { //将父亲节点设置为黑色 xp.red = false; //如果x节点的爷爷节点不为空,则将爷爷节点置为红色 if (xpp != null) { xpp.red = true; // 以爷爷节点作为基点右旋转 root = rotateRight(root, xpp); } } // 原理:凡是进入else代码块,肯定爷爷节点为黑色节点,并且没有右子节点,如果当前节点是父亲节点的右子节点 // 则 以父亲节点为基点进行左旋转,旋转后,爷爷节点以下的节点变成了一棵左斜树,当前节点x节点为原来xp节点的父亲节点, // 而xp节点为x节点的左子节点,如果当前节点本身就是父亲节点的左节点,本身就是一棵左斜树,则不需要做任何操作 // 经过上面的处理后,爷爷节点没有右节点,父亲节点也没有右节点,当前节点为父亲节点的左节点,且当前节点没有左右子节点, // 此时再以爷爷节点进行右旋转,并且将父亲节点设置为黑色,爷爷节点设置为红色,此时就已经消除了新插入红色节点对树的影响,树已经达到了平衡。 // 上面代码的意图就是就是将一棵左斜树变成一棵正常的树,通过节点颜色的转换减少新插入节点对原来树的影响, // 当然,后面的代码 ,如果当前父亲节点为爷爷节点的右节点时,原理也一样 } 我相信这一块代码看得稀里糊途,那还是通过一张图来展示这一块代码的用意,请看图3 } else { // 代码进入到这里必须满足以下几个条件 // 1.当前x节点的父节点不为空 // 2.当前x的父亲节点为红色节点 // 3.当前x节点的父亲节点为爷爷节点的右节点 if (xppl != null && xppl.red) { // 将当前节点的爷爷节点的左节点设置为黑色,父亲节点设置为红色,爷爷节点设置为红色,请看 图4 xppl.red = false; xp.red = false; xpp.red = true; x = xpp; } else { // 凡是进入此else代码肯定要符合以下4个条件 // 1.当前x节点的父节点不为空 // 2.当前x的父亲节点为红色节点 // 3.当前x节点的父亲节点为爷爷节点的右节点 // 4.当前x节点的爷爷节点的左节点为空或为黑色节点 if (x == xp.left) { root = rotateRight(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; } if (xp != null) { // 当前x节点的父亲节点设置为黑色 xp.red = false; if (xpp != null) { // x节点的爷爷节点设置为红色 xpp.red = true; // 进行左旋转 root = rotateLeft(root, xpp); } } } 当前代码块的实现逻辑请看 图5 } } }
我相信代码看到这里的小伙伴肯定对旋转,变色 的代码实现有了一定的理解,但是还缺点什么,其实就相当于我们学习了很多的知识,但是只是一些零散的知识点,并没有将知识连成网,导致觉得是那么回事,但好像又不是。因此,我们就用之前的
漫画:什么是红黑树? 这个例子,来看代码是如何实现。
第1步,向现有的红黑树中插入21 的节点 。
其中黑色加粗代码为本次执行的代码
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, TreeNode<K,V> x) { x.red = true; for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { if ((xp = x.parent) == null) { x.red = false; return x; } else if (!xp.red || (xpp = xp.parent) == null) return root; if (xp == (xppl = xpp.left)) { if ((xppr = xpp.right) != null && xppr.red) { xppr.red = false; xp.red = false; xpp.red = true; x = xpp; } else { if (x == xp.right) { root = rotateLeft(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; } if (xp != null) { xp.red = false; if (xpp != null) { xpp.red = true; root = rotateRight(root, xpp); } } } } else { if (xppl != null && xppl.red) { xppl.red = false; xp.red = false; xpp.red = true; x = xpp; } else { if (x == xp.left) { root = rotateRight(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; } if (xp != null) { xp.red = false; if (xpp != null) { xpp.red = true; root = rotateLeft(root, xpp); } } } } } } 经过了第一轮循环,将爷爷节点的右节点设置为黑色,也就是将27设置为黑色 父亲节点设置为黑色,也就是将22设置为黑色 爷爷节点设置为红色,也就是将25设置为红色 此时x节点是之前插入节点的爷爷节点。
在第一轮循环后,非常重要的一点,就是当前x节点已经是之前插入节点21的爷爷节点。效果如下图所示 。
当前x节点为25的节点,接下来继续循环代码 。
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, TreeNode<K,V> x) { x.red = true; for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { if ((xp = x.parent) == null) { x.red = false; return x; } else if (!xp.red || (xpp = xp.parent) == null) return root; if (xp == (xppl = xpp.left)) { if ((xppr = xpp.right) != null && xppr.red) { xppr.red = false; xp.red = false; xpp.red = true; x = xpp; } else { if (x == xp.right) { root = rotateLeft(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; } if (xp != null) { xp.red = false; if (xpp != null) { xpp.red = true; root = rotateRight(root, xpp); } } } } else { if (xppl != null && xppl.red) { xppl.red = false; xp.red = false; xpp.red = true; x = xpp; } else { if (x == xp.left) { root = rotateRight(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; } if (xp != null) { xp.red = false; if (xpp != null) { xpp.red = true; root = rotateLeft(root, xpp); } } } } } } 经过再一次循环,此时树结构变成了如下图所示,此时x=xpp,也就是x指向了13的节点。
接下来,我们继续下一轮循环。
static <K,V> TreeNode<K,V> balanceInsertion(TreeNode<K,V> root, TreeNode<K,V> x) { x.red = true; for (TreeNode<K,V> xp, xpp, xppl, xppr;;) { if ((xp = x.parent) == null) { x.red = false; return x; } else if (!xp.red || (xpp = xp.parent) == null) return root; if (xp == (xppl = xpp.left)) { if ((xppr = xpp.right) != null && xppr.red) { xppr.red = false; xp.red = false; xpp.red = true; x = xpp; } else { if (x == xp.right) { root = rotateLeft(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; } if (xp != null) { xp.red = false; if (xpp != null) { xpp.red = true; root = rotateRight(root, xpp); } } } } else { if (xppl != null && xppl.red) { xppl.red = false; xp.red = false; xpp.red = true; x = xpp; } else { if (x == xp.left) { root = rotateRight(root, x = xp); xpp = (xp = x.parent) == null ? null : xp.parent; } if (xp != null) { xp.red = false; if (xpp != null) { xpp.red = true; root = rotateLeft(root, xpp); } } } } } } 紧接着第三轮循环,走过的代码如下已经加粗,加黑的代码 。树的结构图变成如下所示 。
细心的小伙伴肯定发现了问题了,这和 漫画:什么是红黑树? 博客中写的不一样哈,在 漫画:什么是红黑树? 中,应该是旋转,而在我们代码分析过程中,发现是直接将根节点变成黑色,就返回了,难道树已经平衡了吗?我们再次回顾红黑树的逻辑。如下
1.节点是红色或黑色。
2.根节点是黑色。
3.每个叶子节点都是黑色的空节点(NIL节点)。
4 每个红色节点的两个子节点都是黑色。(从每个叶子到根的所有路径上不能有两个连续的红色节点)
5.从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点
下面这颗树满足这个条件吗? 我们一条条对,发现是满足的,最重要的是第5点,如根节点13 到每个叶子节点都经历过4个黑色节点。耐心的小伙伴可以自己一个个节点对,发现下面这就是一颗红黑树,也就是说 漫画:什么是红黑树? 中的,插入节点21 后面部分分析错误了 。之前我也是对漫画:什么是红黑树?,在代码分析到这里时,发现不对,我以为是我分析错误了,我再三bug ,发现我的分析没有错,原本我以为是JDK 代码出bug了,我只能将树画出来,再次对比红黑树的 5条规则,发现通过代码分析得来的树也是一颗红黑树,后面我又找了网站 数据结构可视化(Data Structure Visualizations) 来验证,发现确实是 漫画:什么是红黑树 这篇博客写错了,所以读者在阅读源码时需要小心,但是建议初学者还是要去看漫画:什么是红黑树 这篇博客的,对于理解红黑树的原理,还是很有帮助,也是觉得漫画:什么是红黑树是写得非常好的一篇博客,对于学习,我们要折其善者而从之,其不善者而改之,千万不要因为一些瑕疵就全盘否定,一定要 取其精华,去其糟粕,我们才能真正的学习到别人先进的思想 。
我相信分析到这里,对于红黑树的平衡,大家肯定已经有了新的认识。我们接着去看其他代码 。
左旋转的代码实现,虽然看起来只有这么一点代码,但是真正理解起来还是有点费解的,下面,我们逐一分析左旋转的三种情况 。
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root, TreeNode<K,V> p) { TreeNode<K,V> r, pp, rl; if (p != null && (r = p.right) != null) { if ((rl = p.right = r.left) != null) rl.parent = p; if ((pp = r.parent = p.parent) == null) (root = r).red = false; else if (pp.left == p) pp.left = r; else pp.right = r; r.left = p; p.parent = r; } return root; }
- 左旋转的第一种情况
上述代码的情况是怎样的呢?
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root, TreeNode<K,V> p) { TreeNode<K,V> r, pp, rl; if (p != null && (r = p.right) != null) { if ((rl = p.right = r.left) != null) rl.parent = p; if ((pp = r.parent = p.parent) == null) (root = r).red = false; else if (pp.left == p) pp.left = r; else if (pp.right == p) pp.right = r; r.left = p; p.parent = r; } return root; } 第一种情况,就是上图中黑色加粗执行的代码 。 p 为 13的节点 r = p.right = 17 的节点 rl = p.right = r.left,rl 为p.right.left节点也就 是r(节点17 )的左节点15,rl = 15的节点, 第一步,将p.right = r.left ,也就是将p的右节点指向r的左节点。 rl 不为空 第二步 ,则将15 的父亲节点设置为p节点,也就是15的parent节点设置为节点13 。 第三步,将13的父节点10 设置为17 的父节点,如果13的父节点为空,在本例中显然不为空。 所以 (root = r).red = false;代码不会执行。 因为p 节点13 为 pp 节点 10 的右子节点,所以 pp.right == p 为 true 第四步,所以pp.right = r ,也就将 10 节点的右节设置为17 第五步,接着将 r.left = p ,也就是将17的左节点设置为13 第六步,接着将 p.parent = r ,也就是将13的parent节点设置为17
接下来,看左旋转的第二种情况
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root, TreeNode<K,V> p) { TreeNode<K,V> r, pp, rl; if (p != null && (r = p.right) != null) { if ((rl = p.right = r.left) != null) rl.parent = p; if ((pp = r.parent = p.parent) == null) (root = r).red = false; else if (pp.left == p) pp.left = r; else pp.right = r; r.left = p; p.parent = r; } return root; } r = p.right节点,r节点还是17 第一步,rl = p.right = r.left 节点 ,也就是rl节点是15的节点,因为rl不为空,所以 第二步,rl.parent = p ;也就是15的节点的父亲节点指向p节点。 接着 pp = r.parent = p.parent ,将r节点的父亲节点指向p节点的父亲节点,但p是根节点,p的父亲节点为null ,所以 第三步,r节点作为根节点,并且将颜色设置为黑色 第四步,r.left = p ;也就是r节点的左节点等于p节点,17的左节点指向13 第五步,p.parent = r ; p节点的父亲节点设置为r; 也就是将13的父亲节点指向17。
接下来,我们来看第三种情况的左旋转
static <K,V> TreeNode<K,V> rotateLeft(TreeNode<K,V> root, TreeNode<K,V> p) { TreeNode<K,V> r, pp, rl; if (p != null && (r = p.right) != null) { if ((rl = p.right = r.left) != null) rl.parent = p; if ((pp = r.parent = p.parent) == null) (root = r).red = false; else if (pp.left == p) pp.left = r; else pp.right = r; r.left = p; p.parent = r; } return root; } p 节点为10 r = p.right 为15 第一步,rl = p.right = r.left , 将p节点的右节点指向r节点的左节点,也就是将10 的右子节点设置为13 ,如果rl 不为空,则 第二步,rl.parent = p ;将rl的父亲节点设置为p,也就是将13的父亲节点设置为10 第三步,pp = r.parent = p.parent,将r.parent指向p.parent,也就是将15的父亲节点指向17 ,此时pp节点为17 ,显然不为空,则 如果pp.left = p ,也就是说17的左节点为10,在本例中显然成立 。 第四步,pp.left = r ;则将pp的左节点设置为15 第五步,r.left = p ;则将r的左节点设置为p,也就是15的左子节点设置为10 第六步,p.parent = r; p 的父亲节点指向r,也就是 10的父亲节点指向 15;
可能左旋转还有其他的情况,感兴趣的小伙伴可以自己去分析,就带大家分析到这里了。左旋转分析过了,那接下来,我们来看右旋转是怎样实现的呢?还是先上代码 。
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root, TreeNode<K,V> p) { TreeNode<K,V> l, pp, lr; if (p != null && (l = p.left) != null) { if ((lr = p.left = l.right) != null) lr.parent = p; if ((pp = l.parent = p.parent) == null) (root = l).red = false; else if (pp.right == p) pp.right = l; else pp.left = l; l.right = p; p.parent = l; } return root; }
右旋转我们也分三种情况来分析吧。先来看第一种情况
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root, TreeNode<K,V> p) { TreeNode<K,V> l, pp, lr; if (p != null && (l = p.left) != null) { if ((lr = p.left = l.right) != null) lr.parent = p; if ((pp = l.parent = p.parent) == null) (root = l).red = false; else if (pp.right == p) pp.right = l; else if(pp.left == p ) pp.left = l; l.right = p; p.parent = l; } return root; } l = p.left 节点不为空,在本例中, l 为12 ,p 为15 ,l.right 为13 第一步,lr = p.left = l.right, p.left = l.right ,15 节点的左子节点设置为13 第二步,lr.parent = p; 13的父亲节点指向p节点 第三步,pp = l.parent = p.parent, 12 节点的父亲节点指向 25 (p节点的parent节点) ,不为空,则 (root = l).red = false;不会执行 第四步,pp.left = l; 则25的左子节点指向12 第五步,l.right = p ; 则 12 指向 15 第六步,p.parent = l ; 则15的父亲节点指向12;
接下来看右旋转的第二种情况
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root, TreeNode<K,V> p) { TreeNode<K,V> l, pp, lr; if (p != null && (l = p.left) != null) { if ((lr = p.left = l.right) != null) lr.parent = p; if ((pp = l.parent = p.parent) == null) (root = l).red = false; else if (pp.right == p) pp.right = l; else pp.left = l; l.right = p; p.parent = l; } return root; } l = p.left ,l为12 第一步,p.left = l.right ,所以 将15的左节点设置为13 ,并且lr = p.left ,lr 不为空,则执行 第二步,lr.parent = p ;也就是13的父节点设置为15 第三步,pp = l.parent = p.parent,将12的parent设置为15节点的parent,因为15为根节点,所以将12的parent指向空,并且设置12的节点为黑色 。 第四步,l.right = p; 12的右节点为15 第五步,p.parent = l,15的parent节点设置为12 ;
此时我们再来看右旋转的第三种情况。
static <K,V> TreeNode<K,V> rotateRight(TreeNode<K,V> root, TreeNode<K,V> p) { TreeNode<K,V> l, pp, lr; if (p != null && (l = p.left) != null) { if ((lr = p.left = l.right) != null) lr.parent = p; if ((pp = l.parent = p.parent) == null) (root = l).red = false; else if (pp.right == p) pp.right = l; else pp.left = l; l.right = p; p.parent = l; } return root; } 加粗代码就是本次执行的代码 。 l = p.left ,当前p节点为16, p.left节点为13 第一步, lr = p.left = l.right ,则 16 指向 15 ,因为lr为15 ,不为空,所以 第二步,lr.parent = p ; 将lr的父亲节点指向16 第三步,pp = l.parent = p.parent ,将13的父亲节点指向p的父亲节点6。因为p的父亲节点不为空,所以(root = l).red = false;不会执行 p 节点为p.parent的右节点。所以 第四步,pp.right = l ; 也就是将p的右节点指向13 第五步,l.right = p ; 将13的右节点指向16 第六步,p.parent = l; 将p的父亲节点指向13。
其实理解了左旋转,右旋转只是左旋转反过来理解就可以了,相信此时此刻,大家对左右旋转的代码实现有了深刻的理解了。
接下来,我们来看将树的根节点移动到 table桶的位置 。
static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) { int n; // 这一行主要是对于根节点,hash 表 ,表长度的较验 if (root != null && tab != null && (n = tab.length) > 0) { // 计算root 节点的hash值在tab 表桶的位置,不过大家小心, // 其实树节点中任何一个节点的hash值计算都可以,不一定要root节点,只是这里面只传入了root节点 // 计算桶的位置和jdk7 一样 int index = (n - 1) & root.hash; // 获取桶位置 table[index] 这个节点 TreeNode<K,V> first = (TreeNode<K,V>)tab[index]; // 如果当前传入的根节点和table[index]不是同一个节点 ,这里有两种情况 // 1. 如果原来tab[index] 指向的是一个链表,那么不是同一个节点的可能性很大 // 2. 如果之前tab[index] 指向的是一颗树,而在插入节点过程中,树经过平衡,之前的根节点因为旋转,被其他节点替换棹了 if (root != first) { // 在以上两种情况中任意一种下,我们来看hashmap 如何帮我们实现的 Node<K,V> rn; tab[index] = root; // 记录当前根节点的前驱节点 TreeNode<K,V> rp = root.prev; // 如果root 节点的next 节点不为空,则将root.next 指向root.prev if ((rn = root.next) != null) ((TreeNode<K,V>)rn).prev = rp; // 如果root.prev 不为空,则将root.prev.next 指向 root.next if (rp != null) rp.next = rn; // 如果之前的table[index]节点不为空,则用first.prev指向当前root if (first != null) first.prev = root; // root.next 指向之前的root 节点 root.next = first; // 将当前root的前驱节点置空 root.prev = null; } // 较验根节点 assert checkInvariants(root); } }
上面写了一大堆,可能大家都晕了,请看下图 ,前面两个图是树旋转过程中parent和left,right 这些属性的设置过程,而后两个图是调用moveRootToFront()方法后,关于prev和next的设置过程。
接下来,我们来看root节点的较验过程 。
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; // 如果当前节点的next节点的前驱节点不是指向自己,抛出异常 if (tn != null && tn.prev != t) return false; // 如果当前节点不是父亲节点的左右节点,抛出异常 if (tp != null && t != tp.left && t != tp.right) return false; // 如果当前节点的左节点的hash值大于当前节点,抛出异常 if (tl != null && (tl.parent != t || tl.hash > t.hash)) return false; // 如果当前节点的右节点的hash值小于当前节点,抛出异常 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; }
在上面较验过程中,可能对(t.red && tl != null && tl.red && tr != null && tr.red)这个较验有点迷惑,如果当前节点是红色节点,且左右子节点都是红色节点,抛出异常,难道不应该是【如果当前节点是红色节点,只要左右子节点中有任意一个是红色节点,抛出异常】从漫画:什么是红黑树? 这篇博客给我们有错觉应该是这样子的,但实际不是,请看下图 。
在上图中,出现了两个红色节点 17 和15,便是依然是一棵红黑树,因此,JDK8中的代码显然是正确的。
大家看到这里,是不是觉得jdk 8中hashmap已经分析完了,当然没有,我们还只分析了putVal()方法中一种特殊情况,如果tab[index]指向的是一棵树时,此时调用 putTreeVal()方法,而后面还有,如果当前table[index]指向的是一个链表时,并且链表的长度大于阈值时 该怎么办呢?当然后面还有从map中删除元素,树又是怎样重新平衡,jdk 8中map又是如何遍历的呢?等等,还有大部分代码并没有分析,而以上的分析只不过是hashmap的开胃菜,不过不用担心,请听我尾尾道来。
先来分析当链表的长度超过阈值时,代码实现。
final void treeifyBin(Node<K,V>[] tab, int hash) { int n, index; Node<K,V> e; // MIN_TREEIFY_CAPACITY 默认为64 // 如果链表的长度大于阈值8 ,但table数组的长度小于64 ,则也只是对table进行扩容,不会将链表转化为红黑树 if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) resize(); // 取出table[index] 桶位置的Node节点,如果不为空 else if ((e = tab[index = (n - 1) & hash]) != null) { TreeNode<K,V> hd = null, tl = null; do { // 将当前Node节点替换为TreeNode节点 // 下面的逻辑就是遍历整个链表,将Node节点替换成TreeNode,并且建立起节点之前的prev和next关系 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); // 如果tab[index]处的节点不为空,则将链表转化为树 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); }
接下来,我们来看hashmap是如何将链表转化为一棵树的,请看下面代码 。
final void treeify(Node<K,V>[] tab) { TreeNode<K,V> root = null; // 第一次遍历时,x节点就是当前链表的头节点 for (TreeNode<K,V> x = this, next; x != null; x = next) { next = (TreeNode<K,V>)x.next; // 将x的左右节点初始化 x.left = x.right = null; //如果根节点为空,x节点就是预备根节点 if (root == null) { // 根节点的父亲节点置为空,根节点置为黑色节点,此时的根节点指向x节点 x.parent = null; x.red = false; root = x; } else { // 如果根节点已经建立好了 K k = x.key; int h = x.hash; Class<?> kc = null; // 下面的查找过程是不是和putVal()方法的一模一样,里面的细节我就不再缀述了 for (TreeNode<K,V> p = root;;) { int dir, ph; K pk = p.key; if ((ph = p.hash) > h) dir = -1; else if (ph < h) dir = 1; else if ((kc == null && (kc = comparableClassFor(k)) == null) || (dir = compareComparables(kc, k, pk)) == 0) dir = tieBreakOrder(k, pk); TreeNode<K,V> xp = p; // 其实链表每一个节点的插入过程都需要查找树,直到找到叶子节点为止 if ((p = (dir <= 0) ? p.left : p.right) == null) { // dir > 0 表示插入当前找到的叶子节点p的右节点,dir <= 0 ,则插入当前叶子节点为的左节点 x.parent = xp; if (dir <= 0) xp.left = x; else xp.right = x; // 通过以上代码 x节点将会成为p节点的左子节点或右子节点,再调用balanceInsertion()进行树的平衡 root = balanceInsertion(root, x); break; } } } } // 将链表转化为树的最终较验 ,这里并不是每插入一个节点就较验一下,而是当链表全部转化为树时才较验。 // 这也是优化性能的一种体现吧。 moveRootToFront(tab, root); }
我相信有了之前的基础,再来看将链表转化为红黑树的代码,就很轻松了。
既然将链表转化为红黑树的代码分析了,如果当红黑树的节点个数小于阈值6时,则将红黑树转化为链表,首先来理解这种应用场景,如果我从红黑树中删除节点,当删除后的节点小于阈值时,是不是红黑树需要转化为链表,当然从红黑树中删除节点的代码,我们后面再来分析,先来看将树转化为链表的代码。那hashmap又是如何实现的呢?请看下面代码。
final Node<K,V> untreeify(HashMap<K,V> map) { Node<K,V> hd = null, tl = null; for (Node<K,V> q = this; q != null; q = q.next) { // 将treeNode节点替换成Node节点,因为Node节点只有next,因此将之前树的prev和next转化成Node的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); }
接下来,我们来分析从hashMap中移除节点
public V remove(Object key) { Node<K,V> e; // 计算key的hash值,并调用removeNode()方法 return (e = removeNode(hash(key), key, null, false, true)) == null ? null : e.value; } final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) { // 关于从hashMap中移除元素,第一步找到元素,第二步再移除元素 Node<K,V>[] tab; Node<K,V> p; int n, index; // 如果table 不为空,并且有元素,并且table[index = (n-1) & hash] 有值,也就是table[index]桶中有值 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; // table[index] 处就是我们要找的key,则node指向table[index] if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k)))) node = p; // 如果tab[index] 不是我们要找的元素,并且tab[index].next 有值 else if ((e = p.next) != null) { // 如果tab[index]指向的是一颗树,则调用树的find()方法查找对应的元素 if (p instanceof TreeNode) node = ((TreeNode<K,V>)p).getTreeNode(hash, key); else { // 如果tab[index]指向的链表,则遍历整个链表,查找key对应的元素 do { if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) { node = e; break; } p = e; } while ((e = e.next) != null); } } // 如果找到了node,如果matchValue为true ,则必须node的value== 传入的value或node.value.equals(传入的value) // 当然你不用担心,默认情况下,只要找到key对应的node,则一定删除元素 if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) { // 如果tab[index]指向的是一棵树,则调用树的删除方法进行删除 if (node instanceof TreeNode) ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable); // 如果tab指向的是链表,并且key对应的是链表中第一个元素,直接将tab[index]指向被删除的元素的下一个节点即可 // 此时被删除的元素没有任何对象指向它,会被jvm回收掉 else if (node == p) tab[index] = node.next; else // 如果node是链表中非头节点元素,则用node的上一个节点的next指向node的next即可 p.next = node.next; // 修改次数+1 ++modCount; // map中node个数-1 --size; // 留给子类调用 afterNodeRemoval(node); return node; } } return null; } final TreeNode<K,V> getTreeNode(int h, Object k) { return ((parent != null) ? root() : this).find(h, k, null); }
在删除元素时,先查找到元素,再删除元素,查找和删除分别分三种情况 。
查找过程分三种情况
- 如果node是tab[index]指向的节点 。
- 如果node节点是树节点,则调用getTreeNode()方法从红黑树中查找到节点。
- 遍历整个链表,查找到key与当前删除的key相等的结点 。
如果查找到节点不为空,并且matchValue为true或matchValue为false,但是value.equals(传入的value)时,则进行删除元素。
- 如果当前节点是树节点,则调用removeTreeNode()方法,对树进行删除。
- 如果当前tab[index]指向的是链表,并且需要删除的元素是链表的第一个元素。
- 如果当前tab[index]指向的是链表,并且需要删除的元素非链表的头元素。
不知道细心的读者发现没有,在查找过程的每1,2步,和删除元素的1,2步刚好相反,为什么这要做呢?因为即使要删除的元素是tab[index]的根元素,也就是要删除的元素是tab[index]指向的第一个元素, 也需要调用removeTreeNode()方法来删除元素,而不能通过简单的tab[index] = node.next;操作就能完成的。接来下,我们来看removeTreeNode()方法的实现。
final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab, boolean movable) { int n; if (tab == null || (n = tab.length) == 0) return; // 计算桶的位置 int index = (n - 1) & hash; // root节点指向当前节点的根节点 TreeNode<K,V> first = (TreeNode<K,V>)tab[index], root = first, rl; // 用prev和next 分别记录当前需要删除节点的前驱节点和后继节点 TreeNode<K,V> succ = (TreeNode<K,V>)next, pred = prev; if (pred == null) // 如果删除是根节点,则将tab[index]指向当前删除节点的next节点 tab[index] = first = succ; else // 如果要删除的节点不是根节点,则将当前删除节点前驱节点的next节点指向当前节点的后继节点 pred.next = succ; // 如果当前删除节点的next节点不为空,则将当前删除节点的next指向其前驱节点 if (succ != null) succ.prev = pred; // 假如在并发情况下,进入该方法时,当前节点的next节点被其他线程置空,此时当前节点为root节点 // 可能就会出现first==null的情况,对于这种情况,直接返回即可 if (first == null) return; // 正常情况下,root.parent节点肯定为空,在并发情况下,当向树插入新节点,再经历了旋转变色,此时 // root节点,可能不再是原来的root节点,因此要找到调用root节点的root()方法,重新获取新的root节点 if (root.parent != null) // 重新查找新的root节点 root = root.root(); if (root == null || root.right == null || (rl = root.left) == null || rl.left == null) { // 只要当前root节点的任意一个左右子节点为空,则将树转化为链表 tab[index] = first.untreeify(map); // too small return; } TreeNode<K,V> p = this, pl = left, pr = right, replacement; // 如果当前需要被删除的左右子节点都不为空 if (pl != null && pr != null) { TreeNode<K,V> s = pr, sl; // 找到当前被删除节点的右子节点的最左子节点,用s指向它 while ((sl = s.left) != null) // find successor s = sl; // 将当前被删除节点的与其右节点的最左子节点交换颜色 boolean c = s.red; s.red = p.red; p.red = c; // swap colors // 当前被删除节点右子节点的最左子节点的右子节点 TreeNode<K,V> sr = s.right; // pp 为当前被删除节点的父亲节点 TreeNode<K,V> pp = p.parent; if (s == pr) { // p was s's direct parent // 如果当前被删除节点的右子节点的最左子节点就是其右子节点本身 // 也就是说,当前被删除节点的右子节点没有左子节点 // 将p和s交换位置,大家注意,交换后此时的树不正常的,因为之前的p节点hash值肯定小于s的值的。原因是s是p的右子节点 // 但是p节点是最终要被删除的,所以现在也无所谓了,因为s节点的hash值是大于p节点的,所以即使交换之后,s 的 //hash值对p节点的左子树没有影响 p.parent = s; s.right = p; } else { // 如果当前被删除的节点的右子节点存在左节点,sp = s.parent TreeNode<K,V> sp = s.parent; // 将p.parent指向s的parent节点,并且s的parent节点不为空 if ((p.parent = sp) != null) { // 如果s节点为s的父亲节点的左节点,则s节点的父亲节点的左节点指向p if (s == sp.left) sp.left = p; else // 正常情况下,不会出现s 节点为父亲节点的右子节点,因为在上面while循环中找的就是 // p节点的最左子节点为s,正常情况下s节点只可能是 sp的左节点,但是可能出现并发情况, // 因此下面sp.right就是考虑这种情况 sp.right = p; } // s的右节点指向原来p的右节点,原来p右节点parent 指向当前s节点 if ((s.right = pr) != null) pr.parent = s; } // 将p节点的左节点置为空 p.left = null; // 在上面这一块代码中,大家有没有发现规率,就是用当前被删除节点与其右节点的hash值最小的节点做交换, // 当然也分两种情况来处理 // 第一种情况,如果当前被删除节点的右节点没有左节点,也就意味着此时的右节点为当前被删除节点的右子树中hash值最小的节点,直接交换即可 // 第二种情况,如果当前被删除元素的右节点有左节点,则找到其最左子节点,因为只有最左子节点的hash值是右子树最小的, // 也是与当前被删除节点的hash值最接近的,因此找到他,并与他交换位置 ,当然最后不忘记将p节点的左节点设置为空, // 一方面s节点就没有左节点,如果p节点存在左子节点,则p节点可能还指向原来的左节点,因此必须将p.left置空 ,接下来,我们继续看下面的代码 // 将p的右节点指向原来s的右节点 if ((p.right = sr) != null) // 如果原来s的右节点不为空,则将原来s的右节点的父亲节点指向p节点 sr.parent = p; // 因为此时p.left被设置为空,只能用之前保存的pl=p.left来使用 // 将s的左节点指向原来p的左节点 if ((s.left = pl) != null) // 如果原来p的左节点不为空,则将其parent指向s节点 pl.parent = s; // 如果当前s节点的parent节点为空,则s节点为root节点 if ((s.parent = pp) == null) root = s; // 如果p是其父亲节点的左节点,则将p的父亲节点的左节点指向s else if (p == pp.left) pp.left = s; else // 如果p是父亲节点的右节点,则将父亲节点的右节点指向s pp.right = s; // 如果sr不为空,则replacement = sr,否则replacement = p if (sr != null) replacement = sr; else replacement = p; // 关于这一块代码的分析过程,请看图6 } // 如果p节点的右节点为空,左节点不为空的情况,replacement = p的左节点 else if (pl != null) replacement = pl; // 如果p节点的右节点不为空,但是左节点为空,则replacement= p的右节点 else if (pr != null) replacement = pr; else // 如果p的左右节点都为空,则replacement 等于p节点自身 replacement = p; // 如果当前p节点左右子节点有这个为空或和s和p交换后存在右节点 ,一样的存在两种情况 // p节点左右节点肯定有一个节点为空,p节点不需要和其他节点交换,或 // p节点的左右节点都不为空,则p节点的右子树的最小hash值的节点存在右子节点,就会出现 replacement != p if (replacement != p) { // 因为p节点是要被移除的节点,所以replacement的parent节点指向p的parent节点 TreeNode<K,V> pp = replacement.parent = p.parent; // 如果当前p节点是root节点,那么p节点移除后,replacement就变成了root节点 if (pp == null) root = replacement; // 如果p节点是p节点的父亲节点的左节点,则将p节点的父亲节点的左子节点指向replacement else if (p == pp.left) pp.left = replacement; else // 如果p节点是p节点的父亲节点的右节点,则将p节点的父亲节点的右子节点指向replacement pp.right = replacement; //当然p节点是将要被移除的节点,所以p的指向 左右,父亲节点的指针都置为空 p.left = p.right = p.parent = null; } // 如果当前p节点如果是红色节点,则p节点一定是叶子节点,删除 红色的叶子节点, 是不会打破红黑树的规则5的 从任意一个节点到叶子节点,经历相同的黑色节点数 ,此时不需要平衡红黑树,具体的原因请看 图7 TreeNode<K,V> r = p.red ? root : balanceDeletion(root, replacement); // replacement == p 有两种情况, // 第一种情况,p节点本身就没有左右子节点, // 第二种情况,p节点有右子节点,且右子节点的最左子节点没有左右子节点 ,也就是此时的p肯定没有左右子节点 if (replacement == p) { // detach // 因为此时p没有左右子节点,所以没有 p.left = p.right =null 这个操作 TreeNode<K,V> pp = p.parent; // 将p的父亲节点指针置为空 p.parent = null; // 如果父亲节点不为空 if (pp != null) { // 如果p为父亲节点的左节点,则将p的父亲节点的左节点设置为空,这样p就可以被jvm回收了 if (p == pp.left) pp.left = null; // 如果p是父亲节点的右子节点,则将p的父亲节点的右节点置为空,则此时p节点没有任何对象指向它,会被jvm回收掉 else if (p == pp.right) pp.right = null; } } if (movable) // 将tab[index]指向树的根节点,再进行每个节点的红黑树的较验较验 moveRootToFront(tab, r); }
对于红黑树的节点删除,我相信此时此刻,大家肯定有了深刻的理解了,如果当前数中只有一个元素时的处理,如果当前树的节点个数比较小时,则会将树转化为链表,当然第三种情况,节点删除后,树还是树,此时就需要分几种情况来考虑了,如果p是叶子节点时,直接删除即可,如果p不是叶子节点,又存在3种情况,p表示被删除的元素
- p存在左子节点,但是不存在右子节点,此时p不需要交换元素,p肯定是叶子节点的父亲节点,且左子节点为红色节点。
- p存在右子节点,但是不存在左子节点,此时p也不需要交换元素。p肯定是叶子节点的父亲节点,且右子节点为红色节点。
- p既存在左子节点,又存在右子节点,此时p要与右子节点的最左子节点进行交换,也就是右子树的hash值最小的元素交换。当然这里也存在两种情况,如果p节点的右子节点存在左子树,另外一种情况是右子节点不存在左子树的情况,hash map都帮我们考虑了。
当然对于上面1,2 以及第3种情况的交换后的p节点存在右子节点的情况,在调用balanceDeletion方法之前,p节点就已经被删除了,因为p节点为黑色节点,所以,1,2,以及第3种情况的p节点的子节点肯定是红色节点,因此p被移除后,子节点顶替p节点,只需要将顶替p节点的叶子节点由红色设置为黑色即可,这也就是p != replacement的情况 。
p == replacement 有两种情况,原本p节点就是黑色的叶子节点,或p与右子树的最左子节点替换后,p节点没有左右子节点,对于这种情况,那就看 图8 ,图9 了。
当然以上的几种情况是元素的替换,替换后就是删除,删除元素时也有两种考虑,如果替换后的p元素是红色节点,则不需要树的平衡,如果非红色节点,肯定需要对树进行平衡,最后,平衡后,将table[index]重新指向root节点,并进行整颗树的较验,到了这里,我相信你对红黑树节点的删除这一块肯定理解了。
当然有小伙伴肯定会想,如果替换节点时,找p节点的左子树的最大节点,也就是p节点左子节点的最右子节点,可不可以呢?答案是可以的,只是jdk8 采用了找右子树的最小子节点来实现而已。 当然还有一种情况我们没有去分析,如果p节点是黑色节点,删除后,红黑树又是怎样平衡的呢?接下来,我们继续分析删除节点后的红黑树平衡。
static <K,V> TreeNode<K,V> balanceDeletion(TreeNode<K,V> root, TreeNode<K,V> x) { // x 节点就是replacement节点,也是顶替p节点的节点, // 只有p节点是黑色节点时,才会进入到balanceDeletion()方法 , // 当p == replacement ,那么x节点就是黑色节点, // 如果p节点 != replacement时,x一定是红色节点 ,而在removeTreeNode()方法中,如果p == replacement,则 // x节点就是p 节点,如果p != replacement,则x节点就是顶替p节点的节点 ,总结一下, // 第一种情况,p == replacement ,那么x就是p节点,x的颜色为黑色 // 第二种情况,p != replacement , 那么x就是顶替p节点的节点,x的颜色为红色 for (TreeNode<K,V> xp, xpl, xpr;;) { // 如果x节点为空或x为根节点时,直接返回 if (x == null || x == root) return root; // 如果当前节点的父亲节点为空节点,那么x节点肯定是根节点,则将节点设置为黑色并返回x节点 // 可能出现的情况,就是当前树被旋转之后,此时x已经为根节点了,但是此时x != root(之前的根节点) else if ((xp = x.parent) == null) { x.red = false; return x; } // 如果x为红色节点,则将红色节点设置为黑色节点,并返回根节点,有一种特殊情况就是p != replacement时,此时就会直接走下面代码,并返回 , // p != replacement 有三种情况 。 // 1.p 节点有左子节点,但是没有右子节点,此时若树是平衡的,那么p节点的左子节点肯定是红色,因此在removeTreeNode // 方法中,肯定p节点就被p节点的左子节点顶替了,此时x节点肯定是红色节点,因此会走下面代码 // 2.p节点有右子节点,但是没有左子节点,情况和1一样。 // 3. p 节点存在左右子节点,但是p节点和其右子树的最左子节点替换后,p节点还存在右节点,因为p节点是黑色节点, // 此时p节点没有左子节点(因为p节点是和其右子树的最左子节点替换),此时p节点的右节点肯定是红色节点,不然之前树就是不 // 平衡的,同样在removeTreeNode方法中,在调用 balanceDeletion方法之前,当p != replacement 时,p节点会被其子节点顶替掉 // 因此,此时x节点还是红色节点,会走下面的代码,只需要将x节点设置为红色节点并返回即可 else if (x.red) { x.red = false; return root; } // 凡是第一次for循环就走到下面代码的,此时红黑树肯定是平衡的,也就是满足p == replacement时, // 在removeTreeNode方法中,p节点才不会被顶替,才有第一次for循环才走到下面代码的机会 // 同时也说明了,p 节点如果没有被其他节点顶替,则此时树肯定还是一棵平衡树。 // 如果程序执行到这里,肯定满足以下3个条件 // 1.x节点肯定不是根节点 // 2.x节点不为空 // 3.x节点为黑色节点 else if ((xpl = xp.left) == x) { // 如果x节点为父亲节点的左节点 // 如果父亲节点的右节点不为空,且为红色 if ((xpr = xp.right) != null && xpr.red) { // 将父亲节点的右节点置为黑色,父亲节点置为红色,以父亲节点为基点进行左旋转 xpr.red = false; xp.red = true; root = rotateLeft(root, xp); // 重新设置xpr节点, // 如果父亲节点的右节点为红色节点,则先以父亲节点进行左旋转,旋转后,爷爷节点的右子树肯定平衡了, // 此时只需要调整爷爷的左子树即可,也就是调整父亲节点所在树平衡即可, // 如果父亲节点内部不能实现平衡,再去找爷爷节点帮助 // 其实这里也是一种思想,自己内部解决不也,寻求外部帮助,外部提供帮助了,内部再自行解决, // 如果还解决不也,只能再寻求外部帮助了 xpr = (xp = x.parent) == null ? null : xp.right; } // 如果当前父亲节点的右节点为空,则x节点等于父亲节点,继续进行循环处理 // 如果是第一次for循环就进入下面代码,则也是有思想在里面的,因为自身节点会被删除掉,而父亲节点的右节点为空。 // 那么自身节点被删除后,也就说明了父亲节点左右子节点都为空,因此,只需要将父亲节点作为x节点再次循环即可 。 if (xpr == null) x = xp; else { // 进入下面代码的,显然父亲节点的右节点不为空 // 如果x节点父亲节点的右节点的左节点为xprl, 右节点为xprr TreeNode<K,V> xprl = xpr.left, xprr = xpr.right; if ((xprr == null || !xprr.red) && (xprl == null || !xprl.red)) { // 下面有条件判断有4种情况 // 1. xprr == null ,并且xprl ==null,父亲节点的右节点的左右节点都为空 // 2. xprr == null ,并且xprl 为黑色 ,如果父亲节点的右节点的右节点为空,但父亲节点的右节点的左节点不为空,且为黑色 // 3. xprl == null ,并且xprr 为黑色,如果父亲节点的右节点的左节点为空,但父亲节点的右节点的右节点不为空,且为黑色 // 4. xprr !=null && xprl !=null ,且都为黑色,父亲节点的右节点的左右节点都不为空,且都为黑色 // 则:将父亲节点的右节点设置为红色,x = 父亲节点,再次循环 // 这里大家发现规律没有,从相反的角度来考虑,如果父亲节点的右节点没有红色节点,那么无法变出多余的黑色节点, // 因此只能寻求外界帮助,x=xp ,再次for循环 xpr.red = true; x = xp; } else { // 父亲节点的右节点不为空,且有3种情况 // 1. 父亲节点的右节点的左节点不为空,且为红色,但其右节点为空 // 2. 父亲节点的右节点的右节点不为空,且为红色,但其左节点为空 // 3. 父亲节点的右节点的左右节点都不为空,且颜色有为红色的节点 // 在这里大家发现规律没有,只要父亲节点的右节点有红色子节点,总能想办法变出一个黑色节点出来 // 从而达到树的平衡 if (sr == null || !sr.red) { // 如果父亲节点的右节点的右子节点为空或右子节点为黑色 if (sl != null) //将当前父亲节点的右节点的左子节点不为空,则将其设置为黑色 sl.red = false; //将当前父亲节点的右节点设置为红色 xpr.red = true; // 再以父亲节点的右节点为基点进行右旋转,图8中的情况5 root = rotateRight(root, xpr); // 重置父亲节点的右节点 xpr = (xp = x.parent) == null ? null : xp.right; } if (xpr != null) { // 如果父亲节点的右节点不为空,则将其颜色设置与当前父亲父亲节点相同的颜色 xpr.red = (xp == null) ? false : xp.red; // 如果父亲节点的右节点的右子节点不为空,则将其设置为黑色 if ((sr = xpr.right) != null) sr.red = false; } // 如果父亲节点不为空,则将其设置为黑色,并进行左旋转 if (xp != null) { xp.red = false; root = rotateLeft(root, xp); } x = root; } } // 如果当前被删除的节点为父亲节点的左节点时,我能想到的情况都在图8了, // 为什么只有这么多种情况,大家要知道,当前树在被删除一定是一颗平衡树 } else { // symmetric // 进入到这里的代码,肯定当前节点一定是父亲节点的右节点 if (xpl != null && xpl.red) { // 如果当前的父亲节点的左子节点不为空,且为红色 // 则将当前节点父亲节点的左节点设置为黑色,父亲节点设置为红色,再以父亲节点为基点进行右旋转 xpl.red = false; xp.red = true; root = rotateRight(root, xp); xpl = (xp = x.parent) == null ? null : xp.left; } // 如果父亲节点的左节点为空,则当前节点设置为父亲节点 if (xpl == null) x = xp; else { TreeNode<K,V> sl = xpl.left, sr = xpl.right; // 下面有4种情况 。 // 1.如果当前节点的父亲节点的左节点的左右子节点都为空 // 2.如果当前节点的父亲节点的左节点的左子节点为空,且其右子节点为黑色 // 3.如果当前节点的父亲节点的左节点的右子节点为空,且其左子节点为黑色 // 4.如果当前节点的父亲节点的左子节点的左右子节点都为黑色 if ((sl == null || !sl.red) && (sr == null || !sr.red)) { // 当前节点的父亲节点的左节点设置为红色,此时x节点为父亲节点,继续循环 xpl.red = true; x = xp; } else { // 如果当前节点的父亲节点的左节点的左子节点为空,并且其右子节点为红色 // 或 如果当前节点的父亲节点的左节点的左子节点不为空,且其左子节点为黑色 if (sl == null || !sl.red) { // 如果当前节点的父亲节点的左节点的右子节点不为空,则将其设置为黑色 if (sr != null) sr.red = false; // 如果当前节点的父亲节点的左节点的左子节点设置为红色 xpl.red = true; // 以当前节点的父亲节点的左节点为基点进行旋转 root = rotateLeft(root, xpl); // 重置当前节点的父亲节点的左节 xpl = (xp = x.parent) == null ? null : xp.left; } // 如果当前节点的父亲节点的左节点不为空 if (xpl != null) { // 则将当前节点的父亲节点的左节点设置和当前节点的父亲节点一样的颜色 xpl.red = (xp == null) ? false : xp.red; // 如果当前节点的父亲节点的左节点的左子节点不为空,则将其设置为黑色 if ((sl = xpl.left) != null) sl.red = false; } // 如果当前节点的父亲节点不为空,则将其设置为黑色并且右旋转即可 if (xp != null) { xp.red = false; root = rotateRight(root, xp); } x = root; } } } 对于当当前被删除的节点为父亲节点的右节点时,请看 图9 } }
我相信此时此刻,大家肯定被hash map 删除节点的代码给绕晕了,但事实上呢?情况很多种哦,但是其目标我们还是清楚的,如果当前黑色节点被删除,肯定会导致树的不平衡,大概分成两种情况,如果当前被删除的节点是父亲节点的左节点时,则当前被删除节点的父亲节点肯定是要置为黑色节点的,这里面有3种情况,
- 只需要将当前节点的父亲节点设置为黑色即可,因为当前节点为黑色节点需要被删除,需要将当前节点的父亲节点的右子节点设置为红色。
- 当前节点的父亲节点肯定是要设置为黑色,需要进行左旋转才能平衡红黑树,同时要保证父亲节点的右子树中黑色节点数不能减少。
- 当第2种情况不能满足时,父亲节点的右子树不能通过增加黑色节点来平衡树时,只能通过减少爷爷节点的左子树的黑色节点个数,来保证对的平衡。
当然,对于当前节点为父亲节点的右节点时,同样了是分为三种情况来分析的,
- 直接将当前节点的父亲节点设置为黑色节点即可,达到对的平衡,当然,当前节点的父亲节点肯定要是红色节点 。
- 当1情况不满足时,需要通过当前节点的父亲节点进行右旋转,并且将当前父亲节点设置为黑色,并且通过父亲节点的左子树中多增加一个黑色节点来达到树的平衡。
- 当第2种解决办法不能解决时,只能通过将爷爷节点的右子树中增加一个红色节点,从而来达到树的平衡。
具体的实现就是旋转啊,变色这些了,这些东西我也记得清,但是我知道当减少一个黑色节时,肯定会导致树的不平衡,因为整个过程就是来弥补因为黑色节点的减少带来对树的影响,因此有两种方式,一种方式是通过增加黑色节点的个数来弥补影响,另外一种方式,通过减少另一侧的黑色节点个数来弥补影响。
虽然当前节点是黑色节点,需要被删除时,情况有很多,但是,大家一定要记住一点,当前被删除的节点一定是其祖先的最左子节点 或者是其父亲的右节点,且没有左右子节点,而且树在没有移除节点之前是一棵平衡树。
我们之前在对红黑树进行扩容时,当遇到当前节点是一颗树时,需要将红黑树拆分,当时没有分析,留到后面来分析,我相信当你看了红黑树的插入平衡,删除平衡后,再来看这一块代码,简直是简单的不要不要的。下面就来看看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; // 大家不要忽略了一点,hash map 的红黑树链表,不仅仅是一颗红黑树,同时还是一个双向链表 // 因为每个树节点都有prev ,next节点,为什么设置双向链表呢?明显在遍历树时用到了 for (TreeNode<K,V> e = b, next; e != null; e = next) { next = (TreeNode<K,V>)e.next; e.next = null; // 首先我们要明白,bit是什么 ,在这里bit = oldCap 旧数组的容量 // 此时也就明白了,数的扩容转移数据和链表一样,都是通过e.hash & oldCap 是否等于0来判断 // 如果等于0 ,则放到低位,如果不等于0 ,则放到高位? 是不是有小伙伴会想,等于0放到高位, // 不等于0放到低位呢? // 其实hash map 这么做主要是考虑到key为null的情况,因为key为null的节点只能放到tab[0]处,null的hash值默认为0 // 因此,当null的hash值0与oldCap相& ,还是0,就直接放到低位即可,不需要做特殊处理 // 这也可能是hash map 将e.hash & oldCap = 0 就放到低位 ,这样设计的原因吧。 if ((e.hash & bit) == 0) { // 如果低位链表没有头节点,则当前节点e为链表的头节点 if ((e.prev = loTail) == null) loHead = e; else // 将当前链表的尾节点指向e loTail.next = e; // e作为新的尾节点 loTail = e; // lc主要用来记录低位链表的长度 ++lc; } else { // 如果高位链表没有头节点,则当前节点e为链表的头节点 if ((e.prev = hiTail) == null) hiHead = e; else // 将当前链表的尾节点指向e hiTail.next = e; // e作为新的尾节点 hiTail = e; // hc 主要用来记录高位链表的长度 ++hc; } } if (loHead != null) { // 如果低位树的大小小于阈值,则将树转化为链表,并用tab[index]指向它 if (lc <= UNTREEIFY_THRESHOLD) tab[index] = loHead.untreeify(map); else { tab[index] = loHead; // 遍历整个链表,重新生成树,并用tab[index] 指向它 if (hiHead != null) // (else is already treeified) loHead.treeify(tab); } } if (hiHead != null) { // 如果高位树的大小小于阈值,则将树转化为链表,并用tab[index + oldCap] 指向它 if (hc <= UNTREEIFY_THRESHOLD) tab[index + bit] = hiHead.untreeify(map); else { // 遍历整个链表,重新生成树,并用tab[index + oldCap] 指向它 tab[index + bit] = hiHead; if (loHead != null) hiHead.treeify(tab); } } }
jdk8 红黑树也是一个双向链表,链表的结构如下图所示 。
接下来,来看hashMap 的遍历
HashIterator() { expectedModCount = modCount; Node<K,V>[] t = table; current = next = null; index = 0; 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; if (modCount != expectedModCount) throw new ConcurrentModificationException(); if (e == null) throw new NoSuchElementException(); if ((next = (current = e).next) == null && (t = table) != null) { do {} while (index < t.length && (next = t[index++]) == null); } return e; }
hashMap的遍历其实很简单,在HashIterator构造方法中,保存当前modCount修改次数,如果在遍历过程中,其他线程修改了当前hash map ,则抛出异常,而在HashIterator构造函数中,找到tab 表中第一个不为空的链表,并用next指向链表的头节点,在nextNode()方法中,如果当前链表遍历完,则继续寻找下一个链表的头节点,并用next指向它,如果next为空,表示hash map遍历完,在这里,发现jdk 8 中链表的遍历和jdk 7并没有太大区别,虽然在jdk8中用了红黑树,但是红黑树同时也是一个双向链表。 因此对于树的遍历,只需要对链表遍历即可。这里就没有区分当前节点是树节点点呢?还是链表节点了。
分析到这里,对hash map的源码分析也已经到了尾声,在这里,我们总结一下,首先对jdk的插入,扩容 ,删除 ,遍历源码进行分析,jdk8也是按同步的步骤,只是jdk8中,在插入,扩容,删除时,可能会存在链表转红黑树,红黑树转链表,红黑树插入,删除节点时,红黑树需要重新平衡,从而对树有旋转,变色处理,只是在插入和删除元素时,一定要记得在没有插入和删除元素之前,树就是一棵平衡树,有了这个概念之后,再来看源码,就不会那么被绕晕了。 不然,你都不知道为什么需要进行左旋转,右旋转,因为在旋转之前,它就已经确定了红黑树节点的结构,当然逻辑也是非常的严谨也连惯,当发现比较晕时,建议像我一样,将可能的结构画一画,自己再根据结构沿着代码的思路走一走,只有这样,你才能真正的从源码中有所收获 。
我希望看我的博客不仅仅是看到了,你在其他地方看不到的知识点,更重要的是学习到我的一个学习方法,因为在写这个博客之前,我可能和大家一样,就知道hash map 是由数组 + 链表组成 ,在jdk8 中加入了红黑树,但是经过一段时间的学习,我发现,hash map 不仅仅如此,同时在看源码的时候,多想想,作者为什么要这么写,这么写的好处是什么,这样,你才真正的能从大牛的身上学习到他的思想,所以编程不仅仅是知识点学习,更重要是编程思想的学习,只有编程思想的提高,才可能写出好代码 。
话不多说了,如果有什么好的建议或者还有哪里说得不清楚或说错了的,请给我留言,因为一个人在研究这些过程中,也有其他事情做,比如写业务代码,同时写的东西也没有人较正,如果有错误或遗漏,还请多多指正。