hashMap探析

MrHanhan發表於2020-05-29

本篇文章包括:

  • 資料結構
  • 各個引數
  • 為什麼陣列的長度是2的整數次方
  • 為什麼要將裝載因子定義為0.75
  • 為什麼連結串列轉紅黑樹的閾值為8
  • hash碰撞
  • put方法
  • resize方法
  • jdk7中陣列擴容產生環的問題。

1.底層資料結構?

  • 紅黑樹

是一種接近二叉平衡樹的資料結構,有5個性質:

  • 性質1:每個節點要麼是黑色,要麼是紅色。

  • 性質2:根節點是黑色。

  • 性質3:每個葉子節點(NIL||null)是黑色(為空的葉子結點)。

  • 性質4:每個紅色結點的兩個子結點一定都是黑色。

  • 性質5:任意一結點到每個葉子結點的路徑都包含數量相同的黑結點。(保證了紅黑樹的平衡性)

    紅黑樹的查詢效率高,時間複雜度為O(logn),但是新增節點的代價高,因為本身需要保證平衡,方法包括左旋、右旋以及變色。

  1. 各個引數
    /**
     預設的初始容量
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    /**.
     最大容量
     */
    static final int MAXIMUM_CAPACITY = 1 << 30;
    /**
      裝載因子
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    /**
     連結串列轉紅黑樹閾值
     */
    static final int TREEIFY_THRESHOLD = 8;
    /**
     紅黑樹轉連結串列閾值
     */
    static final int UNTREEIFY_THRESHOLD = 6;
    /**
     為避免調整大小和調整樹型閾值之間的衝突,可以重新調整儲存箱的最小表容量(如果儲存箱中的節點太多,則重新調整表      的大小)應至少為4個樹型閾值。
     為了避免進行擴容、樹形化選擇的衝突,規定若桶內的節點的數量大於64則進行擴容,否則進行樹形化
     */
    static final int MIN_TREEIFY_CAPACITY = 64;

初始容量為什麼是16或者說2的次方數

我們先看看2的次方數:

十進位制數 二進位制數
2 0010
4 0100
8 1000
16 0001 0000

發現2的整數次方的數的二進位制剛好都是最高位為1,那又有什麼用呢?這就要說說hashMap的put方法了額。

​ hashMap通過 (n - 1) & hash來計算鍵值對存放的陣列下標,可以自己嘗試計算一下發現如果n是2的整數次方數的話那麼就和n%hash的值一樣,也就是說是為了保證計算後的結果(作為下標)不超出陣列長度減一,從而找到對應的儲存位置。

public V put(K key, V value) {
	//先計算key的hash值,然後呼叫putAal
    return putVal(hash(key), key, value, false, true);
}

 final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
        Node<K,V>[] tab;
        Node<K,V> p; 
        int n, i;
     	//如果陣列長度為0,就進行初始化容量預設為16
        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;
            //當前的節點的hash值、key相等就進行覆蓋
            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;
                    }
                    //連結串列中有key相同的節點
                    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;
    }

//hash值計算方法
    static final int hash(Object key) {
        int h;
        //可以看出允許key為null,hashCode是一個本地方法
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }
  • hash碰撞:如果計算出來的最終的值(也就是要放到的那個陣列下標的位置)對應的位置有元素就產生hash碰撞,解決辦法有開放地址法,在雜湊法,鏈地址(拉鍊)法以及通過建立公共的溢位區來解決。hashMap是使用的鏈地址法。通過尾插法插入到當前連結串列的尾部(jdk7採用的頭插法會導致擴容的時候產生連結串列環的問題)。

裝載因子

  • 預設為0.75,為了在時間和空間上進行折中。如果小了就有可能造成空間的浪費,大了又會產生更多的hash碰撞,造成執行時間增加。

連結串列轉紅黑樹,以及紅黑樹轉連結串列

  • 當連結串列的長度達到8的時候會轉為紅黑樹結構,因為連結串列的查詢效率低,如果連結串列過長就會造成查詢時間過長,而紅黑樹結構的查詢效率較高,但是進行增加元素的時候效率較低。當元素的個數為6的時候紅黑樹結構又會轉為連結串列結構。
  • 為什麼會將閾值定為8?jdk官方解釋:
 Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins.  In
