1. 程式人生 > >JDK1.8原始碼解析-HashMap(一)

JDK1.8原始碼解析-HashMap(一)

JDK1.8原始碼解析-HashMap I

本文主要介紹了JDK1.8中HashMap的實現原理,對部分常用的API進行原始碼解讀,網上該主題的資源非常多,作者參考了很多相關文章不在文中一一列舉了,在此基礎上加入了自己對部分原始碼的理解。

1. HashMap概述

根據JDK1.8中HashMap的JavaDo的描述,HashMap可以允許key為null,value為null的鍵值對,值得注意的是,只存在一組key=null的鍵值對,當key!=null時,可以存在多組value=null的鍵值對。對比HashTable類,HashMap的主要的區別在於執行緒不安全以及允許null的出現,特別要注意的是由於HashTable不允許null出現,所有繼承HashTable的類也遵循該法則,例如Java.Util.Properties

。在Java8中,HashMap通過了紅黑樹對hash collision的情況進行了優化,後文將詳細介紹。因此簡單地說:

【1】新程式碼避免使用HashTable,以及繼承HashTable實現擴充套件。

【2】遇到併發需求時,使用java.util.concurrent.ConcurrentHashMap<K, V>, 或者Collections.synchronizedMap(new HashMap(...))實現同步。

【3】非併發需求時,使用HashMap效能最高。整個HashMap的資料結構通過陣列+連結串列實現,當產生嚴重的hash碰撞時,會造成某個hash桶中的連結串列過長,此時將連結串列轉換成紅黑樹儲存資料,提高增刪改查效能。

2. 原始碼解析

2.1 Hash演算法

hash演算法的目的是通過計算key的雜湊值來確定value應該存放在雜湊桶(陣列)的那個位置。可以想到是通過hash % table.length(桶長度)可以均勻地將鍵值對分佈在桶中,但是模運算消耗比較大,為了提高效能,HashMap規定了陣列長度必須是power of two,並且以非常巧妙的方法做到取模操作。核心思想是當陣列長度len是2的n次方時,len-1的二進位制由n個1組成,當hash值與len-1取與時,相當於只取了hash值在n次位以下的位數,因為當hash值>=2^n 時,hash值必定可以分解為 k*(2^n) + remainder

,因此如果優化後的操作可以保證只取到n位以下的餘數,那麼該操作將等價於取模。需要注意的是,Java8並沒有沿用Java7中單獨設定的hash & (len-1)方法,而是在所有需要用到獲取桶下標的地方顯式地用了該表示式,因此我們可以在原始碼中看到的hash演算法就如下圖所示,該方法Java7與Java8相同:

此處僅以hash>=0舉例,對於int型的hashcode來說,當然可以小於0,只不過在n以上的位數上仍然可能有值。

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

這個方法中用了key的hash值的高位(16位)與低位進行異或,目的是為了混合原始雜湊碼的高位和低位,以此來加大低位的隨機性。而且混合後的低位摻雜了高位的部分特徵,這樣高位的資訊也被變相保留下來。原因是,之前提到了當陣列的長度為2^n ,而取模(hash & (len-1))只取低位,會造成大量碰撞,因此為了更好地反應雜湊碼的全域性屬性,Java8運用了程式碼中的
擾動函式,可能出於效能考慮,相較Java7,Java8只做了一次高低位混合。關於擾動函式的細節超出了本文的範圍,有興趣的讀者可以關注[2]中的介紹。

2.2 Node<K,V>

Node<K,V>是HashMap的內部類實現Map.Entry<K,V>介面,HashMap的雜湊桶陣列中存放的鍵值對物件就是Node<K,V>。類中維護了一個next指標指向連結串列中的下一個元素。值得注意的是,當連結串列中的元素數量超過TREEIFY_THRESHOLD後會HashMap會將連結串列轉換為紅黑樹,此時該下標的元素將成為TreeNode<K,V>,繼承於LinkedHashMap.Entry<K,V>,而LinkedHashMap.Entry<K,V>Node<K,V>的子類,因此HashMap的底層陣列資料型別即為Node<K,V>

原始碼中的陣列申明:

transient Node<K,V>[] table;

原始碼中的預設常量:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 預設陣列長度16
static final int MAXIMUM_CAPACITY = 1 << 30; // 最大陣列容量2^30
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 預設負載比
static final int TREEIFY_THRESHOLD = 8; // 連結串列轉紅黑樹的閾值
static final int UNTREEIFY_THRESHOLD = 6; // 擴容時紅黑樹轉連結串列的閾值

這裡順帶看一下HashMap是如何保證陣列長度必為2^n 的:

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

