1. 程式人生 > >帶你走進Java集合_HashMap原始碼分析1

帶你走進Java集合_HashMap原始碼分析1

前幾篇部落格主要從原始碼角度分析了List集合的兩個重要的實現類ArrayList、LinkedList,今天我們先跳過Set集合,直接講解Map的主要實現類,因為Set集合的主要實現類HashSet、TreeSet底層主要用Map的實現類,所以我們先分析Map,然後回過頭來看Set就非常的簡單了。所有的Map集合JDK7和JDK8以後原始碼實現差別非常的大,我們主要以JDK8的原始碼分析。本篇文章主要講解HashMap,學習HashMap主要學習它的資料結構。因為HashMap內容較多,我們會用幾篇文章去介紹HashMap.

一、HashMap的底層資料結構

在JDK8以後,HashMap底層資料結構程式設計了陣列+連結串列+紅黑樹

通過獲得key的hash值可以獲取被插入的值在陣列的下標,如果key的hash衝突,那就會在這個下標形成連結串列,但是連結串列的時間複雜度為o(n),當衝突多時,連結串列很長,導致查詢效率下降,所以為了防止效率下降,如果連結串列的長度大於8時,連結串列就會變成紅黑樹,而紅黑樹的時間複雜度是o(logn)


二、HashMap的重要屬性

1)DEFAULT_INITIAL_CAPACITY 預設的初始化大小

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
注意HashMap的容器一定是2的整數次冪,原因我們接下來會詳細闡述。

2)MAXIMUM_CAPACITY最大容器

static final int MAXIMUM_CAPACITY = 1 << 30;

3)DEFAULT_LOAD_FACTOR預設載入因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

4)loadFactor 載入因子

final float loadFactor;
這個屬性,剛接觸HashMap原始碼的同學搞不懂是幹什麼的,我們上面說過HashMap的底層是陣列,但是陣列的長度是不變的,所以達到某個臨界點時就需要擴容,而這個載入因子就是這個臨界點,例如一個HashMap的容器大小為16,如果HashMap中的元素超過12=16*0.75,時就需要對容器擴容了,所以載入因子與HashMap的擴容有關,只要size大於容量的0.75倍就需要進行擴容。

4)threshold,我們可以認為他是擴容的閥門。

int threshold;

這個屬性和載入因子一樣,與擴容有關,它的值等於當前容器大小*載入因子,我們上面計算的12=16*0.75,12就是計算出來的threshold的值,就是當元素大小超過12就需要擴容。

上面loadFactor和threshold兩個元素與擴容有關,loadFactor是載入因子,預設為0.75,loadFactor的值可以大於1,但是綜合空間和時間的考慮還是使用預設的載入因子DEFAULT_LOAD_FACTOR=0.75。而閥門threshold的值與載入因子有關。

5)TREEIFY_THRESHOLD 這個值是從連結串列變成紅黑樹的閥門,如果大於這個值就會轉變

static final int TREEIFY_THRESHOLD = 8;

6)table 底層陣列,就是HashMap的底層陣列

transient Node<K,V>[] table;

因為陣列的查詢的時間複雜度是o(1),

(1)如果沒有hash衝突,則所有的資料會放到這個table陣列中,

(2)如果有hash衝突且連結串列的長度小於等於8,則會把連結串列的第一個節點放到table中.

(3)如果有hash衝突且是紅黑樹,則紅黑樹的根節點會放到table中

7)size  HashMap實際存放元素的個數

transient int size

我們先記住這些非常重要的屬性,其他的屬性下面用到的都會講到,我們接下來說一下Node,在連結串列轉換成紅黑樹前,我們的元素都會封裝成一個Node資料結構,原始碼如下:

static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
 }

(1)hash:表示key的hash

(2)key:表示我們給出的key,就是map(key,value)中的key

(3)value:表示我們給出的value,就是map(key,value)中的value

(4)next:如果hash相同,會形成連結串列,當前連結串列節點的下一個連結串列的引用,即連結串列後繼。

如果連結串列的長度大於8後,為了查詢的效能,會把連結串列轉換成紅黑樹TreeNode。

 static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {
        TreeNode<K,V> parent;  // red-black tree links
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;    // needed to unlink next upon deletion
        boolean red;
        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }
 }

TreeNode的原始碼較長,主要是為紅黑樹服務的,我們接下來會有一篇文章介紹紅黑樹,這裡我們主要闡述TreeNode的屬性。

(1)parent:表示節點的父節點

(2)left:表示左節點

(3)right:表示右節點

(4)prev:表示前驅節點

