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一樣,照樣會都新增進去。