1. 程式人生 > 實用技巧 >HashMap在Jdk1.7和1.8中的實現

HashMap在Jdk1.7和1.8中的實現

Java集合類的原始碼是深入學習Java非常好的素材,原始碼裡很多優雅的寫法和思路,會讓人歎為觀止。HashMap的原始碼尤為經典,是非常值得去深入研究的,jdk1.8中HashMap發生了比較大的變化。

一、初窺HashMap

HashMap是應用更廣泛的雜湊表實現,而且大部分情況下,都能在常數時間效能的情況下進行put和get操作。要掌握HashMap,主要從如下幾點來把握:

  • jdk1.7中底層是由陣列(也有叫做“位桶”的)+連結串列實現;jdk1.8中底層是由陣列+連結串列/紅黑樹實現
  • 可以儲存null鍵和null值,執行緒不安全。在HashMap中,null可以作為鍵,這樣的鍵只有一個,但可以有一個或多個鍵所對應的值為null。當get()方法返回null值時,即可以表示HashMap中沒有該key,也可以表示該key所對應的value為null
    。因此,在HashMap中不能由get()方法來判斷HashMap中是否存在某個key,應該用containsKey()方法來判斷。而在Hashtable中,無論是key還是value都不能為null。
  • 初始size為16,擴容:newsize = oldsize*2,size一定為2的n次冪
  • 擴容針對整個Map,每次擴容時,原來陣列中的元素依次重新計算存放位置,並重新插入
  • 插入元素後才判斷該不該擴容,有可能無效擴容(插入後如果擴容,如果沒有再次插入,就會產生無效擴容)
  • 當Map中元素總數超過Entry陣列的75%,觸發擴容操作,為了減少連結串列長度,元素分配更均勻
  • 1.7中是先擴容後插入新值的,1.8中是先插值再擴容

為什麼說HashMap是執行緒不安全的?在接近臨界點時,若此時兩個或者多個執行緒進行put操作,都會進行resize(擴容)和reHash(為key重新計算所在位置),而reHash在併發的情況下可能會形成連結串列環。總結來說就是在多執行緒環境下,使用HashMap進行put操作會引起死迴圈,導致CPU利用率接近100%,所以在併發情況下不能使用HashMap。為什麼在併發執行put操作會引起死迴圈?是因為多執行緒會導致HashMap的Entry連結串列形成環形資料結構,一旦形成環形資料結構,Entry的next節點永遠不為空,就會產生死迴圈獲取Entry。jdk1.7的情況下,併發擴容時容易形成連結串列環,此情況在1.8時就好太多太多了。因為在1.8中當連結串列長度大於閾值(預設長度為8)時,連結串列會被改成樹形(紅黑樹)結構。

二、jdk1.7中HashMap的實現

HashMap底層維護的是陣列+連結串列,我們可以通過一小段原始碼來看看:

 /**
  * The default initial capacity - MUST be a power of two.
  *  即 預設初始大小,值為16
  */
 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

 /**
  * The maximum capacity, used if a higher value is implicitly specified
  * by either of the constructors with arguments.
  * MUST be a power of two <= 1<<30.
  *  即 最大容量,必須為2^30
  */
 static final int MAXIMUM_CAPACITY = 1 << 30;

 /**
  * The load factor used when none specified in constructor.
  * 負載因子為0.75
  */
 static final float DEFAULT_LOAD_FACTOR = 0.75f;

 /**
  * The bin count threshold for using a tree rather than list for a
  * bin.  Bins are converted to trees when adding an element to a
  * bin with at least this many nodes. The value must be greater
  * than 2 and should be at least 8 to mesh with assumptions in
  * tree removal about conversion back to plain bins upon
  * shrinkage.
  * 大致意思就是說hash衝突預設採用單鏈表儲存,當單鏈表節點個數大於8時,會轉化為紅黑樹儲存
  */
 static final int TREEIFY_THRESHOLD = 8;

 /**
  * The bin count threshold for untreeifying a (split) bin during a
  * resize operation. Should be less than TREEIFY_THRESHOLD, and at
  * most 6 to mesh with shrinkage detection under removal.
  * hash衝突預設採用單鏈表儲存,當單鏈表節點個數大於8時,會轉化 
     為紅黑樹儲存。
* 當紅黑樹中節點少於6時,則轉化為單鏈表儲存
  */
 static final int UNTREEIFY_THRESHOLD = 6;

 /**
  * The smallest table capacity for which bins may be treeified.
  * (Otherwise the table is resized if too many nodes in a bin.)
  * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
  * between resizing and treeification thresholds.
  * hash衝突預設採用單鏈表儲存,當單鏈表節點個數大於8時,會轉化為紅黑樹儲存。
  * 但是有一個前提:要求陣列長度大於64,否則不會進行轉化
  */
 static final int MIN_TREEIFY_CAPACITY = 64;

