1. 程式人生 > >JAVA集合-Map集合分析-HashMap

JAVA集合-Map集合分析-HashMap

HashMap的特點:

1.HashMap 是一個散列表,它儲存的內容是鍵值對(key-value)對映。

2.HashMap 繼承於AbstractMap,實現了Map、Cloneable、java.io.Serializable介面。

3.HashMap 的實現不是同步的,這意味著它不是執行緒安全的。它的key、value都可以為null。此外,HashMap中的對映不是有序的。

 

1.類中的關鍵屬性

 // 預設的初始容量是16,必須是2的冪。
    static final int DEFAULT_INITIAL_CAPACITY = 16;

    // 最大容量(必須是2的冪且小於2的30次方,傳入容量過大將被這個值替換)
    static final int MAXIMUM_CAPACITY = 1 << 30;

    // 預設載入因子
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    // 儲存資料的Entry陣列,長度是2的冪。
    // HashMap是採用拉鍊法實現的,每一個Entry本質上是一個單向連結串列
    transient Entry[] table;

    // HashMap的大小,它是HashMap儲存的鍵值對的數量
    transient int size;

    // HashMap的閾值,用於判斷是否需要調整HashMap的容量(threshold = 容量*載入因子)
    int threshold;

    // 載入因子實際大小
    final float loadFactor;

    // HashMap被改變的次數
    transient volatile int modCount;

2.構造方法

2.1無參建構函式

 // 預設建構函式。
    public HashMap() {
        // 設定“載入因子”
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        // 設定“HashMap閾值”,當HashMap中儲存資料的數量達到threshold時,就需要將HashMap的容量加倍。
        threshold = (int)(DEFAULT_INITIAL_CAPACITY * DEFAULT_LOAD_FACTOR);
        // 建立Entry陣列,用來儲存資料
        table = new Entry[DEFAULT_INITIAL_CAPACITY];
        init();
    }

2.2指定容量大小的建構函式

 // 指定“容量大小”的建構函式
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

2.3 指定“容量大小”和“載入因子”的建構函式

 // 指定“容量大小”和“載入因子”的建構函式
    public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        // HashMap的最大容量只能是MAXIMUM_CAPACITY
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        // 找出“大於initialCapacity”的最小的2的冪
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        // 設定“載入因子”
        this.loadFactor = loadFactor;
        // 設定“HashMap閾值”,當HashMap中儲存資料的數量達到threshold時,就需要將HashMap的容量加倍。
        threshold = (int)(capacity * loadFactor);
        // 建立Entry陣列,用來儲存資料
        table = new Entry[capacity];
        init();
    }

2.4包含“子Map”的建構函式

 // 包含“子Map”的建構函式
    public HashMap(Map<? extends K, ? extends V> m) {
        this(Math.max((int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
                      DEFAULT_INITIAL_CAPACITY), DEFAULT_LOAD_FACTOR);
        // 將m中的全部元素逐個新增到HashMap中
        putAllForCreate(m);
    }

3.儲存資料

3.1put()

若要新增到HashMap中的鍵值對對應的key已經存在HashMap中,則找到該鍵值對;然後新的value取代舊的value,並退出!
若要新增到HashMap中的鍵值對對應的key不在HashMap中,則將其新增到該雜湊值對應的連結串列中,並呼叫addEntry()。

public V put(K key, V value) {
     // 若“key為null”,則將該鍵值對新增到table[0]中。
         if (key == null) 
            return putForNullKey(value);
     // 若“key不為null”,則計算該key的雜湊值,然後將其新增到該雜湊值對應的連結串列中。
         int hash = hash(key.hashCode());
     //搜尋指定hash值在對應table中的索引
         int i = indexFor(hash, table.length);
     // 迴圈遍歷Entry陣列,若“該key”對應的鍵值對已經存在,則用新的value取代舊的value。然後退出!
         for (Entry<K,V> e = table[i]; e != null; e = e.next) { 
             Object k;
              if (e.hash == hash && ((k = e.key) == key || key.equals(k))) { //如果key相同則覆蓋並返回舊值
                  V oldValue = e.value;
                 e.value = value;
                 e.recordAccess(this);
                 return oldValue;
              }
         }
     //修改次數+1
         modCount++;
     //將key-value新增到table[i]處
     addEntry(hash, key, value, i);
     return null;
}

3.2Entry的資料結構,Entry實際上是一個單向連結串列

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    // 指向下一個節點
    Entry<K,V> next;
    final int hash;

    // 建構函式。
    // 輸入引數包括"雜湊值(h)", "鍵(k)", "值(v)", "下一節點(n)"
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }

    public final K getKey() {
        return key;
    }

    public final V getValue() {
        return value;
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    // 判斷兩個Entry是否相等
    // 若兩個Entry的“key”和“value”都相等,則返回true。
    // 否則,返回false
    public final boolean equals(Object o) {
        if (!(o instanceof Map.Entry))
            return false;
        Map.Entry e = (Map.Entry)o;
        Object k1 = getKey();
        Object k2 = e.getKey();
        if (k1 == k2 || (k1 != null && k1.equals(k2))) {
            Object v1 = getValue();
            Object v2 = e.getValue();
            if (v1 == v2 || (v1 != null && v1.equals(v2)))
                return true;
        }
        return false;
    }

    // 實現hashCode()
    public final int hashCode() {
        return (key==null   ? 0 : key.hashCode()) ^
               (value==null ? 0 : value.hashCode());
    }

    public final String toString() {
        return getKey() + "=" + getValue();
    }

    // 當向HashMap中新增元素時,繪呼叫recordAccess()。
    // 這裡不做任何處理
    void recordAccess(HashMap<K,V> m) {
    }

    // 當從HashMap中刪除元素時,繪呼叫recordRemoval()。
    // 這裡不做任何處理
    void recordRemoval(HashMap<K,V> m) {
    }
}

