hashmap的一些性能測試

0.前言

本文主要討論哈希衝突下的一些性能測試。
爲什麼要寫這篇文章,不是爲了KPI不是爲了水字數。
hashmap是廣大JAVA程序員最爲耳熟能詳,使用最廣泛的集合框架。它是大廠面試必問,著名八股經必備。在小公司呢?這些年也面過不少人,對於3,5年以上的程序員,問到hashmap也僅限於要求知道底層是數組+鏈表,知道怎麼放進去,知道有哈希衝突這麼一回事即可,可依然免不了裝備的嫌疑。

可hashmap背後的思想,在緩存,在數據傾斜,在負載均衡等分佈式大數據領域都能廣泛看到其身影。瞭解其背後的思想不僅僅只是爲了一個hashmap.

更重要的是,hashmap不像jvm底層原理那麼遙遠,不像併發編程那麼宏大,它只需要通勤路上十分鐘就可搞定基本原理,有什麼理由不呢?

所以本文試着從相對少見的一個微小角度來重新審視一下hashmap.

1.準備工作。

1.1模擬哈希衝突

新建兩個class,一個正常重寫equalshashcode方法,一個故意在hashcode方法裏返回一定範圍內的隨機數,模擬哈希衝突,以及控制哈希衝突的程序。

不衝突的類

@Setter
public class KeyTest2 {
    private String name;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        KeyTest2 keyTest = (KeyTest2) o;

        return name != null ? name.equals(keyTest.name) : keyTest.name == null;
    }

    @Override
    public int hashCode() {
         return name != null ? name.hashCode() : 0;
    }
}

衝突的類

@Setter
@NoArgsConstructor
public class KeyTest {
    private String name;

    private Random random;

    public KeyTest(Random random){
        this.random = random;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        KeyTest keyTest = (KeyTest) o;

        return name != null ? name.equals(keyTest.name) : keyTest.name == null;
    }

    @Override
    public int hashCode() {
        // return name != null ? name.hashCode() : 0;
        return random.nextInt(1000);
    }
}

衆所周知,hashmap在做put的時候,先根據key求hashcode,找到數組下標位置,如果該位置有元素,再比較equals,如果返回true,則替換該元素並返回被替換的元素;否則就是哈希衝突了,即hashcode相同但equals返回false。
哈希衝突的時候在衝突的數組處形成數組,長度達到8以後變成紅黑樹。

1.2 java的基準測試。

這裏使用JMH進行基準測試.
JMH是Java Microbenchmark Harness的簡稱,一般用於代碼的性能調優,精度甚至可以達到納秒級別,適用於 java 以及其他基於 JVM 的語言。和 Apache JMeter 不同,JMH 測試的對象可以是任一方法,顆粒度更小,而不僅限於rest api.

jdk9以上的版本自帶了JMH,如果是jdk8可以使用maven引入依賴。

點擊查看JMH依賴
<dependency>
            <groupId>org.openjdk.jmh</groupId>
            <artifactId>jmh-core</artifactId>
            <version>${jmh.version}</version>
        </dependency>
        <dependency>
            <groupId>org.openjdk.jmh</groupId>
            <artifactId>jmh-generator-annprocess</artifactId>
            <version>${jmh.version}</version>
        </dependency>

2.測試初始化長度

