Map 系列 —— HashMap(JDK1.8)

1. Map 接口概述

本文源碼基於 JDK1.8

Map 接口定義:將 key 映射到 value 的一個對象。Map 不能包含重複的 key,每個 key 最多映射一個 value。

Map 接口提供了三個集合視圖,來表達 Map 的內容

  • key 值的 set 集合
  • value 值的 collection 集合
  • key-value 映射的 set 集合,這個其實就是 Set<Map.Entry<K, V>>

至於 map 的順序問題是由 map 集合視圖上的迭代器返回其元素的順序決定的。但是有一些 map 的實現可以保證排序問題,例如 TreeMap

注意如果將可變對象作爲 map 的 key 則必須非常小心。

所有通用 map 實現類都應該提供兩個“標準”構造函數:

  • 一個無參數的構造函數,創建一個 empty map
  • 一個類型爲 Map 參數的構造函數,創建一個具有相同鍵值的新 map

2. HashMap 概述

基於哈希表的 Map 接口實現。此實現提供所有可選的 map 操作,並允許 null valuesnull key。 (HashMap 大致相當於 Hashtable,除了 HashMap 是不同步的並且允許空值)。HashMap 不保證有序,也不保證順序不隨時間變化。

假設散列函數在 buckets 之間正確的分散元素,那麼 HashMap 爲基本操作(get 和 put)提供了恆定時間性能

HashMap 有兩個影響其性能的參數:初始容量(initial capacity)和負載因子(load factor)。容量就是哈希表中 buckets 的數量,負載因子就是 buckets 填滿程度的最大比例。當 buckets 填充的數目大於 capacity * load factor 時,就需要調整 buckets 的數目爲當前的 2 倍。
如果對迭代性能要求很高的話不要把 initial capacity 設置過大,也不要把 load factor 設置太小。HashMap 開頭的註釋是這樣描述的

Thus, it's very important not to set the initial capacity too high (or the load factor too low) if iteration performance is important.

3. 幾個常量

// 默認的 initial capacity —— 必須是 2 的冪
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

// 最大的 capacity
static final int MAXIMUM_CAPACITY = 1 << 30;

// 默認的加載因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;

// 使用 tree 而不是 list 的閾值
static final int TREEIFY_THRESHOLD = 8;

/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6;

/**
* The smallest table capacity for which bins may be treeified.
* (Otherwise the table is resized if too many nodes in a bin.)
* Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
* between resizing and treeification thresholds.
*/
static final int MIN_TREEIFY_CAPACITY = 64;

