HashMap源碼分析(基於1.8版本)

終於要開篇寫HashMap了,作爲集合屆的頭把交椅,HashMap不可不謂爲響噹噹的,其代碼也讓很多人望而生畏,但其實仔細琢磨一下,其複雜度並沒有特別的讓人害怕(起碼對比ConcurrentHashMap而言),因此,讓我們走近來近距離瞧一瞧這個大名鼎鼎的HashMap吧。

鑑於我本地安裝的版本是1.8的,因此,分析1.8版本的是HashMap。最後會分析1.7和1.8的有什麼區別。

首先,HashMap使用鏈地址法來解決hash衝突的問題,HashMap1.8使用的是數組+鏈表/紅黑樹。

注:雖然是源碼解析,但是並不是所有的源碼都會涉及到,只涉及到經常使用的那些。

首先看看HashMap的類關係圖,瞭解一下它的繼承關係和實現的接口。

再來看看一些一些常量和變量,以及構造方法和hash方法。

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable {
    // 默認的初始化容量,即默認的數組大小,爲16.該值必須是2的次方。
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
    // 最大的容量,爲2的30次方。
    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;
    
    /**
    *   變量
   	**/
    // HashMap的數組定義
    transient Node<K,V>[] table;
    
    // 做遍歷時使用。
    transient Set<Map.Entry<K,V>> entrySet;

    // HashMap大小。
    transient int size;

    // HashMap結構改變的次數。
    transient int modCount;
    
    // 表示size大於它的時候會進行擴容操作。
    int threshold;

    // 負載因子。
    final float loadFactor;
    
	// 參數爲初始容量和負載因子的構造函數。
    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;
		// 將容量調整爲大於參數的最小2次方
        this.threshold = tableSizeFor(initialCapacity);
    }
    // 參數爲初始容量的構造方法。
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }
    // 無參構造函數,會將負載因子設爲默認的。
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }
    // 參數爲Map類型的構造函數
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }
    
    // hashMap自帶的hash函數。
    static final int hash(Object key) {
        int h;
        return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

首先講解一些常量,可以看出來,HashMap容量大小默認是16,且必須是2的次方,這個原因後續會說。負載因子爲0.75,也就是說HashMap中的元素達到容量的0.75就擴容,如16*0.75=12,那麼容量使用達到12就會擴容。因此這個值太小了容易導致擴容頻繁,非常消耗性能,太大了容易導致哈希衝突概率變大,鏈表變長,這樣的話查找效率就低了。總之值的大小的優缺點是對立的,0.75是官方認爲一個較爲平衡的值。

至於變量,註釋已經給出了相應的解釋。

最後看下構造函數和hash方法,在所有的構造函數中,都會設置負載因子和初始化容量,如果用戶沒有給,那麼就使用默認的,其中,初始化容量,即使用戶給了非2的次方數,也會使用tableSize方法調整過來,比如傳11,容量並不會就是11,而是16,傳17,就會是32。始終保持2的次方。至於hash方法,又叫擾動函數,它能使hash的值分佈的更隨機,避免hash衝突太頻繁。

常用API

這次就不列增刪改查了,直接從方法出發。

put方法

	// 調用了下面的方法
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    /**
     * 真正執行put的方法
    */
    // onlyIfAbsent爲true時,不會改變已經存在的值,也就是說,只有key不存在時纔會put。
    // evict不用關心
    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,或是長度爲0,就進行擴容。
        // 這意味着第一次put時就會進行擴容。
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 如果key對應的那個位置爲空,那麼直接創建一個node放置便可。
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else { // 否則,說明有hash衝突了。
            Node<K,V> e; K k;
            // p是這個數組的第一個節點,如果p和要插入的數據是一個值,那麼將p賦值給e
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 否則,如果是紅黑樹的情況。調用紅黑樹查找,這裏不描述紅黑樹的具體。
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            else { // 否則,是鏈表節點,那麼順着鏈表查找與插入的數相等的值。
                for (int binCount = 0; ; ++binCount) {
               		// 如果沒找到,就新建一個node節點,放在p後面。可以看出,這是尾插。
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // 如果插入值之後是第八個節點,那麼樹形化。
                        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;
                }
            }
            // 如果e不爲null,也就是說要插入的鍵值對中的key是存在。
            if (e != null) { // existing mapping for key
            	// 將舊的值取出
                V oldValue = e.value;
                // 如果onlyIfAbsent爲false,或者舊值爲null,將新的值覆蓋
                // (有關onlyIfAbsent的地方,方法開頭已經寫了)
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                // 這個不重要。
                afterNodeAccess(e);
                return oldValue;
            }
        }
        // 如果新增了node節點,就會modCount自增
        ++modCount;
        // 同時由於新增node,size也會自增,自增後超過閾值,也需要擴容。
        if (++size > threshold)
            resize();
        // 這個也不重要。
        afterNodeInsertion(evict);
        return null;
    }

