HashSet底层机制说明
1.HashSet底层是HashiMap
2.添加一个元素是,先得到hash值->会转成索引值
3.找到存储数据表table,看这个索引位置是否已经存放的有元素
4.如果没有,直接加入
5.如果有,调用equals比较,如果相同,就放七天假,如果不相同,则添加到最后
6.如国一条链表的数据超过了TREEIFY_THRESHOLD(默认值为8),并且table数组大小大于等于MIN_TREEIFY_CAPACITY(默认为64),就会进行树化(黑红树)
利用代码分析
我们利用一段代码,通过对代码的debug进行一步一步刨析,对于流程我会讲解的很清楚,看完这个流程会对HashSet的底层机制有一个更深的理解
1.测试代码
HashSet hashSet = new HashSet();
hashSet.add("jack");
hashSet.add("marry");
hashSet.add("jack");
System.out.println("hashSet = " + hashSet);
首先我们要对HashSet的结构有一个大致了解--
在底层有一个table表,实质是一个数组,它会将链表作为数组元素保存起来
我们将对首次添加、再次添加、添加重复元素以及对table空间细节进行分析
2.debug分析首次添加数据过程
将断点下在创建对象这一步
调用HashSet无参构造器
这里调用了HashSet的无参构造器,在构造器内创建了一个新的HashMap对象(其实我们讲HashSet就是在讲HashMap)
调用add()方法
可以看到,此方法返回一个boolean值用来判断操作是否成功
传入的形参是要添加的元素
返回值调用了一个方法,并且和null去比较,我们进入一探究竟,最后方法返回再说这个地方
追溯一下这个PRESENT,其实就是这个对象没什么实质作用,起到的是一个占位的作用
调用put()方法
这个方法传入的形参key就是传入的元素对象,value就是PRESENT的值,底层为了HashMap的两个参数而出现的值(不用很在意)
接着这个方法会调用putVal方法,在传入参数时,会调用hash()方法给出一个值
我们进入这个方法一探究竟
调用hash()方法
传入要添加的元素对象进入方法
这个方法采用了一个算法,三元运算中,它首先判断传入的对象是不是为空,如果是空就返回0,否则会将该元素对象的hashCode无符号右移16位,再将这个值赋给h返回
得到索引进入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指向table,判断一下是否为null,或者将这个指向table的tab数组长度赋给n,判断长度是否为0,如果符合条件,执行if内语句
进入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赋给oldTab表示最开始传入的数组
接着是一个三元运算,判断oldTab是否为空,如果为空则为0,否则为oldTab的长度
得到这个值赋给oldCap,他表示传入数组的长度
将threshold赋给oldThr,也是0,这个表示一个阈值,我们下面会提到为什么会有这个阈值存在
这个if语句,odlCap为0,所以不执行,继续往下走
再判断
oldThr也为0,这个if也不执行,继续走
所以只能执行这个代码块的语句
这里有一个新的量newCap,表示新的数组长度(这里可以看出实际上此方法是一种判断以及扩容的方法)
DEFAULT_INITIAL_CAPACITY是一个系统定义的量,他的默认值为16
DEFAULT_LOAD_FACTOR是一个计算因子,它的默认值是0.75
这里我说下它存在的意义--
在这个方法中,源代码作者很聪明,他做了一个事情,他做了一个数据的缓冲区
我打个比方,需要向盆子里接水,我们有16个盆子,水是源源不断的,我们接到第12个的时候,就将新的盆子拿来准备好,而不是等到16个都接满了再拿
实际上这就是一种数据的缓冲,提高了效率以及速度
那么这个代码块的意思就是,先将数组长度变为16,再将16 * 计算因子 = 12赋给newThr作为一个阈值,这就是刚刚提到的阈值,指的是,一旦到达,这个临界值,就进行扩容,而不是等数组被加满再扩容
继续走代码
newThr不为0 所以不执行if内语句,继续执行
将新的临界值赋给threshold
创建一个新的数组newTab长度为newCap
让table指向这个新数组newTab
接着判断最开始传入的数组是否不为null,因为我们最开始是个空数组,所以不执行,继续走代码
然后返回这个新数组
回到putVal()方法
将这个返回的数组对象赋给tab并取它的长度赋给n
这里将有一个算法,得到tab数组中这个位置的索引,并判断这个位置是否为null
他的算法是(n-1)&hash
如果这个地方为空,那么就把key 也就是要添加的元素对象放到这个数组的索引位置,并且让这个位置的next指向null,因为它的本质是一个链表,他需要有一个next指向
接着modCount记录操作次数
下面这个if语句判断数组内已有元素加1是否大于临界值
可以这样想,这个临界值是要判断是否需要扩容的,将现有的size+1和临界值比较,size+1就是再添加一个元素所需的空间,这个空间大于临界值的话,就需要扩容
这里还没有大于临界值,不执行resize
往下走,这个afterNodeInsertion是HashMap给的一个抽象方法,可以去实现一些业务逻辑
然后返回一个null
返回put()方法
返回add()方法
这里就用到了这个返回值null和null去比较,为真即说明这次操作成功完成了
到此,就是首次添加数据的流程,接下来我们继续分析继续添加数据的流程
3.debug分析继续添加数据过程
以上没有变化的流程我就不演示了,我只说出现改变的流程
进入PutVal()方法
这里我们看到,此次tab,不为空了,并且tab的长度也不为0了,所以不执行这个if语句,继续走
同样是判断索引位置是否存在元素,如果不存在直接添加
但如果,此处索引位置有元素则会执行
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
这里做一个判断,如果传入的hash值和索引位置的哈希值即p.hash相同,并且传入的元素和索引位置元素相同或者传入元素不为空并且传入的对象和索引位置对象不同
那么就会让e指向p
然后会返回这个对象,根据上面的流程,如果返回值不为null,就说明此次添加操作失败了
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
又或者索引位置元素是一个红黑树,就会进行黑红树的操作
如果这两种都不是,那么就执行(这个意思是第一个节点不同,向链后tian'ji)
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;
}
}
分析一下,首先是一个无限循环,第一个判断语句让e指向该索引位置的next指向的元素,如果是空,那么直接将这个元素接在该索引位置元素的后面
这个if语句里还有一个判断语句,他是在判断该元素所在的链上是否有八个元素,如果有就会进行树化,然后break,退出这个无限循环
下一个if语句,因为e指向的是p的next指向的元素,也就是p的下一个元素,e就代表p后面的元素
e的hash值和要加入元素的hash值相同并且e所在位置的元素和要添加的元素相同或者要添加的元素不为空并且e指向的对象和加入元素对象相同,就退出这个无限循环
否则让p指向e,意思就是向后移动一下p继续去循环
接着继续走
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
这里可以分析出,e本身是个空的,但如果进入了这个大的else语句中,就会在某些条件下被赋值,即无法添加元素的时候
到此为止我们可以直到,再次添加数据的流程是,先去判断数组节点处有无元素,如果没有就直接放在此处,这里不用考虑相同的元素会不会加入到不同的索引处,因为他是通过hash值以及一些算法得到的索引,相同的元素由于这个索引会相同,故不会加入,也就是说一种元素,HashSet只接受一次接收,因为相同的元素,索引值相同,位置就不为空,他就会去逐个判断这条链上的元素是否有一样的,没有的话就加入,有的话就加入不了!
4.table的扩容规则以及细节
reseze扩容方法中其实写的很清楚,
他判断下旧数组容量的二倍小于最大容量,并且老数组容量大于16就进行扩容,并且赋的值就是oldCap左移一位就是乘二->赋给newCap
如果没有到16,就将临界值的大小作为数组大小使用,如果这些都不是,就是第一次扩容,就是下面的语句
第一次扩容的大小
可以看到,到了临界值,会以二倍的大小进行扩容,而不是到数组填满再扩容
为了能看到链表的添加,以及树化,我构思了一个想法
我写一个类,里面有一个数,重写这个hashCode方法,使得他们每次返回一样的值,但是,这个元素却不同,就能让他们按顺序排列!
for (int i = 1; i <= 7; i++) {
hashSet.add(new A(i));
}
for (int i = 1; i <= 7; i++) {
hashSet.add(new B(i));
}
}
}
class A {
private int n;
public A(int n) {
this.n = n;
}
@Override
public int hashCode() {
return 100;
}
}
class B {
private int n;
public B(int n) {
this.n = n;
}
@Override
public int hashCode() {
return 200;
}
}
为了得到这种效果,让他们链起来
接下来我们从头开始进行添加
可以看到,size的大小,其实是所有的元素的数量,而不是结点的数量,在链上的每一个元素也会增加size
到13的时候,又进行了扩容
但是!
到了数组长度为64并且加入的链已经超过了8的时候,就会被树化!这两个条件缺一不可
到此我们得出的结论--
1.当数据量到达临界值时,就会进行扩容,并且是以原来数组的二倍容量进行扩容,直到64
2.数组如果到达了长度64,并且节点上的链长度大于等于8,那么存储结构会发生改变,会树化