資料結構學習系列之從原始碼來看HashMap

矢澤妮可發表於2019-01-24

引導

在瞭解HashMap之前,我們應該先明白兩個概念:HashMap,這可以幫助我們更容易瞭解HashMap的執行原理。

那麼何為Hash,又何為Map呢?

Hash

之前寫過的知識普及 Hash

Map

Map是一種K-V形式的資料結構,一個唯一的key,會唯一對應一個value。也就是說,在Map容器裡不允許兩個一模一樣的key。

一個簡單的Map結構如下:

{
  "key1":"value1",
  "key2":"value2",
  "key3":"value3"
}
複製程式碼

對於這種資料結構,並且Map會對外提供一些方法來實現對內部資料的操作:

V put(K key, V value)
V get(Object key)
V remove(Object key)
boolean containsKey(Object key)
複製程式碼

可見Map對於我們操作K-V形式的資料非常方便,實現的方式有很多,最簡單粗暴的實現方式是使用List來儲存每一個K-V組對,對於每種方法的實現只需要暴力迴圈碰撞即可,對於少量資料這種做法未必不可,如果資料量龐大之千萬,我們就要換一種更加高效,速度更快的實現方式:HashMap

HashMap

Map在Java中的實現有很多,HashMap便是其中之一,在JDK漫長的版本更新中,HashMap的實現也是在不斷的更新著:

  • <=JDK1.7:Table陣列 + Entry連結串列
  • >=JDK1.8:Table陣列 + Entry連結串列/紅黑樹

本文我們跳過JDK1.7的實現,來看一下1.8中HashMap原始碼所帶來的魅力衝擊!

實現原理

對於各個版本的HashMap實現原理,主線流程都是一成不變的:

hashmap原理流程圖

這裡有兩個資料結構需要我們知道:

  • Table:雜湊表,存放Node元素。
  • Node:結點元素,存放K-V組對資訊,其結構是一個連結串列/紅黑樹。

另外,在HashMap內部有一些關鍵屬性我們也要了解一下:

  • DEFAULT_INITIAL_CAPACITY:Table陣列初始長度,預設為1 << 42^4 = 16。
  • MAXIMUM_CAPACITY:Table陣列最高長度,預設為1 << 302^30 = 1073741824。
  • DEFAULT_LOAD_FACTOR:負載因子,當總元素數 > 陣列長度 * 負載因子時,Table陣列將會擴容,預設為0.75。
  • TREEIFY_THRESHOLD:樹化閾值,當單個Table內Node數量超過該值,則會將連結串列轉化為紅黑樹,預設為8。
  • UNTREEIFY_THRESHOLD:鏈化閾值,當擴容期間單個Table內Entry數量小於該值,則將紅黑樹轉化為連結串列,預設為6。
  • MIN_TREEIFY_CAPACITY:最小樹化閾值,當Table所有元素超過改值,才會進行樹化(為了防止前期階段頻繁擴容和樹化過程衝突)。
  • size:Table陣列當前所有元素數。
  • threshold:下次擴容的閾值(陣列長度 * 負載因子)

HashMap的內部有著一個Table陣列,而這個陣列的初始長度為DEFAULT_INITIAL_CAPACITY引數值,Table陣列存放的元素型別就是Node,它是一個單向連結串列:

static class Node<K,V> implements Map.Entry<K,V> {
  final int hash; //key的hash值
  final K key;  //key
  V value;  //value
  Node<K,V> next; //下一個結點
}
複製程式碼

每個Table中存的Node元素相當於連結串列的headernext指向下一個結點,而這種鏈式結構的存在正是為了解決hash衝突

hash衝突:兩個元素的經過Hash雜湊之後分在同一個組內,我們將之解釋為Hash衝突

