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

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


公眾號后台回復成長『成長』,將會得到我准備的學習資料,也能回復『加群』,一起學習進步。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM