揭祕 HashMap 實現原理(Java 8)

HashMap 做爲一種容器類型,不管你是否瞭解過其內部的實現原理,它的大名已經頻頻出如今各類互聯網面試中了。從基本的使用角度來講,它很簡單,但從其內部的實現來看(尤爲是 Java 8 的改進以來),它又並不是想象中那麼容易。若是你必定要問了解其內部實現與否對於寫程序究竟有多大影響,我不能給出一個確切的答案。可是做爲一名合格程序員,對於這種遍地都在談論的技術不該該不爲所動。本篇文章主要從 jdk 1.8 的版本初步探尋 HashMap 的基本實現狀況,主要涉及內容以下:node

  • HashMap 的基本組成成員
  • put 方法的具體實現
  • remove 方法的具體實現
  • 其餘一些基本方法的基本介紹

1、HashMap 的基本組成成員程序員

首先,HashMap 是 Map 的一個實現類,它表明的是一種鍵值對的數據存儲形式。Key 不容許重複出現,Value 隨意。jdk 8 以前,其內部是由數組+鏈表來實現的,而 jdk 8 對於鏈表長度超過 8 的鏈表將轉儲爲紅黑樹。大體的數據存儲形式以下:面試

圖片來自網絡

下面分別對其中的基本成員屬性進行說明:數組

//默認的容量,即默認的數組長度 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大的容量,即數組可定義的最大長度 
static final int MAXIMUM_CAPACITY = 1 << 30;

這就是上述提到的數組,數組的元素都是 Node 類型,數組中的每一個 Node 元素都是一個鏈表的頭結點,經過它能夠訪問鏈接在其後面的全部結點。其實你也應該發現,上述的容量指的就是這個數組的長度。網絡

transient Node<K,V>[] table;
//實際存儲的鍵值對個數
transient int size;
//用於迭代防止結構性破壞的標量
transient int modCount;

下面這三個屬性是相關的,threshold 表明的是一個閾值,一般小於數組的實際長度。伴隨着元素不斷的被添加進數組,一旦數組中的元素數量達到這個閾值,那麼代表數組應該被擴容而不該該繼續任由元素加入。而這個閾值的具體值則由負載因子(loadFactor)和數組容量來決定,公式:threshold = capacity * loadFactor。app

int threshold;
final float loadFactor;
//HashMap 中默認負載因子爲 0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

好了,有關 HashMap 的基本屬性大體介紹如上。下面咱們看看它的幾個重載的構造函數。函數

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

這是一個最基本的構造函數,須要調用方傳入兩個參數,initialCapacity 和 loadFactor。程序的大部分代碼在判斷傳入參數的合法性,initialCapacity 小於零將拋出異常,大於 MAXIMUM_CAPACITY 將被限定爲 MAXIMUM_CAPACITY。loadFactor 若是小於等於零或者非數字類型也會拋出異常。源碼分析

整個構造函數的核心在對 threshold 的初始化操做:性能

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

這是一個小巧但精妙的方法,這裏經過異或的位運算將兩個字節的 n 打形成比 cap 大但最接近 2 的 n 次冪的一個數值。例如:學習

這裏寫圖片描述

這裏咱們表示 n 的時候使用了 7 個 x,因此不管 x 爲 0 或者 1,n 的值都是大於 2 的 7 次冪的。咱們從最終結果能夠看到,最後的 n 被打造爲 8 個 1,也就是 2 的 8 次冪減一。

因此從宏觀上看,傳入的容量不管是處於任何範圍,最終都會被打形成比該值大而且比最近的一個 2 的 n 次冪小一的值。爲何這麼作?由於 2 的 n 次冪小一的值在二進制角度看全爲 1,將有利於 HashMap 中的元素搜索,這一點咱們後續將介紹。

那麼經過該方法,咱們將得到一個 2 的整數次冪的容量的值,此處存放至 threshold,實際上咱們獲取的是一個有關數組容量的值,不該該存放至閾值 threshold 中,但在後續實際初始化數組的時候並不會受到影響,這裏多是寫 jdk 的大神偷了一次懶吧。

那麼咱們對於這個最基本的構造函數的介紹就已經結束了,固然,HashMap 中還有不少的重載構造函數,但幾乎都是基於上述的構造函數的。例如:

public HashMap(int initialCapacity) {
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
}