通過以上程式碼可以看出初始容量(16)、負載因子以及對陣列的說明。陣列中的每一個元素其實就是Entry<K,V>[] table,Map中的key和value就是以Entry的形式儲存的。Entry包含四個屬性:key、value、hash值和用於單向連結串列的next。關於Entry<K,V>的具體定義參看如下原始碼:

static class Entry<K,V> implements Map.Entry<K,V> {
    final K key;
    V value;
    Entry<K,V> next;
    int hash;
 
    Entry(int h, K k, V v, Entry<K,V> n) {
        value = v;
        next = n;
        key = k;
        hash = h;
    }
 
    public final K getKey() {
        return key;
    }
 
    public final V getValue() {
        return value;
    }
 
    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }
 
    public final boolean equals(Object o) {
        if (!(o instanceof Map.Entry))
            return false;
        Map.Entry e = (Map.Entry)o;
        Object k1 = getKey();
        Object k2 = e.getKey();
        if (k1 == k2 || (k1 != null && k1.equals(k2))) {
            Object v1 = getValue();
            Object v2 = e.getValue();
            if (v1 == v2 || (v1 != null && v1.equals(v2)))
                return true;
        }
        return false;
    }
 
    public final int hashCode() {
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }
 
    public final String toString() {
        return getKey() + "=" + getValue();
    }
 
    /**
     * This method is invoked whenever the value in an entry is
     * overwritten by an invocation of put(k,v) for a key k that's already
     * in the HashMap.
     */
    void recordAccess(HashMap<K,V> m) {
    }
 
    /**
     * This method is invoked whenever the entry is
     * removed from the table.
     */
    void recordRemoval(HashMap<K,V> m) {
    }
}

HashMap的初始值要考慮載入因子:

  • 雜湊衝突:若干Key的雜湊值按陣列大小取模後,如果落在同一個陣列下標上,將組成一條Entry鏈,對Key的查詢需要遍歷Entry鏈上的每個元素執行equals()比較。
  • 載入因子:為了降低雜湊衝突的概率,預設當HashMap中的鍵值對達到陣列大小的75%時,即會觸發擴容。因此,如果預估容量是100,即需要設定100/0.75=134的陣列大小。
  • 空間換時間:如果希望加快Key查詢的時間,還可以進一步降低載入因子,加大初始大小,以降低雜湊衝突的概率。

HashMap和Hashtable都是用hash演算法來決定其元素的儲存,因此HashMap和Hashtable的hash表包含如下屬性:

  • 容量(capacity):hash表中桶的數量
  • 初始化容量(initial capacity):建立hash表時桶的數量,HashMap允許在構造器中指定初始化容量
  • 尺寸(size):當前hash表中記錄的數量
  • 負載因子(load factor):負載因子等於“size/capacity”。負載因子為0,表示空的hash表,0.5表示半滿的散列表,依此類推。輕負載的散列表具有衝突少、適宜插入與查詢的特點(但是使用Iterator迭代元素時比較慢)

