爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?

1

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


集合是 Java 開發日常開發中經常會使用到的。在之前的一些文章中,我們介紹過一些關於使用集合類應該注意的事項,如《爲什麼阿里巴巴禁止在 foreach 循環裏進行元素的 remove/add 操作》。

關於集合類,還有很多地方需要注意,本文就來分析下問什麼建議集合初始化時,指定集合容量大小?如果一定要設置初始容量的話,設置多少比較合適?

爲什麼要設置 HashMap 的初始化容量

我們先來寫一段代碼在 JDK 1.7 (jdk1.7.0_79)下面來分別測試下,在不指定初始化容量和指定初始化容量的情況下性能情況如何。(jdk 8 結果會有所不同,我會在後面的文章中分析)

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


以上代碼不難理解,我們創建了 3 個 HashMap,分別使用默認的容量(16)、使用元素個數的一半(5千萬)作爲初始容量、使用元素個數(一億)作爲初始容量進行初始化。然後分別向其中 put 一億個 KV。

輸出結果:

未初始化容量,耗時 : 14419

初始化容量5000000,耗時 : 11916

初始化容量爲10000000,耗時 : 7984

從結果中,我們可以知道,在已知 HashMap 中將要存放的 KV 個數的時候,設置一個合理的初始化容量可以有效的提高性能。

當然,以上結論也是有理論支撐的。HashMap 有擴容機制,就是當達到擴容條件時會進行擴容。HashMap 的擴容條件就是當 HashMap 中的元素個數(size)超過臨界值(threshold)時就會自動擴容。在 HashMap 中,threshold = loadFactor * capacity。

所以,如果我們沒有設置初始容量大小,隨着元素的不斷增加,HashMap 會發生多次擴容,而 HashMap 中的擴容機制決定了每次擴容都需要重建 hash 表,是非常影響性能的。

從上面的代碼示例中,我們還發現,同樣是設置初始化容量,設置的數值不同也會影響性能,那麼當我們已知 HashMap 中即將存放的 KV 個數的時候,容量設置成多少爲好呢?

HashMap 中容量的初始化

默認情況下,當我們設置 HashMap 的初始化容量時,實際上 HashMap 會採用第一個大於該數值的 2 的冪作爲初始化容量。

如以下示例代碼:

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


在 jdk1.7 中,初始化容量設置成 1 的時候,輸出結果是 2。在 jdk1.8 中,如果我們傳入的初始化容量爲 1,實際上設置的結果也爲 1,上面代碼輸出結果爲 2 的原因是代碼中 map.put("hahaha", "hollischuang");導致了擴容,容量從 1 擴容到 2。

那麼,話題再說回來,當我們通過 HashMap(int initialCapacity)設置初始容量的時候,HashMap 並不一定會直接採用我們傳入的數值,而是經過計算,得到一個新值,目的是提高 hash 的效率。(1->1、3->4、7->8、9->16)

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


不管是 Jdk 1.7 還是 Jdk 1.8,計算初始化容量的算法其實是如出一轍的,主要代碼如下:

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


上面的代碼挺有意思的,一個簡單的容量初始化,Java 的工程師也有很多考慮在裏面。

上面的算法目的挺簡單,就是:根據用戶傳入的容量值(代碼中的cap),通過計算,得到第一個比他大的 2 的冪並返回。

聰明的讀者們,如果讓你設計這個算法你準備如何計算?如果你想到二進制的話,那就很簡單了。舉幾個例子看一下:

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


請關注上面的幾個例子中,藍色字體部分的變化情況,或許你會發現些規律。5->8、9->16、19->32、37->64 都是主要經過了兩個階段。

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


對應到以上代碼中,Step1:

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


對應到以上代碼中,Step2:

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


Step 2 比較簡單,就是做一下極限值的判斷,然後把 Step 1 得到的數值 +1。

Step 1 怎麼理解呢?其實是對一個二進制數依次向右移位,然後與原值取或。其目的對於一個數字的二進制,從第一個不爲 0 的位開始,把後面的所有位都設置成 1。