最後須要說明一點的是,以上的一些構造函數都沒有直接的建立一個切實存在的數組,他們都是在爲建立數組須要的一些參數作初始化,因此有些在構造函數中並無被初始化的屬性都會在實際初始化數組的時候用默認值替換。

2、put 方法的具體實現

put 方法的源碼分析是本篇的一個重點,由於經過該方法咱們能夠窺探到 HashMap 在內部是如何進行數據存儲的,所謂的數組+鏈表+紅黑樹的存儲結構是如何造成的,又是在何種狀況下將鏈表轉換成紅黑樹來優化性能的。帶着一系列的疑問,咱們看這個 put 方法:

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

添加一個元素只須要傳入一個鍵和一個值便可,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;
    //若是 table 還未被初始化,那麼初始化它
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //根據鍵的 hash 值找到該鍵對應到數組中存儲的索引
    //若是爲 null,那麼說明此索引位置並無被佔用
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    //不爲 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;
        //若是 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);
                    //若是插入後鏈表長度大於等於 8 ,將鏈表裂變成紅黑樹
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                //遍歷的過程當中,若是發現與某個結點的 hash和key,這依然是一次修改操做 
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        //e 不是 null,說明當前的 put 操做是一次修改操做而且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;
}

從總體上來看,該方法的大體處理邏輯已如上述註釋說明,下面咱們針對其中的細節進行詳細的解釋。

首先,咱們看 resize 這個方法是如何對 table 進行初始化的,代碼比較多,分兩部分進行解析:

//第一部分
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;
                return oldTab;
            }
            //未達到極限,將數組容量擴大兩倍,閾值也擴大兩倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; 
        }
        //數組未初始化,但閾值不爲 0,爲何不爲 0 ?
        //上述提到 jdk 大神偷懶的事情就指的這,構造函數根據傳入的容量打造了一個合適的數組容量暫存在閾值中
        //這裏直接使用
        else if (oldThr > 0) 
            newCap = oldThr;
        //數組未初始化而且閾值也爲0,說明一切都以默認值進行構造
        else {
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        //這裏也是在他偷懶的後續彌補
        //newCap = oldThr 以後並無計算閾值,因此 newThr = 0
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
****************後續代碼......*******

這一部分代碼結束後,不管是初始化數組仍是擴容,總之,必需的數組容量和閾值都已經計算完成了。下面看後續的代碼:

************第一部分代碼.....************
//根據新的容量初始化一個數組
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//舊數組不爲 null,此次的 resize 是一次擴容行爲
if (oldTab != null) {
    //將舊數組中的每一個節點位置相對靜止地拷貝值新數組中
    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;
            //若是 e 是紅黑樹結點,紅黑樹分裂,轉移至新表
            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 {
                    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;
                }
            }
        }
    }
}
//不論你是擴容仍是初始化,均可以返回 newTab
return newTab;

對於第二部分的代碼段來講,主要完成的是將舊鏈表中的各個節點按照原序地複製到新數組中。關於頭結點是紅黑樹的狀況咱們暫時不去涉及,下面重點介紹下鏈表的拷貝和優化代碼塊,這部分代碼再也不重複貼出,此處直接進行分析,有須要的能夠參照上述列出的代碼塊或者本身的 jdk 進行理解。

這部分實際上是一個優化操做,將當前鏈表上的一些結點移出來向剛擴容的另外一半存儲空間放。

通常咱們有以下公式:

index = e.hash & (oldCap - 1)

這裏寫圖片描述

隨便舉個例子,此時的 e 在容量擴大兩倍之後的索引值沒有變化,因此這部分結點是不須要移動的,那麼程序如何判斷擴容先後的 index 是否相等呢?

//oldCap 必定是 100...000 的形式
if ((e.hash & oldCap) == 0)

若是原 oldCap 爲 10000 的話,那麼擴容後的 newCap 則爲 100000,會比原來多出一位。因此咱們只要知道原索引值的前一位是 0 仍是 1 便可,若是是 0,那麼它和新容量與後仍是 0 並不改變索引的值,若是是 1 的話,那麼索引值會增長 oldCap。

這樣就分兩步拆分當前鏈表,一條鏈表是不須要移動的,依然保存在當前索引值的結點上,另外一條則須要變更到 index + oldCap 的索引位置上。