點擊查看初始化長度基本測試代碼
/使用模式 默認是Mode.Throughput
@BenchmarkMode(Mode.AverageTime)
// 配置預熱次數,默認是每次運行1秒,運行10次,這裏設置爲3次
@Warmup(iterations = 3, time = 1)
// 本例是一次運行4秒,總共運行3次,在性能對比時候,採用默認1秒即可
@Measurement(iterations = 3, time = 4)
// 配置同時起多少個線程執行
@Threads(1)
//代表啓動多個單獨的進程分別測試每個方法,這裏指定爲每個方法啓動一個進程
@Fork(1)
// 定義類實例的生命週期,Scope.Benchmark:所有測試線程共享一個實例,用於測試有狀態實例在多線程共享下的性能
@State(value = Scope.Benchmark)
// 統計結果的時間單元
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class HashMapPutResizeBenchmark {

    @Param(value = {"1000000"})
    int value;

    /**
     * 初始化長度
     */
    @Benchmark
    public void testInitLen(){
        HashMap map = new HashMap(1000000);
        Random random = new Random();
        for (int i = 0; i < value; i++) {
            KeyTestConflict test = new KeyTestConflict(random, 10000);
            test.setName(i+"");
            map.put(test, test);
        }
    }

    /**
     * 不初始化長度
     */
    @Benchmark
    public void testNoInitLen(){
        HashMap map = new HashMap();
        for (int i = 0; i < value; i++) {
            Random random = new Random();
            KeyTestConflict test = new KeyTestConflict(random, 10000);
            test.setName(i+"");
            map.put(test, test);
        }
    }


    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(HashMapPutResizeBenchmark.class.getSimpleName())
                .mode(Mode.All)
                // 指定結果以json結尾,生成後複製可去:http://deepoove.com/jmh-visual-chart/ 或https://jmh.morethan.io/ 得到可視化界面
                .result("hashmap_result_put_resize.json")
                .resultFormat(ResultFormatType.JSON).build();

        new Runner(opt).run();
    }
}

測試結果圖

對測試結果圖例做一個簡單的說明:

以上基準測試,會得到一個json格式的結果。然後將該結果上傳到官方網站,會得到一個上述圖片的結果。
橫座標,紅色駐圖代表有衝突,淺藍色駐圖無衝突。
衆座標,ops/ns代表平均每次操作花費的時間,單位爲納秒,1秒=1000000000納秒,這樣更精準。
下同。

簡單說,駐圖越高代表性能越低。

我測了兩次,分別是無哈希衝突和有哈希衝突的,這裏只貼一種結果。

測試結果表明,hashmap定義時有初始化對比無初始化,有大約4%到12%的性能損耗。

足夠的初始化長度下,有哈希衝突的測試結果:

足夠的初始化長度下,沒有哈希衝突的測試結果:

3.模擬一百萬個元素put,get的差異。

衆所周知,hashmap在頻繁做resize時,性能損耗非常嚴重。以上是沒初始化長度,無衝突和有衝突的情況下,前者性能是後者性能的53倍。

那麼在初始化長度的情況下呢?

HashMap map = new HashMap(1000000);

同樣的代碼下,得到的測試結果

以上是有初始化長度,無衝突和有衝突的情況下,前者性能是後者性能的58倍。

大差不差,不管有無初始化長度,無衝突的效率都是有衝突效率的50倍以上。說明,這是哈希衝突帶來的性能損耗。

4.模擬無紅黑樹情況下get效率

4.1 將random擴大,哈希衝突嚴重性大大減小,模擬大多數哈希衝突導致的哈希鏈長度均小於8,無法擴展爲紅黑樹,只能遍歷數組。

將KeyTest的hashcode方法改爲:

@Override
    public int hashCode() {
        // return name != null ? name.hashCode() : 0;
        return random.nextInt(130000);
    }

這樣1000000/130000 < 8,這樣大多數的哈希鏈將不會擴展爲紅黑樹。

測試結果爲:

測試結果說明,**有衝突的效率反而比無衝突的效率要高**,差不多高出80%左右。
這其實有點違反常識,我們通常講,hashmap要儘量避免哈希衝突,哈希衝突的情況下寫入和讀取性能都會受到很大的影響。
但是上面的測試結果表明,大數據量相對比較大的時候,適當的哈希衝突(<8)反而讀取效率更高。
個人猜測是,適當的哈希衝突,數組長度大爲減少。

爲了證明以上猜想,直接對ArrayList進行基準測試。

4.1.1 ArrayList不同長度下get效率的基準測試

