1. 程式人生 > 實用技巧 >面試官:"準備用HashMap存1w條資料,構造時傳10000還會觸發擴容嗎?"

面試官:"準備用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 初始容量時,是否應該考慮裝載因子,才有了此文。