1. 程式人生 > >Java集合類原始碼解析:HashMap (基於JDK1.8)

Java集合類原始碼解析:HashMap (基於JDK1.8)

目錄

前言

今天我們來學習Java中較為常用的集合類 HashMap
另外說明一下,本文的 HashMap 原始碼是基於Jdk1.8版本的,如果沒有特別說明的話,之後的集合類原始碼解析都是1.8的版本。

HashMap的資料結構

開啟HashMap原始碼檔案,可以看到它是繼承自

AbstractMap,並實現了Java集合的根介面Map,以及Cloneable和Serializable介面,所以HashMap可以被序列化。

HashMap的底層結構是雜湊表的具體實現,通過相應的雜湊運算就可以很快查詢到目標元素在表中的位置,擁有很快的查詢速度,因此,HashMap被廣泛應用於日常的開發中。理想的情況就是一個元素對應一個Hash值,這樣的查詢效果是最優的。

但實際這是不可能的,因為雜湊表存在“hash (雜湊) 衝突“ 的問題。當發生hash衝突時,HashMap採用 “拉鍊法“ 進行解決,也就是陣列加連結串列的結構。在HashMap的程式碼註釋中,陣列中的元素用 “bucket” (中文讀作 桶) 來稱呼,而雜湊函式的作用就是將key定址到buckets中的一個位置,如果一個 bucket 有多個元素,那麼就以連結串列的形式儲存(jdk1.8之前單純是這樣)。

這是HashMap的儲存結構圖:

關於 “拉鍊法” 和 “hash衝突” 的知識點有疑問的讀者可以看下我之前的文章 資料結構:雜湊表以及雜湊衝突的解決方案
為了方便,下文的 “bucket“ 都用 “桶“ 替代。

深入原始碼

兩個引數

在具體學習原始碼之前,我們需要先了解兩個HashMap中的兩個重要引數,“初識容量” 和 "載入因子",

初識容量是指陣列的數量。載入因子則決定了 HashMap 中的元素在達到多少比例後可以擴容 (rehash),當HashMap的元素數量超過了載入因子與當前容量的乘積後,就需要對雜湊表做擴容操作。

在HashMap中,載入因子預設是0.75,這是結合時間、空間成本均衡考慮後的折中方案,因為 載入因子太大的話發生衝突的可能性會變大,查詢的效率反而低;太小的話頻繁rehash,降低效能。在設定初始容量時應該考慮到對映中所需的條目數及其載入因子,以便最大限度地減少 rehash 操作次數。如果初始容量大於最大條目數除以載入因子,則不會發生 rehash 操作。

成員變數

好了,前面說了那麼多,現在開始深入原始碼學習吧,先了解一下HashMap的主要的成員變數:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75F;
static final int TREEIFY_THRESHOLD = 8;
static final int MIN_TREEIFY_CAPACITY = 64;
transient HashMap.Node<K, V>[] table;
transient Set<Entry<K, V>> entrySet;
transient int size;
int threshold;
final float loadFactor;

可以看出,HashMap主要的成員變數比較多,有些變數還初始化了值,下面一個個來做解釋。

DEFAULT_INITIAL_CAPACITY:預設初識容量 1 << 4 ,也就是16,必須是2的整數次方。

DEFAULT_LOAD_FACTOR:預設載入因子,大小為0.75。

MAXIMUM_CAPACITY:最大容量, 2^ 30 次方。

TREEIFY_THRESHOLD :樹形閾值,大於這個數就要樹形化,也就是轉成紅黑樹。

MIN_TREEIFY_CAPACITY:樹形最小容量。

table:雜湊表的連結陣列,對應桶的下標。

entrySet:鍵值對集合。

size:鍵值對的數量,也就是HashMap的大小。

threshold:閾值,下次需要擴容時的值,等於 容量*載入因子。

loadFactor:載入因子。

介紹玩變數,下面介紹HashMap的構造方法。

四個構造方法

HashMap共有四個構造方法,程式碼如下:

//載入預設大小的載入因子
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; 
}
//載入預設大小的載入因子,並建立一個內容為引數 m 的內容的雜湊表
public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        //新增整個集合
        putMapEntries(m, false);
}
//指定容量和載入因子
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);
}
//指定容量,載入因子預設大小
public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

