小細節,大問題。分享一次代碼優化的過程

某個接口耗時大約8s,一開始我以爲是io(主要是數據庫)或者網絡傳輸的瓶頸問題。
想着多半是SQL優化的問題。
接手一看,沒有進行任何的IO操作或網絡傳輸,僅僅是內存循環處理而已。
我的開發電腦cpu是i7 8代,其運算能力,大概是,整數51.74GIPS,浮點43.99GFLOPS
一個GFLOPS(gigaFLOPS)約等於每秒拾億(=10^9)次的浮點運算
好傢伙,也就是一秒大約440億次浮點運算?


一般來說,現在的計算機,如果不是IO或網絡瓶頸,你很難把一個接口整得很慢。


需求不說了,極致簡化以後大概的性能瓶頸是,需要對兩個list進行嵌套循環(爲什麼要雙循環,能不能移到外面?當前算法是基於這樣,這是本文的前提,換一套算法那是另一個故事),
僞代碼

for (int i = 0; i < list2.size(); i++) {
            for (int j = 0; j < list.size(); j++) {
                list = list.stream().sorted(Comparator.comparing(e -> e.divide(BigDecimal.ONE))).collect(Collectors.toList());
                // list.get(0)
            }
        }

1. list.sort()和list.strem().sorted()排序的差異


簡單寫了個demo
List<Test.Obj> list = new ArrayList<>();
        Random random = new Random();
        for (int i = 0; i < 10000000; i++) {
            Test.Obj obj = new Test.Obj();
            obj.setNum(random.nextInt(10000) + 10);
            list.add(obj);
        }

        Collections.shuffle(list);
        long start = System.currentTimeMillis();
        //
        list.sort(Comparator.comparing(e -> e.getNum()/ 10));
        long end = System.currentTimeMillis();


        Collections.shuffle(list);
        long start2 = System.currentTimeMillis();
        //
        list = list.stream().sorted(Comparator.comparing(e -> e.getNum()/ 10)).collect(Collectors.toList());
        long end2 = System.currentTimeMillis();

        System.out.println(" 第1種耗時: " + (end - start) + " 第2種耗時: " + (end2 - start2));

輸出

 第1種耗時: 3601 第2種耗時: 6503

大致可以得知list原生排序比stream()流效率要高。

通過JMH做一下基準測試,分別測試集合大小在100,10000,100000時兩種排序方式的性能差異。

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import org.openjdk.jmh.results.format.ResultFormatType;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 2, time = 1)
@Measurement(iterations = 5, time = 5)
@Fork(1)
@State(Scope.Thread)
public class SortBenchmark {

    @Param(value = {"100", "10000", "100000"})
    private int operationSize; 


    private static List<Integer> arrayList;

    public static void main(String[] args) throws RunnerException {
        // 啓動基準測試
        Options opt = new OptionsBuilder()
                .include(SortBenchmark.class.getSimpleName()) 
                .result("SortBenchmark.json")
                .mode(Mode.All)
                .resultFormat(ResultFormatType.JSON)
                .build();
        new Runner(opt).run(); 
    }

    @Setup
    public void init() {
        arrayList = new ArrayList<>();
        Random random = new Random();
        for (int i = 0; i < operationSize; i++) {
            arrayList.add(random.nextInt(10000));
        }
    }


    @Benchmark
    public void sort(Blackhole blackhole) {
        arrayList.sort(Comparator.comparing(e -> e));
        blackhole.consume(arrayList);
    }

    @Benchmark
    public void streamSorted(Blackhole blackhole) {
        arrayList = arrayList.stream().sorted(Comparator.comparing(e -> e)).collect(Collectors.toList());
        blackhole.consume(arrayList);
    }

}

性能測試結果:

差異還是非常明顯的。


還有一個非常大的問題在於,這裏對list的排序僅僅只是爲了獲取排序字段最大值的那一列???

你別說,你還真別說!
好傢伙,我直呼好傢伙!

我差點就給饒進去了!

我們爲什麼不能只求極值? 求極值只需遍歷。時間複雜度O(n)。

而java list sort排序使用的是歸併排序,平均時間複雜度:O(nlogn),只有在list本身已經完全有序的情況下(有病嗎),才能達到最佳時間複雜度O(n)。

優化方法:先統一使用list sort()排序,然後每次內部循環只求最大值所有列。