這裏咱們只介紹了普通鏈表的分裂狀況,至於紅黑樹的裂變實際上是相似的,依然分出一些結點到 index + oldCap 的索引位置上,只不過遍歷的方式不一樣而已。

這樣,咱們對於 resize 這個擴容的方法已經解析完成了,下面接着看 putVal 方法,篇幅比較長,該方法的源碼已經在介紹 resize 以前貼出,建議讀者根據本身的 jdk 對照着理解。

上面咱們說到,若是在 put 一個元素的時候判斷內部的 table 數組還未初始化,那麼調用 resize 根據相應的參數信息初始化數組。接下來的這個判斷語句就很簡單了:

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

根據鍵的 hash 值找到對應的索引位置,若是該位置爲 null,說明尚未頭結點,因而 newNode 並存儲在該位置上。

不然的話說明該位置已經有頭結點了,或者說已經存在一個鏈表或紅黑樹了,那麼咱們要作的只是新建一個節點添加到鏈表或者紅黑樹的最後位置便可。

第一步,

if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
      e = p;

p 指向當前節點,若是咱們要插入的節點的鍵以及鍵所對應的 hash 值和 p 節點徹底同樣的話,那麼說明此次 put 是一次修改操做,新建一個引用指向這個須要修改的節點。

第二步,

else if (p instanceof TreeNode)
     e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);

若是當前 p 節點是紅黑樹結點,那麼須要調用不一樣於鏈表的的添加節點的方法來添加一個節點到紅黑樹中。(主要是維持平衡,建議讀者去了解下紅黑樹,此處沒有深談是限於它的複雜度和文章篇幅)。

第三步,

else {
     for (int binCount = 0; ; ++binCount) {
     if ((e = p.next) == null) {
         p.next = newNode(hash, key, value, null);
         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;
    }
}

這裏主要處理的是向普通鏈表的末尾添加一個新的結點,e 不斷地日後移動,若是發現 e 爲 null,那麼說明已經到鏈表的末尾了,那麼新建一個節點添加到鏈表的末尾便可,由於 p 是 e 的父節點,因此直接讓 p.next 指向新節點便可。添加以後,若是發現鏈表長度超過 8,那麼將鏈表轉儲成紅黑樹。

在遍歷的過程當中,若是發現 e 所指向的當前結點和咱們即將插入的節點信息徹底匹配,那麼也說明這是一次修改操做,因爲 e 已經指向了該須要被修改的結點,因此直接 break 便可。

那麼最終,不管是第一步中找到的頭節點即須要被修改的節點,仍是第三步在遍歷中找到的須要被修改的節點,它們的引用都是 e,此時咱們只須要用傳入的 Value 值替換 e 指向的節點的 value 便可。正如這段代碼同樣:

if (e != null) { // existing mapping for key
     V oldValue = e.value;
     if (!onlyIfAbsent || oldValue == null)
          e.value = value;
     afterNodeAccess(e);
     return oldValue;
 }

若是 e 爲 null,那更簡單了,說明這次 put 是添加新元素而且新元素也已經在上述代碼中被添加到 HashMap 中了,咱們只須要關心下,新加入一個元素後是否達到數組的閾值,若是是則調用 resize 方法擴大數組容量。該方法已經詳細闡述過,此處再也不贅述。

因此,這個 put 方法是集添加與修改一體的一個方法,若是執行的是添加操做則會返回 null,是修改操做則會返回舊結點的 value 值。

那麼至此,咱們對添加操做的內部實現想必已經瞭解的不錯了,接下來看看刪除操做的內部實現。

3、remove 方法的具體實現

刪除操做就是一個查找+刪除的過程,相對於添加操做其實容易一些,但那是你基於上述添加方法理解的不錯的前提下。

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

根據鍵值刪除指定節點,這是一個最多見的操做了。顯然,removeNode 方法是核心。

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;
    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 = p;
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    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)))) {
            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;
            --size;
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

刪除操做須要保證在表不爲空的狀況下進行,而且 p 節點根據鍵的 hash 值對應到數組的索引,在該索引處一定有節點,若是爲 null ,那麼間接說明此鍵所對應的結點並不存在於整個 HashMap 中,這是不合法的,因此首先要在這兩個大前提下才能進行刪除結點的操做。

第一步,

if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))
     node = p;

