【Java集合】HashSet源码深度分析
参考笔记:java HashSet 源码分析(深度讲解)_java hashset源码-CSDN博客
目录
1.前言
2.HashSet简介
3.HashSet的底层实现
4.HashSet的源码解读
(0)准备工作
(1) 向集合中中添加第一个元素(141)
① 跳入无参构造
② 跳入add方法
③ 跳入put方法
④ 跳入putVal方法
⑤ 跳入resize方法
⑥ 跳出resize方法
⑦ 跳出putVal方法
⑧ 跳出put方法
⑨ 跳出add方法
(2)向集合中添加第二个元素 "Cyan"
① 跳入putVal方法
② 跳出putVal方法,回到演示类
(3)向集中添加一个重复元素 "Cyan"(重要):
① 跳入putVal方法
② 进入putVal方法的else语句
③ 解读putVal方法的外层else语句(详细)
④ 从putVal函数跳出,并逐层返回到测试类中
(4)HashMap底层扩容机制演示
① 准备工作
② 向集合中添加第1个元素
③ 向集合中添加第13个元素
④ 进入resize方法
⑤ 跳出resize方法,逐层返回到演示类
⑥ 向集合中添加第25个元素
(5)HashMap链表树化为红黑树
① 准备工作
② 将8个元素挂载到数组的同一个链表下
③ 将第9个元素添加到集合中(数组扩容,但无树化)
④ 将第10个元素添加到集合中(数组扩容,但无树化)
⑤ 将第11个元素添加到集合中(链表树化为红黑树)
5.关于HashMap$TreeNode的补充
6.完结
1.前言
本篇博文是对单列集合 Set 的实现类 HashSet 的内容补充。之前在 Set 集合的详解篇,只是拿 HashSet 演示了 Set 接口中的常用方法,并没有对它进行深究
本文会从底层源码的角度对 HashSet 进入深入研究,通过 Debug 从底层解释 HashSet 如何添加元素、如何判断重复元素、扩容机制、链表转化为红黑树的过程
注意:本篇博文对 HashSet 源码的解读基于主流的 JDK 8.0 的版本
2.HashSet简介
HashSet 是单列集合 Set 接口的常用实现类之一,满足 Set 集合"无序,不可重复"的特点。HashSet 类位于 java.util.HashSet 下,其类定义、继承关系图如下:
3.HashSet的底层实现
① HashSet 的底层其实是 HashMap 。这一点很好证明,我们可以用无参构造创建一个 HashSet 类对象,并通过 Ctrl + b/B 快捷键来查看一下该无参构造的源码,如下图所示 :
而 HashMap 的底层实现是 "数组 + 链表 + 红黑树" 的结构。简单来说,即数组的元素是一个链表,并且在某些条件下会将链表树化为红黑树
② 向 HashSet 集合中添加一个元素时,会先得到一个该元素的 hash 值(哈希值),然后在底层将它转化为一个索引值。这个索引值决定该元素在集合中应该存放的位置,这也解释了为什么尽管 Set 集合是无序的,但输出 Set 集合时元素的排列顺序总是一致的
③ 得到元素 hash 值,将其转换为索引值后,添加元素的规则:
-
当索引值对应的位置没有元素存在时:直接将当前元素加入集合
-
当索引值对应的位置有元素存在时,调用 equals 方法判断当前添加元素与该位置处的元素是否相等
-
相等:放弃添加该元素(因为 HashSet 不允许重复)
-
不相等:将当前元素添加到(挂到)该位置处对应的链表的最后。这便实现了 "数组+链表" 的结构。如下图所示:
-
注:上图中 "table数组长度" = 16 ,"table数组的元素个数" = 3 + 1 + 1 + 1 = 6
可以看到,table 数组中所有结点都是 HashMap$Node,Node 是 HashMap 的一个静态内部类,其源码定义如下:
④ 第一次向集合中添加元素时,底层的 table 数组长度会扩容到 16 ,临界值 threshold = 16 * 0.75 = 12;(此处的 0.75 是增长因子,后面会说到)当数组中元素的个数达到临界值 12 ,再添加元素到数组中时,会对数组进行第二次扩容,数组长度 = 16 * 2 = 32,此时临界值 thresold = 12 * 2 = 24 ;当数组中元素的个数达到 24 ,再添加元素到数组中时,会对数组进行第三次扩容,数组长度 = 32 * 2 = 64,此时临界值 threshold = 24 * 2 = 48,以此类推
(1)对底层 table 数组的扩容都是调用 resize( ) 完成的
(2)设置临界值threshold的目的:
可以尽可能防止发生线程阻塞情况。如果一直到 table 数组满才去扩容,那么当数组可用空间已经不多时,并且此时有许多线程同时向集合中添加元素,就可能因为扩容不及时造成阻塞。因此 java 设计者就想出了这样一个思路,到达临界值时数组就要准备开始扩容了,未雨绸缪,就不容易发生阻塞,即起到一个缓冲的作用
(3)(2)中提到的 "元素" 既可以是数组某一个索引处的链表的第一个结点;也可以是数组某索引处链表中挂载到后面的结点。如下图所示:
即,只要向 table 数组中加入一个元素,都算作数组的元素加 1
⑤ 在 JDK 8.0 版本中,对某个链表是否转换为红黑树,会进行下述步骤:
-
判断该链表是否满足所含元素 > 8 ?
-
> 8 :还需要进一步的判断:
-
若 table 数组的长度 >= 64 :对该链表进行树化,转换为黑红树
-
若 table 数组的长度 < 64:调用 resize 函数对 table 数组进行扩容,将 table 数组的长度扩大一倍,临界值扩大一致,不对该链表进行树化
-
-
< = 8 : 不对该链表进行树化
-
4.HashSet的源码解读
(0)准备工作
用以下代码作为演示类,一步一步 Debug :
import java.util.HashSet;
public class demo {
public static void main(String[] args) {
HashSet hashSet = new HashSet();
hashSet.add(141);
hashSet.add("Cyan");
hashSet.add("Cyan");
}
}
红温预警:别看就这几行代码,底层源码多到你红温
(1) 向集合中中添加第一个元素(141)
① 跳入无参构造
首先我们跳入 HashSet 的无参构造,如下图所示 :
可以看到,HashSet 底层确实调用了 HashMap ,所以讲HashSep的底层源码,实际上就是讲HashMap的底层。这里不用管它,直接回到测试类中,可以看到 hashSet 集合中的一些信息,如下图所示 :
上图中的 table ,就是前文说的 "数组 + 链表 + 红黑树" 中的数组,后面还会细讲
② 跳入add方法
接着,跳入 add 方法,如下图所示:
由于添加的第一个元素是 int 类型 ,所以底层会进行自动装箱 int ---> Integer。这里不管它,直接跳出。并重新跳入 add 方法,如下图所示 :
可以看到, add 方法内部又调用 map.put( ) ,显然添加元素的操作是在 put 方法中完成的。 可以看到, add 方法如果添加元素成功,就会返回 true 。所以,如果我们添加元素成功,此处的 map.put ( ) 一定会返回 null,只有这样,才能满足 "null == null" 的判断,最终使 add 方法返回 true
再看 map.put () 的实参 (e,PRESENT),传入了一个 e (即当前要添加的元素),还有一个叫 "PRESENT" 的东西。这个 PRESENT 是什么东西呢?它是 HashSet 类的一个属性,在源码中的定义如下:
可以看到, PRESENT 是个 Object 类型的对象,那不就是啥也没有呗?是的, PRESENT 此处在 put 函数中就是充当一个占位的作用,并无实际意义
③ 跳入put方法
继续,跳入 put 方法,如下图所示 :
mmp,没想到这些个集合类都喜欢套包皮,可以看到,此处 put 方法内部又调用了 putVal 方法
先不说这个 putVal 方法,先看 put 方法的形参列表。 key 就是之前传入的 e = 141,即当前要添加的元素。而 value 就是那个用来占位的 PRESENT ,没有实际作用
再看 putVal 方法的实参,一个 hash 方法的返回值(未知),一个 key(已知),一个value(已知),onlyIfAbsent = false 、 evict = true 。最后这两个 boolean 类型的变量先不用管,之后我们用到再说,先来看看这个 hash 方法,直接追进去看看,如下图所示 :
hash 方法的作用:返回当前元素对应的哈希值。return 语句后跟了一个三目运算符, 判断条件是 "key == null" ,显然为 false ,所以要返回的是冒号后面的内容 "(h = key.hashCode()) ^ (h >>> 16)"
"(h = key.hashCode()) ^ (h >>> 16)" 就是得到哈希值的一个算法。另外 hashCode() 方法具体如何实现我们不需要深究,而且该方法在 Java 源码中用 native 关键字修饰,底层是用 C/C++ 实现的,所以也看不到它的方法体,如下:
接下来跳出 hash 方法,回到 put 方法
④ 跳入putVal方法
正片开始!!!我们跳入 putVal 方法,putVal 方法内部语句很多,所以这里直接把源码搬过来,putVal 方法源码如下 :
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
红温了
一步一步来看
首先,定义了几个辅助变量,如下 :
tab 是 Node 类型的数组,而 p 只是单个 Node 类型的引用, p 后面就是用来充当 tab 数组中的某个元素的 。至于后面的 n 、 i 变量,这里暂时不用管管,后面会用到的
接着,第一个 if 条件语句的判断,如下 :
可以看到,只要第一个条件满足就会进入 if 语句。这里的 "table" 是 HashMap 中维护的一个数组,其源码如下:
可以看到,table 是一个 Node(HashMap$Node) 类型的数组,并且没有显式初始化,默认为空引用 null 。它就是上文我们提到的 HashMap 的底层是 "数组+链表+红黑树" 中的数组。再回到 if 条件语句,显然第一个条件 "(tab = table) == null" 满足,要执行 if 语句中的内容
if 语句中的内容出现了一个新的方法 resize() 。(我靠是真的烦,但是先别烦因为后面还有更烦的)我们来观察,由于 if 语句的判断条件中 tab 已经被 table 赋值,就是说 tab 现在也为 null 了;而此处又令 resize 函数的返回值赋值给了 tab ,所以可以猜测 resize 函数的返回值肯定是一个 Node 类型的数组
⑤ 跳入resize方法
跳入 resize 方法,由于 resize 方法的源码也是多的一批,所以还是直接把源码拷贝过来吧。其源码如下:
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
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]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
这代码量看着真吓人
还是老规矩,一步一步来看
首先,将 table 引用赋值给了一个 Node 类型的数组 oldTab ,即 oldTab 引用现在也是null 了。如下 :
接着,后面几句代码如下:
三目运算符的判断条件成立,因此 oldCap = 0 。下一条语句,oldThr = threshold = 0 。注意,此处的 threshold 变量指临界值,是 HashMap 类的一个属性,我们后面会讲到。threshold 本身就是"门槛"的意思,其源码如下 :
继续,又定义了 newCap、newThr 两个变量。newCap 即 newCapacity,指的是新数组的容量,newThr 即 newThreshold ,指的是新的临界值。目前这些都不重要,我们直接看第一个 if 语句,如下 :
判断条件不成立,不进入 if 语句; else-if(oldThr>0) 语句判断也不成立,直接跳入 else 语句,如下 :
else 语句中将刚才定义的两个新变量 newCap 、newThr 赋值了。先看第一条语句,"DEFAULT_INITIAL_CAPACITY",即"默认初始容量",其源码如下 :
所以 newCap = DEFAULT_INITIAL_CAPACITY = 16 。见名知意,即新数组的初始容量是 16
else 中的第二条赋值语句要注意了,如下 :
"DEFAULT_LOAD_FACTOR",直译过来就是 "默认增长因子" ,其值默认为 0.75 。这里是将默认增长因子和默认初始初始容量的乘积赋值给了 newThr 变量,所以 newThr = (int) 0.75 * 16 = 12 。见名知意,newThr 即 newThreshold ,表示新数组的临界值为 12
言归正状,继续往下 Debug :
这个 if 条件语句,判断条件是 "临界值为0吗",显然不满足,不进入
再往下,将计算求得的新临界值 12 赋值给 threshold 变量,如下:
继续往下执行,下面是重点:
这里出现了 new 的操作,new 了一个长度为 16 的 Node 类型的新数组,然后将新数组的地址赋给了 newTable 引用,并由 newTab 引用传递给 table 。到此, table 已经由 null 变为了长度为 16 的数组,如下图所示
再往下是一个非常大的 if 条件语句,如下 :
该 if 语句的作用是:如果旧数组不为空,则需要将旧数组中的元素全部拷贝到新数组中。 由于此时旧数组 oldTab = null,因此条件不成立,不执行
OK,这下 resize 方法执行完了,接下来返回 new 出的新数组,如下 :
⑥ 跳出resize方法
接下来我们跳出 resize 方法,返回 putVal 方法中。 如下图所示 :
可以看到, n = 16 ,即新数组的长度
接着一个 if 条件语句。仔细看它的判断条件,它是先将 tab 数组中的一个特定元素给到p,再判断 p == nul 是否成立,其实就是判断 tab 数组某个索引处的元素是否为空。至于这个索引的计算方式:[ i = (n-1) & hash ],只需要知道这是利用添加元素的 hash 值并根据该算法得到该元素应该存放在集合中哪个索引位置。这里计算得到的索引位置 i = (n-1) & hash = 13,如下图所示:
这里便验证了上文我们在 "HashSet的底层实现" 中提到的——"当我们向HashSet集合中添加一个元素时,会先得到一个该元素的hash值(哈希值),然后在底层将它转化为一个索引值,这个索引值决定该元素在集合中应该存放的位置"
因为 141 是向集合中添加的第一个元素,所以集合的对应索引处肯定为 null ,条件满足,继续执行 if 中的语句,"tab[i] = tab[13] = newNode(hash,key,value,null)",直接将该元素加入 tab 数组中索引为 13 的位置。注意,这里存入的值有该元素的 hash值(141),key(141),value(PRESENT占位符),next(null),存入 hash 值的目的是为了将来再次添加元素时防止元素重复
继续往下 Debug ,如下图所示:
1° modCount老演员了,表示修改集合的次数
2° if 语句,判断当前集合中元素的个数 size 是否超过了临界值 threshold ,如果超过临界值就调用 resize 方法对 table 数组进行扩容
3° afterNodeInsertion方法,这里可以不管它。因为在HashMap中,这是个空方法。该方法存在的目的在于留给它的子类,比如 linkedHashMap类,去实现一个双向链表的功能等。我们可以看一下 afterNodeInsertion 方法的源码,如下 :
到这, putVal 方法也结束,并最终返回了 null ,代表添加元素成功
⑦ 跳出putVal方法
接下来跳出 putVal 方法,回到 put 方法,如下 :
⑧ 跳出put方法
跳出 put 方法,回到 add 方法,如下:
⑨ 跳出add方法
接着我们跳出 add 方法,回到演示类,可以看到第一个元素 141 已经成功添加到了集合索引为 13 的位置,如下 GIF 图所示 :
可以看到,元素 141 确实存放在 table 数组索引为 13 的位置,并且结点类型确实是 HashMap$Node
🆗,到此第一个元素的添加执行完毕,我们也看到了数组 table 第一次扩容后的长度为 16 ,临界值 threshold = 12
(2)向集合中添加第二个元素 "Cyan"
① 跳入putVal方法
对于第二个元素 "Cyan" 的添加,前面几个重复的步骤这里就不演示了,我们直接到关键部分。逐层跳入,跳到 putVal 方法,如下 :
还是老规矩,一步一步来看
首先,看下 putVal 的形参,hash 就是根据当前元素的哈希码值;key 就是 "Cyan" 字符串;value 就是用于占位的 PRESENT ,不需要管;至于后面两个 boolean 类型变量,可以看到分别传入了 false、true
其次,方法中第一行还是定义了那几个辅助变量 tab、p、n、i ,这里不再赘述
接着,第一个 if 条件语句,因为前面刚刚添加第一个元素 141 时已经对 table 数组作了第一次扩容,所以 table 现在肯定不为空。所以 ,第一个判断条件不成立; n = tab.length = 16,所以第二个判断条件也不成立。因此,不进入第一个 if 语句
继续,来看第二个 if 语句,仍然是将当前元素 "Cyan" 通过特定算法并结合 hash 值转换为其对应的索引值,并判断 tab 数组中对应索引处的元素是否为 null 。如下 :
可以看到,计算得到的索引值为 3 ,该索引处没有存放元素,因此满足 if 语句的判断条件,直接将 "Cyan" 添加到 tab 数组索引为 3 的位置
然后,就又到了老面孔时间,如下 :
1° 更新修改集合的次数 modCount
2° if 语句,判断当前集合中元素的个数 size 是否超过了临界值 threshold ,如果超过临界值就调用 resize 方法对 table 数组进行扩容,这里显然不需要扩容
3° afterNodeInsertion 方法不需要管,为空方法
② 跳出putVal方法,回到演示类
逐层返回到演示类中。可以看到第二个元素 "Cyan" 已经成功添加到了集合索引为 3 的位置,如下图所示:
(3)向集中添加一个重复元素 "Cyan"(重要):
① 跳入putVal方法
Set 集合具有 "无序,不可重复" 的特点。第三个元素 "Cyan" 属于重复添加,所以它在底层肯定会被 "干掉 " 。本节通过 Debug ,来看看它是被怎么给 "干掉的"
同样地,前面一些相同的步骤我们不再赘述,直接从 putVal 方法讲起。跳入 putVal 方法,如下 :
第一个 if 语句的判断条件不成立,跳过
第二个 if 语句:显然,由于前面已经添加过 "Cyan" 元素,它的索引值是确定的,所以 tab 数组对应索引处的元素不可能为 null 。因此,第二个 if 语句也不进去,执行 else 部分的语句
② 进入putVal方法的else语句
进入第二个 if 语句的 else 部分。这个 else 部分代码非常多,如下:
else {
HashMap.Node<K,V> e; K k;
if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
e = p;
else if (p instanceof HashMap.TreeNode)
e = ((HashMap.TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
(这都是啥啊????)没关系,仍然是一步一步来看
首先,先分析一下这个 else 语句的内部结构,该 else 语句的内容是由一个
else{
if(...){
}else if{
}else{
}
if(...){
}
}
if --- else if --- else 的复合条件语句 + 最后一个单独的 if 语句构成的
其次,else 语句内部定义了两个局部辅助变量—— Node 类型的 e 、K 类型的 k 。
③ 解读putVal方法的外层else语句(详细)
先来看 if 的判断,如下 :
该 if 语句完成的任务是:如果检查到是重复元素,则放弃添加
首先它要先满足 tab 数组该索引处的元素的哈希值 == 当前欲添加元素的哈希值,因为我们添加的是重复元素 "Cyan" ,所以哈希值肯定相等。然后,在满足哈希值相等的基础上,还需要满足以下两个条件之一:
1° 当前欲添加元素的值和数组该索引处的元素的值相等(或者是同一个对象)
2° 当前欲添加元素不为空,并且其内容与数组该索引处的元素的内容相等。需要注意的是:此处的 equals 方法遵守动态绑定机制,取决于 key 对象,可以由程序员手动控制,也就是说它可以不是 String 类型,可以是由程序员自定义的一个类,根据类中重写的 equals 方法进行比较
显然,此处满足 if 条件语句的判断,所以直接就放弃添加了。这里我们先不继续往下执行,借着这个机会把 else 语句中的内容说明白了
继续往下看,else-if 语句,如下 :
此处完成的任务是:判断当前索引处的元素是不是一颗红黑树,如果结点p后跟着是一棵红黑树, 那么就会调用红黑树的putTreeVal方法来进行元素的添加。这里就不追进去演示了,红黑树的 putTreeVal 方法非常非常复杂,仅里面调用的其他方法都超过了 5 个。大家有兴趣可以自己去看看
继续往下走,如下:
这里的 else 语句完成的任务是:如果之前的第一个 if 语句:
判断出欲添加的元素与索引处的元素不重复,可以添加;并且 else if 语句:
判断出数组的该索引处不是一颗红黑树,那就要执行此处的 else 语句了。所以,链表元素的挂载显然就是在这个 else 语句中完成的
可以看到,这个 else 语句内部是由一个 for 循环构成的,仔细观察就会发现,这是一个死循环,因为它没有设置条件语句,判断永远成立。先说结论,只有两种情况可以 break 出这个死循环:
(1)链表中某个元素与当前欲添加元素重复,放弃添加,跳出循环
(2)链表中任何一个元素与当前欲添加元素都不重复,添加成功,跳出循环
这个 for 循环内部又有两个 if 语句,不着急,一步一步讲解
1° 先看第一个 if 语句,它要判断当前索引处元素的 next 指向是不是为空,如果为空,就直接将该元素添加到下一个结点的位置,即令当前索引处的结点的 next 指向这个新结点,如下示意图所示 :
上图中 table 数组长度 = 16,元素个数 = 2
并且,假如成功添加该元素,会立刻进行判断——如果当前链表中的元素个数已经超过了 8 个,就要调用 treeifyBin 方法准备对数组该索引处的链表进行树化,将其转化为一颗红黑树。当然,不止是要求当前链表中的元素个数超过 8 个,还要求 table 数组的长度达到 64 。关于这一点可以看看 treeifyBin 方法的源码,如下图所示 :
可以看到,treeifyBin 方法内还有一个判断,如果当前 tab 数组的长度 < 64 ,就会先调用 resize 方法进行扩容, table 数组的长度扩大一倍,临界值扩大一倍,但不会对该链表进行树化
2° 回到前面,看第二个 if 语句,如下 :
此处即通过 for 循环判断当前欲添加元素有没有和当前索引处的链表中的元素相同的,如果有,直接 break ,放弃添加,重复了还加啥?如下图所示 :
注意,第二个 if 语句后面还有一步关键操作 "p = e" ,这是啥意思捏?
别忘了我们第一个 if 语句的判断条件中,执行了表达式 e = p.next ;如下所示:
那么最后一行的 p = e 就相当于 p = e = p.next,也就是说, p 的指针已经后移了一位,下一次 for 循环进行判断时,e = p.next 执行,判断的就是该索引处链表的下一个元素了,其实目的就是把链表中的元素挨个比较一遍。这里如果不理解的话可以在纸上画一画,很容易就明白了
以上是对 putVal 方法的解读,即给大家说明了 HashMap 底层是如何添加元素的,是如何做到 "不可重复且无序" 的
接下来我们回归正题,别忘了我们正在添加重复的 "Cyan" 元素
继续向下执行,如下 :
后面这几行没那么重要,这里只需要知道, putVal 方法到这里就结束了,并且返回的并不是 null ,这表明是个重复元素,添加失败
④ 从putVal函数跳出,并逐层返回到测试类中
接下来就是从 putVal 函数跳出,并逐层返回到测试类,如下图所示:
可以看到,集合中并没有加入第二个 "Cyan" 元素,并且当前集合中的元素个数 size = 2
(4)HashMap底层扩容机制演示
① 准备工作
以下代码作为演示类,一步一步 Debug ,演示 HashMap 底层的扩容机制
import java.util.HashSet;
public class demo {
public static void main(String[] args) {
HashSet hashSet = new HashSet();
for (int i = 1; i <=12; i++) {
hashSet.add(i);
}
hashSet.add(13);//添加第13个元素
for (int i = 13; i <=24; i++) {
hashSet.add(i);
}
hashSet.add(25);//添加第25个元素
}
}
② 向集合中添加第1个元素
当刚创建好 HashSet 对象时,底层的 table 数组是空的,如下:
并且, 可以看到此时集合中元素的个数 size = 0 ,临界值 threshold = 0,增长因子loadFactor = 0.75
我们通过第一个 for 循环,向集合中添加第一个元素。如下图所示 :
可以看到,此时 table 数组的容量已经由 0 ---> 16 ,临界值 threshold 由 0 ---> 12;并且,当前集合中元素的个数 size = 1
说明:HashSet 中的 table 数组长度第一次扩容为 16 的过程在前文有讲解,这里就不再赘述了,大家可以往前翻翻
③ 向集合中添加第13个元素
先通过 for 循环将集合添加到 12 个元素,如下图所示 :
可以看到,此时 size = 12,临界值 threshold = 12。根据我们之前的理论,如果我们继续向集合中添加元素,table 数组长度就应该由 16 ---> 32 ,临界值 threshold 由 12 ---> 24
下面我们就添加第 13 个元素,添加的过程我们在前面已经讲解得很详细了,我会跳的比较快,最终将光标停留在扩容函数 resize 上:
可以看到,关键代码位于 putVal 方法中的最后几行, if 语句判断数组的元素个数 size 是否大于临界值 threshold ,大于的话就调用 resize 函数对 table 数组进行扩容,如下:
此时数组中的元素个数 size = 13 > threshold,所以进入 resize 方法扩容
④ 进入resize方法
由于 resize 方法的代码非常多,所以我直接以 源码+注释 的形式呈现:
//table数组扩容
final Node<K,V>[] resize() {
//oldTab记录旧数组,用于后续的拷贝操作
Node<K,V>[] oldTab = table;
//记录旧数组的长度(注意,不是元素个数),oldCap = 16
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//记录就旧数组的临界值,oldThr = 12
int oldThr = threshold;
//newCap记录新数组的长度,newThr记录新数组的临界值
int newCap, newThr = 0;
if (oldCap > 0) {//oldCap = 16,跳入该if语句
if (oldCap >= MAXIMUM_CAPACITY) {//不会执行,不用管
threshold = Integer.MAX_VALUE;
return oldTab;
}
//执行此处代码,新数组长度newCap = oldCap * 2 = 16 * 2 = 32
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//新数组的临界值newThr = oldThr * 2 = 12 * 2 = 24
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {//newThr = 24 ≠ 0,不执行
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//将临界值更改为 threshold = newThr = 24
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//创建一个容量为 newCap = 32 的新数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//将 table 数组赋值为 newTab
table = newTab;
//后续就是将原数组中的内容拷贝到新数组中的操作,不看也行
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
⑤ 跳出resize方法,逐层返回到演示类
接下来就是跳出 resize 方法,逐层返回到演示类,如下图所示:
可以看到, table 数组的长度由 16 ----> 32 ,临界值 threshold 由 12 ---> 24
⑥ 向集合中添加第25个元素
我们先通过 for 循环将集合添加到 24 个元素,如下图所示 :
可以看到, size 再次到达了临界值,即 size = threshold = 24 。那么下一次添加元素(第 25 个元素)时,会再次调用 resize 方法对 table 数组进行扩容。结果如下所示:
可以看到,table 数组的长度由 32 ----> 64,临界值 threshold 由 24 ---> 48
🆗,经过上述实践,前文 "HashSet的底层实现" 中结论确实是正确的
(5)HashMap链表树化为红黑树
① 准备工作
在前文的 "HashSet底层实现" 我们提到,链表要转化为红黑树时,会进行下述步骤:
-
判断该链表是否满足所含元素 > 8 ?
-
> 8 :还需要进一步的判断:
-
若 table 数组的长度 >= 64 :对该链表进行树化,转换为黑红树
-
若 table 数组的长度 < 64:调用 resize 函数对 table 数组进行扩容,将 table 数组的长度扩大一倍,临界值扩大一致,不对该链表进行树化
-
-
< = 8 : 不对该链表进行树化
-
但是现在有个问题:要演示某个链表树化为红黑树的过程,我们如何才能保证每添加一个元素都能挂到同一个链表上呢?
很简单,我们可以自定一个义类然后重写根父类 Object 的 hashCode 方法,令 hashCode 方法的返回值是一个固定值,那么该类所有的对象实例的哈希码值就相同;从而经过特定算法:
使得该类对象在数组中对应的索引值便相同。因此只要我们一直向集合中添加 new 出来的该类对象,就可以准确将它们挂载到同一个链表下
🆗,如下代码为演示类,代码中自定义一个 Fruit 类,在 Fruit 类中给出带参构造和重写的 hashCode 方法
import java.util.HashSet;
public class demo {
public static void main(String[] args) {
HashSet hashSet = new HashSet();
for (int i = 1; i <=8; i++) {
hashSet.add(new Fruit("水果"+i));
}
hashSet.add(new Fruit("水果9"));//添加第9个元素,添加后链表元素个数 = 9,table数组长度 = 32
hashSet.add(new Fruit("水果10"));//添加第10个元素,添加后链表元素个数 = 10,table数组长度 = 64
//链表元素个数 = 10,table数组长度 = 64 ,添加新元素后,会对该链表进行树化
hashSet.add(new Fruit("水果10"));//添加第11个元素,链表树化为红黑树
}
}
class Fruit {
private String name;
public Fruit(String name) {
this.name = name;
}
@Override
//所有对象实例的hashCode值都为233
public int hashCode() {
return 233;
}
}
② 将8个元素挂载到数组的同一个链表下
在 for 循环中,我们向集合中添加不同的 Fruit 对象,由于 Fruit 类重写了 hashCode 方法,返回相同的哈希码值。因此这些对象最后都会添加到数组的同一索引处,即挂载到同一链表下。如下 GIF 图所示 :
可以看到,此时 table 数组的长度为 16,临界值 threshold = 12,如下图所示:
③ 将第9个元素添加到集合中(数组扩容,但无树化)
经过前面的操作,此时该链表的元素个数 = 8 ,table 数组的长度 = 16,此时我们再添加第 9 个元素到集合中,则该链表元素个数为 9 > 8 ,table 数组的长度为 16 < 64,因此会调用 resize 数组扩容方法,将 table 数组的长度扩大一倍 16 ---> 32,临界值 threshold 扩大一倍 12 ---> 24,但不会对链表进行树化
执行流程如下:
执行结果如下:
可以看到,添加完第 9 个元素后, HashSet 集合的 table 数组长度由 16---> 32 ,临界值 threshold 由12---> 24
④ 将第10个元素添加到集合中(数组扩容,但无树化)
经过前面的操作,此时该链表的元素个数 = 9 ,table 数组的长度 = 32,此时我们再添加第 10 个元素到集合中,则该链表元素个数为 10 > 8 ,table 数组的长度为 32 < 64,因此仍然是调用 resize 数组扩容方法,将 table 数组的长度扩大一倍 32 ---> 64,临界值 threshold 扩大一倍 24 ---> 48,但依然不会对链表进行树化
这里的执行流程与 ③ 是完全一致的,因此这里我直接展示添加完第 10 个元素后的集合情况,如下图:
可以看到,添加完第 10 个元素后, HashSet 集合的 table 数组长度由 32---> 64 ,临界值 threshold 由 24---> 48
⑤ 将第11个元素添加到集合中(链表树化为红黑树)
经过我们前面的操作,该链表的元素个数为 10 > 8 ,table数组的长度为 64 > = 64 。因此下一步再次添加元素到该链表时,就要对该链表进行树化了。在树化前,我们明确一下当前链表的状态,如下图:
可以看到,目前该链表的每一个结点还是 HashMap$Node 类型,并且有 4 个属性 hash、key、 value、next
接下来我们向集合中添加第 11 个元素,添加之后该链表的状态如下图所示:
可以看到,该链表中每个结点的类型由 HashMap$Node ---> HashMap$TreeNode,成功转化成了红黑树。 TreeNode 中除了与 Node 相同的 4 个属性 hash、key、value、next 外,还多出了很多其他的属性,例如 parent、left、right 等
5.关于HashMap$TreeNode的补充
在链表转换为红黑树后,链表中每个结点的类型由 HashMap$Node ---> HashMap$TreeNode,如下:
可以看到,TreeNode 结点有非常多的属性,分别是 parent、left、right、prev、red、before、after、hash、key、value、next,那么这些属性究竟是怎么来的呢?这里追溯一下它的继承关系图,如下所示:
从继承关系图中可知,TreeNode、Node 都是 HashMap 类中的静态内部类,而 Entry 是 LinkedHashMap 类的静态内部类。TreeNode 类中定义了 parent、left、right、red 属性,从父类 Entry 中继承了 before、after ,从爷爷类 Node 中继承了 hash、key、value、next
所以 TreeNode 所有的属性是: parent、left、right、prev、red、before、after、hash、key、value、next
由于本人对红黑树不是特别了解,所以这些属性的作用本文不作讲解
6.完结
🆗,以上就是本文 HashSet 源码分析的全部内容了。回顾一下,本文通过 Debug ,从底层解释了 HashSet (其实就是 HashMap )如何添加元素、如何判断重复元素、扩容机制、链表转化为红黑树的过程
由于涉及的底层代码较多,所以整篇博文文字比较多和臃肿。静下心来自己动手 Debug 过一遍收获会更大