原始碼|jdk原始碼之HashMap分析(一)

frapples發表於2019-01-19

hash表是應用最廣泛的資料結構,是對鍵值對資料結構的一種重要實現。
它能夠將關鍵字key對映到記憶體中的某一位置,查詢和插入都能達到平均時間複雜度為O(1)的效能。
HashMap是java對hash表的實現,它是非執行緒安全的,也即不會考慮併發的場景。

<!– more –>

HashMap實現思路

hash表是常見的資料結構,大學都學過,以前也曾用C語言實現過一個:
https://github.com/frapples/c…

偷點懶,這裡就大概總結一下了,畢竟這篇博文jdk程式碼才是重點。

在使用者的角度來看,HashMap能夠儲存給定的鍵值對,並且對於給定key的查詢和插入都達到平均時間複雜度為O(1)。

實現hash表的關鍵在於:

  1. 對於給定的key,如何將其對應到記憶體中的一個對應位置。這通過hash演算法做到。
  2. 通過一個陣列儲存資料,通過hash演算法hash(K) % N來將關鍵字key對映陣列對應位置上。
  3. hash演算法存在hash衝突,也即多個不同的K被對映到陣列的同一個位置上。如何解決hash衝突?有三種方法。

    1. 分離連結串列法。即用連結串列來儲存衝突的K。
    2. 開放定址法。當位置被佔用時,通過一定的演算法來試選其它位置。hash(i) = (hash(key) + d(i)) % N,i代表第i次試選。常用的有平方探測法,d(i) = i^2。
    3. 再雜湊。如果衝突,就再用hash函式再巢狀算一次,直到沒有衝突。

HashMap程式碼分析

Node節點

先來看Node節點。這表明HashMap採用的是分離連結串列的方法實現。
Node為連結串列節點,其中儲存了鍵值對,key和value。

不過實際上,HashMap的真正思路更復雜,會用到平衡樹,這個後面再說。

    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

        Node(int hash, K key, V value, Node<K,V> next) {
            this.hash = hash;
            this.key = key;
            this.value = value;
            this.next = next;
        }
        /* ... */
    }

還能發現,這是一個單連結串列。對於HashMap來說,單連結串列就已經足夠了,雙向連結串列反而多一個浪費記憶體的欄位。

除此之外,還能夠注意到節點額外儲存了hash欄位,為key的hash值。
仔細一想不難明白,HashMap能夠儲存任意物件,物件的hash值是由hashCode方法得到,這個方法由所屬物件自己定義,裡面可能有費時的操作。

而hash值在Hash表內部實現會多次用到,因此這裡將它儲存起來,是一種優化的手段。

TreeNode節點

這個TreeNode節點,實際上是平衡樹的節點。
看屬性有一個red,所以是紅黑樹的節點。

    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);
        }
        /* ... */
    }

除此之外,還能發現這個節點有prev屬性,此外,它還在父類那裡繼承了一個next屬性。
這兩個屬性是幹嘛的?通過後面程式碼可以發現,這個TreeNode不僅用來組織紅黑樹,還用來組織雙向連結串列。。。

HashMap會在連結串列過長的時候,將其重構成紅黑樹,這個看後面的程式碼。

屬性欄位

    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    static final int MAXIMUM_CAPACITY = 1 << 30;
    static final float DEFAULT_LOAD_FACTOR = 0.75f;
    static final int TREEIFY_THRESHOLD = 8;
    static final int UNTREEIFY_THRESHOLD = 6;
    static final int MIN_TREEIFY_CAPACITY = 64;

    transient Node<K,V>[] table;
    transient Set<Map.Entry<K,V>> entrySet;
    transient int size;
    transient int modCount;
    int threshold;
    final float loadFactor;

最重要的是tablesizeloadFactor這三個欄位:

  1. table可以看出是個節點陣列,也即hash表中用於對映key的陣列。由於連結串列是遞迴資料結構,這裡陣列儲存的是連結串列的頭節點。
  2. size,hash表中元素個數。
  3. loadFactor,裝填因子,控制HashMap擴容的時機。

至於entrySet欄位,實際上是個快取,給entrySet方法用的。
modCount欄位的意義和LinkedList一樣,前面已經分析過了。