3.3 addEntry()的作用是新增Entry。將“key-value”插入指定位置,bucketIndex是位置索引。

void addEntry(int hash, K key, V value, int bucketIndex) {
    // 儲存“bucketIndex”位置的值到“e”中
    Entry<K,V> e = table[bucketIndex];
    // 設定“bucketIndex”位置的元素為“新Entry”,
    // 設定“e”為“新Entry的下一個節點”
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    // 若HashMap的實際大小 不小於 “閾值”,則調整HashMap的大小
    if (size++ >= threshold)//相比creatEntry多的兩句
        resize(2 * table.length);
}

與addEntry()相似的另一個函式createEntry()

void createEntry(int hash, K key, V value, int bucketIndex) {
    // 儲存“bucketIndex”位置的值到“e”中
    Entry<K,V> e = table[bucketIndex];
    // 設定“bucketIndex”位置的元素為“新Entry”,
    // 設定“e”為“新Entry的下一個節點”
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e);
    size++;
}

addEntry()與CreatEntry的區別
(01) addEntry()一般用在 新增Entry可能導致“HashMap的實際容量”超過“閾值”的情況下。
       例如,我們新建一個HashMap,然後不斷通過put()向HashMap中新增元素;put()是通過addEntry()新增Entry的。
       在這種情況下,我們不知道何時“HashMap的實際容量”會超過“閾值”;
       因此,需要呼叫addEntry()
(02) createEntry() 一般用在 新增Entry不會導致“HashMap的實際容量”超過“閾值”的情況下。
        例如,我們呼叫HashMap“帶有Map”的建構函式,它繪將Map的全部元素新增到HashMap中;
       但在新增之前,我們已經計算好“HashMap的容量和閾值”。也就是,可以確定“即使將Map中的全部元素新增到HashMap中,都不會超過HashMap的閾值”。
       此時,呼叫createEntry()即可。

3.4putForNullKey(V value)

private V putForNullKey(V value) {
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null) {   //如果有key為null的物件存在,則覆蓋掉
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
           }
       }
        modCount++;
        addEntry(0, null, value, 0); //如果鍵為null的話,則hash值為0
        return null;
    }

注意:如果key為null的話,hash值為0,物件儲存在陣列中索引為0的位置。即table[0]

它是通過key的hashCode值計算hash碼(具體函式檢視3.1Entry的資料結構)

 

3.5 為什麼雜湊表的容量一定要是2的整數次冪?

一般對雜湊表的雜湊很自然地會想到用hash值對length取模(即除法雜湊法),Hashtable中也是這樣實現的,這種方法基本能保證元素在雜湊表中雜湊的比較均勻,但取模會用到除法運算,效率很低,HashMap中則通過h&(length-1)的方法來代替取模,同樣實現了均勻的雜湊,但效率要高很多。(& 按位與 快速取模方法)

得到hash碼之後就會通過hash碼去計算出應該儲存在陣列中的索引,計算索引的函式如下:

 static int indexFor(int h, int length) { //根據hash值和陣列長度算出索引值
         return h & (length-1);  //這裡不能隨便算取,用hash&(length-1)是有原因的,這樣可以確保算出來的索引是在陣列大小範圍內,不會超出
     }

 接下來,我們分析下為什麼雜湊表的容量一定要是2的整數次冪

首先,length為2的整數次冪的話,h&(length-1)就相當於對length取模,這樣便保證了雜湊的均勻,同時也提升了效率;

