面試官:"準備用HashMap存1w條數據,構造時傳10000還會觸發擴容嗎?"

// 預計存入 1w 條數據,初始化賦值 10000,避免 resize。
HashMap<String,String> map = new HashMap<>(10000)
// for (int i = 0; i < 10000; i++)

Java 集合的擴容

HashMap 算是我們最常用的集合之一,雖然對於 Android 開發者,Google 官方推薦了更省內存的 SparseArray 和 ArrayMap,但是 HashMap 依然是最常用的。

我們通過 HashMap 來存儲 Key-Value 這種鍵值對形式的數據,其內部通過哈希表,讓存取效率最好時可以達到 O(1),而又因爲可能存在的 Hash 衝突,引入了鏈表和紅黑樹的結構,讓效率最差也差不過 O(logn)。

整體來說,HashMap 作爲一款工業級的哈希表結構,效率還是有保障的。

編程語言提供的集合類,雖然底層還是基於數組、鏈表這種最基本的數據結構,但是和我們直接使用數組不同,集合在容量不足時,會觸發動態擴容來保證有足夠的空間存儲數據

動態擴容,涉及到數據的拷貝,是一種「較重」的操作。那如果能夠提前確定集合將要存儲的數據量範圍,就可以通過構造方法,指定集合的初始容量,來保證接下來的操作中,不至於觸發動態擴容。

這就引入了本文開篇的問題,如果使用 HashMap,當初始化是構造函數指定 1w 時,後續我們立即存入 1w 條數據,是否符合與其不會觸發擴容呢?

在分析這個問題前,那我們先來看看,HashMap 初始化時,指定初始容量值都做了什麼?

PS:本文所涉及代碼,均以 JDK 1.8 中 HashMap 的源碼舉例。

HashMap 的初始化

在 HashMap 中,提供了一個指定初始容量的構造方法 HashMap(int initialCapacity),這個方法最終會調用到 HashMap 另一個構造方法,其中的參數 loadFactor 就是默認值 0.75f。

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

其中的成員變量 threshold 就是用來存儲,觸發 HashMap 擴容的閾值,也就是說,當 HashMap 存儲的數據量達到 threshold 時,就會觸發擴容。

從構造方法的邏輯可以看出,HashMap 並不是直接使用外部傳遞進來的 initialCapacity,而是經過了 tableSizeFor() 方法的處理,再賦值到 threshole 上。

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

tableSizeFor() 方法中,通過逐步位運算,就可以讓返回值,保持在 2 的 N 次冪。以方便在擴容的時候,快速計算數據在擴容後的新表中的位置。

那麼當我們從外部傳遞進來 1w 時,實際上經過 tableSizeFor() 方法處理之後,就會變成 2 的 14 次冪 16384,再算上負載因子 0.75f,實際在不觸發擴容的前提下,可存儲的數據容量是 12288(16384 * 0.75f)。

這種場景下,用來存放 1w 條數據,綽綽有餘了,並不會觸發我們猜想的擴容。

HashMap 的 table 初始化

當我們把初始容量,調整到 1000 時,情況又不一樣了,具體情況具體分析。

再回到 HashMap 的構造方法,threshold 爲擴容的閾值,在構造方法中由 tableSizeFor() 方法調整後直接賦值,所以在構造 HashMap 時,如果傳遞 1000,threshold 調整後的值確實是 1024,但 HashMap 並不直接使用它。

仔細想想就會知道,初始化時決定了 threshold 值,但其裝載因子(loadFactor)並沒有參與運算,那在後面具體邏輯的時候,HashMap 是如何處理的呢?

在 HashMap 中,所有的數據,都是通過成員變量 table 數組來存儲的,在 JDK 1.7 和 1.8 中雖然 table 的類型有所不同,但是數組這種基本結構並沒有變化。那麼 table、threshold、loadFactor 三者之間的關係,就是:

table.size == threshold * loadFactor

那這個 table 是在什麼時候初始化的呢?這就要說會到我們一直在迴避的問題,HashMap 的擴容。

在 HashMap 中,動態擴容的邏輯在 resize() 方法中。這個方法不僅僅承擔了 table 的擴容,它還承擔了 table 的初始化。

