// 預計存入1w條數據,初始化賦值10000,避免 resize。
HashMap<String,String> map = new HashMap<>(10000)
// for (int i = 0; i < 10000; i++)
複製代碼
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(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 條數據,綽綽有餘了,並不會觸發咱們猜測的擴容。
當咱們把初始容量,調整到 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 的初始容量,應該如何科學的計算,本質上你傳遞進去的值可能並沒有法直接存儲這麼多數據,會有一個動態調整的過程。其中就須要將咱們預期的值進行放大,比較科學的就是依據裝載因子進行放大。
最後咱們再總結一下:
HashMap 構造方法傳遞的 initialCapacity,雖然在處理後被存入了 loadFactor 中,但它實際表示 table 的容量。
構造方法傳遞的 initialCapacity,最終會被 tableSizeFor()
方法動態調整爲 2 的 N 次冪,以方便在擴容的時候,計算數據在 newTable 中的位置。
若是設置了 table 的初始容量,會在初始化 table 時,將擴容閥值 threshold 從新調整爲 table.size * loadFactor。
HashMap 是否擴容,由 threshold 決定,而 threshold 又由初始容量和 loadFactor 決定。
若是咱們預先知道 HashMap 數據量範圍,能夠預設 HashMap 的容量值來提高效率,可是須要注意要考慮裝載因子的影響,才能保證不會觸發預期以外的動態擴容。
HashMap 做爲 Java 最經常使用的集合之一,市面上優秀的文章不少,可是不多有人從初始容量的角度來分析其中的邏輯,而初始容量又是集合中比較實際的優化點。其實很多人也搞不清楚,在設置 HashMap 初始容量時,是否應該考慮裝載因子,纔有了此文。