(5)HashMap原理解析——爲啥線程不安全?

目錄

一、 HashMap的數據結構

二、HashMap的功能實現源碼解析

1. hash方法

2. 由鏈表改爲紅黑樹

3.擴容

4. 擴容後的新位置

5. 搬家

三、 怎樣將HashMap升級爲線程安全的

1. HashMap爲啥線程不安全呢?

2.  HashMap應該怎樣實現線程安全呢?


一、 HashMap的數據結構

1.7版本與1.8版本數據結構的區別

    1.7版本使用的數據結構是數組 + 鏈表的形式。對於新增的節點使用的是頭插法,新增的節點增加在離桶最近的地方。

    

1.8版本使用的是 數組 + 鏈表/紅黑樹的形式。新增節點使用的是尾插法,新增的節點在鏈表的尾部。當鏈表的長度>=8時,會轉換爲紅黑樹結構。

二、HashMap的功能實現源碼解析

1. hash方法

    如果沒有指明HashMap的初始化大小值,則其默認初始化大小是16。

    當我們有一個新的值被put方法放入HashMap時,它應該在0~15之間有一個具體的位置。那麼應該用什麼方法確定它的位置呢?

    我們常想到的就是用隨機取模的方法來做,Random(16).nextInt(),簡單粗暴。但是如果我們對同一個key比如"hello",反覆地放入同一個HashMap,則其每次的位置都是隨機的且位置不同,這樣對於查找並不方便,最好用一種與key本身帶有某種關係的算法,同一個key往往放在同一個位置。我們看下HashMap的源碼是怎樣確定key的位置的。

    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 {
            ....
        }
    }

     源碼的算法是 tab[i = (n - 1) & hash]

     tab是HashMap內部的Node數組,tab[i]就是第i個的位置。 i 的取值是(n-1) & hash。

     n -1 是數組的長度 - 1, 那麼hash是什麼呢?

    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    它的計算原理是: hashCode  ^  (hashCode   >>> 16)

    每一個對象都有一個hashCode,是一個32位的int值。  算式的意思是將它的高16位與低16位相異或

    爲什麼要用異或

    我們用了hashCode的高16位與低16位來進行運算,我們當然可以取與(&)運算或者或( | )運算,但是這樣的結果與異或(^)相比較一下,就可以發現任意兩個數X和Y進行(&)或( | )運算之後,每一位取0和1 的概率都是不一樣的,也就是說某個操作數(即高16位或者低16位)被賦予的權重是不一樣的,這就會使hash計算後分佈不夠均勻。而異或(^)運算則沒有這個問題。

    HashMap數組的長度n必須爲2的整數次方。這個n值爲什麼要這麼規定?

    tab[i] 中位置 i 的位置是 (n-1) & hash。 我們明白了hash的計算結果,是一個16位的值。

    n爲2的整數次方,比如說16,則n-1 = 15,換算成二進制就是 00001111。

    長度必須爲2的整數次方的原因就是

    (1) n-1 與 hash相與,最大值爲15,最小值爲0,其結果值分佈在0 ~ 15之間,完美契合座標範圍

    

    (2)n-1的二進制,其值全部爲1,可以採樣到hashCode後面所有位的值。如果n-1中間有某幾位爲0,則該位與(&)的結果一定是0,取不到值,則tab[] 數組某些位置就會爲空,永遠也不會被存放值,造成內存浪費。

2. 由鏈表改爲紅黑樹

    當鏈表的長度 >= TREEIFY_THRESHOLD (=8)時,就會將鏈表改爲紅黑樹。因爲鏈表查找的時間複雜度爲O(n),而紅黑樹的查找時間複雜度爲O(logn)。 (紅黑樹的內容在數據結構篇中查看)

    當紅黑樹的節點 <= UNTREEIFY_THRESHOLD (=6)時,又會從紅黑樹轉換爲鏈表。

3.擴容

    隨着Node數組存放的數據越來越多,達到 0.75 *f (f爲Node數組的長度)時,就會對HashMap進行擴容。

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;  //如果到了 1 << 30,則擴容到Integer.MAX_VALUE
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // 擴容一倍
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // 初始時用默認值16
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        ...
    }

     如果是首次初始化則初始化爲16,如果 >= 2^30則擴容爲Integer.MAX_VALUE,其他情況則擴容爲原來的2倍。

4. 擴容後的新位置

    擴容的過程中,容量變爲原來的2倍,原Node數組中的節點位置,就需要計算新的 hash & (n-1)來確定。

    新的位置只可能在兩個位置: 

  •     原來的位置
  •     [原下標 + 原容量] 的位置

    這個很容易理解:

    

    下標就是 hash & (n-1), hash沒有變, 只有 n 擴大爲原來的2倍,則 n-1的二進制就是比原來的最左側多了一個 1 。那麼計算 hash & (n-1),如果最左側的1上hash碼爲0,則爲原來的數;如果最左側1上hash碼爲1,則爲原來的數 + 原來的容量。

5. 搬家

    當HashMap擴容之後,hashMap的各個Node節點都要移動到新的位置上去。這個過程再去增刪改查一定是不安全的,因此就需要先禁止這些操作,等到各Node節點存放到新的位置之後才能操作。

 

三、 怎樣將HashMap升級爲線程安全的

    與HashTable相比較,HashTable爲啥是線程安全的?

    因爲HashTable的幾乎所有方法都加上了synchronized 關鍵字。

    public synchronized V put(K key, V value) { 
        ...
    }

    這樣是實現了安全性,但是性能也肯定被降低了,算是犧牲了性能來保證了安全性。

 

1. HashMap爲啥線程不安全呢?

      我們來看一下HashMap中有哪些操作。

     (1) 對於一個普通的put操作,步驟有:

     hash(key)

     數組初始化

     將該key/value值存放入某個位置

    (2)擴容

      數組擴容

      移動數據

      這些步驟除了hash(key) 之外,其他都是線程不安全的。這就是HashMap線程不安全的原因。

 

2.  HashMap應該怎樣實現線程安全呢?

    除了像HashTable那樣low地爲每個方法用synchronized修改,我們可以根據每個步驟來對其進行優化。

   (1) 對於一個普通的put/remove 操作,步驟有:

     hash(key)   --------- 線程安全

     數組初始化  -------- 線程不安全,只能有一個線程在處理,可以用CAS來解決。

     將該key/value值存放入某個位置   -------- 線程不安全,插入時如果爲null則用CAS 解決, 如果不爲null,可以使用synchronized(i  i爲數組下標)的方式,儘量減小鎖的粒度。

    (2)擴容

      數組擴容  -------  線程不安全,只能由一個線程操作,用CAS解決。

      移動數據  ------- 線程不安全,必須禁止其他的增刪改查操作,之後借鑑ConcurrentHash中的方式,將每一個桶將由不同的線程去負責搬運它們的位置,將鎖的粒度減小到單個桶的範圍。

      此外其他的方法都要仿照這種形式進行改造。所以對多線程來說,還是要用 ConcurrentHash 作爲更好的選擇。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章