除此之外,hash表裡還有一個“負載極限”,“負載極限”是一個0~1的數值,“負載極限”決定了hash表的最大填滿程度。當hash表中的負載因子達到指定的“負載極限”時,hash表會自動成倍地增加容量(桶的數量),並將原有的物件重新分配,放入新的桶內,這稱為rehashing。

HashMap和Hashtable的構造器允許指定一個負載極限,HashMap和Hashtable預設的“負載極限”為0.75,這表明當該hash表的3/4已經被填滿時,hash表會發生rehashing。

“負載極限”的預設值(0.75)是時間和空間成本上的一種折中:

  • 較高的“負載極限”可以降低hash表所佔用的記憶體空間,但會增加查詢資料的時間開銷,而查詢是最頻繁的操作(HashMap的get()與put()方法都要用到查詢)
  • 較低的“負載極限”會提高查詢資料的效能,但會增加hash表所佔用的記憶體開銷

程式猿可以根據實際情況來調整“負載極限”值。

當向 HashMap 中put一對鍵值時,它會根據 key的 hashCode 值計算出一個位置, 該位置就是此物件準備往陣列中存放的位置。 該計算過程參看如下程式碼:

transient int hashSeed = 0;
final int hash(Object k) {
     int h = hashSeed;
     if (0 != h && k instanceof String) {
         return sun.misc.Hashing.stringHash32((String) k);
     }
 
     h ^= k.hashCode();
 
     // This function ensures that hashCodes that differ only by
     // constant multiples at each bit position have a bounded
     // number of collisions (approximately 8 at default load factor).
     h ^= (h >>> 20) ^ (h >>> 12);
     return h ^ (h >>> 7) ^ (h >>> 4);
 }
 
 /**
  * Returns index for hash code h.
  */
 static int indexFor(int h, int length) {
     // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
     return h & (length-1);
 }

通過hash計算出來的值將會使用indexFor方法找到它應該所在的table下標。當兩個key通過hashCode計算相同時,則發生了hash衝突(碰撞),HashMap解決hash衝突的方式是用連結串列(拉鍊法)。當發生hash衝突時,則將存放在陣列中的Entry設定為新值的next(這裡要注意的是,比如A和B都hash後都對映到下標i中,之前已經有A了,當map.put(B)時,將B放到下標i中,A則為B的next,所以新值存放在陣列中,舊值在新值的連結串列上)。即將新值作為此連結串列的頭節點,為什麼要這樣操作?據說後插入的Entry被查詢的可能性更大(因為get查詢的時候會遍歷整個連結串列),此處有待考究,如果有哪位大神知道,請留言告知。有一種說法就是連結串列查詢複雜度高,可插入和刪除效能高,如果將新值插在末尾,就需要先經過一輪遍歷,這個時間複雜度高,開銷大,如果是插在頭結點,省去了遍歷的開銷,還發揮了連結串列插入效能高的優勢。

如果該位置沒有物件存在,就將此物件直接放進陣列當中;如果該位置已經有物件存在了,則順著此存在的物件的鏈開始尋找(為了判斷是否值相同,map不允許<key,value>鍵值對重複), 如果此鏈上有物件的話,再去使用 equals方法進行比較,如果對此鏈上的每個物件的 equals 方法比較都為 false,則將該物件放到陣列當中,然後將陣列中該位置以前存在的那個物件連結到此物件的後面。

新增節點到連結串列中:找到陣列下標後,會先進行key判重,如果沒有重複,就準備將新值放入到連結串列的表頭。

