1. 程式人生 > 其它 >Java學習筆記 -- HashSet原始碼分析

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;

如下分析:

  1. DEFAULT_INITIAL_CAPACITY為預設初始化容量,也就是第一次新增資料,陣列擴容為16。

  2. DEFAULT_LOAD_FACTOR為預設載入因子,通過原始碼發現如果建立HashMap集合物件,loadFactor預設等於12,如下:

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    
  3. table就是存放資料的陣列,每個位置存放一個節點,也有可能掛著一個單項鍊表。

  4. MIN_TREEIFY_CAPACITY為可以樹形化容器的最小table容量,預設為64,TREEIFY_THRESHOLD為閾值,預設為8,這兩個屬性聯合使用,主要用在擴容機制,當陣列中某一個位置的單向連結串列的節點數量到達TREEIFY_THRESHOLD後,就會將該單項鍊表進行樹化,轉換為紅黑樹結構,但是有個條件,那就是陣列的容量大小必須達到MIN_TREEIFY_CAPACITY,也就是64,如果沒達到,就會對陣列擴容,然後繼續判斷,如果容量還沒達到,繼續擴容,當陣列容量達到該值後,就會呼叫相關方法,對該連結串列進行樹化。

  5. entrySet存放的是HashMap中的鍵,對應的就是存放在HashSet中的物件值。

  6. 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;
}

如下分析:

  1. 第一行程式碼定義了一些輔助變數:

    Node<K,V>[] tab; Node<K,V> p; int n, i;
    
  2. 接著到達判斷語句,並且將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。

  3. 進入到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。

  4. 接著進行判斷,前兩個條件都不成立,到達最後的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。

  5. 繼續往下走,開始初始化賦值:

    // 將新的閾值賦值給threshold,第一次等於12,第二次等於24.....
    threshold = newThr;
    // 建立一個新陣列,大小就是16
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    // 賦值給table
    table = newTab;
    
  6. 最後返回新陣列:

    return newTab;
    
  7. 回到putVal()方法,進行下一個判斷:

    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    

    這裡邊有一個演算法,也就是(n - 1) & hash,它最終返回的結果就是陣列的下標,並且賦值給了變數i,然後通過下標取出該位置的節點值賦值給變數p,最後判斷是否為null,其實就是判斷該位置有沒有節點已存在,如果沒有,直接建立節點,放到該位置。由於是第一次新增,陣列中所有位置都為null,所以這裡直接就將新節點放到這裡了。

  8. 接著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;

所以:

  1. HashSet底層使用的是HashMap,value值是一個空Object。
  2. HashSet存放資料是無序不可重複的,不一定放到那個位置了,或者掛在那個連結串列的末尾了,另外,如果連結串列節點的個數到達閾值,並且陣列容量也達到64,就會擴容,並且更新閾值threshold。
  3. HashSet存放的物件必須重寫equals()和hashCode()方法,不然每次新增都會呼叫物件的hashCode()返回的雜湊值都不一樣,而如果重寫了equals()沒有重寫hashCode(),那麼兩個物件equals一樣,照樣會都新增進去。