Java 8:HashMap的效能提升
HashMap是一個高效通用的資料結構,它在每一個Java程式中都隨處可見。先來介紹些基礎知識。你可能也知道,HashMap使用key的hashCode()和equals()方法來將值劃分到不同的桶裡。桶的數量通常要比map中的記錄的數量要稍大,這樣每個桶包括的值會比較少(最好是一個)。當通過key進行查詢時,我們可以在常數時間內迅速定位到某個桶(使用hashCode()對桶的數量進行取模)以及要找的物件。
這些東西你應該都已經知道了。你可能還知道雜湊碰撞會對hashMap的效能帶來災難性的影響。如果多個hashCode()的值落到同一個桶內的時候,這些值是儲存到一個連結串列中的。最壞的情況下,所有的key都對映到同一個桶中,這樣hashmap就退化成了一個連結串列——查詢時間從O(1)到O(n)。我們先來測試下正常情況下hashmap在Java 7和Java 8中的表現。為了能完成控制hashCode()方法的行為,我們定義瞭如下的一個Key類:
class Key implements Comparable<Key> { private final int value; Key(int value) { this.value = value; } @Override public int compareTo(Key o) { return Integer.compare(this.value, o.value); } @Override public boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Key key = (Key) o; return value == key.value; } @Override public int hashCode() { return value; } }
Key類的實現中規中矩:它重寫了equals()方法並且提供了一個還算過得去的hashCode()方法。為了避免過度的GC,我將不可變的Key物件快取了起來,而不是每次都重新開始建立一遍:
class Key implements Comparable<Key> { public class Keys { public static final int MAX_KEY = 10_000_000; private static final Key[] KEYS_CACHE = new Key[MAX_KEY]; static { for (int i = 0; i < MAX_KEY; ++i) { KEYS_CACHE[i] = new Key(i); } } public static Key of(int value) { return KEYS_CACHE[value]; } }
現在我們可以開始進行測試了。我們的基準測試使用連續的Key值來建立了不同的大小的HashMap(10的乘方,從1到1百萬)。在測試中我們還會使用key來進行查詢,並測量不同大小的HashMap所花費的時間:
import com.google.caliper.Param; import com.google.caliper.Runner; import com.google.caliper.SimpleBenchmark; public class MapBenchmark extends SimpleBenchmark { private HashMap<Key, Integer> map; @Param private int mapSize; @Override protected void setUp() throws Exception { map = new HashMap<>(mapSize); for (int i = 0; i < mapSize; ++i) { map.put(Keys.of(i), i); } } public void timeMapGet(int reps) { for (int i = 0; i < reps; i++) { map.get(Keys.of(i % mapSize)); } } }
有意思的是這個簡單的HashMap.get()裡面,Java 8比Java 7要快20%。整體的效能也相當不錯:儘管HashMap裡有一百萬條記錄,單個查詢也只花了不到10納秒,也就是大概我機器上的大概20個CPU週期。相當令人震撼!不過這並不是我們想要測量的目標。
假設有一個很差勁的key,他總是返回同一個值。這是最糟糕的場景了,這種情況完全就不應該使用HashMap:
class Key implements Comparable<Key> { //... @Override public int hashCode() { return 0; } }
Java 7的結果是預料中的。隨著HashMap的大小的增長,get()方法的開銷也越來越大。由於所有的記錄都在同一個桶裡的超長連結串列內,平均查詢一條記錄就需要遍歷一半的列表。因此從圖上可以看到,它的時間複雜度是O(n)。
不過Java 8的表現要好許多!它是一個log的曲線,因此它的效能要好上好幾個數量級。儘管有嚴重的雜湊碰撞,已是最壞的情況了,但這個同樣的基準測試在JDK8中的時間複雜度是O(logn)。單獨來看JDK 8的曲線的話會更清楚,這是一個對數線性分佈:
為什麼會有這麼大的效能提升,儘管這裡用的是大O符號(大O描述的是漸近上界)?其實這個優化在JEP-180中已經提到了。如果某個桶中的記錄過大的話(當前是TREEIFY_THRESHOLD = 8),HashMap會動態的使用一個專門的treemap實現來替換掉它。這樣做的結果會更好,是O(logn),而不是糟糕的O(n)。它是如何工作的?前面產生衝突的那些KEY對應的記錄只是簡單的追加到一個連結串列後面,這些記錄只能通過遍歷來進行查詢。但是超過這個閾值後HashMap開始將列表升級成一個二叉樹,使用雜湊值作為樹的分支變數,如果兩個雜湊值不等,但指向同一個桶的話,較大的那個會插入到右子樹裡。如果雜湊值相等,HashMap希望key值最好是實現了Comparable介面的,這樣它可以按照順序來進行插入。這對HashMap的key來說並不是必須的,不過如果實現了當然最好。如果沒有實現這個介面,在出現嚴重的雜湊碰撞的時候,你就並別指望能獲得性能提升了。
這個效能提升有什麼用處?比方說惡意的程式,如果它知道我們用的是雜湊演算法,它可能會發送大量的請求,導致產生嚴重的雜湊碰撞。然後不停的訪問這些key就能顯著的影響伺服器的效能,這樣就形成了一次拒絕服務攻擊(DoS)。JDK 8中從O(n)到O(logn)的飛躍,可以有效地防止類似的攻擊,同時也讓HashMap效能的可預測性稍微增強了一些。我希望這個提升能最終說服你的老大同意升級到JDK 8來。
測試使用的環境是:Intel Core i7-3635QM @ 2.4 GHz,8GB記憶體,SSD硬碟,使用預設的JVM引數,執行在64位的Windows 8.1系統 上。