void addEntry(int hash, K key, V value, int bucketIndex) {
    // 如果當前 HashMap 大小已經達到了閾值,並且新值要插入的陣列位置已經有元素了,那麼要擴容
    if ((size >= threshold) && (null != table[bucketIndex])) {
        // 擴容
        resize(2 * table.length);
        // 擴容以後,重新計算 hash 值
        hash = (null != key) ? hash(key) : 0;
        // 重新計算擴容後的新的下標
        bucketIndex = indexFor(hash, table.length);
    }
    // 往下看
    createEntry(hash, key, value, bucketIndex);
}
// 這個很簡單,其實就是將新值放到連結串列的表頭,然後 size++
void createEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    table[bucketIndex] = new Entry<>(hash, key, value, e);
    size++;
}

這個方法的主要邏輯就是先判斷是否需要擴容,需要帶的話先擴容,然後再將這個新的資料插入到擴容後的陣列的相應位置處的連結串列的表頭。

擴容就是用一個新的大陣列替換原來的小陣列,並將原來陣列中的值遷移到新的陣列中。由於是雙倍擴容,遷移過程中,會將原來table[i]中的連結串列的所有節點,分拆到新的陣列的newTable[i]和newTable[i+oldLength]位置上。如原來陣列長度是16,那麼擴容後,原來table[0]處的連結串列中的所有元素會被分配到新陣列中newTable[0]和newTable[16]這兩個位置。擴容期間,由於會新建一個新的空陣列,並且用舊的項填充到這個新的陣列中去。所以,在這個填充的過程中,如果有執行緒獲取值,很可能會取到 null 值,而不是我們所希望的、原來新增的值。

圖中,左邊部分即代表雜湊表,也稱為雜湊陣列(預設陣列大小是16,每對key-value鍵值對其實是存在map的內部類entry裡的),陣列的每個元素都是一個單鏈表的頭節點,跟著的藍色連結串列是用來解決衝突的,如果不同的key對映到了陣列的同一位置處,就將其放入單鏈表中。

前面說過HashMap的key是允許為null的,當出現這種情況時,會放到table[0]中。

private V putForNullKey(V value) {
    for (Entry<K,V> e = table[0]; e != null; e = e.next) {
        if (e.key == null) {
            V oldValue = e.value;
            e.value = value;
            e.recordAccess(this);
            return oldValue;
        }
    }
    modCount++;
    addEntry(0, null, value, 0);
    return null;
} 

當size>=threshold( threshold等於“容量*負載因子”)時,會發生擴容

void addEntry(int hash, K key, V value, int bucketIndex) {
    if ((size >= threshold) && (null != table[bucketIndex])) {
        resize(2 * table.length);
        hash = (null != key) ? hash(key) : 0;
        bucketIndex = indexFor(hash, table.length);
    }
 
    createEntry(hash, key, value, bucketIndex);
}

特別提示:jdk1.7中resize,只有當 size>=threshold並且 table中的那個槽中已經有Entry時,才會發生resize。即有可能雖然size>=threshold,但是必須等到相應的槽至少有一個Entry時,才會擴容,可以通過上面的程式碼看到每次resize都會擴大一倍容量(2 * table.length)。

三、jdk1.8中HashMap的實現