不難發現,上面第三個建構函式可以自定義載入因子和容量,首先判斷傳入的載入因子是否符合要求,然後根據制定的容量執行 tableSizeFor() 方法,它會根據容量來指定閾值,為何要多這一步呢?

因為buckets陣列的大小約束對於整個HashMap都至關重要,為了防止傳入一個不是2次冪的整數,必須要有所防範。tableSizeFor()函式會嘗試修正一個整數,並轉換為離該整數最近的2次冪。

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;
}

比如傳入一個整數244,經過位移,或運算後會返回最近的2次冪 256

插入資料的方法:put()

在集合中最常用的操作是儲存資料,也就是插入元素的過程,在HashMap中,插入資料用的是 put() 方法。

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

put方法沒有做多餘的操作,只是傳入 keyvalue 還有 hash 值 進入到 putVal方法中並返回對應的值,點選進入方法,一步步跟進原始碼:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    //雜湊表如果為空,就做擴容操作 resize()
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //要插入位置沒有元素,直接新建一個包含key的節點
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    //如果要插入的桶已經有元素,替換
    else {
        Node<K,V> e; K k;
        //key要插入的位置發生碰撞,讓e指向p
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        //沒碰撞,但是p是屬於紅黑樹的節點,執行putTreeVal()方法
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        //p是連結串列節點,遍歷連結串列,查詢並替換
        else {
            //遍歷陣列,如果連結串列長度達到8,轉換成紅黑樹
            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;
                }
                // 找到目標節點,退出迴圈,e指向p
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 節點已存在,替換value,並返回舊value
        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() 方法計算雜湊值
  • 然後呼叫 putVal() 方法中根據雜湊值進行相關操作
  • 如果當前 雜湊表內容為空,做擴容
  • 如果要插入的桶中沒有元素,新建個節點並放進去
  • 否則從要插入的桶中第一個元素開始查詢(這裡為什麼是第一個元素,下面會講到)
    1. 如果沒有碰撞,賦值給e,結束查詢
    2. 有碰撞,而且當前採用的還是 紅黑樹的節點,呼叫 putTreeVal() 進行插入
    3. 連結串列節點的話從傳統的連結串列陣列中查詢、找到賦值給e,結束
    4. 如果連結串列長度達到8,轉換成紅黑樹
  • 最後檢查是否需要擴容

put方法的程式碼中有幾個關鍵的方法,分別是:

  1. hash():雜湊函式,計算key對應的位置
  2. resize():擴容
  3. putTreeVal():插入紅黑樹的節點
  4. treeifyBin():樹形化容器

前面兩個是HashMap的桶連結串列操作的核心方法,後面的方法是Jdk1.8之後有關紅黑樹的操作,後面會講到,先來看前兩個方法。

雜湊函式:hash()

hash() 方法是HashMap 中的核心函式,在儲存資料時,將key傳入中進行運算,得出key的雜湊值,通過這個雜湊值運算才能獲取key應該放置在 “桶” 的哪個位置,下面是方法的原始碼:

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

從原始碼中可以看出,傳入key之後,hash() 會獲取key的hashCode進行無符號右移 16 位,然後進行按位異或,並把運算後的值返回,這個值就是key的雜湊值。這樣運算是為了減少碰撞衝突,因為大部分元素的hashCode在低位是相同的,不做處理的話很容易造成衝突。

之後還需要把 hash() 的返回值與table.length - 1做與運算,得到的結果即是陣列的下標(為什麼這麼算,下面會說),在上面的 putVal() 方法中就可以看到有這樣的程式碼操作,舉個例子圖:

table.length - 1就像是一個低位掩碼(這個設計也優化了擴容操作的效能),它和hash()做與操作時必然會將高位遮蔽(因為一個HashMap不可能有特別大的buckets陣列,至少在不斷自動擴容之前是不可能的,所以table.length - 1的大部分高位都為0),只保留低位,這樣一來就總是隻有最低的幾位是有效的,就算你的hashCode()實現得再好也難以避免發生碰撞。這時,hash()函式的價值就體現出來了,它對hash code的低位添加了隨機性並且混合了高位的部分特徵,顯著減少了碰撞衝突的發生。
另外,在putVal方法的原始碼中,我們可以看到有這樣一段程式碼

if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);

上面的註釋也說明了,這是檢測要插入位置是否有元素,沒有的話直接新建一個包含key的節點,那麼這裡為什麼要用 i = (n - 1) & hash 作為索引運算呢?

