面試官:」準備用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 做爲一款工業級的哈希表結構,效率仍是有保障的。數組

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

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

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

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

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

HashMap 的初始化

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

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

從構造方法的邏輯能夠看出,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()
    // ...
}
在 res
複製代碼

在 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 初始容量時,是否應該考慮裝載因子,纔有了此文。

2019秋招必備面試題彙總+阿里P6P7安卓進階資料分享

相關文章
相關標籤/搜索