最後,threshold這個欄位,含義是不確定的,像女孩子的臉一樣多變。。。
坦誠的說這樣做很不好,可能java為了優化時省點記憶體吧,看後面的程式碼就知道了,這裡總結下:

  1. 如果table還沒有被分配,threshold為初始的空間大小。如果是0,則是預設大小,DEFAULT_INITIAL_CAPACITY
  2. 如果table已經分配了,這個值為擴容閾值,也就是table.length * loadFactor

建構函式

    /**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    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);
    }

    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

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

第一個建構函式是重點,它接收兩個引數initialCapacity代表初始的table也即hash桶陣列的大小,loadFactor可以自定義擴容閾值。

  this.threshold = tableSizeFor(initialCapacity);

這裡也用到了類似前面ArrayList的“延遲分配”的思路,一開始table是null,只有在第一次插入資料時才會真正分配空間。
這樣,由於實際場景中會出現大量空表,而且很可能一直都不新增元素,這樣“延遲分配”的優化技巧能夠節約記憶體空間。
這裡就體現出threshold的含義了,hash桶陣列的空間未分配時它儲存的是table初始的大小。

tableSizeFor函式是將給定的數對齊到2的冪。這個函式用位運算優化過,我沒怎麼研究具體的思路。。。
但是由此可以知道,hash桶陣列的初始大小一定是2的冪,實際上,hash桶陣列大小總是為2的冪。

get函式

hash二次運算

先從get函式看起。

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

我們發現,呼叫getNode時:

        return (e = getNode(hash(key), key)) == null ? null : e.value;

其中呼叫了hash這個靜態函式:

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

也就是說,用於HashMap的hash值,還需要經過這個函式的二次計算。那這個二次計算的目的是什麼呢?
通過閱讀註釋:

  • Computes key.hashCode() and spreads (XORs) higher bits of hash
  • to lower. Because the table uses power-of-two masking, sets of
  • hashes that vary only in bits above the current mask will
  • always collide. (Among known examples are sets of Float keys
  • holding consecutive whole numbers in small tables.) So we
  • apply a transform that spreads the impact of higher bits
  • downward. There is a tradeoff between speed, utility, and
  • quality of bit-spreading. Because many common sets of hashes
  • are already reasonably distributed (so don`t benefit from
  • spreading), and because we use trees to handle large sets of
  • collisions in bins, we just XOR some shifted bits in the
  • cheapest possible way to reduce systematic lossage, as well as
  • to incorporate impact of the highest bits that would otherwise
  • never be used in index calculations because of table bounds.

嗯。。。大概意思是說,由於hash桶陣列的大小是2的冪次方,對其取餘隻有低位會被使用。這個特點用二進位制寫法研究一下就發現了:如1110 1100 % 0010 0000 為 0000 1100,高位直接被忽略掉了。

也即高位的資訊沒有被利用上,會加大hash衝突的概率。於是,一種思路是把高位的資訊混合到低位上去,提高區分度。就是上面這個hash函式了。

getNode函式

    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            if ((e = first.next) != null) {
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
    }

get函式呼叫了getNode,它接受給定的key,定位出對應的節點。這裡檢查了table為null的情況。此外first = tab[(n - 1) & hash]實際上就是first = tab[hash % n]的優化,這個細節太多,等會再分析。

程式碼雖然有點多,但是大部分都是一些特別情況的檢查。首先是根據key的hash值來計算這個key放在了hash桶陣列的哪個位置上。找到後,分三種情況處理:

  1. 這個位置上只有一個元素。
  2. 這個位置上是一個連結串列。
  3. 這個位置上是一棵紅黑樹。

三種情況三種不同的處理方案。比較奇怪的是為什麼1不和2合併。。。

如果是紅黑樹的話,呼叫紅黑樹的查詢函式來最終找到這個節點。
如果是連結串列的話,則遍歷連結串列找到這個節點。值得關注的是對key的比較:

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

類似於hashCode方法,equals方法也是所屬物件自定義的,比較可能比較耗時。
所以這裡先比較Node節點儲存的hash值和引用,這樣儘量減少呼叫equals比較的時機。

模運算的優化

回到剛才的位運算:

first = tab[(n - 1) & hash]

這個位運算,實際上是對取餘運算的優化。由於hash桶陣列的大小一定是2的冪次方,因此能夠這樣優化。

思路是這樣的,bi是b二進位制第i位的值:

b % 2i = (2NbN + 2N-1 bN-1+ … + 2ibi + … 20b0) % 2i

設x >= i,則一定有2xbx % 2i = 0

所以,上面的式子展開後就是:
b % 2i = 2i-1bi-1 + 2i-2bi-2 + … 20b0

反映到二進位制上來說,以8位二進位制舉個例子:

  1. 顯然2的冪次方N的二進位制位是隻有一個1的。8的二進位制為00001000,1在第3位。
  2. 任何一個數B餘這個數N,反映二進位制上,就是高於等於第3位的置0,低於的保留。如10111010 % 00001000 = 00000010

這樣,就不難理解上面的(n - 1) & hash了。以上面那個例子,
00001000 – 1 = 00000111,這樣減一之後,需要保留的對應位為全是1,需要置0的對應位全都是0。把它與B作與運算,就能得到結果。

put函式

沒想到寫這個比想象中的費時間。。。還有很多其他事情要做呢
這個put函式太長了,容我偷個懶直接貼程式碼和我自己的註釋吧

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

    // onlyIfAbsent含義是如果那個位置已經有值了,是否替換
    // evict什麼鬼?table處於創造模式?先不管
    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為null或者沒有值的時候reisze(),因此這個函式還負責初始分配
        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);
        else {
            Node<K,V> e; K k;
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            else if (p instanceof TreeNode)
                // 如果hash桶掛的是二叉樹,呼叫TreeNode的putTreeVal方法完成插入
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else {
                // 如果掛的是連結串列,插入實現
                // 遍歷連結串列,順便binCount變數統計長度
                for (int binCount = 0; ; ++binCount) {
                    // 情況一:到尾巴了,就插入一條
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 插入會導致連結串列變長
                        // 可以發現,TREEIFY_THRESHOLD是個閾值,超過了就呼叫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)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        // 如果hash桶陣列的大小超過了閾值threshold,就resize(),可見resize負責擴容
        if (++size > threshold)
            resize();
        // evice的含義得看afterNodeInsertion函式才能知道
        afterNodeInsertion(evict);
        return null;
    }

思路大概是這樣的邏輯:

  1. 判斷table是否分配,如果沒有就先分配空間,和前面提到的“延時分配”對應起來。
  2. 同樣,根據hash值定位hash桶陣列的位置。然後:

    1. 該位置為null。直接建立一個節點插入。
    2. 該位置為平衡樹。呼叫TreeNode的一個方法完成插入,具體邏輯在這個方法裡。
    3. 該位置為連結串列。遍歷連結串列,進行插入。會出現兩種情況:

      1. 遍歷到連結串列尾,說明這個key不存在,應該直接在連結串列尾插入。但是這導致連結串列增長,需要觸發連結串列重構成平衡樹的判斷邏輯。
      2. 找到一個key相同的節點,單獨拎出來處理,得看onlyIfAbsent的引數。
    4. 完畢之後,這個時候hash表中可能多了一個元素。也只有多了一個元素的情況下控制流才能走到這。這時維護size欄位,並且觸發擴容的判斷邏輯。

在這裡我有幾點疑惑:

  1. 為什麼null的情況、一個節點的情況、單連結串列的情況不合並在一起處理?因為效能?
  2. 為什麼採用尾插法不用頭插法?頭插法根據區域性性原理豈不是更好嗎?

在遍歷連結串列時會同時統計連結串列長度,然後連結串列如果被插入,會觸發樹化邏輯:

if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
    treeifyBin(tab, hash);

TREEIFY_THRESHOLD的值是8,也就是說,插入後的連結串列長度如果超過了8,則會將這條連結串列重構為紅黑樹,以提高定位效能。

在插入後,如果hash表中元素個數超過閾值,則觸發擴容邏輯:

    if (++size > threshold)
        resize();

記得前面說過,threshold在table已經分配的時候,代表是擴容閾值,即table.length * loadFactor

最後

考慮到篇幅夠長了,還是拆分成兩篇比較好,剩下的留到下一篇博文再寫吧。

相關文章