下面這段解釋摘自http://www.importnew.com/29724.html

這其實是一種優化手段,由於陣列的大小永遠是一個2次冪,在擴容之後,一個元素的新索引要麼是在原位置,要麼就是在原位置加上擴容前的容量。這個方法的巧妙之處全在於&運算,之前提到過&運算只會關注n
– 1(n =
陣列長度)的有效位,當擴容之後,n的有效位相比之前會多增加一位(n會變成之前的二倍,所以確保陣列長度永遠是2次冪很重要),然後只需要判斷hash在新增的有效位的位置是0還是1就可以算出新的索引位置,如果是0,那麼索引沒有發生變化,如果是1,索引就為原索引加上擴容前的容量。

效果圖如下:

這樣在每次擴容時都不用重新計算hash,省去了不少時間,而且新增有效位是0還是1是帶有隨機性的,之前兩個碰撞的Entry又有可能在擴容時再次均勻地散佈開,真可謂是非常精妙的設計。

動態擴容:resize()

在HashMap中,初始化陣列或者新增元素個數超過閾值時都會觸發 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) {
        //超過最大值不會擴容,把閾值設定為int的最大數
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        //向左移動1位擴大為原來2倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    //舊陣列大小為0,舊閾值>0,說明之前建立了雜湊表但沒有新增元素,初始化容量等於閾值
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    else {               // zero initial threshold signifies using defaults
        //舊容量、舊閾值都是0,說明還沒建立雜湊表,容量為預設容量,閾值為 容量*載入因子
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    //新閾值還沒有值,重新根據新的容量newCap計算大小
    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) {
        //遍歷舊陣列的每一個‘桶’,移動到新陣列newTab
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                oldTab[j] = null;
                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;
                        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;
}

上面的原始碼有點長,但總體邏輯就三步:

  1. 計算新桶陣列的容量大小 newCap 和新閾值 newThr
  2. 根據計算出的 newCap 建立新的桶陣列,並初始化桶的陣列table
  3. 將鍵值對節點重新對映到新的桶數組裡。如果節點是 TreeNode 型別,則需要拆分紅黑樹 (呼叫split()方法 )。如果是普通節點,則節點按原順序進行分組。

前面兩步的邏輯比較簡單,這裡不多敘述。重點是第三點,涉及到了紅黑樹的拆分,這是因為擴容後,桶陣列變多了,原有的陣列上元素較多的紅黑樹就需要重新拆分,對映成連結串列,防止單個桶的元素過多。

紅黑樹的拆分是呼叫TreeNode.split() 來實現的,這裡不單獨講。放到後面的紅黑樹一起分析。

節點樹化、紅黑樹的拆分

紅黑樹的引進是HashMap 在 Jdk1.8之後最大的變化,在1.8以前,HashMap的資料結構就是陣列加連結串列,某個桶的連結串列有可能因為資料過多而導致連結串列過長,遍歷的效率低下,1.8之後,HashMap對連結串列的長度做了處理,當連結串列長度超過8時,自動轉換為紅黑樹,有效的提升了HashMap的效能。

但紅黑樹的引進也使得程式碼的複雜度提高了不少,添加了有關紅黑樹的操作方法。本文只針對這些方法來做解析,不針對紅黑樹本身做展開,想了解紅黑樹的讀者可以看我之前的文章

資料結構:紅黑樹的結構以及方法剖析 (上) 以及 資料結構:紅黑樹的結構以及方法剖析 (下)

節點樹化

HashMap中的樹節點的程式碼用 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);
    }

可以看到就是個紅黑樹節點,有父親、左右孩子、前一個元素的節點,還有個顏色值。知道節點的結構後,我們來看有關紅黑樹的一些操作方法。

先來分析下樹化的程式碼:

//將普通的連結串列轉化為樹形節點連結串列
final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    // 桶陣列容量小於 MIN_TREEIFY_CAPACITY,優先進行擴容而不是樹化
    if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
        resize();
    else if ((e = tab[index = (n - 1) & hash]) != null) {
        TreeNode<K,V> hd = null, tl = null;
        do {
            //把節點轉換為樹形節點
            TreeNode<K,V> p = replacementTreeNode(e, null);
            if (tl == null)
                //把轉化後的頭節點賦給hd
                hd = p;
            else {
                p.prev = tl;
                tl.next = p;
            }
            tl = p;
        } while ((e = e.next) != null);
        if ((tab[index] = hd) != null)
            // 樹形節點不為空,轉換為紅黑樹
            hd.treeify(tab);
    }
}
TreeNode<K,V> replacementTreeNode(Node<K,V> p, Node<K,V> next) {
        return new TreeNode<>(p.hash, p.key, p.value, next);
    }

