1. 程式人生 > >Java 1.8 HashMap實現(譯註)

Java 1.8 HashMap實現(譯註)

譯者序

作者整個部落格只有這一篇文章,而就這一篇文章,卻是介紹HashMap與Java中Hash策略的精品。作者從Java 2講述到Java 8,細數種種變更,並且用數學公式和清晰的思路解釋其原理。全文行文流暢,排版規範典雅,有著論文般的美感,就技術部落格而言,實乃佳品。配合HashMap原始碼消化更佳。
因為時間倉促,在翻譯的過程中,難免會有錯漏,希望多加指正。

概述

這篇文件闡述了Java中的HashMap,從早期版本一直到基於Oracle的JDK和OpenJDK的Java 7,8中的實現原理。在文件中,所有引用的原始碼都來自於Oracle JDK和OpenJDK——這兩者在純粹的Java SDK實現上是完全相同的。我希望這篇文件能夠幫助各位到開發者,甚至是那些從未使用過Java的開發者。因為這些內容與如何設計框架或者庫無關,它們更多的針對於如何去以實現語言無關的HashMap。

HashMap是Java集合框架(Java Collection Framework, JCF)中一個基礎類,它在1998年12月,加入到Java 2版本中。在此之後,Map介面本身除了在Java 5中引入了泛型以外,再沒有發生過明顯變化。然而HashMap的實現,則為了提升效能,不斷地在改變。

實現HashMap時一個重要的考量,就是如何儘可能地規避雜湊碰撞。而HashMap實現變更的路線圖,也大多與此相關。

HashMap與HashTable

HashMap和HashTable這兩個術語,在此文件中指的都是Java的API。

HashTable在Java出現之初,就已經被引入,而HashMap直到Java 2,才隨著JCF出現到人們的視野之中。
HashTable和HashMap一樣,也實現了Map介面,因此他們從函式的視角上是等價的。

public class Hashtable<K,V> extends Dictionary<K,V>
    implements Map<K,V>, Cloneable, java.io.Serializable {

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {

Code No.1 HashTable 與 HashMap的宣告

然而,在它們之間,有許多處不同。首先,HashTable是一個執行緒安全

的API,它的方法通過synchronized關鍵字進行修飾。儘管並不推薦使用HashTable來開發一個高效能的應用,但是它確實能夠保證你的應用執行緒安全。相反,HashMap並不保證執行緒安全。因此當你構建一個多執行緒應用時,請使用ConcurrentHashMap。
而在單執行緒應用中,HashMap有這個比HashTable更好的效能,這得益於HashMap使用了多種方式來規避雜湊碰撞,其中,使用輔助Hash函式是一種著名的方式。在Java 8中,一種更好的方式被用來處理高頻碰撞的問題。不過,我們需要記住一點,沒有完美的雜湊函式。但是即使我們無法創造一個完美的世界,讓它變得更好也是值得的。

這裡,我想要指出HashTable和HashMap這個兩個術語的來源。基本上,他們都可以被看做是一種關聯陣列,關聯陣列與陣列最大的不同,就是對於每一個數據,關聯陣列會有一個key與之關聯,當使用關聯陣列時,每個資料都可以通過對應的Key來獲取。關聯陣列有許多別名,比如Map(對映)、Dictionary(字典)和Symbol-Table(符號表)。儘管名字不同,他們的含義都是相同的。
字典和符號表都是非常直觀的術語,無須解釋它們的行為。對映來自於數學領域。在函式中,一個域(集合)中的值被與另一個域(集合)中的值關聯,這種關聯關係叫做對映。
對映
*Figure No.1 函式中的對映 XfY

因此HashTable和HashMap都是基礎的關聯陣列,雜湊指的是一種通過Key來獲取資料的過程。

雜湊分佈和雜湊碰撞

對於每個物件X和Y,如果當(且僅當,譯者注)X.equals(Y)為false,使得X.hashCode() != Y.hashCode()為true,這樣的函式叫做完美Hash函式。下面是完美雜湊函式的數學表達.

X,YS,(h(X)=h(Y))X=Y:S h
基於物件中變化的域(欄位),我們很容易構造一個完美雜湊函式。一個Boolean物件有true和false兩個值,因此Boolean物件的Hash值可以通過一個二進位制位 bit 表達,即0b0, 0b1。對於一些Number物件,比如IntegerLongDouble等,他們都可以使用自身原始的值作為Hash值。然而,想要構造這樣的完美雜湊函式,我們需要無限的記憶體大小,這種假設顯然是不可能的。而且,即時我們能夠為每個POJO(Plain Ordinary Java Object)或者String物件構造一個理論上不會有衝突的雜湊函式,但是hashCode()函式的返回值是int型。根據鴿籠理論,當我們的物件超過232個時,這些物件會發生雜湊碰撞。
這裡還有一個點需要我們考慮。我們是否可以在某些限制下,通過允許雜湊碰撞來節省記憶體?這往往是一個提升總體效能不錯的方式。許多關聯陣列的實現,包括HashMap,使用了大小為M的桶來儲存N個物件(MN)。在這種情況下,我們使用模值hashValue % M作為桶的索引,而不是hashValue本身。
int index = X.hashCode() % M;

Code No.2 獲取hash桶索引的方式

因此,當一個物件的插入HashMap,發生雜湊衝突的概率是1M,這與雜湊函式的實現無關。根據我們的需要,即使是存在雜湊衝突的環境中,資料的讀取也應該能夠被良好的執行。這裡有兩種著名的方式來解決這個問題,一種是開放定址,一種是分離連結。其他的用於解決Hash衝突的方式,大多基於這兩種方法。
hash實現
Figure No.2 Open Adressing and Seperate Chaning

開放定址是一種解決雜湊衝突的方式,當計算出的桶索引的位置被佔據時,通過一定的探索方式,來尋找未被佔據的雜湊桶(適合數量確定,衝突較少的情況,譯者注)。而分離連結則將每一個雜湊桶作為一個連結串列的頭結點,當雜湊碰撞發生時,僅需在連結串列中進行儲存、查詢。

這兩種方法都有著同樣的最壞時間複雜度O(M),但是開放定址使用連續的空間,因此有著快取效率的提升。因此當資料量較小時,能夠放到系統快取中時,開放定址會表現出比分離連結更好的效能。但是當資料量增長時,它的效能就會越差,因為我們無法期待一個大的陣列能夠得到快取效能優化。這也是HashMap使用分離連結串列來解決雜湊衝突的原因。此外,開放定址還有一個弱點。我們呼叫remove()方法會十分頻繁,當我們刪除資料時,一個特定的雜湊衝突,可能會干擾總體的效能,而分離連結串列則沒有這樣的缺點。

transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
// the reason why transient keyword is used is because of efficienty,
// when it comes to serialize the HashMap instance, 
// storing key-value pairs is the better
// than serializing object itself.


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() { … }
public final V getValue() { …}
        public final V setValue(V newValue) { … }
        public final boolean equals(Object o) { … }
        public final int hashCode() {…}
        public final String toString() { …}

void recordAccess(HashMap<K,V> m) {… }

void recordRemoval(HashMap<K,V> m) {…}
}

Code No.3 Java 7中雜湊桶的實現

程式碼4呈現了put()使用分離連結串列實現的方式。


public V put(K key, V value) {
        if (table == EMPTY_TABLE) {  
            inflateTable(threshold); 
            // creating 'table' array
        } 

        // null can be a key in HashMap
        if (key == null)
            return putForNullKey(value);

        // rather than using value.hashCode() without altering
        // modified hash values is used 
        // with a Supplement Hash Function
        // the Supplement Hash Function is explained 
        // in 'Supplement Hash Function' section
        int hash = hash(key);

        // value 'i' is an index of hash bucket
        // indexFor() is related with 'hash % table.length'
        int i = indexFor(hash, table.length);


        // scaning a linked list in a hash bucket
        // if there is a data with the correspondence key
        // the data is replaced with new one.
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }


        // modCount is for managing how many times 
        // this HashMap has been modificated
        // it is also used to determine 
        // whether throw ConcurrentModificationException
        modCount++;


        // create new Entry only if the key is never used yet.
        addEntry(hash, key, value, i);
        return null;
    }