put方法還是比較好理解的。籠統概括一下。首先,如果是第一次put元素,那麼就會先擴容,達到默認的16或者用戶自定義的大小。然後使用(n - 1) & hash進行取模運算,這個與運算和hash % n的效果是一樣的,同時由於是位操作,因此會比%快。取模運算後看key的hash值是在數組的哪個位置,如果該位置上沒有元素,那麼直接新建一個node元素放在該位置上。否則說明數組上已經有元素了,那麼首先判斷該key是不是頭結點,如果是,將頭節點賦給e。如果不是,且頭節點是紅黑樹的節點,那麼走紅黑樹的查找。否則是鏈表,遍歷鏈表,如果找到了,將節點賦給e。如果遍歷了還是沒有,就新建node節點放在鏈表尾部,此時,如果新增的元素剛好是第8個節點,那麼樹形化。最後,如果e的值不爲null,說明key已經在map中存在了,覆蓋然後返回舊值就行了,當然,**onlyIfAbsent爲true,且有舊值時是不能覆蓋的。**否則,要插入的鍵值對是新增的,那麼增加
modCount,同時增加size,如果大於閾值,就擴容。

注意點

我們現在看看樹形化的代碼,這裏有一個需要引起注意的地方

	final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
        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;
            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);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

裏面具體如何樹形化的我就不解釋了,但是可以看到該方法的開頭, 如果數組爲空,或者數組長度小於MIN_TREEIFY_CAPACITY(即64),那麼都會先擴容。

也就是說!!! 擴容的時機其實並不僅僅是數組大小大於閾值纔會擴容,在樹形化時如果數組大小沒有達到64,也是會先擴容的!!!

總結一下put方法:

  1. 第一次put時,會導致擴容。
  2. 鏈表長爲8時會樹形化。
  3. 新增節點後,如果大於閾值,會導致擴容。
  4. 樹形化時,如果數組大小沒有達到默認的64,會先擴容,而不是樹形化。