當我們首次調用 HashMap 的 put() 方法存數據時,如果發現 table 爲 null,則會調用 resize() 去初始化 table,具體邏輯在 putVal() 方法中。

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length; // 調用 resize()
    // ...
}

resize() 方法中,調整了最終 threshold 值,以及完成了 table 的初始化。

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;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                 oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1; 
    }
    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; // ④
    // ....
}

注意看代碼中的註釋標記。

因爲 resize() 還糅合了動態擴容的邏輯,所以我將初始化 table 的邏輯用註釋標記出來了。其中 xxxCap 和 xxxThr 分別對應了 table 的容量和動態擴容的閾值,所以存在舊和新兩組數據。

當我們指定了初始容量,且 table 未被初始化時,oldThr 就不爲 0,則會走到代碼 的邏輯。在其中將 newCap 賦值爲 oldThr,也就是新創建的 table 會是我們構造的 HashMap 時指定的容量值。

之後會進入代碼 的邏輯,其中就通過裝載因子(loadFactor)調整了新的閾值(newThr),當然這裏也做了一些限制需要讓 newThr 在一個合法的範圍內。

在代碼 中,將使用 loadFactor 調整後的閾值,重新保存到 threshold 中。並通過 newCap 創建新的數組,將其指定到 table 上,完成 table 的初始化(代碼 )。

到這裏也就清楚了,雖然我們在初始化時,傳遞進來的 initialCapacity 雖然被賦值給 threshold,但是它實際是 table 的尺寸,並且最終會通過 loadFactor 重新調整 threshold

那麼回到之前的問題就有答案了,雖然 HashMap 初始容量指定爲 1000,但是它只是表示 table 數組爲 1000,擴容的重要依據擴容閾值會在 resize() 中調整爲 768(1024 * 0.75)。

它是不足以承載 1000 條數據的,最終在存夠 1k 條數據之前,還會觸發一次動態擴容。

通常在初始化 HashMap 時,初始容量都是根據業務來的,而不會是一個固定值,爲此我們需要有一個特殊處理的方式,就是將預期的初始容量,再除以 HashMap 的裝載因子,默認時就是除以 0.75。

例如想要用 HashMap 存放 1k 條數據,應該設置 1000 / 0.75,實際傳遞進去的值是 1333,然後會被 tableSizeFor() 方法調整到 2048,足夠存儲數據而不會觸發擴容。

當想用 HashMap 存放 1w 條數據時,依然設置 10000 / 0.75,實際傳遞進去的值是 13333,會被調整到 16384,和我們直接傳遞 10000 效果是一樣的。

小結時刻

到這裏,就瞭解清楚了 HashMap 的初始容量,應該如何科學的計算,本質上你傳遞進去的值可能並無法直接存儲這麼多數據,會有一個動態調整的過程。其中就需要將我們預期的值進行放大,比較科學的就是依據裝載因子進行放大。

最後我們再總結一下:

  1. HashMap 構造方法傳遞的 initialCapacity,雖然在處理後被存入了 loadFactor 中,但它實際表示 table 的容量。
  2. 構造方法傳遞的 initialCapacity,最終會被 tableSizeFor() 方法動態調整爲 2 的 N 次冪,以方便在擴容的時候,計算數據在 newTable 中的位置。
  3. 如果設置了 table 的初始容量,會在初始化 table 時,將擴容閾值 threshold 重新調整爲 table.size * loadFactor。
  4. HashMap 是否擴容,由 threshold 決定,而 threshold 又由初始容量和 loadFactor 決定。
  5. 如果我們預先知道 HashMap 數據量範圍,可以預設 HashMap 的容量值來提升效率,但是需要注意要考慮裝載因子的影響,才能保證不會觸發預期之外的動態擴容。

HashMap 作爲 Java 最常用的集合之一,市面上優秀的文章很多,但是很少有人從初始容量的角度來分析其中的邏輯,而初始容量又是集合中比較實際的優化點。其實不少人也搞不清楚,在設置 HashMap 初始容量時,是否應該考慮裝載因子,纔有了此文。

如果本文對你有所幫助,留言、轉發、點好看是最大的支持,謝謝!


公衆號後臺回覆成長『成長』,將會得到我準備的學習資料,也能回覆『加羣』,一起學習進步。

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