(5)red:表示是紅樹還是黑樹

三、HashMap的建構函式

在講解HashMap的建構函式之前,我們要記住一個非常重要的知識點:HashMap是懶載入的,他在建構函式中並沒有對底層陣列進行初始化,陣列初始化的工作在第一次呼叫put時初始化的,建構函式主要是對一些屬性進行了賦值。

1)第一個建構函式:無參建構函式

public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

可以看出它並沒有對陣列進行初始化,而是將載入因子初始化成預設的載入因子0.75

2)第二個建構函式

public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

這個建構函式呼叫了下面的建構函式

3)第三個建構函式

public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);
        this.loadFactor = loadFactor;
        this.threshold = tableSizeFor(initialCapacity);
    }

(1)首先判斷給出的初始化容量initialCapacity是否合法,判斷給出的載入因子是否合法,我們上面說了,預設的載入因子0.75是基於空間和時間的綜合考慮,一般使用預設的載入因子即可

(2)初始化載入因子

(3)上面我們說了,容器的大小必須是2的倍數(原因我們後面會分析),但是使用者給出的可能不是2的倍數,所以HashMap原始碼要對使用者給出的容器大小變成2的倍數,並計算出閥門的大小。

HashMap的原始碼怎樣把使用者給出的初始化容量大小變成2的倍數的呢?讓我們分析tableSizeFor來揭開其中的面紗。

static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

該方法用來返回大於或者等於輸入引數最接近的2的整數次冪的值。例如:

舉例1:我們輸入的cap=7,那麼大於或者等於輸入引數最接近的2的整數次冪的值8

舉例2:我們輸入的cap=8,那麼大於或者等於輸入引數最接近的2的整數次冪的值8

舉例3:我們輸入的cap=9,那麼大於或者等於輸入引數最接近的2的整數次冪的值16

舉例4:我們輸入的cap=15,大於或者等於輸入引數最接近的2的整數次冪的值16

1)第一行程式碼int n=cap-1,為什麼減1呢?這是因為如果不減1,如果給出的cap是2的整數次冪,例如cap=8,通過下面的多次運算,結果變成了16,這就違背了最接近的2的整數次冪。而通過cap-1,最終算出來的是8。所以要進行cap-1.

2)我們要知道一個知識點:或運算,記住這一點:遇1則1

我們舉例說明:cap=7

(1)int n=cap-1 計算結果 n=6   二進位制表示:0110

(2)n|=n>>>1  


 (2)n|=n>>>2


   (3)n|=n>>>4

              

(4)n|=n>>>8


(5)n|=n>>>16


通過幾步的與運算,此時n=7,

(6)最後一句程式碼則return 8,即返回大於或等於最接近輸入引數的2的整數次冪

所以這個方法非常的巧妙的把使用者給的容器的大小變成了2的整數次冪並返回

這個方法要注意一下知識點:

1)為了防止給出的cap本來就是2的整數次冪,所以先進行減1處理,防止給出的是8,到最後的計算後獲取的是16。

2)通過幾次與運算後,從給出的最高位1開始的位數都是1,例如:1000,通過與運算變成了0111,

3)這個方法不是初始化底層陣列的大小的,初始化陣列的動作在第一次put的時候,這個方法計算出來的值初始化給了threshold,但是我們前面不是講了,threshold=tableSizeFor(initialCapacity)*laodFactor嗎?,在構造方法中,並沒有對table這個成員變數進行初始化,table的初始化被推遲到了put方法中,在put方法中會對threshold重新計算。

通過我們上面的講解,是不是對HashMap的建構函式有了非常清晰的認識,我們總結一下

1)HashMap的建構函式並沒有對底層的陣列進行初始化,而是放到了第一次呼叫put的時候,建構函式只是

初始化了載入因子loadFactor和閥門threshold

2)如果使用者指定了容器的大小,HashMap只是把給定的initCap通過呼叫tableSizeFor方法,把給定的引數變成大於或者等於最接近輸入引數的2的整數次冪,並初始化給閥門threshold

本篇文章就先寫到這裡,我們上面有一個問題還沒有解決,為什麼需要變成2的整數次冪呢?下一篇文章我將給大家分析其中的原因。總結一下這一篇穩重的知識點:

1)HashMap的底層資料結構是陣列+連結串列+紅黑樹

2)載入因子loadFactor和閥門threshold是為擴容服務的。

3)底層陣列的容量大小一定是2的整數次冪(後文解釋)

4)HashMap建構函式並沒有初始化陣列,初始化陣列的動作在第一次呼叫put的時候進行的。