Code No.4 Java 1.7中HashMap的put()方法的實現

Java 8 HashMap的分離連結串列

從Java 2到Java 1.7,HashMap在分離連結串列上的改變並不多,他們的演算法基本上是相同的。如果我們假設物件的Hash值服從平均分佈,那麼獲取一個物件需要的次數時間複雜度應該是O(NM)(原為E(NM),但數學期望應改為E(N2M)疑有誤,譯者注)。Java 8 在沒有降低雜湊衝突的度的情況下,使用紅黑書代替連結串列,將這個值降低到了O(log(NM))(與上同,疑有誤,譯者注)。

資料越多,O(NM)O(log(NM))的差別就會越明顯。此外,在實踐中,Hash值的分佈並非均勻的,正如”生日問題”所描述那樣,雜湊值有時也會集中在幾個特定值上。因此使用平衡樹比如紅黑樹有著比使用連結串列更強的效能。

使用連結串列還是樹,與一個雜湊桶中的元素數目有關。程式碼5中中展示了Java 8的HashMap在使用樹和使用連結串列之間切換的閾值。當衝突的元素數增加到8時,連結串列變為樹;當減少至6時,樹切換為連結串列。中間有2個緩衝值的原因是避免頻繁的切換浪費計算機資源。

static final int TREEIFY_THRESHOLD = 8;

static final int UNTREEIFY_THRESHOLD = 6;

Code No.5 Java 8 HashMap中的TREEIFY_THRESHOLD & UNTREEIFY_THRESHOLD

Java 8 HashMap使用Node類替代了Entry類,它們的結構大體相同。一個顯著地差別是,Node類具有匯出類TreeNode,通過這種繼承關係,一個連結串列很容易被轉換成樹。

Java 8 HashMap使用的樹是紅黑樹,它的實現基本與JCF中的TreeMap相同。通常,樹的有序性通過兩個或更多物件比較大小來保證。Java 8 HashMap中的樹也通過物件的Hash值(這個hash值與雜湊桶索引值不同,索引值在這個hash值的基礎上對桶大小M取模,譯者注)作為物件的排序鍵。因為使用Hash值作為排序鍵打破了Total Ordering(可以理解為數學中的小於等於關係,譯者注),因此這裡有一個tieBreakOrder()方法來處理這個問題。

transient Node<K,V>[] table;


static class Node<K,V> implements Map.Entry<K,V> {
  // the name of class is different from Java7's
  // but this class has almost identical structure 
  // with Java7's except for 'treefying'

}