* usages with well-distributed user hashCodes, tree bins are
* rarely used.  Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
*
* 0:    0.60653066
* 1:    0.30326533
* 2:    0.07581633
* 3:    0.01263606
* 4:    0.00157952
* 5:    0.00015795
* 6:    0.00001316
* 7:    0.00000094
* 8:    0.00000006
* more: less than 1 in ten million

總之就是在8的時候再產生插入的操作的概率非常小,因為紅黑樹的增加節點的效率是很低的,不該有過多的增加節點的操作。

看看resize方法

final Node<K,V>[] resize() {
    //舊陣列
    Node<K,V>[] oldTab = table;
    //舊陣列容量
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //舊陣列的擴容閾值
    int oldThr = threshold;
    //新陣列的大小,擴容閾值
    int newCap, newThr = 0;
    //當舊陣列長度不為0
    if (oldCap > 0) {
        //舊陣列的長度已經為最大了就不進行擴容,直接將閾值賦值為最大
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //對陣列的容量和閾值擴大為原來的兩倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //當陣列大小為0的時候對陣列進行初始化,後面會對threshold進行處理,因為閾值是裝載因子與陣列的長度的乘積
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {
        // zero initial threshold signifies using defaults
        //使用無參構造進行new陣列,第一次put的時候會對陣列進行預設的初始化
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    if (newThr == 0) {
        //對陣列的閾值賦值
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    //以下是將舊陣列的元素轉移到新的陣列中去
    @SuppressWarnings({"rawtypes","unchecked"})
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    if (oldTab != null) {
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            //如果當前下標有元素,有以下幾種情況
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                ///1.如果當前元素沒有後繼元素,則直接進行hash計算下標將節點放在新陣列對應的下標處
                if (e.next == null)
                    newTab[e.hash & (newCap - 1)] = e;
                //如果是紅黑樹結構,則拆分紅黑樹,並且有可能轉為連結串列結構
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { // preserve order
                    //這裡說明是連結串列結構,則採用尾插法進行元素的轉移
                    Node<K,V> loHead = null, loTail = null;//低位
                    Node<K,V> hiHead = null, hiTail = null;//高位
                    Node<K,V> next;
                    do {
                        next = e.next;
                        //如果當前元素的hash值與舊陣列進行與運算得到0則用低位記錄
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                            //否則用高位記錄
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    //低位的保持不變
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    //高位的進行轉移,轉移到當前陣列的下標加上舊陣列長度的位置
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}
  • 為什麼jdk7中擴容會產生環的問題

看resize方法:

 //擴容  
    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, initHashSeedAsNeeded(newCapacity));//將老的表中的資料拷貝到新的結構中  
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);//修改閥值  
    }  

transfer方法:

//將老的表中的資料拷貝到新的陣列中  
    void transfer(Entry[] newTable, boolean rehash) {  
        int newCapacity = newTable.length;//容量  
        for (Entry<K,V> e : table) { //遍歷所有桶
            while(null != e) {  //遍歷桶中所有元素(是一個連結串列)
                Entry<K,V> next = e.next; //1 
                if (rehash) {//如果是重新Hash,則需要重新計算hash值  
                    e.hash = null == e.key ? 0 : hash(e.key);  
                }  
                int i = indexFor(e.hash, newCapacity);//定位Hash桶  
                e.next = newTable[i];//2
                newTable[i] = e;//newTable[i]的值總是最新插入的值
                e = next;//繼續下一個元素  
            }  
        }  
    }  
  • 分析擴容的過程
 for (Entry<K,V> e : table) { 
            while(null != e) {
                Entry<K,V> next = e.next; 
                //if (rehash) { 
                 //  e.hash = null == e.key ? 0 : hash(e.key);  
              // }  
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }  
  • 兩個執行緒同時進行擴容(假設擴容後的元素在陣列中的下標還是原來的下標),假設執行緒1先進行

  • 執行緒1擴容完畢後連結串列的順序已經倒置:

  • 執行緒2進行擴容的時候就形成了環形連結串列:

由於執行緒2中存放的han1的next還指向著han2,所以導致環形連結串列的產生。

jdk8中採用尾插法避免了這個問題,通過採用高位指標和低位指標來進行連結串列元素的轉移,巧妙的避開了環形連結串列的問題。

相關文章