其次,length為2的整數次冪的話,為偶數,這樣length-1為奇數,奇數的最後一位是1,這樣便保證了h&(length-1)的最後一位可能為0,也可能為1(這取決於h的值),即與後的結果可能為偶數,也可能為奇數,這樣便可以保證雜湊的均勻性,而如果length為奇數的話,很明顯length-1為偶數,它的最後一位是0,這樣h&(length-1)的最後一位肯定為0,即只能為偶數,這樣任何hash值都只會被雜湊到陣列的偶數下標位置上,這便浪費了近一半的空間,因此,length取2的整數次冪,是為了使不同hash值發生碰撞的概率較小,這樣就能使元素在雜湊表中均勻地雜湊。

我們舉個例子來說明:

 假設陣列長度分別為15和16,優化後的hash碼分別為8和9,那麼&運算後的結果如下: 

h & (table.length-1)                     hash                             table.length-1
8& (15-1):                                 0100                   &              1110                   =                0100
9& (15-1):                                 0101                   &              1110                   =                0100
       -----------------------------------------------------------------------------------------------------------------------
8& (16-1):                                 0100                   &              1111                   =                0100
9& (16-1):                                 0101                   &              1111                   =                0101

從上面的例子中可以看出:當它們和15-1(1110)“與”的時候,產生了相同的結果,也就是說它們會定位到陣列中的同一個位置上去,這就產生了碰撞,8和9會被放到陣列中的同一個位置上形成連結串列,那麼查詢的時候就需要遍歷這個鏈 表,得到8或者9,這樣就降低了查詢的效率。同時,我們也可以發現,當陣列長度為15的時候,hash值會與15-1(1110)進行“與”,那麼 最後一位永遠是0,而0001,0011,0101,1001,1011,0111,1101這幾個位置永遠都不能存放元素了,空間浪費相當大,更糟的是這種情況中,陣列可以使用的位置比陣列長度小了很多,這意味著進一步增加了碰撞的機率,減慢了查詢的效率!而當陣列長度為16時,即為2的n次方時,2n-1得到的二進位制數的每個位上的值都為1,這使得在低位上&時,得到的和原hash的低位相同,加之hash(int h)方法對key的hashCode的進一步優化,加入了高位計算,就使得只有相同的hash值的兩個值才會被放到陣列中的同一個位置上形成連結串列。

   所以說,當陣列長度為2的n次冪的時候,不同的key算得得index相同的機率較小,那麼資料在陣列上分佈就比較均勻,也就是說碰撞的機率小,相對的,查詢的時候就不用遍歷某個位置上的連結串列,這樣查詢效率也就較高了。

3.6 putAll()的作用是將"m"的全部元素都新增到HashMap中

public void putAll(Map<? extends K, ? extends V> m) {
    // 有效性判斷
    int numKeysToBeAdded = m.size();
    if (numKeysToBeAdded == 0)
        return;

    // 計算容量是否足夠,
    // 若“當前實際容量 < 需要的容量”,則將容量x2。
    if (numKeysToBeAdded > threshold) {
        int targetCapacity = (int)(numKeysToBeAdded / loadFactor + 1);
        if (targetCapacity > MAXIMUM_CAPACITY)
            targetCapacity = MAXIMUM_CAPACITY;
        int newCapacity = table.length;
        while (newCapacity < targetCapacity)
            newCapacity <<= 1;
        if (newCapacity > table.length)
            resize(newCapacity);
    }

    // 通過迭代器,將“m”中的元素逐個新增到HashMap中。
    for (Iterator<? extends Map.Entry<? extends K, ? extends V>> i = m.entrySet().iterator(); i.hasNext(); ) {
        Map.Entry<? extends K, ? extends V> e = i.next();
        put(e.getKey(), e.getValue());
    }
}

3.7resize() 重新調整HashMap的大小,newCapacity是調整後的單位

void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
       }

        Entry[] newTable = new Entry[newCapacity];
        transfer(newTable);//用來將原先table的元素全部移到newTable裡面
        table = newTable;  //再將newTable賦值給table
        threshold = (int)(newCapacity * loadFactor);//重新計算臨界值
    }

新建了一個HashMap的底層陣列,上面程式碼中第10行為呼叫transfer方法,將HashMap的全部元素新增到新的HashMap中,並重新計算元素在新的陣列中的索引位置

當HashMap中的元素越來越多的時候,hash衝突的機率也就越來越高,因為陣列的長度是固定的。所以為了提高查詢的效率,就要對HashMap的陣列進行擴容,陣列擴容這個操作也會出現在ArrayList中,這是一個常用的操作,而在HashMap陣列擴容之後,最消耗效能的點就出現了:原陣列中的資料必須重新計算其在新陣列中的位置,並放進去,這就是resize。

  那麼HashMap什麼時候進行擴容呢?

當HashMap中的元素個數超過陣列大小*loadFactor時,就會進行陣列擴容,loadFactor的預設值為0.75,這是一個折中的取值。也就是說,預設情況下,陣列大小為16,那麼當HashMap中元素個數超過16*0.75=12的時候,就把陣列的大小擴充套件為 2*16=32,即擴大一倍,然後重新計算每個元素在陣列中的位置,擴容是需要進行陣列複製的,複製陣列是非常消耗效能的操作,所以如果我們已經預知HashMap中元素的個數,那麼預設元素的個數能夠有效的提高HashMap的效能。