resize方法

	final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        // 舊的容量,如果是第一次擴容,舊容量就是0
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        // 舊的閾值
        int oldThr = threshold;
        // 新的容量和閾值。
        int newCap, newThr = 0;
        if (oldCap > 0) {
        	// 這塊就是數組擴容,不多說。
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            // 將數組擴容至2倍
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                 // 閾值也翻倍。
                newThr = oldThr << 1; // double threshold
        }
        // 說明是第一次擴容,且使用了帶參的構造方法,將閾值賦給新容量。
        else if (oldThr > 0) 
            newCap = oldThr;
        else { // 說明是第一次擴容,使用的是無參的構造方法,那麼使用系統默認的值
            newCap = DEFAULT_INITIAL_CAPACITY;
            // 這裏可以看見,閾值等於負載因子 * 容量
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        // 這裏不解釋了。
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        // 替換新閾值,同時初始化數組。
        threshold = newThr;
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        // 說明不是第一次擴容,需要遍歷數組遷移元素。
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    // 如果該位置上只有一個元素,那麼遷移這個元素就行了。
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    // 如果該位置是紅黑樹,提一句,遷移數據之後,長度小於UNTREEIFY_THRESHOLD(即6),那麼就會轉回爲鏈表
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        // 這個do-while是將數組分爲兩個鏈表,一個是與舊容量相與爲1,一個是爲0
                        do {
                            next = e.next;
                            // 如果e的hash值與舊容量進行與運算後還是爲0
                            if ((e.hash & oldCap) == 0) {
                            	// 如果loTail 爲null,那麼loHead指向e.
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else { // 如果進行與運算爲1
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        // 如果相與爲0,那麼待在原位置。
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        // 如果爲1,則遷去新位置,這個新位置是原位置+原容量。
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

擴容也是比較好理解的,記錄舊的容量和舊的閾值。如果是第一次擴容,那麼將容量設爲默認的或者用戶傳來的。如果不是,將容量擴容至原來的兩倍,同時,在這裏可以看到閾值 = 負載因子 * 容量。

如果不是第一次擴容,需要進行元素遷移。如果是鏈表的遷移,在擴容中只用判斷原來的 hash 值與原容量按位與操作是 0 或 1 就行,0 的話索引就不變,1 的話索引變成原索引加上擴容前數組,這裏爲什麼是這樣解釋一下,新容量的大小是原大小的兩倍,之前當key判斷自己應該在數組中的哪個位置時,使用的是(n - 1) & hash,這裏的n是指數組大小。那麼元素遷移要判斷自己位置時,也就是(新容量 - 1) & hash,新容量-1和舊容量-1在二進制中只是多了最高位上的1,而這個1就是舊容量上的1,因此只要與舊容量進行&運算就行。

可能文字解釋的比較繞口。使用實例解釋一下吧。

// 假設hash值是10101。
// oldSize是 10000.
// newSize就是 100000.
// (oldSize - 1) & hash = 01111 & 10101 = 00101.
// (newSize - 1) & hash = 011111 & 10101 = 10101.
// 可以看出newSize - 1比oldSize - 1只是多了1,而這個1的位置就是oldSize的1的位置,其他並沒有變
// 因此在newSize是oldSize兩倍的情況下,(newSize - 1) & hash與oldSize & hash的結果是一樣的

get方法

	// 調用下面的方法
	public V get(Object key) {
        Node<K,V> e;
        return (e = getNode(hash(key), key)) == null ? null : e.value;
    }
    
    final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
        // 如果數組不爲null,且長度不爲空,且key的hash對應的數組位置不爲null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            // 如果第一個元素就是,直接返回第一個元素
            if (first.hash == hash && 
                ((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);
            }
        }
        // 沒找到就返回null。
        return null;
    }

get方法對比put和resize方法還是很簡單的。這裏不解釋了。

在這裏插入代碼片

與1.7版本的對比

由於不會寫1.7版本的hashMap源碼,因此這裏說一下兩者之間的區別。

  • 首先,是結構上的區別。1.7的是數組+鏈表,1.8是數組+鏈表/紅黑樹。
  • 其次,是數據上的區別。初始化時1.8是直接用resize方法的,1.7使用了額外的inflateTable方法。插入數據時,1.8使用的是尾插法,1.7是頭插法。
  • 最後,是擴容時的區別。
    1. 1.8遷移元素使用的是hash值和原容量進行&操作,新的位置一般在原位置或者原位置+原容量的位置上,而1.7還是使用的原來的方法,即先進行擾動處理,再進行(n - 1) & hash,判斷新位置在哪;
    2. 同時,1.8擴容還是使用的尾插法,而1.7擴容還是使用頭插法,這樣很容易導致環形鏈表死循環的情況;
    3. 1.8是先插入後判斷是否需要擴容,1.7是先擴容再插入。

與HashTable的對比

HashTable其實現在很少用了,但還是提一下主要的區別吧。

  • HashTable繼承自Dictionary類,而HashMap繼承自Map。
  • HashMap允許key和value爲null的,HashTable則不行。
  • HashTable的所有方法都是加上了sychronized的,性能因此會比較低,而HashMap不是。

總結一下(1.8版本)

  1. HashMap是基於數組+鏈表/紅黑樹的,HashMap有閾值,該閾值大小是由負載因子相乘容量大小決定的,一旦容量大於該閾值,就會導致擴容,同樣的第一次put操作也會擴容。一旦鏈表長度達到8,就樹形化爲紅黑樹,如果此時數組長度大小小於默認的64,就會先擴容,而不是樹形化。
  2. HashMap在添加元素和擴容時都是使用尾插法,而且是先插值後判斷是否需要擴容。
  3. HashMap是線程不安全的,在多線程併發訪問時需要同步,可以使用替代的ConcurrentHashMap或者使用 Collections.synchronizedMap()修飾。

以上,是關於HashMap 1.8的全部內容。

謝謝各位的觀看。本人才疏學淺,如有錯誤之處,歡迎指正,共同進步。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章