在JDK1.7之前的版本,hash衝突的解決方法是將被衝突的Node結點放於一個連結串列中,而Table中的元素則是鏈頭,當然在JDK1.8中,當Table中鏈長超過TREEIFY_THRESHOLD閾值後,將會將連結串列轉變為紅黑樹的實現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;
}
複製程式碼

當發生hash衝突的Node不斷變多,那麼這個鏈將會越來越長,那麼遍歷碰撞key時的耗時就會不斷增加,這也就直接導致了效能的不足,從JDK1.8開始,HashMap對於單個Table中的Node超出某個閾值時,將會開始樹化操作(連結串列轉化為紅黑樹),這對於搜尋的效能將會有很大的提升,而插入和刪除的操作所帶來的效能影響微乎其微。

put方法

HashMap的內部會有一個Table陣列,這個陣列的當前長度就是我們要實現對映的目標範圍,當我們執行put方法時,keyvalue要經歷這些事情:

  • 通過Hash雜湊獲取到對應的Table
  • 遍歷Table下的Node結點,做更新/新增操作
  • 擴容檢測

具體實現我們可以根據原始碼來詳細瞭解一下:

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)
        // HashMap的懶載入策略,當執行put操作時檢測Table陣列初始化。
        n = (tab = resize()).length;
    if ((p = tab[i = (n - 1) & hash]) == null)
        //通過``Hash``函式獲取到對應的Table,如果當前Table為空,則直接初始化一個新的Node並放入該Table中。
        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))))
            //輸入的key命中了當前Table的首元素,直接更新。
            e = p;
        else if (p instanceof TreeNode)
            //如果當前Node型別為TreeNode,呼叫``putTreeVal``方法。
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else {
            //如果不是TreeNode,則就是連結串列,遍歷並與輸入key做命中碰撞。
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                    //如果當前Table中不存在當前key,則新增。
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        //超過了``TREEIFY_THRESHOLD``則轉化為紅黑樹。
                        treeifyBin(tab, hash);
                    break;
                }
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    //做命中碰撞,使用hash、記憶體和equals同時判斷(不同的元素hash可能會一致)。
                    break;
                p = e;
            }
        }
        if (e != null) {
            //如果命中不為空,更新操作。
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    if (++size > threshold)
        //擴容檢測。
        resize();
    afterNodeInsertion(evict);
    return null;
}
複製程式碼

對於其過程中的關於Node連結串列和紅黑樹的轉換過程我們可以暫時遮蔽掉,那麼整個流程並不是很繞,那麼我們繼續深入的來看一下HashMap的擴容實現。

resize方法

HashMap的擴容大致的實現是將老Table陣列中所有的Entry取出來,重新對其hashcode做Hash雜湊到新的新的Table之中,也就是一個re-put的過程,具體還是通過原始碼來講解:

final Node<K,V>[] resize() {
    //保留老的hash表
    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) {
            //擴容閾值為int最大值
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //否則計算擴容後的閾值
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    else if (oldThr > 0)
        // 如果之前的容量等於0,並且之前的閾值大於零,則新的hash表長度就等於它
        newCap = oldThr;
    else {              
        // 初始閾值為零表示使用預設值
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //如果新的閾值為 0 ,就得用 新容量*載入因子 重計算一次
    if (newThr == 0) {

        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    //常見擴容後的hash表
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab; //A
    if (oldTab != null) {
        //遍歷舊的hash表,將之內部元素轉移到新的hash表
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                if (e.next == null)
                    //如果當前Table內只有一個元素,重新做hash雜湊並賦值
                    newTab[e.hash & (newCap - 1)] = e; //B
                else if (e instanceof TreeNode)
                    //如果舊雜湊表中這個位置的桶是樹形結構,就要把新雜湊表裡當前桶也變成樹形結構
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else { //保留舊雜湊表桶中連結串列的順序
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {  //遍歷當前Table內的Node,賦值給新的Table
                        next = e.next;
                        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;
}
複製程式碼

get方法

在我們看完HashMap對於put方法的實現之後,get方法則顯得簡單易懂,其程式碼與put相近無幾,主要差別是沒有了擴容和新增/更新的操作:

final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    //判斷hash表是否為空,表重讀是否大於零並且當前key對應分佈的表內是否有Node存在
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash &&
            ((k = first.key) == key || (key != null && key.equals(k))))
            // 檢測第一個Node,命中則不需要在做do...while...迴圈
            return first;
        if ((e = first.next) != null) {
            if (first instanceof TreeNode)
                //如果Table內是樹形結構,則使用對應的檢索方法
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do { //如果是連結串列,則做while迴圈,直到命中或者遍歷結束
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}
複製程式碼

containsKey方法

根據get方法的結果是否為空就可以直到是否包含該key:

public boolean containsKey(Object key) {
    return getNode(hash(key), key) != null;
}
複製程式碼

remove方法

同樣類似於put操作,首先會查詢對應的key所在位置,如果為空,則不操作,反之,將之移除:

final Node<K,V> removeNode(int hash, Object key, Object value,
                               boolean matchValue, boolean movable) {
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    //判斷hash表是否為空,表重讀是否大於零並且當前key對應分佈的表內是否有Node存在
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (p = tab[index = (n - 1) & hash]) != null) {
        Node<K,V> node = null, e; K k; V v;
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            // 第一個Node命中
            node = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                //如果Table內是樹形結構,則使用對應的檢索方法
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do { //如果是連結串列,則做while迴圈,直到命中或者遍歷結束
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                         (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
            //如果命中到了對應的Node,則根據Node結構進行對應的移除操作
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
            else if (node == p)
                tab[index] = node.next;
            else
                p.next = node.next;
            ++modCount;
            //修改hash表元素數
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}
複製程式碼

為何執行緒不安全?

看完了HashMap的實現之後,就該談一談它為什麼存線上程安全問題!

資料丟失

首先,我們將目光放在put方法的實現中,假設有兩個執行緒在同時進行put操作,對應的資料分別為:

thread-1: put(1, 'abc');
thread-2: put(1, 'efg');
複製程式碼

假設此時Hash表的長度為10,且已經有兩個元素在,負載因子為預設值0.75f,那麼操作過程一定不會擴容,並且兩個執行緒put的key都是1,那麼它們將會分配到同一個table中,下方程式碼為put方法中的其中一段,其主要作用是遍歷當前表內Node,尋找與當前key一樣的Node結點,之後再做新增/更新操作。

for (int binCount = 0; ; ++binCount) {
   if ((e = p.next) == null) {
       p.next = newNode(hash, key, value, null); // A
       if (binCount >= TREEIFY_THRESHOLD - 1)
           treeifyBin(tab, hash);
       break;
   }
   if (e.hash == hash &&
       ((k = e.key) == key || (key != null && key.equals(k))))
       break;
   p = e;
}
複製程式碼

假設兩個執行緒同時執行到了A這個位置,此時獲取到的p是統一個物件,下一刻,cpu運轉,兩個執行緒同時執行,那麼p.next的值將會是最後一個執行緒put的value值,而前一個則會丟失,這就會導致丟資料的情況!

當然該情景同樣會發生於resizeremove操作,至於為什麼,大家可以思考一下!

size不準確

這個就很簡單了,為什麼不準確呢,來看一下size變數在HashMap內部的定義:

transient int size;
複製程式碼

記憶體不可見並且增減操作未加鎖,多執行緒操作下屬於非原子操作!

閉環死鎖

這個問題在JDK1.8版本的HashMap中已經不存在了,至於為啥,我要先講一下在1.8之前的HashMap為什麼會存在閉環死鎖問題!

閉環這個名詞上我們分析一下是什麼問題,什麼是閉環的,如果連結串列形成了一個環會不會就是閉環呢?而連結串列如何才會形成環?帶著這些問題,我們在腦海中抽象出一個模型:

graph LR
A-->B
B-->A
複製程式碼

假設某一個Table中的Node連結串列發生了上述問題,那麼我們在遍歷時進行do{ }while ((e = e.next) != null);操作就會發生死鎖的問題,那麼看來我們的猜想方向是正確的,那麼我們就具體分析一下HashMap在什麼操作之中會產生閉環的問題,不過在此之前,我們要明白因果:

因:???
果:閉環
複製程式碼

我們知道,只有當兩個結點內部的next相互引用對方的時候才會死鎖,這種場景只能在兩個已經存在同一個鏈上的結點同時以相反的方向被操作next引用的時候才會發生,而在HashMap內部,符合這種場景的只有一個方法:resize,那我們就來看一下JDK1.7的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];
    boolean oldAltHashing = useAltHashing;
    useAltHashing |= sun.misc.VM.isBooted() &&
            (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
    boolean rehash = oldAltHashing ^ useAltHashing;
    //fu
    transfer(newTable, rehash);
    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) { // A
        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;
        }
    }
}
複製程式碼

我們發現,在JDK1.7的HashMap的擴容實現中,老的Table中的Node鏈的順序賦值給新的Table中時的操作是反置的:

e.next = newTable[i];
newTable[i] = e;
e = next;
複製程式碼

上述操作是將當前Node的next指標指向當前Table的頭結點,之後當前Node又變為了Table的頭結點,此時假設A、B兩個執行緒同時執行到了transfer方法中的A位置,並且此時的oldTablenewTable的結構是這樣的:

oldTable[]
table-1: a -> b -> c -> null
table-2: null
table-3: null

newTable[]
table-1: null
table-2: null
table-3: null
table-4: null
table-5: null
table-6: null
複製程式碼

如果很巧,兩個執行緒在同一個CPU上執行,那麼就會存在一個搶佔時間片的場景,假設A先搶到了時間片,然後執行一番操作之後,oldTablenewTable的結構如下:

oldTable[]
table-1: a -> null
table-2: null
table-3: null

newTable[]
table-1: null
table-2: c -> b -> a -> null
table-3: null
table-4: null
table-5: null
table-6: null
複製程式碼

之後還沒等它做oldTable = newTable操作,B搶到了時間片,並也做了同樣一番操作,oldTablenewTable的結構如下:

oldTable[]
table-1: a -> null
table-2: null
table-3: null

newTable[]
table-1: null
table-2: a -> c -> b -> a
table-3: null
table-4: null
table-5: null
table-6: null
複製程式碼

此時A或者B誰先oldTable = newTable已經無所謂了,因為newTable中已經產生了閉環,之後在進行get或者put操作時,如果不小心觸發到了while迴圈,那將會一直死迴圈:

do{
  //do some thing
}while ((e = e.next) != null);  //e = e.next將會永不為空
複製程式碼

從上述場景產生的過程中我們發現,a -> c -> b -> a這種閉環問題的罪魁禍首是因為1.7中的HashMap在擴容時為了免去再次遍歷連結串列,很聰明的將當前結點作為新連結串列的頭結點,這就會導致順序反轉,所以無序化導致了閉環的產生,而這種問題不僅僅是在HashMap中體現,Mysql的死鎖問題的原因常常也是因為反序加行鎖導致的!

而在開頭說過,JDK1.8已經避免了這個問題,這是為什麼呢?看下程式碼就知道了:

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;
       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;  //A
   }
   if (hiTail != null) {
       hiTail.next = null;
       newTab[j + oldCap] = hiHead; //B
   }
}
複製程式碼

同樣是擴容的操作,JDK1.8中的HashMap通過兩個鏈分別去儲存頭結點和尾結點以保證它有序,並且不會頻繁的去賦值newTable,而是在迴圈之後直接賦值(請注意A、B標記處),這樣就非常簡單的避免了產生閉環的陷阱!

相關文章