在jdk1.8中HashMap的內部結構可以看作是陣列(Node<K,V>[] table)和連結串列的複合結構,陣列被分為一個個桶(bucket),通過雜湊值決定了鍵值對在這個陣列中的定址(雜湊值相同的鍵值對,則以連結串列形式儲存。有一點需要注意,如果連結串列大小超過閾值(TREEIFY_THRESHOLD,8),圖中的連結串列就會被改造為樹形(紅黑樹)結構。

transient Node<K,V>[] table;

Entry的名字變成了Node,原因是和紅黑樹的實現TreeNode相關聯。1.8與1.7最大的不同就是利用了紅黑樹,即由陣列+連結串列(或紅黑樹)組成。

在分析jdk1.7中HashMap的hash衝突時,不知大家是否有個疑問就是萬一發生碰撞的節點非常多怎麼辦?如果說成百上千個節點在hash時發生碰撞,儲存一個連結串列中,那麼如果要查詢其中一個節點,那就不可避免的花費O(N)的查詢時間,這將是多麼大的效能損失。這個問題終於在JDK1.8中得到了解決,在最壞的情況下,連結串列查詢的時間複雜度為O(n),而紅黑樹一直是O(logn),這樣會提高HashMap的效率。

jdk1.7中HashMap採用的是位桶+連結串列的方式,即我們常說的雜湊連結串列的方式,而jdk1.8中採用的是位桶+連結串列/紅黑樹的方式,也是非執行緒安全的。當某個位桶的連結串列的長度達到某個閥值的時候,這個連結串列就將轉換成紅黑樹。

jdk1.8中,當同一個hash值的節點數不小於8時,將不再以單鏈表的形式儲存了,會被調整成一顆紅黑樹(上圖中null節點沒畫)。這就是jdk1.7與jdk1.8中HashMap實現的最大區別。

HashMap根據鏈地址法(拉鍊法)來解決衝突,在jdk1.8中,如果連結串列長度大於8且節點陣列長度大於64的時候,就把連結串列下所有的節點轉為紅黑樹。

通過分析put方法的原始碼,可以讓這種區別更直觀:

static final int TREEIFY_THRESHOLD = 8;
 
public V put(K key, V value) {
        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;
    //如果當前map中無資料,執行resize方法。並且返回n
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
     //如果要插入的鍵值對要存放的這個位置剛好沒有元素,那麼把他封裝成Node物件,放在這個位置上即可
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
    //否則的話,說明這上面有元素
        else {
            Node<K,V> e; K k;
        //如果這個元素的key與要插入的一樣,那麼就替換一下。
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
        //1.如果當前節點是TreeNode型別的資料,執行putTreeVal方法
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
        //還是遍歷這條鏈子上的資料,跟jdk7沒什麼區別
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
            //2.完成了操作後多做了一件事情,判斷,並且可能執行treeifyBin方法
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    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) //true || --
                    e.value = value;
           //3.
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
    //判斷閾值,決定是否擴容
        if (++size > threshold)
            resize();
        //4.
        afterNodeInsertion(evict);
        return null;
    }

treeifyBin()就是將連結串列轉換成紅黑樹。

樹化操作的過程有點複雜,可以結合原始碼來看看。將原本的單鏈錶轉化為雙向連結串列,再遍歷這個雙向連結串列轉化為紅黑樹

final void treeifyBin(Node<K,V>[] tab, int hash) {
     int n, index; Node<K,V> e;
     //樹形化還有一個要求就是陣列長度必須大於等於64,否則繼續採用擴容策略
     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;//hd指向首節點,tl指向尾節點
         do {
             TreeNode<K,V> p = replacementTreeNode(e, null);//將連結串列節點轉化為紅黑樹節點
            if (tl == null) // 如果尾節點為空,說明還沒有首節點
                hd = p;  // 當前節點作為首節點
            else { // 尾節點不為空,構造一個雙向連結串列結構,將當前節點追加到雙向連結串列的末尾
                p.prev = tl; // 當前樹節點的前一個節點指向尾節點
                tl.next = p; // 尾節點的後一個節點指向當前節點
            }
            tl = p; // 把當前節點設為尾節點
        } while ((e = e.next) != null); // 繼續遍歷單鏈表
        //將原本的單鏈錶轉化為一個節點型別為TreeNode的雙向連結串列
        if ((tab[index] = hd) != null) // 把轉換後的雙向連結串列,替換陣列原來位置上的單向連結串列
            hd.treeify(tab); // 將當前雙向連結串列樹形化
    }
}

大家要特別注意一點,樹化有個要求就是陣列長度必須大於等於MIN_TREEIFY_CAPACITY(64),否則繼續採用擴容策略。

總的來說,HashMap預設採用陣列+單鏈表方式儲存元素,當元素出現雜湊衝突時,會儲存到該位置的單鏈表中。但是單鏈表不會一直增加元素,當元素個數超過8個時,會嘗試將單鏈錶轉化為紅黑樹儲存。但是在轉化前,會再判斷一次當前陣列的長度,只有陣列長度大於64才處理。否則,進行擴容操作。