// LinkedHashMap.Entry extends HashMap.Node
// so TreeNode instacne can be inserted into 'table' array
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {


        TreeNode<K,V> parent;  
        TreeNode<K,V> left;
        TreeNode<K,V> right;
        TreeNode<K,V> prev;   


        // in Red-Black Tree node is either Red or Black.
        boolean red;

        TreeNode(int hash, K key, V val, Node<K,V> next) {
            super(hash, key, val, next);
        }

        final TreeNode<K,V> root() {
        // returns the root of Tree Node
        }

        static <K,V> void moveRootToFront(Node<K,V>[] tab, TreeNode<K,V> root) {
        // root is the 'first gate' whenever work with trees. 
        }


        // for traversing
        final TreeNode<K,V> find(int h, Object k, Class<?> kc) {}
        final TreeNode<K,V> getTreeNode(int h, Object k) {}


        /**
         * Tie-breaking utility for ordering insertions when equal
         * hashCodes and non-comparable. We don't require a total
         * order, just a consistent insertion rule to maintain
         * equivalence across rebalancings. Tie-breaking further than
         * necessary simplifies testing a bit.
         */
        static int tieBreakOrder(Object a, Object b) {
            int d;
            if (a == null || b == null ||
                (d = a.getClass().getName().
                 compareTo(b.getClass().getName())) == 0)
                d = (System.identityHashCode(a) <= System.identityHashCode(b) ?
                     -1 : 1);
            return d;
        }


        final void treeify(Node<K,V>[] tab) {
          // turn a  linked list to a tree.
        }


        final Node<K,V> untreeify(HashMap<K,V> map) {
          // turn a tree to a linked list
        }


        // method names explain everything.
        final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,
                                       int h, K k, V v) {}
        final void removeTreeNode(HashMap<K,V> map, Node<K,V>[] tab,
                                  boolean movable) {}



        // according to Red-Black theconstruction rule,
        // these methods are to keep trees' balance
        final void split (…)
        static <K,V> TreeNode<K,V> rotateLeft(…)
        static <K,V> TreeNode<K,V> rotateRight(…)
        static <K,V> TreeNode<K,V> balanceInsertion(…)
        static <K,V> TreeNode<K,V> balanceDeletion(…)




        static <K,V> boolean checkInvariants(TreeNode<K,V> t) {
        // this is for verifying the construction of a tree.
        }
    }

Code No.6 Java 8中的Node類

Hash桶動態擴容

小數目的雜湊桶可以有效的利用記憶體,但是會產生更高概率的雜湊碰撞,最終損失效能。因此,HashMap會在資料量達到一定大小時,將雜湊桶的數量擴充到兩倍。當雜湊桶的數量變為兩倍後,NM會對應下降,Hash值重複的Key的數量也得以減少。

雜湊桶的預設數量是16,最大值是230。當雜湊桶的數量成倍增長時,所有的資料需要重新插入。一種HashMap構造器包含初始桶數量這個引數。如果我們能夠在使用這個構造器時指定桶的數量,這將使HashMap節約不必要的重新構造分離連結串列的時間。

// newCapacity always has a value of powers of 2 $
void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;

        // MAXIMIM_CAPACITY는 230이다.
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }

        Entry[] newTable = new Entry[newCapacity];


        // after creating new hash buckets, all stored key-value paired data
        // are stored in new hash buckets.
        transfer(newTable, initHashSeedAsNeeded(newCapacity));
        table = newTable;
        threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
    }


    void transfer(Entry[] newTable, boolean rehash) {
        int newCapacity = newTable.length;

        // traversing all hash buckets
        for (Entry<K,V> e : table) {

            // traversing a linked list in a hash bucket
            while(null != e) {
                Entry<K,V> next = e.next;
                if (rehash) {
                    e.hash = null == e.key ? 0 : hash(e.key);
                }

                // as we have new M, the size of hash buckets
                // so need to recompute new index value(hashCode % M)
                int i = indexFor(e.hash, newCapacity);
                e.next = newTable[i];
                newTable[i] = e;
                e = next;
            }
        }
    }

Code No.7 Java 1.7中的雜湊桶擴容

確定是否需要對桶進行擴充套件的臨界值是loadFactor×currentBucketSize,其中loadFactor是負載因子,currentBucketSize是當前桶的數量。當資料量到達這個大小時,擴容就會發生,直到桶的數量達到230為止。預設的負載銀子是0.75,它與預設桶大小16,一同作為構造預設的HashMap的引數。

因為在臨界點的擴容會導致所有資料重新插入,那麼從一個預設的HashMap一直擴容到當前包含有N個元素的HashMap的消耗,也就是資料的插入次數,可以大致估算出。(原文公式不嚴格,沒有給出上下界,因此沒有評估意義。此處公式和結論由譯者給出,譯者注)
ϕ(N)=N+34(16+32+64++2(log43N))
=N+34k=4(log43N)2k

=N+34(2