上面的程式碼並不太複雜,大致邏輯是根據hash表的元素個數判斷是需要擴容還是樹形化,然後依次呼叫不同的程式碼執行。

值得注意的是,在判斷容器是否需要樹形化的標準是連結串列長度需要大於或等於 MIN_TREEIFY_CAPACITY,前面也說了,它是HashMap的成員變數,初始值是64,那麼為什麼要滿足這個條件才會樹化呢?

下面這段摘自https://segmentfault.com/a/1190000012926722#articleHeader6

當桶陣列容量比較小時,鍵值對節點 hash
的碰撞率可能會比較高,進而導致連結串列長度較長。這個時候應該優先擴容,而不是立馬樹化。畢竟高碰撞率是因為桶陣列容量較小引起的,這個是主因。容量小時,優先擴容可以避免一些列的不必要的樹化過程。同時,桶容量較小時,擴容會比較頻繁,擴容時需要拆分紅黑樹並重新對映。所以在桶容量比較小的情況下,將長連結串列轉成紅黑樹是一件吃力不討好的事。

所以,HashMap的樹化過程也是儘量的考慮了容器效能,再看回上面的程式碼,連結串列樹化之前是先把節點轉為樹形節點,然後再呼叫 treeify() 轉換為紅黑樹,並且樹形節點TreeNode 繼承自 Node 類,所以 TreeNode 仍然包含 next 引用,原連結串列的節點順序最終通過 next 引用被儲存下來。

下面看下轉換紅黑樹的過程:

final void treeify(Node<K,V>[] tab) {
    TreeNode<K,V> root = null;
    for (TreeNode<K,V> x = this, next; x != null; x = next) {
        next = (TreeNode<K,V>)x.next;
        x.left = x.right = null;
        if (root == null) {     //第一次進入迴圈,確定頭節點,並且是黑色
            x.parent = null;
            x.red = false;
            root = x;
        }   
        else {      //後面進入迴圈走的邏輯,x 指向樹中的某個節點
            K k = x.key;
            int h = x.hash;
            Class<?> kc = null;
            //從根節點開始,遍歷所有節點跟當前節點 x 比較,調整位置,
            for (TreeNode<K,V> p = root;;) {
                int dir, ph;
                K pk = p.key;
                if ((ph = p.hash) > h)  //當比較節點的雜湊值比 x 大時, dir 為 -1
                    dir = -1;
                else if (ph < h)        //雜湊值比 x 小時 dir 為 1
                    dir = 1;
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0)
                    // 比較節點和x的key
                    dir = tieBreakOrder(k, pk);

                TreeNode<K,V> xp = p;
                //把 當前節點變成 x 的父親
                //如果當前比較節點的雜湊值比 x 大,x 就是左孩子,否則 x 是右孩子
                if ((p = (dir <= 0) ? p.left : p.right) == null) {
                    x.parent = xp;
                    if (dir <= 0)
                        xp.left = x;
                    else
                        xp.right = x;
                    root = balanceInsertion(root, x);
                    break;
                }
            }
        }
    }
    moveRootToFront(tab, root);
}

可以看到,程式碼的總體邏輯就是拿樹中的節點與當前節點做比較,進而確定節點在樹中的位置,具體實現的細節還是比較複雜的,這裡不一一展開了。

紅黑樹拆分

介紹了節點的樹化後,我們來學習下紅黑樹的拆分過程,HashMap擴容後,普通的節點需要重新對映,紅黑樹節點也不例外。

在將普通連結串列轉成紅黑樹時,HashMap 通過兩個額外的引用 next 和 prev 保留了原連結串列的節點順序。這樣再對紅黑樹進行重新對映時,完全可以按照對映連結串列的方式進行。這樣就避免了將紅黑樹轉成連結串列後再進行對映,無形中提高了效率。

下面看一下拆分的方法原始碼:

//map 容器本身
//tab 表示儲存桶頭結點的雜湊表
//index 表示從哪個位置開始修剪
//bit 要修剪的位數(雜湊值)
final void split(HashMap<K,V> map, Node<K,V>[] tab, int index, int bit) {
    TreeNode<K,V> b = this;
    // Relink into lo and hi lists, preserving order
    // 修剪後的兩個連結串列,下面用lo樹和hi樹來替代
    TreeNode<K,V> loHead = null, loTail = null;
    TreeNode<K,V> hiHead = null, hiTail = null;
    int lc = 0, hc = 0;
    for (TreeNode<K,V> e = b, next; e != null; e = next) {
        next = (TreeNode<K,V>)e.next;
        e.next = null;
        //如果當前節點雜湊值的最後一位等於要修剪的 bit 值,用於區分位於哪個桶
        if ((e.hash & bit) == 0) {
            //把節點放到lo樹的結尾
            if ((e.prev = loTail) == null)
                loHead = e;
            else
                loTail.next = e;
            loTail = e;
            ++lc;
        }
        //把當前節點放到hi樹
        else {
            if ((e.prev = hiTail) == null)
                hiHead = e;
            else
                hiTail.next = e;
            hiTail = e;
            ++hc;
        }
    }
    
    if (loHead != null) {
        // 如果 loHead 不為空,且連結串列長度小於等於 6,則將紅黑樹轉成連結串列
        if (lc <= UNTREEIFY_THRESHOLD)
            tab[index] = loHead.untreeify(map);
        else {
            tab[index] = loHead;
            /* 
             * hiHead != null 時,表明擴容後,
             * 有些節點不在原位置上了,需要重新樹化
             */
            if (hiHead != null) // (else is already treeified)
                loHead.treeify(tab);
        }
    }
    //與上面類似
    if (hiHead != null) {
        if (hc <= UNTREEIFY_THRESHOLD)
            tab[index + bit] = hiHead.untreeify(map);
        else {
            tab[index + bit] = hiHead;
            if (loHead != null)
                hiHead.treeify(tab);
        }
    }
}

原始碼的邏輯大概是這樣:拆分後,將紅黑樹拆分成兩條由 TreeNode 組成的連結串列(hi樹和lo樹)。如果連結串列長度小於 UNTREEIFY_THRESHOLD,則將連結串列轉換成普通連結串列。否則根據條件重新將 TreeNode 連結串列樹化。這裡用兩張圖來展示一下拆分前後的變化

紅黑樹拆分前:

拆分後:

至此,有關紅黑樹的一些轉換操作就介紹完畢了,除此之外,hashMap還提供了很多操作紅黑樹的方法,原理都差不多,讀者們可以自己去研究。

總結

HashMap的原始碼解析就告一段落了,最後,總結一下HashMap的一些特性:

1、HashMap 允許 key, value 為 null;

2、HashMap原始碼裡沒有做同步操作,多個執行緒操作可能會出現執行緒安全的問題,建議用Collections.synchronizedMap 來包裝,變成執行緒安全的Map,例如:

Map map = Collections.synchronizedMap(new HashMap<String,String>());

3、Jdk1.7以前,當HashMap中某個桶的結構為連結串列時,遍歷的時間複雜度為O(n),1.8之後,桶中過多元素的話會轉換成了紅黑樹,這時候的遍歷時間複雜度就是O(logn)。

心得

最後,說下心得,老實說,在寫這篇文章之前,我對HashMap只是的瞭解僅僅停留在用過的層面,沒有對原始碼做深入的瞭解,直到心血來潮想學習下Java的集合類才去看HashMap的原始碼,看完原始碼後,我被深深的震撼了,說實話,我沒想過平時最常見的工具類的原始碼是這麼複雜,一個HashMap就涉及到了如此眾多的技術知識,比如紅黑樹,連結串列轉換,hash運算等,通過簡單的程式碼就整合了這些知識點,而且還保證了HashMap的高效效能。說實話,我對設計者是非常佩服的,估計此生我都寫不出如此牛逼的程式碼。我也終於能理解為什麼那麼多公司面試時很喜歡問集合類的底層實現了,因為集合中涉及的技術知識是非常高深的,若能吃透集合類的原始碼,那人能不NB嗎?

最後,感謝這幾位大神的技術文章

https://blog.csdn.net/u011240877/article/details/53351188

https://blog.csdn.net/u011240877/article/details/53351188

http://www.importnew.com/29724.html