將雙向連結串列轉化為紅黑樹的實現:

 final void treeify(Node<K,V>[] tab) {
     TreeNode<K,V> root = null;  // 定義紅黑樹的根節點
     for (TreeNode<K,V> x = this, next; x != null; x = next) { // 從TreeNode雙向連結串列的頭節點開始逐個遍歷
         next = (TreeNode<K,V>)x.next; // 頭節點的後繼節點
         x.left = x.right = null;
         if (root == null) {
             x.parent = null;
             x.red = false;
             root = x; // 頭節點作為紅黑樹的根,設定為黑色
        }
        else { // 紅黑樹存在根節點
            K k = x.key; 
            int h = x.hash;
            Class<?> kc = null;
            for (TreeNode<K,V> p = root;;) { // 從根開始遍歷整個紅黑樹
                int dir, ph;
                K pk = p.key;
                if ((ph = p.hash) > h) // 當前紅黑樹節點p的hash值大於雙向連結串列節點x的雜湊值
                    dir = -1;
                else if (ph < h) // 當前紅黑樹節點的hash值小於雙向連結串列節點x的雜湊值
                    dir = 1;
                else if ((kc == null &&
                          (kc = comparableClassFor(k)) == null) ||
                         (dir = compareComparables(kc, k, pk)) == 0) // 當前紅黑樹節點的hash值等於雙向連結串列節點x的雜湊值,則如果key值採用比較器一致則比較key值
                    dir = tieBreakOrder(k, pk); //如果key值也一致則比較className和identityHashCode

                TreeNode<K,V> xp = p; 
                if ((p = (dir <= 0) ? p.left : p.right) == null) { // 如果當前紅黑樹節點p是葉子節點,那麼雙向連結串列節點x就找到了插入的位置
                    x.parent = xp;
                    if (dir <= 0) //根據dir的值,插入到p的左孩子或者右孩子
                        xp.left = x;
                    else
                        xp.right = x;
                    root = balanceInsertion(root, x); //紅黑樹中插入元素,需要進行平衡調整(過程和TreeMap調整邏輯一模一樣)
                    break;
                }
            }
        }
    }
    //將TreeNode雙向連結串列轉化為紅黑樹結構之後,由於紅黑樹是基於根節點進行查詢,所以必須將紅黑樹的根節點作為陣列當前位置的元素
    moveRootToFront(tab, root);
}

然後將紅黑樹的根節點移動端陣列的索引所在位置上:

static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
     int n;
     if (root != null && tab != null && (n = tab.length) > 0) {
         int index = (n - 1) & root.hash; //找到紅黑樹根節點在陣列中的位置
         TreeNode<K,V> first = (TreeNode<K,V>)tab[index]; //獲取當前陣列中該位置的元素
         if (root != first) { //紅黑樹根節點不是陣列當前位置的元素
             Node<K,V> rn;
             tab[index] = root;
             TreeNode<K,V> rp = root.prev;
            if ((rn = root.next) != null) //將紅黑樹根節點前後節點相連
                ((TreeNode<K,V>)rn).prev = rp;
            if (rp != null)
                rp.next = rn;
            if (first != null) //將陣列當前位置的元素,作為紅黑樹根節點的後繼節點
                first.prev = root;
            root.next = first;
            root.prev = null;
        }
        assert checkInvariants(root);
    }
}

putVal方法處理的邏輯比較多,包括初始化、擴容、樹化,近乎在這個方法中都能體現,針對原始碼簡單講解下幾個關鍵點:

  • 如果Node<K,V>[] table是null,resize方法會負責初始化,即如下程式碼:

if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;
  • resize方法兼顧兩個職責,建立初始儲存表格,或者在容量不滿足需求的時候,進行擴容(resize)。

  在放置新的鍵值對的過程中,如果發生下面條件,就會發生擴容。

