// 預計存入 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 的初始容量,應該如何科學的計算,本質上你傳遞進去的值可能並無法直接存儲這么多數據,會有一個動態調整的過程。其中就需要將我們預期的值進行放大,比較科學的就是依據裝載因子進行放大。
最后我們再總結一下:
- HashMap 構造方法傳遞的 initialCapacity,雖然在處理后被存入了 loadFactor 中,但它實際表示 table 的容量。
- 構造方法傳遞的 initialCapacity,最終會被
tableSizeFor()
方法動態調整為 2 的 N 次冪,以方便在擴容的時候,計算數據在 newTable 中的位置。 - 如果設置了 table 的初始容量,會在初始化 table 時,將擴容閾值 threshold 重新調整為 table.size * loadFactor。
- HashMap 是否擴容,由 threshold 決定,而 threshold 又由初始容量和 loadFactor 決定。
- 如果我們預先知道 HashMap 數據量范圍,可以預設 HashMap 的容量值來提升效率,但是需要注意要考慮裝載因子的影響,才能保證不會觸發預期之外的動態擴容。
HashMap 作為 Java 最常用的集合之一,市面上優秀的文章很多,但是很少有人從初始容量的角度來分析其中的邏輯,而初始容量又是集合中比較實際的優化點。其實不少人也搞不清楚,在設置 HashMap 初始容量時,是否應該考慮裝載因子,才有了此文。
如果本文對你有所幫助,留言、轉發、點好看是最大的支持,謝謝!
公眾號后台回復成長『成長』,將會得到我准備的學習資料,也能回復『加群』,一起學習進步。