Java 中的5個程式碼效能提升技巧,最高提升近10倍

程式猿阿朗發表於2021-12-24
文章持續更新,可以關注公眾號程式猿阿朗或訪問未讀程式碼部落格
本文 Github.com/niumoo/JavaNotes 已經收錄,歡迎Star。

這篇文章介紹幾個 Java 開發中可以進行效能優化的小技巧,雖然大多數情況下極致優化程式碼是沒有必要的,但是作為一名技術開發者,我們還是想追求程式碼的更小、更快,更強。如果哪天你發現程式的執行速度不盡人意,可能會想到這篇文章。

提示:我們不應該為了優化而優化,這有時會增加程式碼的複雜度。

這篇文章中的程式碼都在以下環境中進行效能測試。

  • JMH version: 1.33(Java 基準測試框架)
  • VM version: JDK 17, OpenJDK 64-Bit Server VM, 17+35-2724

通過這篇文章的測試,將發現以下幾個操作的效能差異。

  1. 預先分配 HashMap 的大小,提高 1/4 的效能。
  2. 優化 HashMap 的 key,效能相差 9.5 倍。
  3. 不使用 Enum.values() 遍歷,Spring 也曾如此優化。
  4. 使用 Enum 代替 String 常量,效能高出 1.5 倍。
  5. 使用高版本 JDK,基礎操作有 2-5 倍效能差異。

當前文章屬於Java 效能分析優化系列文章,點選可以檢視所有文章。

當前文章中的測試使用 JMH 基準測試,相關文章:使用JMH進行Java程式碼效能測試

預先分配 HashMap 的大小

HashMap 是 Java 中最為常用的集合之一,大多數的操作速度都非常快,但是 HashMap 在調整自身的容量大小時是很慢且難以自動優化,因此我們在定義一個 HashMap 之前,應該儘可能的給出它的容量大小。給出 size 值時要考慮負載因子,HashMap 預設負載因子是 0.75,也就是要設定的 size 值要除於 0.75。

相關文章:HashMap 原始碼分析解讀

下面使用 JMH 進行基準測試,測試分別向初始容量為 16 和 32 的 HashMap 中插入 14 個元素的效率。

/**
 * @author https://www.wdbyte.com
 */
@State(Scope.Benchmark)
@Warmup(iterations = 3,time = 3)
@Measurement(iterations = 5,time = 3)
public class HashMapSize {

    @Param({"14"})
    int keys;

    @Param({"16", "32"})
    int size;

    @Benchmark
    public HashMap<Integer, Integer> getHashMap() {
        HashMap<Integer, Integer> map = new HashMap<>(size);
        for (int i = 0; i < keys; i++) {
            map.put(i, i);
        }
        return map;
    }
}

HashMap 的初始容量是 16,負責因子 0.75,即最多插入 12 個元素,再插入時就要進行擴容,所以插入 14 個元素過程中需要擴容一次,但是如果 HashMap 初始化時就給了 32 容量,那麼最多可以承載 32 * 0.75 = 24 個元素,所以插入 14 個元素時是不需要擴容操作的。

# JMH version: 1.33
# VM version: JDK 17, OpenJDK 64-Bit Server VM, 17+35-2724

Benchmark               (keys)  (size)   Mode  Cnt        Score        Error  Units
HashMapSize.getHashMap      14      16  thrpt   25  4825825.152 ± 323910.557  ops/s
HashMapSize.getHashMap      14      32  thrpt   25  6556184.664 ± 711657.679  ops/s

可以看到在這次測試中,初始容量為32 的 HashMap 比初始容量為 16 的 HashMap 每秒可以多操作 26% 次,已經有 1/4 的效能差異了。

優化 HashMap 的 key

如果 HashMap 的 key 值需要用到多個 String 字串時,把字串作為某個類屬性,然後使用這個類的例項作為 key 會比使用字串拼接效率更高。

下面測試使用兩個字串拼接作為 key,和把兩個字串作為 MutablePair 類的屬性引用,然後使用 MutablePair 物件作為 key 的執行效率差異。

/**
 * @author https://www.wdbyte.com
 */
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
public class HashMapKey {

    private int size = 1024;
    private Map<String, Object> stringMap;
    private Map<Pair, Object> pairMap;
    private String[] prefixes;
    private String[] suffixes;

    @Setup(Level.Trial)
    public void setup() {
        prefixes = new String[size];
        suffixes = new String[size];
        stringMap = new HashMap<>();
        pairMap = new HashMap<>();
        for (int i = 0; i < size; ++i) {
            prefixes[i] = UUID.randomUUID().toString();
            suffixes[i] = UUID.randomUUID().toString();
            stringMap.put(prefixes[i] + ";" + suffixes[i], i);
            // use new String to avoid reference equality speeding up the equals calls
            pairMap.put(new MutablePair(prefixes[i], suffixes[i]), i);
        }
    }