4. remove()刪除“鍵為key”元素

public V remove(Object key) {
    Entry<K,V> e = removeEntryForKey(key);
    return (e == null ? null : e.value);
}


// 刪除“鍵為key”的元素
final Entry<K,V> removeEntryForKey(Object key) {
    // 獲取雜湊值。若key為null,則雜湊值為0;否則呼叫hash()進行計算
    int hash = (key == null) ? 0 : hash(key.hashCode());
    int i = indexFor(hash, table.length);
    Entry<K,V> prev = table[i];
    Entry<K,V> e = prev;

    // 刪除連結串列中“鍵為key”的元素
    // 本質是“刪除單向連結串列中的節點”
    while (e != null) {
        Entry<K,V> next = e.next;
        Object k;
        if (e.hash == hash &&
            ((k = e.key) == key || (key != null && key.equals(k)))) {
            modCount++;
            size--;
            if (prev == e)
                table[i] = next;
            else
                prev.next = next;
            e.recordRemoval(this);
            return e;
        }
        prev = e;
        e = next;
    }

    return e;
}

5.get() 的作用是獲取key對應的value

從HashMap中get元素時,首先計算key的hashCode,找到陣列中對應位置的某一元素,然後通過key的equals方法在對應位置的連結串列中找到需要的元素。

public V get(Object key) {
    if (key == null)
        return getForNullKey();
    // 獲取key的hash值
    int hash = hash(key.hashCode());
    // 在“該hash值對應的連結串列”上查詢“鍵值等於key”的元素
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
         e != null;
         e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
            return e.value;
    }
    return null;
}

這裡需要強調的是:HashMap將“key為null”的元素都放在table的位置0處,即table[0]中;“key不為null”的放在table的其餘位置!

6.clear()

clear() 的作用是清空HashMap。它是通過將所有的元素設為null來實現的。

public void clear() {
    modCount++;
    Entry[] tab = table;
    for (int i = 0; i < tab.length; i++)
        tab[i] = null;
    size = 0;
}

7. containsValue()

containsValue() 的作用是判斷HashMap是否包含“值為value”的元素

containsNullValue()分為兩步進行處理:第一,若“value為null”,則呼叫containsNullValue()。第二,若“value不為null”,則查詢HashMap中是否有值為value的節點。

public boolean containsValue(Object value) {
    // 若“value為null”,則呼叫containsNullValue()查詢
    if (value == null)
        return containsNullValue();

    // 若“value不為null”,則查詢HashMap中是否有值為value的節點。
    Entry[] tab = table;
    for (int i = 0; i < tab.length ; i++)
        for (Entry e = tab[i] ; e != null ; e = e.next)
            if (value.equals(e.value))
                return true;
    return false;
}


//如果value為null 呼叫的函式
//作用判斷HashMap中是否包含“值為null”的元素。
private boolean containsNullValue() {
    Entry[] tab = table;
    for (int i = 0; i < tab.length ; i++)
        for (Entry e = tab[i] ; e != null ; e = e.next)
            if (e.value == null)
                return true;
    return false;
}

8.總結

1.要知道hashMap在JDK1.8以前是一個連結串列雜湊這樣一個數據結構,而在JDK1.8以後是一個數組加連結串列加紅黑樹的資料結構。

2.為什麼HashMap是非執行緒安全?

①通過Entry內部的next變數可以知道使用的是連結串列,這時候我們可以知道,如果多個執行緒,在某一時刻同時操作HashMap並執行put操作,而有大於兩個key的hash值相同,如圖中a1、a2,這個時候需要解決碰撞衝突,而解決衝突的辦法上面已經說過,對於連結串列的結構在這裡不再贅述,暫且不討論是從連結串列頭部插入還是從尾部初入,這個時候兩個執行緒如果恰好都取到了對應位置的頭結點e1,而最終的結果可想而知,a1、a2兩個資料中勢必會有一個會丟失

②擴容方法也不是同步的,通過程式碼我們知道在擴容過程中,會新生成一個新的容量的陣列,然後對原陣列的所有鍵值對重新進行計算和寫入新的陣列,之後指向新生成的陣列。

  當多個執行緒同時檢測到總數量超過門限值的時候就會同時呼叫resize操作,各自生成新的陣列並rehash後賦給該map底層的陣列table,結果最終只有最後一個執行緒生成的新陣列被賦給table變數,其他執行緒的均會丟失。而且當某些執行緒已經完成賦值而其他執行緒剛開始的時候,就會用已經被賦值的table作為原始陣列,這樣也會有問題。