if (++size > threshold)
    resize();
  • 具體鍵值對在雜湊表中的位置(陣列index)取決於下面的位運算:
i = (n - 1) & hash

仔細觀察雜湊值的源頭,會發現它並不是key本身的hashCode,而是來自於HashMap內部的另一個hash方法。為什麼這裡需要將高位資料移位到低位進行異或運算呢?這是因為有些資料計算出的雜湊值差異主要在高位,而HashMap裡的雜湊定址是忽略容量以上的高位的,那麼這種處理就可以有效避免類似情況下的雜湊碰撞。

在jdk1.8中取消了indefFor()方法,直接用(tab.length-1)&hash,所以看到這個,代表的就是陣列的下角標。

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

為什麼HashMap為什麼要樹化?
之前在極客時間的專欄裡看到過一個解釋。本質上這是個安全問題。因為在元素放置過程中,如果一個物件雜湊衝突,都被放置到同一個桶裡,則會形成一個連結串列,我們知道連結串列查詢是線性的,會嚴重影響存取的效能。而在現實世界,構造雜湊衝突的資料並不是非常複雜的事情,惡意程式碼就可以利用這些資料大量與伺服器端互動,導致伺服器端CPU大量佔用,這就構成了雜湊碰撞拒絕服務攻擊,國內一線網際網路公司就發生過類似攻擊事件。

為什麼要將連結串列中轉紅黑樹的閾值設為8?

我們可以這麼來看,當連結串列長度大於或等於閾值(預設為 8)的時候,如果同時還滿足容量大於或等於 MIN_TREEIFY_CAPACITY(預設為 64)的要求,就會把連結串列轉換為紅黑樹。同樣,後續如果由於刪除或者其他原因調整了大小,當紅黑樹的節點小於或等於 6 個以後,又會恢復為連結串列形態。

每次遍歷一個連結串列,平均查詢的時間複雜度是 O(n),n 是連結串列的長度。紅黑樹有和連結串列不一樣的查詢效能,由於紅黑樹有自平衡的特點,可以防止不平衡情況的發生,所以可以始終將查詢的時間複雜度控制在 O(log(n))。最初連結串列還不是很長,所以可能 O(n) 和 O(log(n)) 的區別不大,但是如果連結串列越來越長,那麼這種區別便會有所體現。所以為了提升查詢效能,需要把連結串列轉化為紅黑樹的形式。

還要注意很重要的一點,單個 TreeNode 需要佔用的空間大約是普通 Node 的兩倍,所以只有當包含足夠多的 Nodes 時才會轉成 TreeNodes,而是否足夠多就是由 TREEIFY_THRESHOLD 的值決定的。而當桶中節點數由於移除或者 resize 變少後,又會變回普通的連結串列的形式,以便節省空間。

預設是連結串列長度達到 8 就轉成紅黑樹,而當長度降到 6 就轉換回去,這體現了時間和空間平衡的思想,最開始使用連結串列的時候,空間佔用是比較少的,而且由於連結串列短,所以查詢時間也沒有太大的問題。可是當連結串列越來越長,需要用紅黑樹的形式來保證查詢的效率。

在理想情況下,連結串列長度符合泊松分佈,各個長度的命中概率依次遞減,當長度為 8 的時候,是最理想的值。

事實上,連結串列長度超過 8 就轉為紅黑樹的設計,更多的是為了防止使用者自己實現了不好的雜湊演算法時導致連結串列過長,從而導致查詢效率低,而此時轉為紅黑樹更多的是一種保底策略,用來保證極端情況下查詢的效率。

通常如果 hash 演算法正常的話,那麼連結串列的長度也不會很長,那麼紅黑樹也不會帶來明顯的查詢時間上的優勢,反而會增加空間負擔。所以通常情況下,並沒有必要轉為紅黑樹,所以就選擇了概率非常小,小於千萬分之一概率,也就是長度為 8 的概率,把長度 8 作為轉化的預設閾值。

