一些Java程式設計老手在做CodeReview時,都會告訴其他人,使用HashMap時建議指定容量大小,原因是指定容量後,程式碼效能會更好一些。後來隨著阿里Java開發手冊在業內廣為傳播,這一點早已深入人心,我自己也早已習慣在使用HashMap時指定容量大小。但我今天突發奇想,想知道指定容量和不指定容量時效能究竟有多少的差異,測試部分測試資料的結果讓我大跌眼睛,有些情況下指定容量的效能還比不指定容量時差!! ,但其他部分還是很符合我之前的認知的。
先說下我的測試平臺和測試方法,我使用了openjdk17和jmh單執行緒測試,測試程式碼如下:
@Benchmark
@BenchmarkMode(Mode.Throughput)
@Measurement(iterations = 2, time = 5)
@Threads(1)
@Fork(0)
@Warmup(iterations = 1, time = 5)
public void withoutCap() {
Map<Integer, Integer> map = new HashMap<>();
for (int i = 0; i < CAP; i++) {
map.put(random.nextInt(), 1);
}
}
@Benchmark
@BenchmarkMode(Mode.Throughput)
@Measurement(iterations = 2, time = 5)
@Threads(1)
@Fork(0)
@Warmup(iterations = 1, time = 5)
public void withCap() {
Map<Integer, Integer> map = new HashMap<>(CAP);
for (int i = 0; i < CAP; i++) {
map.put(random.nextInt(), 1);
}
}
這裡為了避免Java中小資料快取,我特意使用了隨機數作為KEY,而VALUE一視同仁都使用了1。兩個方法就是新建一個HashMap並不斷往map裡put資料,唯一差異就是一個指定了CAP引數。 在我設定了不同引數後,得到了以下資料(越高越好):
資料量 | 不指定容量(ops/s) | 指定容量(ops/s) |
---|---|---|
2 | 51095433 | 24000032 |
4 | 25161756 | 11813275 |
8 | 10767176 | 5900641 |
16 | 2978374 | 2987958 |
32 | 1231637 | 1545394 |
64 | 567643 | 764260 |
256 | 129350 | 185540 |
1024 | 27475 | 35799 |
1025 | 27195 | 68466 |
4096 | 6681 | 9937 |
32768 | 807 | 1177 |
65536 | 377 | 567 |
可以看出,容量16是個分水嶺,當容量為16時,二者幾乎沒啥差異,這也很容易理解,當不指定容量時預設初始容量就是16。但容量大於16時,指定容量時的效能會高於不指定時的效能,隨著數量的增加,前者會比後者效能高出50%。但當資料量小於16時,不指定容量大小反而效能更高,最多甚至相差2倍,這就和我們之前的認知不一樣了。
上面資料中還有個很奇怪的點,那就是當資料量為1025時,效能居然還高於1024,而且差異巨大。就好比別人比你多幹了1份活,但用的時間比你少一半。我跑了多次都是這個結果,這不是測試誤差,這個結果和計算機底層儲存實現有關,具體原理可以參考問題 為什麼轉置512x512的矩陣比轉置513x513的矩陣慢?
備註:以上資料經過多次執行測試,資料雖有波動,但資料波動基本都在3%以內。
那為什麼在大資料量的情況下,指定容量的程式碼效能會更好呢?這就得說到HashMap的實現原理,更詳細內容可以參考我之前寫的HashMap原始碼淺析。這裡為了方便大家直觀地理解效能差異產生的原因,我們用牧場養羊類比下。 假設你要開始養羊,你得現有場地吧,假設你先找了塊小場地,但隨著你的羊群發展壯大,場地不夠用了,你就得搬到一個更大的新場地,如果發展速度特別快,你就得頻繁搬家,搬家就逐漸變成了負擔。但如果你一開始就知道你最多能養多少的羊,直接找個足夠大的場地,不就能省去一直搬家的成本了嗎!
這裡你把羊類比成資料,場地類比為記憶體,在HashMap中,如果開始不指定容量大小,JVM預設會給你一個非常小的(16)的容量空間,如果之後資料量變多,就需要重新申請更大的空間,並把資料遷移到新空間上,於是額外增加了時間消耗。這便是效能差異產生的原因。
但當容量小於16時,指定容量的方式反而效能更差。這個我之前從未看過其他資料有說過,我簡單談下自己的分析和理解。 當呼叫new HashMap()和new HashMap(CAP)時,分別執行了不同的建構函式,而二者的建構函式的邏輯是有差異的,當指定容量時,執行了容量引數檢查的程式碼:
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);
}
static final int tableSizeFor(int cap) {
int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
不指定容量時,構造方法內只有一行this.loadFactor = DEFAULT_LOAD_FACTOR;
,在put的資料量一致時,後續所有的程式碼執行流程都是一致的,所以指定容量時,上面容量引數檢查的程式碼帶來了額外的效能負擔,所以導致資料量較小時指定容量時反而效能更差一些。
最後回到文章標題上來,Java中使用HashMap時指定初始化容量效能一定會更好嘛?答案是不一定,指定容量也有可能效能會更差。當然,絕大多數情況下還是建議指定容量的,類似的還有ArrayList,也建議指定容量。 別人給出的結論不一定的完全正確的,只有知道產生結論的原因,才能更有效的利用這個結論。