此方法的目的是為了保證有參構造傳入的初始化陣列長度是>=cap的最小2的k次冪。對n不斷地無符號右移並且位或可以將n從最高位為1開始的所有右側位數變成1,最後n+1即為大於n的最小2的k次冪,每一次移m位會將2m位全變成1。

第一行程式碼int n = cap - 1是為了保證如果cap本身就是2^k 那麼結果也將是其本身。

2.3 put方法

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 當底層陣列==null,初始化陣列獲取陣列長度
        if ((tab = table) == null || (n = tab.length) == 0)                  
            n = (tab = resize()).length;
        // 根據hash值獲取桶下標中當前元素,如果為null,說明之前沒有存放過與key相對應的value,直接插入        
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            // 處理hash碰撞情況
            Node<K,V> e; K k;
            // hash碰撞,並且當前桶中的第一個元素即為相同key,e!=null, 見注1
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 如果當前元素型別為TreeNode,表示為紅黑樹,putTreeVal返回待存放的node, e可能為null
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                // 連結串列結構,遍歷連結串列找到需要插入的位置
                for (int binCount = 0; ; ++binCount) {
                    // 遍歷至連結串列尾部,無相同key的元素,插入連結串列尾部, e=null
                    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的元素,退出遍歷,(e=p.next)!=null
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    // p指向p.next,繼續檢查連結串列中下一個元素
                    p = e;
                }
            }
            // e!=null時,說明遍歷連結串列或樹過程中找到了key相同的元素
            // 根據onlyIfAbsent或者舊value是否為null來判斷是否要覆蓋value
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 用於LinkedHashMap的回撥方法,HashMap為空實現
                afterNodeAccess(e);
                // 返回替換之前的舊元素, 見注3
                return oldValue;
            }
        }
        // 新鍵值對的新增屬於"Structural modifications", modCount要自增,見注2
        ++modCount;
        // 當前鍵值對數超過threshold時,對桶陣列進行擴容,詳見2.4
        if (++size > threshold)
            resize();
        // 用於LinkedHashMap的回撥方法,HashMap為空實現
        afterNodeInsertion(evict);
        // 新新增鍵值對,返回null, 見注3
        return null;
    }


【1】 p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k)))這個判斷在put方法裡出現了2次,都是為了校驗新加的元素和當前元素是不是擁有相同的key,如果是的話將會覆蓋舊的value。這裡需要注意的是,當兩個物件相等,hashCode()方法必須返回相同的hash,但反過來不一定。這句判斷主要有2個維度,第一,對於相同的物件key,他們的hash必須相等,對於null,2.1中的程式碼我們也看到了,null對應的hash等於0,所以兩個key=null,他們也擁有相同的hash,也可以推斷,如果一對鍵值對,key=null,那麼它永遠存放在HashMap底層陣列的第一個桶中。第二,如果key!=null,我們應該用equals方法來判斷物件是否相等,所以(k = p.key) == key這句話是為了短路當key為同一個物件(反覆put覆蓋舊value)或者null的時候的情況。

【2】在許多非執行緒安全的集合類中都會看到modCount成員變數,簡單地講,這個變數的用途是當迭代器在做集合遍歷的時候能夠快速識別其他執行緒對當前物件的結構性修改,從而丟擲java.util.ConcurrentModificationException實現fail-fast機制。一般我們在做集合迭代時會用Iterator iterator = map.entrySet().iterator();獲取迭代器,呼叫iterator.next()方法後獲取迭代器中的下一個元素。在HashMap中,每一次iterator()將返回一個新的java.util.HashMap.EntryIterator<K, V>物件,EntryIteratorjava.util.HashMap.HashIterator<K, V>的子類,無參構造時會初始化expectedModCount=modCount成員變數,該變數就是用來校驗當前的迭代器與其所屬的map物件是否保持同步。每一次java.util.HashMap.EntryIterator.next()被呼叫,都會呼叫父類的java.util.HashMap.HashIterator.nextNode()方法,方法中,當檢測到expectedModCount!=modCount就會丟擲ConcurrentModificationException,說明所屬的map物件發生了所謂的
“Structural modifications”從而實現fail-fast機制。

【3】根據HashMap中Javadoc的描述,put方法會返回覆蓋的舊鍵值對的value,當返回為null時表示,map中不存在對應的key,鍵值對為新新增的項,或者map中對應key的value本身為null。

2.4 resize方法