須要刪除的結點就是這個頭節點,讓 node 引用指向它。不然說明待刪除的結點在當前 p 所指向的頭節點的鏈表或紅黑樹中,因而須要咱們遍歷查找。

第二步,

else if ((e = p.next) != null) {
     if (p instanceof TreeNode)
          node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
     else {
         do {
              if (e.hash == hash &&((k = e.key) == key ||(key != null && key.equals(k)))) {
                     node = e;
              break;
         }
         p = e;
         } while ((e = e.next) != null);
     }
}

若是頭節點是紅黑樹結點,那麼調用紅黑樹本身的遍歷方法去獲得這個待刪結點。不然就是普通鏈表,咱們使用 do while 循環去遍歷找到待刪結點。找到節點以後,接下來就是刪除操做了。

第三步,

if (node != null && (!matchValue || (v = node.value) == value ||(value != null && value.equals(v)))) {
       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;
       --size;
       afterNodeRemoval(node);
       return node;
 }

刪除操做也很簡單,若是是紅黑樹結點的刪除,直接調用紅黑樹的刪除方法進行刪除便可,若是是待刪結點就是一個頭節點,那麼用它的 next 結點頂替它做爲頭節點存放在 table[index] 中,若是刪除的是普通鏈表中的一個節點,用該結點的前一個節點直接跳過該待刪結點指向它的 next 結點便可。

最後,若是 removeNode 方法刪除成功將返回被刪結點,不然返回 null。

這樣,相對複雜的 put 和 remove 方法的內部實現,咱們已經完成解析了。下面看看其餘經常使用的方法實現,它們或多或少都於這兩個方法有所關聯。

4、其餘經常使用的方法介紹

除了經常使用的 put 和 remove 兩個方法外,HashMap 中還有一些好用的方法,下面咱們簡單的學習下它們。

一、clear

public void clear() {
    Node<K,V>[] tab;
    modCount++;
    if ((tab = table) != null && size > 0) {
        size = 0;
        for (int i = 0; i < tab.length; ++i)
            tab[i] = null;
    }
}

該方法調用結束後將清除 HashMap 中存儲的全部元素。

二、keySet

//實例屬性 keySet
transient volatile Set<K>        keySet;

public Set<K> keySet() {
    Set<K> ks;
    return (ks = keySet) == null ? (keySet = new KeySet()) : ks;
}
final class KeySet extends AbstractSet<K> {
    public final int size()                 { return size; }
    public final void clear()               { HashMap.this.clear(); }
    public final Iterator<K> iterator()     { return new KeyIterator(); }
    public final boolean contains(Object o) { return containsKey(o); }
    public final boolean remove(Object key) {
        return removeNode(hash(key), key, null, false, true) != null;
    }
    public final Spliterator<K> spliterator() {
        return new KeySpliterator<>(HashMap.this, 0, -1, 0, 0);
    }
}

HashMap 中定義了一個 keySet 的實例屬性,它保存的是整個 HashMap 中全部鍵的集合。上述所列出的 KeySet 類是 Set 的一個實現類,它負責爲咱們提供有關 HashMap 中全部對鍵的操做。

能夠看到,KeySet 中的全部的實例方法都依賴當前的 HashMap 實例,也就是說,咱們對返回的 keySet 集中的任意一個操做都會直接映射到當前 HashMap 實例中,例如你執行刪除一個鍵的操做,那麼 HashMap 中將會少一個節點。

三、values

public Collection<V> values() {
    Collection<V> vs;
    return (vs = values) == null ? (values = new Values()) : vs;
}

values 方法其實和 keySet 方法相似,它返回了全部節點的 value 屬性所構成的 Collection 集合,此處再也不贅述。

四、entrySet

public Set<Map.Entry<K,V>> entrySet() {
    Set<Map.Entry<K,V>> es;
    return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

它返回的是全部節點的集合,或者說是全部的鍵值對集合。

五、get

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

get 方法的內部實現實際上是咱們介紹過的 put 方法中的一部分,因此此處也再也不贅述。

至此,咱們簡單的解析了 HashMap 的內部實現,雖說並無面面俱到,可是最基本的、最核心的部分應該是敘述清晰的。總結不到之處,望不吝賜教!

相關文章
相關標籤/搜索