面試官:"準備用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 依然是最經常使用的。java

咱們經過 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。this

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 時,就會觸發擴容。spa

從構造方法的邏輯能夠看出,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 初始容量時,是否應該考慮裝載因子,纔有了此文。

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


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

相關文章
相關標籤/搜索