模擬一個哈希衝突非常嚴重下,底層數組長度較小的list,和哈希衝突不嚴重情況下,底層數組較大的list,再隨機測試Get的效率如何。

點擊查看測試代碼
//使用模式 默認是Mode.Throughput
@BenchmarkMode(Mode.AverageTime)
// 配置預熱次數,默認是每次運行1秒,運行10次,這裏設置爲3次
@Warmup(iterations = 3, time = 1)
// 本例是一次運行4秒,總共運行3次,在性能對比時候,採用默認1秒即可
@Measurement(iterations = 3, time = 4)
// 配置同時起多少個線程執行
@Threads(1)
//代表啓動多個單獨的進程分別測試每個方法,這裏指定爲每個方法啓動一個進程
@Fork(1)
// 定義類實例的生命週期,Scope.Benchmark:所有測試線程共享一個實例,用於測試有狀態實例在多線程共享下的性能
@State(value = Scope.Benchmark)
// 統計結果的時間單元
@OutputTimeUnit(TimeUnit.NANOSECONDS)
public class ArrayListGetBenchmark {

    //    @Param(value = {"1000","100000","1000000"})
    @Param(value = {"1000000"})
    int value;


    @Benchmark
    public void testConflict(){
        int len = 10000;
        Random random = new Random(len);
        for (int i = 0; i < 100; i++) {
            int index = random.nextInt(len);
            System.out.println("有衝突,index = " + index);
            ConflictHashMapOfList.list.get(index);
        }
    }

    @Benchmark
    public void testNoConflict(){
        int len = 1000000;
        Random random = new Random(len);
        for (int i = 0; i < 100; i++) {
            int index = random.nextInt(len);
            System.out.println("無衝突,index = " + index);
            NoConflictHashMapOfList.list.get(index);
        }
    }


    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
                .include(ArrayListGetBenchmark.class.getSimpleName())
                .mode(Mode.All)
                // 指定結果以json結尾,生成後複製可去:http://deepoove.com/jmh-visual-chart/ 或https://jmh.morethan.io/ 得到可視化界面
                .result("arraylist_result_get_all.json")
                .resultFormat(ResultFormatType.JSON).build();

        new Runner(opt).run();
    }


    @State(Scope.Thread)
    public static class ConflictHashMapOfList {
        volatile static ArrayList list = new ArrayList();
        static int randomMax = 10000;
        static {
            // 模擬哈希衝突嚴重,數組長度較小
            for (int i = 0; i < randomMax; i++) {
                list.add(i);
            }
        }

    }

    @State(Scope.Thread)
    public static class NoConflictHashMapOfList {
        volatile static ArrayList list = new ArrayList();
        static int randomMax = 1000000;
        static {
            // 模擬沒有哈希衝突,數組長度較大
            for (int i = 0; i < randomMax; i++) {
                list.add(i);
            }
        }

    }
}

測試結果如下:


可以看到,這裏不能(有誤,待重測)間接證實了以上的猜想。
當然這裏的代碼可能並不嚴謹,也歡迎大家一起討論。

4.2 jdk1.8版本,哈希衝突嚴重下的get效率測試


測試結果說明:在jdk8,無衝突效率是有有衝突的3倍左右。

4.3 將jdk版本降爲1.7,在哈希衝突依然嚴重的情況下,get效率如何?


測試結果說明:在jdk7,無衝突效率是有有衝突的12倍左右。

結合4.1和4.2的測試對比,說明jdk1.8紅黑樹的優化效率確實提升很大。

5.總結

1.初始化的時候指定長度,長度要考慮到負載因子0.75.初始化的影響受到哈希衝突的影響,沒有那麼大(相對於倍數而言),但也不小。
2.哈希衝突嚴重時,put性能急劇下降。(幾十倍級)
3.相同元素個數的前提下,在哈希衝突時,get效率反而更高。
4.相比之前的版本,哈希衝突嚴重時,jdk8紅黑樹對get效率有非常大的提升。

測試代碼和測試結果在 這裏

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