    @Benchmark
    @OperationsPerInvocation(1024)
    public void stringKey(Blackhole bh) {
        for (int i = 0; i < prefixes.length; i++) {
            bh.consume(stringMap.get(prefixes[i] + ";" + suffixes[i]));
        }
    }

    @Benchmark
    @OperationsPerInvocation(1024)
    public void pairMap(Blackhole bh) {
        for (int i = 0; i < prefixes.length; i++) {
            bh.consume(pairMap.get(new MutablePair(prefixes[i], suffixes[i])));
        }
    }
}

測試結果:

# JMH version: 1.33
# VM version: JDK 17, OpenJDK 64-Bit Server VM, 17+35-2724

Benchmark              Mode  Cnt         Score         Error  Units
HashMapKey.pairMap    thrpt   25  89295035.436 ± 6498403.173  ops/s
HashMapKey.stringKey  thrpt   25   9410641.728 ±  389850.653  ops/s

可以發現使用物件引用作為 key 的效能,是使用 String 拼接作為 key 的效能的 9.5 倍

不使用 Enum.values() 遍歷

我們通常會使用 Enum.values() 進行列舉類遍歷,但是這樣每次呼叫都會分配列舉類值數量大小的陣列用於操作,這裡完全可以快取起來,以減少每次記憶體分配的時間和空間消耗。

/**
 * 列舉類遍歷測試
 *
 * @author https://www.wdbyte.com
 */
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public class EnumIteration {
    enum FourteenEnum {
        a,b,c,d,e,f,g,h,i,j,k,l,m,n;

        static final FourteenEnum[] VALUES;
        static {
            VALUES = values();
        }
    }

    @Benchmark
    public void valuesEnum(Blackhole bh) {
        for (FourteenEnum value : FourteenEnum.values()) {
            bh.consume(value.ordinal());
        }
    }

    @Benchmark
    public void enumSetEnum(Blackhole bh) {
        for (FourteenEnum value : EnumSet.allOf(FourteenEnum.class)) {
            bh.consume(value.ordinal());
        }
    }

    @Benchmark
    public void cacheEnums(Blackhole bh) {
        for (FourteenEnum value : FourteenEnum.VALUES) {
            bh.consume(value.ordinal());
        }
    }
}

執行結果

# JMH version: 1.33
# VM version: JDK 17, OpenJDK 64-Bit Server VM, 17+35-2724

Benchmark                   Mode  Cnt         Score         Error  Units
EnumIteration.cacheEnums   thrpt   25  15623401.567 ± 2274962.772  ops/s
EnumIteration.enumSetEnum  thrpt   25   8597188.662 ±  610632.249  ops/s
EnumIteration.valuesEnum   thrpt   25  14713941.570 ±  728955.826  ops/s

很明顯使用快取後的遍歷速度是最快的,使用 EnumSet 遍歷效率是最低的,這很好理解,陣列的遍歷效率是大於雜湊表的。

可能你會覺得這裡使用 values() 快取和直接使用 Enum.values() 的效率差異很小,其實在某些呼叫頻率很高的場景下是有很大區別的,在 Spring 框架中,曾使用 Enum.values() 這種方式在每次響應時遍歷 HTTP 狀態碼列舉類,這在請求量大時造成了不必要的效能開銷,後來進行了 values() 快取優化。

下面是這次提交的截圖:

Spring Enum.values 改動

使用 Enum 代替 String 常量

使用 Enum 列舉類代替 String 常量有明顯的好處,列舉類強制驗證,不會出錯,同時使用列舉類的效率也更高。即使作為 Map 的 key 值來看,雖然 HashMap 的速度已經很快了,但是使用 EnumMap 的速度可以更快。

提示:不要為了優化而優化,這會增加程式碼的複雜度。

下面測試使用使用 Enum 作為 key,和使用 String 作為 key,在 map.get 操作下的效能差異。

/**
 * @author https://www.wdbyte.com
 */
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
public class EnumMapBenchmark {

    enum AnEnum {
        a, b, c, d, e, f, g,
        h, i, j, k, l, m, n,
        o, p, q,    r, s, t,
        u, v, w,    x, y, z;
    }

    /** 要查詢的 key 的數量 */
    private static int size = 10000;
    /** 隨機數種子 */
    private static int seed = 99;

    @State(Scope.Benchmark)
    public static class EnumMapState {
        private EnumMap<AnEnum, String> map;
        private AnEnum[] values;

        @Setup(Level.Trial)
        public void setup() {
            map = new EnumMap<>(AnEnum.class);
            values = new AnEnum[size];
            AnEnum[] enumValues = AnEnum.values();
            SplittableRandom random = new SplittableRandom(seed);
            for (int i = 0; i < size; i++) {
                int nextInt = random.nextInt(0, Integer.MAX_VALUE);
                values[i] = enumValues[nextInt % enumValues.length];
            }
            for (AnEnum value : enumValues) {
                map.put(value, UUID.randomUUID().toString());
            }
        }
    }