這兩個小項改掉,響應時間直接砍半,來到了4-5秒。


2. 別在計算列上進行排序

以下代碼分別對元素直接排序,和在排序時對元素進行計算並對結果排序。


List<Integer> arrayList = new ArrayList<>();
        for (int i = 0; i < 10000000; i++) {
            arrayList.add(i + 100);
        }
        Collections.shuffle(arrayList);
        long start = System.currentTimeMillis();
        arrayList.sort(Comparator.comparing(e -> e));
        System.out.println(System.currentTimeMillis() - start);

        Collections.shuffle(arrayList);
        long start2 = System.currentTimeMillis();
	int divisor = 2;
        arrayList.sort(Comparator.comparing(e -> e/divisor));
        System.out.println(System.currentTimeMillis() - start2);

當divisor=2和1000時
分別輸出

4897
6499
4797
3383

以下代碼輸出排序執行次數


List<Integer> arrayList = new ArrayList<>();
        for (int i = 0; i < 10000000; i++) {
            arrayList.add(i + 100);
        }

        Collections.shuffle(arrayList);
        long start2 = System.currentTimeMillis();
        java.util.concurrent.atomic.AtomicInteger count = new AtomicInteger();
        int divisor = 2;
        arrayList.sort(Comparator.comparing(e -> {
            int i = e/divisor;
            count.getAndIncrement();
            return i;
        }));
        System.out.println("count " + count.get());

當divisor=2和1000時
count分別輸出

440496096
278856902

第一個輸出440496096意味着e/divisor將被執行這麼多次。其實它可以先遍歷一次計算出來再排序,這樣它就只需執行10000000次。
第二個輸出278856902表示,除數越大,結果就有很多相同的數,這本身代表着部份有序性。這可以減少大量的排序。


優化方法:先統一將需要排序的值算出來,再進行排序。


3. BigDecimal的精度與效率


普通除法與BigDecimal除法的差異

int elementCount = 10000000;
        List<Integer> arrayList = IntStream.rangeClosed(1, elementCount).boxed().collect(Collectors.toCollection(ArrayList::new));
        Collections.shuffle(arrayList);
        List<BigDecimal> list2 = new ArrayList<>(elementCount);
        for (int i = 0; i < elementCount ; i++) {
            list2.add(new BigDecimal(i));
        }
        Collections.shuffle(arrayList);
        Collections.shuffle(list2);

        long start = System.currentTimeMillis();
        for (int num : arrayList) {
            num = num / 10;
        }
        System.out.println(System.currentTimeMillis() - start);

        long start2 = System.currentTimeMillis();
        for (BigDecimal num : list2) {
            num = num.divide(new BigDecimal(10), 2, RoundingMode.HALF_UP);
        }
        System.out.println(System.currentTimeMillis() - start2);

輸出

101
497

可以看到,BigDecimal除法和double/int數據類型的除法,前者耗時是後者的5倍左右。

如果divide不設置精度num = num.divide(new BigDecimal(10))
差異更大。

99
3677

當然,這種生產環境肯定不會這樣使用,除不盡會拋出異常。

優化方法:這裏不需要獲取高精準度,所以這裏改用double進行除法。除數是變量,這裏沒有使用位移。


小結


總之就是一些不起眼的小細節,在平常的時候其實無所謂。
比如,假設一個場景,人員表分頁查詢返回前端最多100來條了,需要根據身份證號碼計算年齡並排序,考慮到直接在SQL裏計算可能使身份證唯一索引失效,拿到代碼中計算並排序。

userList = userList.stream().sorted(Comparator.comparing(e -> getAge(e.getIdcard()))).collect(Collectors.toList());

100來條的數據量根本不需要去考慮,list.sort()和stream().sorted()的性能差異。
以及是不是在排序列上進行了計算。
甚至於我可能需要在某個列上進行BigDecimal的四則運算。又怎樣?
在這點數據量上又算得了什麼呢?

但如果不注意這些細節,剛好遇上了開頭所說的這個場景,那這些小細節可能就會產生非常巨大的性能差異。


通過以上3個改進點。一頓操作猛如虎,接口耗時從7-8秒穩定在了500-600毫秒。

此算法框架下,基本滿足了要求。
更高的響應速度的話,基本就要從根上換一套算法了。

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