如果開發中發現 HashMap 內部出現了紅黑樹的結構,那可能是我們的雜湊演算法出了問題,所以需要選用合適的hashCode方法,以便減少衝突。

四、分析Hashtable、HashMap、TreeMap的區別

  • HashMap是繼承自AbstractMap類,而HashTable是繼承自Dictionary類。不過它們都同時實現了map、Cloneable(可複製)、Serializable(可序列化)這三個介面。儲存的內容是基於key-value的鍵值對對映,不能有重複的key,而且一個key只能對映一個value。HashSet底層就是基於HashMap實現的。
  • Hashtable的key、value都不能為null;HashMap的key、value可以為null,不過只能有一個key為null,但可以有多個null的value;TreeMap鍵、值都不能為null。
  • Hashtable、HashMap具有無序特性。TreeMap是利用紅黑樹實現的(樹中的每個節點的值都會大於或等於它的左子樹中的所有節點的值,並且小於或等於它的右子樹中的所有節點的值),實現了SortMap介面,能夠對儲存的記錄根據鍵進行排序。所以一般需求排序的情況下首選TreeMap,預設按鍵的升序排序(深度優先搜尋),也可以自定義實現Comparator介面實現排序方式。

一般情況下我們選用HashMap,因為HashMap的鍵值對在取出時是隨機的,其依據鍵的hashCode和鍵的equals方法存取資料,具有很快的訪問速度,所以在Map中插入、刪除及索引元素時其是效率最高的實現。而TreeMap的鍵值對在取出時是排過序的,所以效率會低點。

TreeMap是基於紅黑樹的一種提供順序訪問的Map,與HashMap不同的是它的get、put、remove之類操作都是o(log(n))的時間複雜度,具體順序可以由指定的Comparator來決定,或者根據鍵的自然順序來判斷。

對HashMap做下總結:
HashMap基於雜湊散列表實現 ,可以實現對資料的讀寫。將鍵值對傳遞給put方法時,它呼叫鍵物件的hashCode()方法來計算hashCode,然後找到相應的bucket位置(即陣列)來儲存值物件。當獲取物件時,通過鍵物件的equals()方法找到正確的鍵值對,然後返回值物件。HashMap使用連結串列來解決hash衝突問題,當發生衝突了,物件將會儲存在連結串列的頭節點中。HashMap在每個連結串列節點中儲存鍵值對物件,當兩個不同的鍵物件的hashCode相同時,它們會儲存在同一個bucket位置的連結串列中,如果連結串列大小超過閾值(TREEIFY_THRESHOLD,8),連結串列就會被改造為樹形結構。

有個問題要特別宣告下:

  • HashMap在jdk1.7中採用表頭插入法,在擴容時會改變連結串列中元素原本的順序,以至於在併發場景下導致連結串列成環的問題。
  • 在jdk1.8中採用的是尾部插入法,在擴容時會保持連結串列元素原本的順序,就不會出現連結串列成環的問題了。

我們可以簡單列下HashMap在1.7和1.8之間的變化:

  • 1.7中採用陣列+連結串列,1.8採用的是陣列+連結串列/紅黑樹,即在1.7中連結串列長度超過一定長度後就改成紅黑樹儲存。
  • 1.7擴容時需要重新計算雜湊值和索引位置,1.8並不重新計算雜湊值,巧妙地採用和擴容後容量進行&操作來計算新的索引位置。
  • 1.7是採用表頭插入法插入連結串列,1.8採用的是尾部插入法。
  • 在1.7中採用表頭插入法,在擴容時會改變連結串列中元素原本的順序,以至於在併發場景下導致連結串列成環的問題;在1.8中採用尾部插入法,在擴容時會保持連結串列元素原本的順序,就不會出現連結串列成環的問題了。