    @State(Scope.Benchmark)
    public static class HashMapState{
        private HashMap<String, String> map;
        private String[] values;

        @Setup(Level.Trial)
        public void setup() {
            map = new HashMap<>();
            values = new String[size];
            AnEnum[] enumValues = AnEnum.values();
            int pos = 0;
            SplittableRandom random = new SplittableRandom(seed);
            for (int i = 0; i < size; i++) {
                int nextInt = random.nextInt(0, Integer.MAX_VALUE);
                values[i] = enumValues[nextInt % enumValues.length].toString();
            }
            for (AnEnum value : enumValues) {
                map.put(value.toString(), UUID.randomUUID().toString());
            }
        }
    }

    @Benchmark
    public void enumMap(EnumMapState state, Blackhole bh) {
        for (AnEnum value : state.values) {
            bh.consume(state.map.get(value));
        }
    }

    @Benchmark
    public void hashMap(HashMapState state, Blackhole bh) {
        for (String value : state.values) {
            bh.consume(state.map.get(value));
        }
    }
}

執行結果:

# JMH version: 1.33
# VM version: JDK 17, OpenJDK 64-Bit Server VM, 17+35-2724

Benchmark                  Mode  Cnt      Score      Error  Units
EnumMapBenchmark.enumMap  thrpt   25  22159.232 ± 1268.800  ops/s
EnumMapBenchmark.hashMap  thrpt   25  14528.555 ± 1323.610  ops/s

很明顯,使用 Enum 作為 key 的效能比使用 String 作為 key 的效能高出 1.5 倍。但是仍然要根據實際情況考慮是否使用 EnumMap 和 EnumSet。

使用高版本 JDK

String 類應該是 Java 中使用頻率最高的類了,但是 Java 8 中的 String 實現相比高版本 JDK ,則佔用空間更多,效能更低。

下面測試 String 轉 bytes 和 bytes 轉 String 在 Java 8 以及 Java 11 中的效能開銷。

/**
 * @author https://www.wdbyte.com
 * @date 2021/12/23
 */
@State(Scope.Benchmark)
@Warmup(iterations = 3, time = 3)
@Measurement(iterations = 5, time = 3)
public class StringInJdk {

    @Param({"10000"})
    private int size;
    private String[] stringArray;
    private List<byte[]> byteList;

    @Setup(Level.Trial)
    public void setup() {
        byteList = new ArrayList<>(size);
        stringArray = new String[size];
        for (int i = 0; i < size; i++) {
            String uuid = UUID.randomUUID().toString();
            stringArray[i] = uuid;
            byteList.add(uuid.getBytes(StandardCharsets.UTF_8));
        }
    }

    @Benchmark
    public void byteToString(Blackhole bh) {
        for (byte[] bytes : byteList) {
            bh.consume(new String(bytes, StandardCharsets.UTF_8));
        }
    }

    @Benchmark
    public void stringToByte(Blackhole bh) {
        for (String s : stringArray) {
            bh.consume(s.getBytes(StandardCharsets.UTF_8));
        }
    }
}

測試結果:

# JMH version: 1.33
# VM version: JDK 1.8.0_151, Java HotSpot(TM) 64-Bit Server VM, 25.151-b12

Benchmark                 (size)   Mode  Cnt     Score     Error  Units
StringInJdk.byteToString   10000  thrpt   25  2396.713 ± 133.500  ops/s
StringInJdk.stringToByte   10000  thrpt   25  1745.060 ±  16.945  ops/s

# JMH version: 1.33
# VM version: JDK 17, OpenJDK 64-Bit Server VM, 17+35-2724

Benchmark                 (size)   Mode  Cnt     Score     Error  Units
StringInJdk.byteToString   10000  thrpt   25  5711.954 ±  41.865  ops/s
StringInJdk.stringToByte   10000  thrpt   25  8595.895 ± 704.004  ops/s

可以看到在 bytes 轉 String 操作上,Java 17 的效能是 Java 8 的 2.5 倍左右,而 String 轉 bytes 操作,Java 17 的效能是 Java 8 的 5 倍。關於字串的操作非常基礎,隨處可見,可見高版本的優勢十分明顯。

一如既往,當前文章中的程式碼示例都存放在 github.com/niumoo/JavaNotes.

參考

訂閱

可以微信搜一搜 程式猿阿朗 或訪問 程式猿阿朗部落格 閱讀。
本文 Github.com/niumoo/JavaNotes 已經收錄,有很多知識點和系列文章,歡迎Star。

image.png

相關文章