隨便拿一個二進制數,套一遍上面的公式就發現其目的了:

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


通過幾次無符號右移和按位或運算,我們把 1100 1100 1100 轉換成了1111 1111 1111 ,再把 1111 1111 1111 加 1,就得到了 1 0000 0000 0000,這就是大於 1100 1100 1100 的第一個 2 的冪。

好了,我們現在解釋清楚了 Step 1 和 Step 2 的代碼。就是可以把一個數轉化成第一個比他自身大的 2 的冪。(可以開始佩服 Java 的工程師們了,使用無符號右移和按位或運算大大提升了效率。)

但是還有一種特殊情況套用以上公式不行,這些數字就是 2 的冪自身。如果數字 4 套用公式的話。得到的會是 8 :

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


爲了解決這個問題,JDK 的工程師把所有用戶傳進來的數在進行計算之前先 -1,就是源碼中的第一行:

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


HashMap 中初始容量的合理值

當我們使用HashMap(int initialCapacity)來初始化容量的時候,jdk 會默認幫我們計算一個相對合理的值當做初始容量。那麼,是不是我們只需要把已知的 HashMap 中即將存放的元素個數直接傳給 initialCapacity 就可以了呢?

關於這個值的設置,在《阿里巴巴 Java 開發手冊》有以下建議:

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


這個值,並不是阿里巴巴的工程師原創的,在 guava(21.0 版本)中也使用的是這個值。

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


在return (int) ((float) expectedSize / 0.75F + 1.0F);上面有一行註釋,說明了這個公式也不是 guava 原創,參考的是 JDK8 中 putAll 方法中的實現的。感興趣的讀者可以去看下 putAll 方法的實現,也是以上的這個公式。

雖然,當我們使用HashMap(int initialCapacity)來初始化容量的時候,jdk 會默認幫我們計算一個相對合理的值當做初始容量。但是這個值並沒有參考 loadFactor 的值。

也就是說,如果我們設置的默認值是 7,經過 JDK 處理之後,會被設置成 8,但是,這個 HashMap 在元素個數達到 8*0.75 = 6 的時候就會進行一次擴容,這明顯是我們不希望見到的。

如果我們通過expectedSize / 0.75F + 1.0F計算,7/0.75 + 1 = 10 ,10 經過 JDK 處理之後,會被設置成 16,這就大大的減少了擴容的機率。

當 HashMap 內部維護的哈希表的容量達到 75% 時(默認情況下),會觸發 rehash,而 rehash 的過程是比較耗費時間的。所以初始化容量要設置成 expectedSize/0.75 + 1 的話,可以有效的減少衝突也可以減小誤差。

所以,我可以認爲,當我們明確知道 HashMap 中元素的個數的時候,把默認容量設置成 expectedSize / 0.75F + 1.0F 是一個在性能上相對好的選擇,但是,同時也會犧牲些內存。

小結

當我們想要在代碼中創建一個 HashMap 的時候,如果我們已知這個 Map 中即將存放的元素個數,給 HashMap 設置初始容量可以在一定程度上提升效率。

但是,JDK 並不會直接拿用戶傳進來的數字當做默認容量,而是會進行一番運算,最終得到一個 2 的冪。

但是,爲了最大程度的避免擴容帶來的性能消耗,我們建議可以把默認容量的數字設置成 expectedSize / 0.75F + 1.0F 。在日常開發中,可以使用

爲啥阿里巴巴Java開發手冊建議集合初始化時,指定集合容量大小?


來創建一個 HashMap,計算的過程 guava 會幫我們完成。

但是,以上的操作是一種用內存換性能的做法,真正使用的時候,要考慮到內存的影響。

覺得文章不錯就給小老弟點個關注吧,更多內容陸續奉上。

最後,分享一份面試寶典《Java核心知識點整理.pdf》,覆蓋了JVM、鎖、高併發、反射、Spring原理、微服務、Zookeeper、數據庫、數據結構等等。加入我的個人粉絲羣(Java架構技術棧:644872653)獲取免費領取方式。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章