4. Node 節點

    // 基本的 hash bin node,用於大多數 entries
    static class Node<K,V> implements Map.Entry<K,V> {
        final int hash;
        final K key;
        V value;
        Node<K,V> next;

5. 相關實現

5.1 構造函數

HashMap 一共有四個構造函數:

    /**
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }

    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(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

其中默認的構造函數,我們看到僅僅只是初始化了 loadFactor,令其等於默認值 0.75 。

5.2 put 方法的實現

put 方法的大致思路:

  1. 對 key 做 hash()
  2. 如果 table 數組爲空,則調用 resize() 重新創建
  3. 計算 index
  4. 如果沒碰撞直接存放到 table[index] 裏
  5. 如果碰撞了以鏈表的形式存入 table
  6. 如果碰撞導致鏈表過長(大於等於 TREEIFY_THRESHOLD),就將鏈表轉換爲紅黑樹
  7. 如果節點已存在就替換 old value
  8. 如果 size 超過了 threshold (load factor * current capacity),就得 resize() 重建
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }

    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 爲空則調用 resize 重新創建
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 計算 index,如果該處節點爲空,則新建一個節點
        if ((p = tab[i = (n - 1) & hash]) == null)
            tab[i] = newNode(hash, key, value, null);
        else {
            Node<K,V> e; K k;
            // 節點 p 已存在,且鍵值相等
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                e = p;
            // 節點 p 已存在,且 hash 值衝突,遍歷 Tree
            else if (p instanceof TreeNode)
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
            // 節點 p 已存在,且 hash 值衝突,遍歷鏈表
            else {
                for (int binCount = 0; ; ++binCount) {
                    if ((e = p.next) == null) {
                        p.next = newNode(hash, key, value, null);
                        // binCount 大於閾值,把鏈表轉換爲紅黑樹
                        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;
                }
            }
            // 找到節點,更新 value
            if (e != null) { // existing mapping for key
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                return oldValue;
            }
        }
        ++modCount;
        if (++size > threshold)
            resize();
        afterNodeInsertion(evict);
        return null;
    }

之前我們分析了空的構造函數,只是初始化了一個 loadFactor 值,然後我們就接着進行 put() 方法的分析,這也符合我們平常使用 HashMap 的習慣。從這個流程我們看到,table 數組一開始肯定是空的,threshold 還沒看到哪裏有賦值,那麼整個 put 方法分析下來可知 resize() 方法是我們接下來的重點。

5.3 resize 方法的實現

resize 方法用於初始化或擴容 table 數組的大小。
當 put 時,如果 table 爲空,或者 put 完發現當前的 size 已經超過了 threshold ,就會去調用 resize 進行擴容。

resize 大致步驟:

  1. 初始化的情況:newCap 賦值爲 DEFAULT_INITIAL_CAPACITY,newThr 賦值爲 (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY),按默認的情況也就是 newCap = 16,newThr = 12。分配新buckets,即 Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 然後 return newTab; 結束。

  2. 擴容的情況:
    如果超過最大值,則不再擴充,讓 threshold = Integer.MAX_VALUE 然後 return oldTab 結束。
    沒超過最大值,擴容爲原來的 2 倍,然後把每個 Node 移動到新的 Node 數組中去。

接下來我們模擬一下擴容情況,移動 Node 的過程,即下面列出的代碼中最後一段循環代碼,這是擴容的主要邏輯。這裏有幾個約定,首先我們假設 value 值等於 hash 值從 0,1,2,3 ... 以此類推,然後初始 capacity 是默認值 16,加載因子也一樣默認值 0.75,因此有如下的過程,一圖勝千言:

其中 e.hash & oldCap 這個設計非常巧妙,既省去了重新計算 hash 值的時間(在 jdk1.7 版本中是需要計算 hash 值然後確定下標的位置),而且把之前衝突的節點分散到新的 table 中去了。要知道在我們 put 時,計算下標的代碼是這樣的 (n - 1) & hash

    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) {
            // 如果容量超過了最大值,那麼不再擴充,只好讓碰撞繼續發生
            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) // initial capacity was placed in threshold
            newCap = oldThr;
        else {
            // zero initial threshold signifies using defaults
            // 當 threshold == 0 時,使用默認值初始化
            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;
        // 如果 newCap 過大,可能會造成 java.lang.OutOfMemoryError
        @SuppressWarnings({"rawtypes","unchecked"})
            Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            // 移動每個節點到擴容後的 newTab 中
            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;
                    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;
                        // 分割鏈表元素,計算鏈表中元素的新位置,然後放置到 newTab 中
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

5.4 hash 方法的實現

在 get 和 put 時,都有計算下標的過程,計算下標與 hash 值有關,所以我們來看看 hash()方法。

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

可以看到,這裏對 key 的 hashCode 值做了一個處理,即:高 16bit 不變,低 16bit 和高 16bit 做了一個異或。
計算下標的過程大抵如下所示:

那麼爲什麼要將低 16bit 和高 16bit 做一個異或呢,源碼註釋中是這樣寫的:

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.

大意就是我們通過 & 位運算來計算下標,而 capacity 總爲 2 的冪,所以當 capacity 較小時,hash 值的高 16 位根本參與不進來,現在進行異或後,將高 16 位的值也參與進來,從而減少碰撞的發生。

6. 總結

Q. HashMap 的特點
A. 是基於 Map 的實現,允許存儲 null keynull value,是不同步的,不保證有序。

Q. HashMap 的存儲過程
A. 調用 put(K key, V value) 方法進行存儲。首先通過 hash(key) 計算出 key 的 hash 值,其中 hash 方法會將 key 的 hashCode 的高 16bit 與低 16bit 進行異或,得到一個 hash 值。然後通過 (n - 1) & hash 得到 bucket 的下標位置。根據 key 和 hash 值尋找是否已存在節點,如果已存在則更新舊值(是否更新舊值根據 onlyIfAbsent 字段決定),不存在的話則調用 newNode 生成新 Node,並存儲起來。在 bucket 中尋找的時候通過遍歷鏈表或者紅黑樹,在 jdk 1.8 中,當 bucket 中碰撞衝突的元素超過某個限制(默認是 8),則使用紅黑樹替代鏈表,從而提高查詢速度。

Q. 爲什麼轉換成紅黑樹的限制是 8?
A. 首先可以肯定當鏈表長度不斷變長時,肯定會對查詢性能有一定的影響,因此需要轉換成紅黑樹。但是 TreeNodes 佔用空間是普通 Nodes 的兩倍,並且我們需要避免頻繁的在鏈表和紅黑樹之間來回轉換,所以我們需要一個閾值來確定什麼時候進行轉換。根據源碼註釋所說,在理想情況下隨機 hashCode 算法下所有 bucket 中節點的分佈頻率會遵循泊松分佈,在源碼中可以看到一個 bucket 中鏈表長度達到 8 個元素的概率爲 0.00000006,所以官方選擇 8 作爲整個限制是通過嚴謹科學的概率統計得來的。

Q. 爲什麼 capacity 的長度一定得是 2 的冪?
A. 我們來看看如果不是 2 的冪會有什麼影響

  1. 如果不是 2 的冪次方,那麼計算下標時所採用的的 (n-1) & hash 就得放棄,改用 hash % n,顯然 % 的效率要明顯低於 &
  2. resize 中 e.hash & oldCap 這個手段失效了。

Q. 爲什麼擴容是 2 倍,而不是50%,75%什麼的?
A. 取模用與操作(hash & (arrayLength-1))會比較快,所以數組的大小永遠是 2 的 N次方,所以擴容後的大小必須是 2 的冪。

參考資料

Java HashMap工作原理及實現
圖解 HashMap 原理
TREEIFY_THRESHOLD 爲什麼是 8

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