Java学习笔记 -- HashSet源码分析
HashSet概述
Hashset 实现 set 接口,底层是基于 HashMap 实现并且使用 HashMap 来保存所有元素,但与 HashMap 不同的是 HashMap 存储键值对,HashSet仅存储对象,也就是把将要存的对象放到key部分,而value部分直接给一个空Object。
HashSet 使用存放的对象也是Key来计算 HashCode 值。
构造函数:
public HashSet() {
map = new HashMap<>();
}
HashSet属性
HashSet底层使用的HashMap,数据是存放在了一个 数组+单项链表 的数据结构上边了,如下:
数组类型为节点Node,每一个位置存放一个节点,节点有数据域和next指针域,指向下一个节点,构成单向链表。
属性如下:
// 声明HashMap集合
private transient HashMap<E,Object> map;
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
PRESENT就是和key对应的value值,是一个虚拟的,没啥用处,因为HashSet存放只存放对象,而底层又用的HashMap,所以value就废了。
HashMap的属性:
// The default initial capacity - MUST be a power of two.
// 默认初始化容量
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// The load factor used when none specified in constructor.
// 默认的加载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 可以树形化容器的最小表容量
static final int MIN_TREEIFY_CAPACITY = 64;
// 阈值
static final int TREEIFY_THRESHOLD = 8;
// 存放Node节点的数组
transient Node<K,V>[] table;
// 获取HashMap中的key部分,返回值Set类型
transient Set<Map.Entry<K,V>> entrySet;
// 集合中节点数量
transient int size;
// 集合修改次数
transient int modCount;
// 容量乘以加载因子所得结果,如果key-value的数量达到该值,则调用resize方法,扩大容量,同时修改threshold的值。
// 比如刚开始 DEFAULT_INITIAL_CAPACITY * 0.75 = 12
int threshold;
// 加载因子。
final float loadFactor;
如下分析:
-
DEFAULT_INITIAL_CAPACITY为默认初始化容量,也就是第一次添加数据,数组扩容为16。
-
DEFAULT_LOAD_FACTOR为默认加载因子,通过源码发现如果创建HashMap集合对象,loadFactor默认等于12,如下:
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted }
-
table就是存放数据的数组,每个位置存放一个节点,也有可能挂着一个单项链表。
-
MIN_TREEIFY_CAPACITY为可以树形化容器的最小table容量,默认为64,TREEIFY_THRESHOLD为阈值,默认为8,这两个属性联合使用,主要用在扩容机制,当数组中某一个位置的单向链表的节点数量到达TREEIFY_THRESHOLD后,就会将该单项链表进行树化,转换为红黑树结构,但是有个条件,那就是数组的容量大小必须达到MIN_TREEIFY_CAPACITY,也就是64,如果没达到,就会对数组扩容,然后继续判断,如果容量还没达到,继续扩容,当数组容量达到该值后,就会调用相关方法,对该链表进行树化。
-
entrySet存放的是HashMap中的键,对应的就是存放在HashSet中的对象值。
-
threshold也是阈值,以判断数组是否需要扩容,它是容量乘以加载因子所得结果,如首次添加数据数组扩容到了默认初始容量16,那么threshold = 16 * 0.75 = 12,当数组容量到达12这个阈值,数组大小将会扩容到16 * 2 = 32,此时threshold = 32 * 0.75 = 24,当数组容量到达24时就会继续扩容到 32 * 2 = 64,此时threshold = 64 * 0.75 = 48,以此类推。
HashSet原理
首次添加数据
编写Java代码如下:
Set<String> set = new HashSet<>();
set.add("张三");
首次实例化HashSet集合对象,底层实例化HashMap对象,然后调用add()方法,添加数据:
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
这里返回的结果Boolean类型,也就是说如果方法结束后返回null,说明添加成功。
底层调用的就是是HashMap中的put()方法,并且value的位置传入的就是虚拟值PRESENT,继续跟进:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
这里调用了putVal()方法,进行存值,需要注意的是,在存值之前首先将key作为参数,调用了hash()方法:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
可以看到,内部调用key的hashCode()方法获取hash值,然后通过位运算返回一个int类型的值。
拿到hash值进入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;
}
如下分析:
-
第一行代码定义了一些辅助变量:
Node<K,V>[] tab; Node<K,V> p; int n, i;
-
接着到达判断语句,并且将table赋值给了tab,将table.length赋值给了变量n:
if ((tab = table) == null || (n = tab.length) == 0){ n = (tab = resize()).length; }
这里非常关键,第一次添加数据,table为null,所以tab也为null,则n = tab.length = 0,所以该判断成立,调用resize()方法进行扩容,将扩容后的结果重新给tab赋值,并将扩容后的数组容量大小重新赋值给变量n。
-
进入到resize()方法,由于代码过多,只看主要代码即可:
// 首先将table数组赋值给了变量oldTab Node<K,V>[] oldTab = table; // 判断是否为空,如果不为空,将长度赋值给oldCap,如果为空,则赋值0 int oldCap = (oldTab == null) ? 0 : oldTab.length; // 将默认阈值赋值给oldThr int oldThr = threshold; // 定义两个新的变量 int newCap, newThr = 0;
由于是第一次添加数据,数组一定为空,所以oldCap = 0,oldThr = 0.75。
-
接着进行判断,前两个条件都不成立,到达最后的else:
if (oldCap > 0) { // 略... }else if (oldThr > 0){ // 略... }else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); }
可以看到,这里设置新容量newCap = DEFAULT_INITIAL_CAPACITY,也就是16,新阈值newThr = 16 * 0.75 = 12。
-
继续往下走,开始初始化赋值:
// 将新的阈值赋值给threshold,第一次等于12,第二次等于24..... threshold = newThr; // 创建一个新数组,大小就是16 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; // 赋值给table table = newTab;
-
最后返回新数组:
return newTab;
-
回到putVal()方法,进行下一个判断:
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
这里边有一个算法,也就是
(n - 1) & hash
,它最终返回的结果就是数组的下标,并且赋值给了变量i
,然后通过下标取出该位置的节点值赋值给变量p
,最后判断是否为null,其实就是判断该位置有没有节点已存在,如果没有,直接创建节点,放到该位置。由于是第一次添加,数组中所有位置都为null,所以这里直接就将新节点放到这里了。 -
接着else就不会走了,直接来到最后
return null
,那么add()方法return map.put(e, PRESENT)==null
返回的就是true,添加失功。
所以得出结论:首次添加数据,调用key的hashCode()方法获取哈希值,然后判断数组是否为空,最后将数据扩容到16的大小,阈值初始化为12,通过算法获取将哈希值转换为数组下标,也就是找到对应的存放位置,然后放到该位置。
再次添加数据
set.add("李四");
set.add("李四");
再次进入到putVal()方法:
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);
第一个判断直接跳过,因为table里边已经有数据了,数组大小为16,其中有一个位置存放一个Node节点,数据域为张三
第二个判断依旧是通过算法找到位置,并且取出该位置的节点赋值给节点p,判断是否为空,如果成立,直接创建节点放入,如果不为空,继续往下走:
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);
// 略...
}
1、第一个判断:成立的条件是p.hash == hash
,也就是该位置已存在节点的hash值和将要添加的新节点的hash值要相等,并且下边两个条件必须满足一个:
(k = p.key) == key
表示已存在节点的key和新节点的key相同,比较的是地址。(key != null && key.equals(k))
表示key不为空,并且equals相同,比较的是内容。
如果成立,说明添加的重复数据,将已存在节点p赋值给e,直接就结束,如下:
if (e != null) { // existing mapping for key
// 首先取出已存在节点的value值,在这里就是一个空Object,如果使用的hashmap添加数据,value值就是我们添加的value值。
V oldValue = e.value;
// onlyIfAbsent这个参数的作用在于,如果我们传入的key已存在我们是否去替换,true:不替换,false:替换。
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
内部判断左边的条件也成立,onlyIfAbsent默认为false,取反为true,里边就是e.value = value
,从上边代码可以看出,如果存放的数据已存在,那就会覆盖value值,就算value值为null,并不会覆盖key值。
最后返回已存在节点的value值,也就是方法最终返回的不是null,那么add()方法return map.put(e, PRESENT)==null
返回的就是false,添加失败,所以HashSet集合数据不可重复。
2、如果第一个条件不成立,就说明该位置已存在的节点和我们这次要添加的节点不同,接下来就是要判断该位置的单项链表的每一个节点,进行比对,注意:是从链表的第二个节点开始,第一个已经比对过了,不成立,并且赋值给了节点p。
首先到达:else if (p instanceof TreeNode)
,这里判断该位置对应的是不是红黑树,还是链表,如果是树结构,则按照树结构的方式变量查询。
3、如若不是,继续往下走,说明该位置有节点,但是不同,所以要判断链表上每一个节点,到达else里边:
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;
}
}
这里边就是循环,查询单链表每一个节点和将要添加的节点进行比对,如果某一个比对成功,直接break,如果一直到最后p.next为null,则说明该链表上每一个节点都和新节点不同,最后添加到链表的末尾p.next = newNode(hash, key, value, null)
。
另外:接着的判断if (binCount >= TREEIFY_THRESHOLD - 1)
就是判断是否到达了指定阈值,也就是链表的长度如果达到8,就转为红黑树结构。
接着,下方的判断就不成立了:
if (e != null) {
// 略...
}
最后返回:
++modCount;
// 判断是否需要扩容
if (++size > threshold)
resize();
// 里边啥都没有,留给子类重写
afterNodeInsertion(evict);
return null;
所以:
- HashSet底层使用的是HashMap,value值是一个空Object。
- HashSet存放数据是无序不可重复的,不一定放到那个位置了,或者挂在那个链表的末尾了,另外,如果链表节点的个数到达阈值,并且数组容量也达到64,就会扩容,并且更新阈值threshold。
- HashSet存放的对象必须重写equals()和hashCode()方法,不然每次添加都会调用对象的hashCode()返回的哈希值都不一样,而如果重写了equals()没有重写hashCode(),那么两个对象equals一样,照样会都添加进去。