resize()雖然不是公有方法,但是它是HashMap實現擴容機制的核心方法,在put方法中出現了兩處。第一在當底層陣列為null的時候,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;
    if (oldCap > 0) {
        // 如果舊的容量已經等於最大的容量,將threshold設為最大的Integer, 保證今後不再擴容
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        }
        // 如果新的容量擴容一倍後小於最大容量,新的threshold也擴容一倍
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; // double threshold
    }
    // 舊的容量為0,但threshold大於零,代表有參構造有cap傳入,threshold已經被初始化成最小2的n次冪
    // 直接將該值賦給新的容量
    else if (oldThr > 0) // initial capacity was placed in threshold
        newCap = oldThr;
    // 無參構造建立的map,給出預設容量和threshold 16, 16*0.75
    else {               // zero initial threshold signifies using defaults
        newCap = DEFAULT_INITIAL_CAPACITY;
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 新的threshold = 新的cap * 0.75
    if (newThr == 0) {
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }
    threshold = newThr;
    // 計算出新的陣列長度後賦給當前成員變數table
    @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    table = newTab;
    // 如果原先的陣列沒有初始化,那麼resize的初始化工作到此結束,否則進入擴容元素重排邏輯
    if (oldTab != null) {
        // 遍歷新陣列的所有桶下標
        for (int j = 0; j < oldCap; ++j) {
            Node<K,V> e;
            if ((e = oldTab[j]) != null) {
                // 舊陣列的桶下標賦給臨時變數e,並且解除舊陣列中的引用,否則就陣列無法被GC回收
                oldTab[j] = null;
                // 如果e.next==null,代表桶中就一個元素,不存在連結串列或者紅黑樹
                if (e.next == null)
                    // 用同樣的hash對映演算法把該元素加入新的陣列
                    newTab[e.hash & (newCap - 1)] = e;
                // 如果e是TreeNode並且e.next!=null,那麼處理樹中元素的重排
                else if (e instanceof TreeNode)
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                // e是連結串列的頭並且e.next!=null,那麼處理連結串列中元素重排
                else { // preserve order
                    // loHead,loTail 代表擴容後不用變換下標,見注1
                    Node<K,V> loHead = null, loTail = null;
                    // hiHead,hiTail 代表擴容後變換下標,見注1
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    // 遍歷連結串列
                    do {             
                        next = e.next;
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                // 初始化head指向連結串列當前元素e,e不一定是連結串列的第一個元素,初始化後loHead
                                // 代表下標保持不變的連結串列的頭元素
                                loHead = e;
                            else                                
                                // loTail.next指向當前e
                                loTail.next = e;
                            // loTail指向當前的元素e
                            // 初始化後,loTail和loHead指向相同的記憶體,所以當loTail.next指向下一個元素時,
                            // 底層陣列中的元素的next引用也相應發生變化,造成lowHead.next.next.....
                            // 跟隨loTail同步,使得lowHead可以連結到所有屬於該連結串列的元素。
                            loTail = e;                           
                        }
                        else {
                            if (hiTail == null)
                                // 初始化head指向連結串列當前元素e, 初始化後hiHead代表下標更改的連結串列頭元素
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    // 遍歷結束, 將tail指向null,並把連結串列頭放入新陣列的相應下標,形成新的對映。
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}


【1】重點關注下Java8對rehash的優化,實際上Java8沒有將原來陣列中的元素rehash再作對映,而是巧妙地利用了擴容後newCap = 2*oldCap的特性。之前提到過HashMap利用(hash & len-1)來確定元素的下標,由於len是2的n次冪,所以len-1的0~n-1位都為1,hash & len-1就是利用了這n位作掩碼產生下標。現在當新的容量為舊容量的兩倍,相當於len左移1位,所以新的掩碼將較之前多1位,這1位就決定了原來的hash將被對映到新的下標還是保持原先的下標不變。通過(e.hash & oldCap) == 0可以判斷原先的高位是否為1。因為如果原先在n位上是1,那麼與操作的結果就是1,反之則為0。而最後只需要將高位為1的hash移到新的下標j+oldCap,因為新的掩碼多一位必然使得與操作後保留hash中的這一高位,相當於下標增加了原來的cap。一張圖勝過一萬句話,下圖借用引用[1]中示例,更清晰地解釋了這波優越的操作。

hashMap 1.8 雜湊演算法例圖1
hashMap 1.8 雜湊演算法例圖2

3. 小節

本篇介紹了HashMap的基本資料結構及其實現,通過put方法引出了Java8對resize的優化,整體上都是圍繞Java8針對陣列容量的優化設計。由於篇幅限制,關於紅黑樹的優化以及其他一些常用公有方法,將留到下一篇再介紹。

以上

© 著作權歸作者所有

引用