某個接口耗時大約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毫秒。
此算法框架下,基本滿足了要求。
更高的響應速度的話,基本就要從根上換一套算法了。