源碼|jdk源碼之HashMap分析(一)

hash表是應用最普遍的數據結構,是對鍵值對數據結構的一種重要實現。
它可以將關鍵字key映射到內存中的某一位置,查詢和插入都能達到平均時間複雜度爲O(1)的性能。
HashMap是java對hash表的實現,它是非線程安全的,也即不會考慮併發的場景。java

<!-- more -->node

HashMap實現思路

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

偷點懶,這裏就大概總結一下了,畢竟這篇博文jdk代碼纔是重點。github

在使用者的角度來看,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

最後

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

相關文章
相關標籤/搜索