JAVA拾遗 — JMH与8个测试陷阱

前言

JMH 是 Java Microbenchmark Harness(微基准测试)框架的缩写(2013年首次发布)。与其他众多测试框架相比,其特色优势在于它是由 Oracle 实现 JIT 的相同人员开发的。在此,我想特别提一下 Aleksey Shipilev (JMH 的作者兼布道者)和他优秀的博客文章。笔者花费了一个周末,将 Aleksey 大神的博客,特别是那些和 JMH 相关的文章通读了几遍,外加一部公开课视频 《”The Lesser of Two Evils” Story》 ,将自己的收获归纳在这篇文章中,文中不少图片都来自 Aleksey 公开课视频。

阅读本文前

本文没有花费专门的篇幅在文中介绍 JMH 的语法,如果你使用过 JMH,那当然最好,但如果没听过它,也不需要担心(跟我一周前的状态一样)。我会从 Java Developer 角度来谈谈一些常见的代码测试陷阱,分析他们和操作系统底层以及 Java 底层的关联性,并借助 JMH 来帮助大家摆脱这些陷阱。

通读本文,需要一些操作系统相关以及部分 JIT 的基础知识,如果遇到陌生的知识点,可以留意章节中的维基百科链接,以及笔者推荐的博客。

笔者能力有限,未能完全理解 JMH 解决的全部问题,如有错误以及疏漏欢迎留言与我交流。

初识 JMH

测试精度

测试精度

上图给出了不同类型测试的耗时数量级,可以发现 JMH 可以达到微秒级别的的精度。

这样几个数量级的测试所面临的挑战也是不同的。

  • 毫秒级别的测试并不是很困难
  • 微秒级别的测试是具备挑战性的,但并非无法完成,JMH 就做到了
  • 纳秒级别的测试,目前还没有办法精准测试
  • 皮秒级别…Holy Shit

图解:

Linpack : Linpack benchmark 一类基础测试,度量系统的浮点计算能力

SPEC:Standard Performance Evaluation Corporation 工业界的测试标准组织

pipelining:系统总线通信的耗时

Benchmark 分类

测试在不同的维度可以分为很多类:集成测试,单元测试,API 测试,压力测试… 而 Benchmark 通常译为基准测试(性能测试)。你可以在很多开源框架的包层级中发现 Benchmark,用于阐释该框架的基准水平,从而量化其性能。

基准测试又可以细分为 :Micro benchmark,Kernels,Synthetic benchmark,Application benchmarks.etc.本文的主角便属于 Benchmark 的 Micro benchmark。基础测试分类详细介绍 here

motan中的benchmark

为什么需要有 Benchmark

If you cannot measure it, you cannot improve it.

–Lord Kelvin

俗话说,没有实践就没有发言权,Benchmark 为应用提供了数据支持,是评价和比较方法好坏的基准,Benchmark 的准确性,多样性便显得尤为重要。

Benchmark 作为应用框架,产品的基准画像,存在统一的标准,避免了不同测评对象自说自话的尴尬,应用框架各自使用有利于自身场景的测评方式必然不可取,例如 Standard Performance Evaluation Corporation (SPEC) 即上文“测试精度”提到的词便是工业界的标准组织之一,JMH 的作者 Aleksey 也是其中的成员。

JMH 长这样

1
2
3
4
@Benchmark
public void measure() {
// this method was intentionally left blank.
}

使用起来和单元测试一样的简单

它的测评结果

1
2
Benchmark                                Mode  Cnt           Score           Error  Units
JMHSample_HelloWorld.measure thrpt 5 3126699413.430 ± 179167212.838 ops/s

为什么需要 JMH 测试

你可能会想,我用下面的方式来测试有什么不好?

1
2
3
long start = System.currentTimeMillis();
measure();
System.out.println(System.currentTimeMillis()-start);

难道 JMH 不是这么测试的吗?

1
2
3
@Benchmark
public void measure() {
}

事实上,这是本文的核心问题,建议在阅读时时刻带着这样的疑问,为什么不使用第一种方式来测试。在下面的章节中,我将列举诸多的测试陷阱,他们都会为这个问题提供论据,这些陷阱会启发那些对“测试”不感冒的开发者。

预热

在初识 JMH 小节的最后,花少量的篇幅来给 JMH 涉及的知识点开个头,介绍一个 Java 测试中比较老生常谈的话题 — 预热(warm up),它存在于下面所有的测试中。

«Warmup» = waiting for the transient responses to settle down

特别是在编写 Java 测试程序时,预热从来都是不可或缺的一环,它使得结果更加真实可信。

warmup plateaus

上图展示了一个样例测评程序随着迭代次数增多执行耗时变化的曲线,可以发现在 120 次迭代之后,性能才趋于最终稳定,这意味着:预热阶段需要有至少 120 次迭代,才能得到准确的基础测试报告。(JVM 初始化时的一些准备工作以及 JIT 优化是主要原因,但不是唯一原因)。需要被说明的事,JMH 的运行相对耗时,因为,预热被前置在每一个测评任务之前。

使用 JMH 解决 12 个测试陷阱

陷阱1:死码消除

死码消除

measureWrong 方法想要测试 Math.log 的性能,得到的结果和空方法 baseline 一致,而 measureRight 相比 measureWrong 多了一个 return,正确的得到了测试结果。

这是由于 JIT 擅长删除“无效”的代码,这给我们的测试带来了一些意外,当你意识到 DCE 现象后,应当有意识的去消费掉这些孤立的代码,例如 return。JMH 不会自动实施对冗余代码的消除。

死码消除这个概念很多人其实并不陌生,注释的代码,不可达的代码块,可达但不被使用的代码等等,我这里补充一些 Aleksey 提到的概念,用以阐释为何一般测试方法难以避免引用对象发生死码消除现象:

  1. Fast object combinator.
  2. Need to escape object to limit thread-local optimizations.
  3. Publishing the object ⇒ reference heap write ⇒ store barrier.

很绝望,个人水平有限,我没能 get 到这些点,只能原封不动地贴给大家看了。

JMH 提供了专门的 API — Blockhole 来避免死码消除问题。

1
2
3
4
@Benchmark
public void measureRight(Blackhole bh) {
bh.consume(Math.log(PI));
}

陷阱2:常量折叠与常量传播

常量折叠 (Constant folding) 是一个在编译时期简化常数的一个过程,常数在表示式中仅仅代表一个简单的数值,就像是整数 2,若是一个变数从未被修改也可作为常数,或者直接将一个变数被明确地被标注为常数,例如下面的描述:

1
i = 320 * 200 * 32;

多数的现代编译器不会真的产生两个乘法的指令再将结果储存下来,取而代之的,他们会辨识出语句的结构,并在编译时期将数值计算出来(在这个例子,结果为 2,048,000)。

有些编译器,常数折叠会在初期就处理完,例如 Java 中的 final 关键字修饰的变量就会被特殊处理。而将常数折叠放在较后期的阶段的编译器,也相当常见。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
private double x = Math.PI;

// 编译器会对 final 变量特殊处理
private final double wrongX = Math.PI;

@Benchmark
public double baseline() { // 2.220 ± 0.352 ns/op
return Math.PI;
}

@Benchmark
public double measureWrong_1() { // 2.220 ± 0.352 ns/op
// 错误,结果可以被预测,会发生常量折叠
return Math.log(Math.PI);
}

@Benchmark
public double measureWrong_2() { // 2.220 ± 0.352 ns/op
// 错误,结果可以被预测,会发生常量折叠
return Math.log(wrongX);
}

@Benchmark
public double measureRight() { // 22.590 ± 2.636 ns/op
return Math.log(x);
}

经过 JMH 可以验证这一点:只有最后的 measureRight 正确测试出了 Math.log 的性能,measureWrong_1,measureWrong_2 都受到了常量折叠的影响。

常数传播(Constant propagation) 是一个替代表示式中已知常数的过程,也是在编译时期进行,包含前述所定义,内建函数也适用于常数,以下列描述为例:

1
2
3
int x = 14;
int y = 7 - x / 2;
return y * (28 / x + 2);

传播可以理解变量的替换,如果进行持续传播,上式会变成:

1
2
3
int x = 14;
int y = 0;
return 0;

陷阱3:永远不要在测试中写循环

这个陷阱对我们做日常测试时的影响也是巨大的,所以我直接将他作为了标题:永远不要在测试中写循环!

本节设计不少知识点,循环展开(loop unrolling),JIT & OSR 对循环的优化。对于前者循环展开的定义,建议读者直接查看 wiki 的定义,而对于后者 JIT & OSR 对循环的优化,推荐两篇 R 大的知乎回答:

循环长度的相同、循环体代码相同的两次for循环的执行时间相差了100倍?

OSR(On-Stack Replacement)是怎样的机制?

对于第一个回答,建议不要看问题,直接看答案;第二个回答,阐释了 OSR 都对循环做了哪些手脚。

测试一个耗时较短的方法,入门级程序员(不了解动态编译的同学)会这样写,通过循环放大,再求均值。

1
2
3
4
5
6
7
8
9
10
public class BadMicrobenchmark {
public static void main(String[] args) {
long startTime = System.nanoTime();
for (int i = 0; i < 10_000_000; i++) {
reps();
}
long endTime = System.nanoTime();
System.out.println("ns/op : " + (endTime - startTime));
}
}

实际上,这段代码的结果是不可预测的,太多影响因子会干扰结果。原理暂时不表,通过 JMH 来看看几个测试方法,下面的 Benchmark 尝试对 reps 方法迭代不同的次数,想从中获得 reps 真实的性能。(注意,在 JMH 中使用循环也是不可取的,除非你是 Benchmark 方面的专家,否则在任何时候,你都不应该写循环)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
int x = 1;
int y = 2;

@Benchmark
public int measureRight() {
return (x + y);
}

private int reps(int reps) {
int s = 0;
for (int i = 0; i < reps; i++) {
s += (x + y);
}
return s;
}

@Benchmark
@OperationsPerInvocation(1)
public int measureWrong_1() {
return reps(1);
}

@Benchmark
@OperationsPerInvocation(10)
public int measureWrong_10() {
return reps(10);
}

@Benchmark
@OperationsPerInvocation(100)
public int measureWrong_100() {
return reps(100);
}

@Benchmark
@OperationsPerInvocation(1000)
public int measureWrong_1000() {
return reps(1000);
}

@Benchmark
@OperationsPerInvocation(10000)
public int measureWrong_10000() {
return reps(10000);
}

@Benchmark
@OperationsPerInvocation(100000)
public int measureWrong_100000() {
return reps(100000);
}

结果如下:

1
2
3
4
5
6
7
8
Benchmark                               Mode  Cnt  Score   Error  Units
JMHSample_11_Loops.measureRight avgt 5 2.343 ± 0.199 ns/op
JMHSample_11_Loops.measureWrong_1 avgt 5 2.358 ± 0.166 ns/op
JMHSample_11_Loops.measureWrong_10 avgt 5 0.326 ± 0.354 ns/op
JMHSample_11_Loops.measureWrong_100 avgt 5 0.032 ± 0.011 ns/op
JMHSample_11_Loops.measureWrong_1000 avgt 5 0.025 ± 0.002 ns/op
JMHSample_11_Loops.measureWrong_10000 avgt 5 0.022 ± 0.005 ns/op
JMHSample_11_Loops.measureWrong_100000 avgt 5 0.019 ± 0.001 ns/op

如果不看事先给出的错误和正确的提示,上述的结果,你会选择相信哪一个?实际上跑分耗时从 2.358 随着迭代次数变大,降为了 0.019。手动测试循环的代码 BadMicrobenchmark 也存在同样的问题,实际上它没有做预热,效果只会比 JMH 测试循环更加不可信。

Aleksey 在视频中给出结论:假设单词迭代的耗时是 𝑀 ns. 在 JIT,OSR,循环展开等因素的多重作用下,多次迭代的耗时理论值为 𝛼𝑀 ns, 其中 𝛼 ∈ [0; +∞)。

正确的测试循环的姿势可以看这里:here

陷阱4:使用 Fork 隔离多个测试方法

相信我,这个陷阱中涉及到的例子绝对是 JMH sample 中最诡异的,并且我还没有找到科学的解释(说实话视频中这一段我尝试听了好几遍,没听懂,原谅我的听力)

首先定义一个 Counter 接口,并实现了两份代码完全相同的实现类:Counter1,Counter2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public interface Counter {
int inc();
}

public class Counter1 implements Counter {
private int x;

@Override
public int inc() {
return x++;
}
}

public class Counter2 implements Counter {
private int x;

@Override
public int inc() {
return x++;
}
}

接着让他们在同一个 VM 中按照先手顺序进行评测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
public int measure(Counter c) {
int s = 0;
for (int i = 0; i < 10; i++) {
s += c.inc();
}
return s;
}

/*
* These are two counters.
*/
Counter c1 = new Counter1();
Counter c2 = new Counter2();

/*
* We first measure the Counter1 alone...
* Fork(0) helps to run in the same JVM.
*/
@Benchmark
@Fork(0)
public int measure_1_c1() {
return measure(c1);
}

/*
* Then Counter2...
*/
@Benchmark
@Fork(0)
public int measure_2_c2() {
return measure(c1);
}

/*
* Then Counter1 again...
*/
@Benchmark
@Fork(0)
public int measure_3_c1_again() {
return measure(c1);
}

@Benchmark
@Fork(1)
public int measure_4_forked_c1() {
return measure(c1);
}

@Benchmark
@Fork(1)
public int measure_5_forked_c2() {
return measure(c2);
}

这一个例子中多了一个 Fork 注解,让我来简单介绍下它。Fork 这个关键字顾名思义,是用来将运行环境复制一份的意思,在我们之前的多个测试中,实际上每次测评都是默认使用了相互隔离的,完全一致的测评环境,这得益于 JMH。每个试验运行在单独的 JVM 进程中。也可以指定(额外的) JVM 参数,例如这里为了演示运行在同一个 JVM 中的弊端,特地做了反面的教材:Fork(0)。试想一下 c1,c2,c1 again 的耗时结果会如何?

1
2
3
4
5
6
Benchmark                                 Mode  Cnt   Score   Error  Units
JMHSample_12_Forking.measure_1_c1 avgt 5 2.518 ± 0.622 ns/op
JMHSample_12_Forking.measure_2_c2 avgt 5 14.080 ± 0.283 ns/op
JMHSample_12_Forking.measure_3_c1_again avgt 5 13.462 ± 0.164 ns/op
JMHSample_12_Forking.measure_4_forked_c1 avgt 5 3.861 ± 0.712 ns/op
JMHSample_12_Forking.measure_5_forked_c2 avgt 5 3.574 ± 0.220 ns/op

你会不会感到惊讶,第一次运行的 c1 竟然耗时最低,在我的认知中,JIT 起码会启动预热的作用,无论如何都不可能先运行的方法比之后的方法快这么多!但这个结果也和 Aleksey 视频中介绍的相符。

JMH samples 中的这个示例主要还是想要表达同一个 JVM 中运行的测评代码会互相影响,从结果也可以发现:c1,c2,c1_again 的实现相同,跑分却不同,因为运行在同一个 JVM 中;而 forked_c1 和 forked_c2 则表现出了一致的性能。所以没有特殊原因,Fork 的值一般都需要设置为 >0。

陷阱5:方法内联

熟悉 C/C++ 的朋友不会对方法内联感到陌生,方法内联就是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用(减少了操作指令周期)。在 Java 中,无法手动编写内联方法,但 JVM 会自动识别热点方法,并对它们使用方法内联优化。一段代码需要执行多少次才会触发 JIT 优化通常这个值由 -XX:CompileThreshold 参数进行设置:

  • 1、使用 client 编译器时,默认为1500;
  • 2、使用 server 编译器时,默认为10000;

但是一个方法就算被 JVM 标注成为热点方法,JVM 仍然不一定会对它做方法内联优化。其中有个比较常见的原因就是这个方法体太大了,分为两种情况。

  • 如果方法是经常执行的,默认情况下,方法大小小于 325 字节的都会进行内联(可以通过-XX:MaxFreqInlineSize=N来设置这个大小)
  • 如果方法不是经常执行的,默认情况下,方法大小小于 35 字节才会进行内联(可以通过-XX:MaxInlineSize=N来设置这个大小)

我们可以通过增加这个大小,以便更多的方法可以进行内联;但是除非能够显著提升性能,否则不推荐修改这个参数。因为更大的方法体会导致代码内存占用更多,更少的热点方法会被缓存,最终的效果不一定好。

如果想要知道方法被内联的情况,可以使用下面的JVM参数来配置

1
2
3
-XX:+PrintCompilation //在控制台打印编译过程信息
-XX:+UnlockDiagnosticVMOptions //解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
-XX:+PrintInlining //将内联方法打印出来

方法内联的其他隐含条件

  • 虽然 JIT 号称可以针对代码全局的运行情况而优化,但是 JIT 对一个方法内联之后,还是可能因为方法被继承,导致需要类型检查而没有达到性能的效果
  • 想要对热点的方法使用上内联的优化方法,最好尽量使用final、private、static这些修饰符修饰方法,避免方法因为继承,导致需要额外的类型检查,而出现效果不好情况。

方法内联也可能对 Benchmark 产生影响;或者说有时候我们为了优化代码,而故意触发内联,也可以通过 JMH 来和非内联方法进行性能对比:

1
2
3
4
5
6
7
8
9
10
11
12
13
public void target_blank() {
// this method was intentionally left blank
}

@CompilerControl(CompilerControl.Mode.DONT_INLINE)
public void target_dontInline() {
// this method was intentionally left blank
}

@CompilerControl(CompilerControl.Mode.INLINE)
public void target_inline() {
// this method was intentionally left blank
}
1
2
3
4
Benchmark                                Mode  Cnt   Score    Error  Units
JMHSample_16_CompilerControl.blank avgt 3 0.323 ± 0.544 ns/op
JMHSample_16_CompilerControl.dontinline avgt 3 2.099 ± 7.515 ns/op
JMHSample_16_CompilerControl.inline avgt 3 0.308 ± 0.264 ns/op

可以发现,内联与不内联的性能差距是巨大的,有一些空间换时间的味道,在 JMH 中使用 CompilerControl.Mode 来控制内联是否开启。

陷阱6:伪共享与缓存行

又遇到了我们的老朋友:CPU Cache 和缓存行填充。这个并发性能杀手,我在之前的文章中专门介绍过,如果你没有看过,可以戳这里:JAVA 拾遗 — CPU Cache 与缓存行。在 Benchmark 中,有时也不能忽视缓存行对测评的影响。

受限于篇幅,在此不展开有关伪共享的陷阱,完整的测评可以戳这里:JMHSample_22_FalseSharing

JMH 为解决伪共享问题,提供了 @State 注解,但并不能在单一对象内部对个别的字段增加,如果有必要,可以使用并发包中的 @Contended 注解来处理。

Aleksey 曾为 Java 并发包提供过优化,其中就包括 @Contended 注解。

陷阱7:分支预测

分支预测(Branch Prediction)是这篇文章中介绍的最后一个 Benchmark 中的“捣蛋鬼”。还是从一个具体的 Benchmark 中观察结果。下面的代码尝试遍历了两个长度相等的数组,一个有序,一个无序,并在迭代时加入了一个判断语句,这是分支预测的关键:if(v > 0)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private static final int COUNT = 1024 * 1024;

private byte[] sorted;
private byte[] unsorted;

@Setup
public void setup() {
sorted = new byte[COUNT];
unsorted = new byte[COUNT];
Random random = new Random(1234);
random.nextBytes(sorted);
random.nextBytes(unsorted);
Arrays.sort(sorted);
}

@Benchmark
@OperationsPerInvocation(COUNT)
public void sorted(Blackhole bh1, Blackhole bh2) {
for (byte v : sorted) {
if (v > 0) { //关键
bh1.consume(v);
} else {
bh2.consume(v);
}
}
}

@Benchmark
@OperationsPerInvocation(COUNT)
public void unsorted(Blackhole bh1, Blackhole bh2) {
for (byte v : unsorted) {
if (v > 0) { //关键
bh1.consume(v);
} else {
bh2.consume(v);
}
}
}
1
2
3
Benchmark                               Mode  Cnt  Score   Error  Units
JMHSample_36_BranchPrediction.sorted avgt 25 2.752 ± 0.154 ns/op
JMHSample_36_BranchPrediction.unsorted avgt 25 8.175 ± 0.883 ns/op

从结果看,有序数组的遍历比无序数组的遍历快了 2-3 倍。关于这点的介绍,最佳的解释来自于 Stack Overflow 一个 2w 多赞的答案:Why is it faster to process a sorted array than an unsorted array?

分叉路口

假设我们是在 19 世纪,而你负责为火车选择一个方向,那时连电话和手机还没有普及,当火车开来时,你不知道火车往哪个方向开。于是你的做法(算法)是:叫停火车,此时火车停下来,你去问司机,然后你确定了火车往哪个方向开,并把铁轨扳到了对应的轨道。

还有一个需要注意的地方是,火车的惯性是非常大的,所以司机必须在很远的地方就开始减速。当你把铁轨扳正确方向后,火车从启动到加速又要经过很长的时间。

那么是否有更好的方式可以减少火车的等待时间呢?

有一个非常简单的方式,你提前把轨道扳到某一个方向。那么到底要扳到哪个方向呢,你使用的手段是——“瞎蒙”:

  • 如果蒙对了,火车直接通过,耗时为 0。
  • 如果蒙错了,火车停止,然后倒回去,你将铁轨扳至反方向,火车重新启动,加速,行驶。

如果你很幸运,每次都蒙对了,火车将从不停车,一直前行!如果不幸你蒙错了,那么将浪费很长的时间。

虽然不严谨,但你可以用同样的道理去揣测 CPU 的分支预测,有序数组使得这样的预测大部分情况下是正确的,所以带有判断条件时,有序数组的遍历要比无序数组要快。

这同时也启发我们:在大规模循环逻辑中要尽量避免大量判断(是不是可以抽取到循环外呢?)。

陷阱8:多线程测试

多线程测试

在 4 核的系统之上运行一个测试方法,得到如上的测试结果, Ops/nsec 代表了单位时间内的运行次数,Scale 代表 2,4 线程相比 1 线程的运行次数倍率。

这个图可供我们提出两个问题:

  1. 为什么 2 线程 -> 4 线程几乎没有变化?
  2. 为什么 2 线程相比 1 线程只有 1.87 倍的变化,而不是 2 倍?

    1 电源管理

降频

第一个影响因素便是多线程测试会受到操作系统电源管理(Power Management)的影响,许多系统存在能耗和性能的优化管理。 (Ex: cpufreq, SpeedStep, Cool&Quiet, TurboBoost)

当我们主动对机器进行降频之后,整体性能发生下降,但是 Scale 在线程数 1 -> 2 的过程中变成了严谨的 2 倍。

这样的问题并非无法规避,补救方法便是禁用电源管理, 保证 CPU 的时钟频率 。

JMH 通过长时间运行,保证线程不出现 park(time waiting) 状态,来保证测试的精准性。

2 操作系统调度和分时调用模型

造成多线程测试陷阱的第二个问题,需要从线程调度模型出发来理解:分时调度模型和抢占式调度模型。

分时调度模型是指让所有的线程轮流获得 CPU 的使用权,并且平均分配每个线程占用的 CPU 的时间片,这个也比较好理解;抢占式调度模型,是指优先让可运行池中优先级高的线程占用 CPU,如果可运行池中的线程优先级相同,那么就随机选择一个线程,使其占用 CPU。处于运行状态的线程会一直运行,直至它不得不放弃 CPU。一个线程会因为以下原因而放弃 CPU。

需要注意的是,线程的调度不是跨平台的,它不仅仅取决于 Java 虚拟机,还依赖于操作系统。在某些操作系统中,只要运行中的线程没有遇到阻塞,就不会放弃 CPU;在某些操作系统中,即使线程没有遇到阻塞,也会运行一段时间后放弃 CPU,给其它线程运行的机会。

无论是那种模型,线程上下文的切换都会造成损耗。到这儿为止,还是只回答了第一个问题:为什么 2 线程相比 1 线程只有 1.87 倍的变化,而不是 2 倍?

由于上述的两个图我都是从 Aleksey 的视频中抠出来的,并不清楚他的实际测试用例,对于 2 -> 4 线程性能差距并不大只能理解为系统过载,按道理说 4 核的机器,运行 4 个线程应该不至于只比 2 个线程快这么一点。

对于线程分时调用以及线程调度带来的不稳定性,JMH 引入了 bogus iterations 的概念,它保障了在多线程测试过程中,只在线程处于忙碌状态的过程中进行测量。

bogus iterations

bogus iterations 这个值得一提,我理解为“伪迭代”,并且也只在 JVM 的注释以及 Aleksey 的几个博客中有介绍,可以理解为 JMH 的内部原理的专用词。

总结

本文花了大量的篇幅介绍了 JMH 存在的意义,以及 JMH sample 中提到的诸多陷阱,这些陷阱会非常容易地被那些不规范的测评程序所触发。我觉得作为 Java 语言的使用者,起码有必要了解这些现象的存在,毕竟 JMH 已经帮你解决了诸多问题了,你不用担心预热问题,不用自己写比较 low 的循环去评测,规避这些测试陷阱也变得相对容易。

实际上,本文设计的知识点,仅仅是 Aleksey 博客中的内容、 JMH 的 38 个 sample 的冰山一角,有兴趣的朋友可以戳这里查看所有的 JMH sample

陷阱内心 os:像我这么diao的陷阱,还有 30 个!

kafka

例如 Kafka 这样优秀的开源框架,提供了专门的 module 来做 JMH 的基础测试。尝试使用 JMH 作为你的 Benchmark 工具吧。

欢迎关注我的微信公众号:「Kirito的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

分享到

JAVA 拾遗 — CPU Cache 与缓存行

最近的两篇文章,介绍了我参加的中间件比赛中一些相对重要的优化,但实际上还存在很多细节优化,出于篇幅限制并未提及,在最近的博文中,我会将他们整理成独立的知识点,并归类到我的系列文章「JAVA 拾遗」中。

查看更多

分享到

天池中间件大赛百万队列存储设计总结【复赛】

维持了 20 天的复赛终于告一段落了,国际惯例先说结果,复赛结果不太理想,一度从第 10 名掉到了最后的第 36 名,主要是写入的优化卡了 5 天,一直没有进展,最终排名也是定格在了排行榜的第二页。痛定思痛,这篇文章将自己复赛中学习的知识,成功的优化,未成功的优化都罗列一下。

最终排名

赛题介绍

题面描述很简单:使用 Java 或者 C++ 实现一个进程内的队列引擎,单机可支持 100 万队列以上。

1
2
3
4
public abstract class QueueStore {
abstract void put(String queueName, byte[] message);
abstract Collection<byte[]> get(String queueName, long offset, long num);
}

编写如上接口的实现。

put 方法将一条消息写入一个队列,这个接口需要是线程安全的,评测程序会并发调用该接口进行 put,每个queue 中的内容按发送顺序存储消息(可以理解为 Java 中的 List),同时每个消息会有一个索引,索引从 0 开始,不同 queue 中的内容,相互独立,互不影响,queueName 代表队列的名称,message 代表消息的内容,评测时内容会随机产生,大部分长度在 58 字节左右,会有少量消息在 1k 左右。

get 方法从一个队列中读出一批消息,读出的消息要按照发送顺序来,这个接口需要是线程安全的,也即评测程序会并发调用该接口进行 get,返回的 Collection 会被并发读,但不涉及写,因此只需要是线程读安全就可以了,queueName 代表队列的名字,offset 代表消息的在这个队列中的起始索引,num 代表读取的消息的条数,如果消息足够,则返回 num 条,否则只返回已有的消息即可,若消息不足,则返回一个空的集合。

评测程序介绍

  1. 发送阶段:消息大小在 58 字节左右,消息条数在 20 亿条左右,即发送总数据在 100G 左右,总队列数 100w
  2. 索引校验阶段:会对所有队列的索引进行随机校验;平均每个队列会校验1~2次;(随机消费)
  3. 顺序消费阶段:挑选 20% 的队列进行全部读取和校验; (顺序消费)
  4. 发送阶段最大耗时不能超过 1800s;索引校验阶段和顺序消费阶段加在一起,最大耗时也不能超过 1800s;超时会被判断为评测失败。
  5. 各个阶段线程数在 20~30 左右

测试环境为 4c8g 的 ECS,限定使用的最大 JVM 大小为 4GB(-Xmx 4g)。带一块 300G 左右大小的 SSD 磁盘。对于 Java 选手而言,可使用的内存可以理解为:堆外 4g 堆内 4g。

赛题剖析

首先解析题面,接口描述是非常简单的,只有一个 put 和一个 get 方法。需要注意特别注意下评测程序,发送阶段需要对 100w 队列,每一次发送的量只有 58 字节,最后总数据量是 100g;索引校验和顺序消费阶段都是调用的 get 接口,不同之处在于前者索引校验是随机消费,后者是对 20% 的队列从 0 号索引开始进行全量的顺序消费,评测程序的特性对最终存储设计的影响是至关重要的。

复赛题目的难点之一在于单机百万队列的设计,据查阅的资料显示

  • Kafka 单机超过 64 个队列/分区,Kafka 分区数不宜过多
  • RocketMQ 单机支持最高 5 万个队列

至于百万队列的使用场景,只能想到 IOT 场景有这样的需求。相较于初赛,复赛的设计更加地具有不确定性,排名靠前的选手可能会选择大相径庭的设计方案。

复赛的考察点主要有以下几个方面:磁盘块读写,读写缓冲,顺序读写与随机读写,pageCache,稀疏索引,队列存储设计等。

由于复赛成绩并不是很理想,优化 put 接口的失败是导致失利的罪魁祸首,最终成绩是 126w TPS,而第一梯队的 TPS 则是到达了 200 w+ 的 TPS。鉴于此,不太想像初赛总结那样,按照优化历程罗列,而是将自己做的方案预研,以及设计思路分享给大家,对文件 IO 不甚了解的读者也可以将此文当做一篇科普向的文章来阅读。

思路详解

确定文件读写方式

作为忠实的 Java 粉丝,自然选择使用 Java 来作为参赛语言,虽然最终的排名是被 Cpp 大佬所垄断,但着实无奈,毕业后就把 Cpp 丢到一边去了。Java 中的文件读写接口大致可以分为三类:

  1. 标准 IO 读写,位于 java.io 包下,相关类:FileInputStream,FileOuputStream
  2. NIO 读写,位于 java.nio 包下,相关类:FileChannel,ByteBuffer
  3. Mmap 内存映射,位于 java.nio 包下,相关类:FileChannel,MappedByteBuffer

标准 IO 读写不具备调研价值,直接 pass,所以 NIO 和 Mmap 的抉择,成了第一步调研对象。

第一阶段调研了 Mmap。搜索一圈下来发现,几乎所有的文章都一致认为:Mmap 这样的内存映射技术是最快的。很多没有接触过内存映射技术的人可能还不太清楚这是一种什么样的技术,简而言之,Mmap 能够将文件直接映射到用户态的内存地址,使得对文件的操作不再是 write/read,而转化为直接对内存地址的操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void test1() throws Exception {
String dir = "/Users/kirito/data/";
ensureDirOK(dir);
RandomAccessFile memoryMappedFile;
int size = 1 * 1024 * 1024;
try {
memoryMappedFile = new RandomAccessFile(dir + "testMmap.txt", "rw");
MappedByteBuffer mappedByteBuffer = memoryMappedFile.getChannel().map(FileChannel.MapMode.READ_WRITE, 0, size);
for (int i = 0; i < 100000; i++) {
mappedByteBuffer.position(i * 4);
mappedByteBuffer.putInt(i);
}
memoryMappedFile.close();
} catch (Exception e) {
e.printStackTrace();
}
}

如上的代码呈现了一个最简单的 Mmap 使用方式,速度也是没话说,一个字:快!我怀着将信将疑的态度去找了更多的佐证,优秀的源码总是第一参考对象,观察下 RocketMQ 的设计,可以发现 NIO 和 Mmap 都出现在了源码中,但更多的读写操作似乎更加青睐 Mmap。RocketMQ 源码 org.apache.rocketmq.store.MappedFile 中两种写方法同时存在,请教 @匠心零度 后大概得出结论:RocketMQ 主要的写是通过 Mmap 来完成。

两种写入方式

但是在实际使用 Mmap 来作为写方案时遇到了两大难题,单纯从使用角度来看,暴露出了 Mmap 的局限性:

  1. Mmap 在 Java 中一次只能映射 1.5~2G 的文件内存,但实际上我们的数据文件大于 100g,这带来了第一个问题:要么需要对文件做物理拆分,切分成多文件;要么需要对文件映射做逻辑拆分,大文件分段映射。RocketMQ 中限制了单文件大小来避免这个问题。

文件做物理拆分

  1. Mmap 之所以快,是因为借助了内存来加速,mappedByteBuffer 的 put 行为实际是对内存进行的操作,实际的刷盘行为依赖于操作系统的定时刷盘或者手动调用 mappedByteBuffer.force() 接口来刷盘,否则将会导致机器卡死(实测后的结论)。由于复赛的环境下内存十分有限,所以使用 Mmap 存在较难的控制问题。

rocketmq存在定时force线程

经过这么一折腾,再加上资料的搜集,最终确定,Mmap 在内存较为富足并且数据量小的场景下存在优势(大多数文章的结论认为 Mmap 适合大文件的读写,私以为是不严谨的结论)。

第二阶段调研 Nio 的 FileChannel,这也是我最终确定的读写方案。

由于每个消息只有 58 字节左右,直接通过 FileChannel 写入一定会遇到瓶颈,事实上,如果你这么做,复赛连成绩估计都跑不出来。另一个说法是 ssd 最小的写入单位是 4k,如果一次写入低于 4k,实际上耗时和 4k 一样。这里涉及到了赛题的一个重要考点:块读写。

云盘ssd写入性能

根据阿里云的 ssd 云盘介绍,只有一次写入 16kb ~ 64kb 才能获得理想的 IOPS。文件系统块存储的特性,启发我们需要设置一个内存的写入缓冲区,单个消息写入内存缓冲区,缓冲区满,使用 FileChannel 进行刷盘。经过实践,使用 FileChannel 搭配缓冲区发挥的写入性能和内存充足情况下的 Mmap 并无区别,并且 FileChannel 对文件大小并无限制,控制也相对简单,所以最终确定使用 FileChannel 进行读写。

确定存储结构和索引结构

由于赛题的背景是消息队列,评测 2 阶段的随机检测以及 3 阶段的顺序消费一次会读取多条连续的消息,并且 3 阶段的顺序消费是从队列的 0 号索引一直消费到最后一条消息,这些因素都启发我们:应当将同一个队列的消息尽可能的存到一起。前面一节提到了写缓冲区,便和这里的设计非常契合,例如我们可以一个队列设置一个写缓冲区(比赛中 Java 拥有 4g 的堆外内存,100w 队列,一个队列使用 DirectByteBuffer 分配 4k 堆外内存 ,可以保证缓冲区不会爆内存),这样同一个缓冲区的消息一起落盘,就保证了块内消息的顺序性,即做到了”同一个队列的消息尽可能的存到一起“。按块存取消息目前看来有两个优势:

  1. 按条读取消息=>按块读取消息,发挥块读的优势,减少了 IO 次数
  2. 全量索引=>稀疏索引。块内数据是连续的,所以只需要记录块的物理文件偏移量+块内消息数即可计算出某一条消息的物理位置。这样大大降低了索引的数量,稍微计算一下可以发现,完全可以使用一个 Map 数据结构,Key 为 queueName,Value 为 List 在内存维护队列块的索引。如果按照传统的设计方案:一个 queue 一个索引文件,百万文件必然会超过默认的系统文件句柄上限。索引存储在内存中既规避了文件句柄数的问题,速度也不必多数,文件 IO 和 内存 IO 不是一个量级。

由于赛题规定消息体是非定长的,大多数消息 58 字节,少量消息 1k 字节的数据特性,所以存储消息体时使用 short+byte[] 的结构即可,short 记录消息的实际长度,byte[] 记录完整的消息体。short 比 int 少了 2 个字节,2*20亿消息,可以减少 4g 的数据量。

稠密索引

稠密索引是对全量的消息进行索引,适用于无序消息,索引量大,数据可以按条存取。

稀疏索引

稀疏索引适用于按块存储的消息,块内有序,适用于有序消息,索引量小,数据按照块进行存取。

由于消息队列顺序存储,顺序消费的特性,加上 ssd 云盘最小存取单位为 4k(远大于单条消息)的限制,所以稀疏索引非常适用于这种场景。至于数据文件,可以做成参数,根据实际测试来判断到底是多文件效果好,还是单文件,此方案支持 100g 的单文件。

内存读写缓冲区

在稀疏索引的设计中,我们提到了写入缓冲区的概念,根据计算可以发现,100w 队列如果一个队列分配一个写入缓冲区,最多只能分配 4k,这恰好是最小的 ssd 写入块大小(但根据之前 ssd 云盘给出的数据来看,一次写入 64k 才能打满 io)。

一次写入 4k,这导致物理文件中的块大小是 4k,在读取时一次同样读取出 4k。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 写缓冲区
private ByteBuffer writeBuffer = ByteBuffer.allocateDirect(4 * 1024);
// 用 short 记录消息长度
private final static int SINGLE_MESSAGE_SIZE = 2;

public void put(String queueName,byte[] message){
// 缓冲区满,先落盘
if (SINGLE_MESSAGE_SIZE + message.length > writeBuffer.remaining()) {
// 落盘
flush();
}
writeBuffer.putInt(SINGLE_MESSAGE_SIZE);
writeBuffer.put(message);
this.blockLength++;
}

不足 4k 的部分可以选择补 0,也可以跳过。评测程序保证了在 queue 级别的写入是同步的,所以对于同一个队列,我们无法担心同步问题。写入搞定之后,同样的逻辑搞定读取,由于 get 操作是并发的,2阶段和3阶段会有 10~30 个线程并发消费同一个队列,所以 get 操作的读缓冲区可以设计成 ThreadLocal<ByteBuffer> ,每次使用时 clear 即可,保证了缓冲区每次读取时都是崭新的,同时减少了读缓冲区的创建,否则会导致频繁的 full gc。读取的伪代码暂时不贴,因为这样的 get 方案不是最终方案。

到这里整体的设计架构已经出来了,写入流程和读取流程的主要逻辑如下:

写入流程:

put流程

读取流程:

读取流程

内存读缓存优化

方案设计经过好几次的推翻重来,才算是确定了上述的架构,这样的架构优势在于非常简单明了,实际上我的第一版设计方案的代码量是上述方案代码量的 2~3 倍,但实际效果却不理想。上述架构的跑分成绩大概可以达到 70~80w TPS,只能算作是第三梯队的成绩,在此基础上,进行了读取缓存的优化才达到了 126w 的 TPS。在介绍读取缓存优化之前,先容我介绍下 PageCache 的概念。

PageCache

Linux 内核会将它最近访问过的文件页面缓存在内存中一段时间,这个文件缓存被称为 PageCache。如上图所示。一般的 read() 操作发生在应用程序提供的缓冲区与 PageCache 之间。而预读算法则负责填充这个PageCache。应用程序的读缓存一般都比较小,比如文件拷贝命令 cp 的读写粒度就是 4KB;内核的预读算法则会以它认为更合适的大小进行预读 I/O,比如 16-128KB。

所以一般情况下我们认为顺序读比随机读是要快的,PageCache 便是最大的功臣。

回到题目,这简直 nice 啊,因为在磁盘中同一个队列的数据是部分连续(同一个块则连续),实际上一个 4KB 块中大概可以存储 70 多个数据,而在顺序消费阶段,一次的 offset 一般为 10,有了 PageCache 的预读机制,7 次文件 IO 可以减少为 1 次!这可是不得了的优化,但是上述的架构仅仅只有 70~80w 的 TPS,这让我产生了疑惑,经过多番查找资料,最终在 @江学磊 的提醒下,才定位到了问题。

linux io

两种可能导致比赛中无法使用 pageCache 来做缓存

  1. 由于我使用 FIleChannel 进行读写,NIO 的读写可能走的正是 Direct IO,所以根本不会经过 PageCache 层。
  2. 测评环境中内存有限,在 IO 密集的情况下 PageCache 效果微乎其微。

虽然说不确定到底是何种原因导致 PageCache 无法使用,但是我的存储方案仍然满足顺序读取的特性,完全可以自己使用堆外内存自己模拟一个“PageCache”,这样在 3 阶段顺序消费时,TPS 会有非常高的提升。

一个队列一个读缓冲区用于顺序读,又要使得 get 阶段不存在并发问题,所以我选择了复用读缓冲区,并且给 get 操作加上了队列级别的锁,这算是一个小的牺牲,因为 2 阶段不会发生冲突,3 阶段冲突概率也并不大。改造后的读取缓存方案如下:

读取流程-优化

经过缓存改造之后,使用 Direct IO 也可以实现类似于 PageCache 的优化,并且会更加的可控,不至于造成频繁的缺页中断。经过这个优化,加上一些 gc 的优化,可以达到 126w TPS。整体方案算是介绍完毕。

其他优化

还有一些优化对整体流程影响不大,拎出来单独介绍。

2 阶段的随机索引检测和 3 阶段的顺序消费可以采取不同的策略,2 阶段可以直接读取所需要的数据,而不需要进行缓存(因为是随机检测,所以读缓存肯定不会命中)。

将文件数做成参数,调整参数来判断到底是多文件 TPS 高还是单文件,实际上测试后发现,差距并不是很大,单文件效果略好,由于是 ssd 云盘,又不存在磁头,所以真的不太懂原理。

gc 优化,能用数组的地方不要用 List。尽量减少小对象的出现,可以用数组管理基本数据类型,小对象对 gc 非常不友好,无论是初赛还是复赛,Java 比 Cpp 始终差距一个垃圾回收机制。必须保证全程不出现 full gc。

失败的优化与反思

本次比赛算是留下了不小的遗憾,因为写入的优化一直没有做好,读取缓存做好之后我 2 阶段和 3阶段的总耗时相加是 400+s,算是不错的成绩,但是写入耗时在 1300+s。我上述的方案采用的是多线程同步刷盘,但也尝试过如下的写入方案:

  1. 异步提交写缓冲区,单线程直接刷盘
  2. 异步提交写缓冲区,设置二级缓冲区 64k~64M,单线程使用二级缓冲区刷盘
  3. 同步将写缓冲区的数据拷贝至一个 LockFreeQueue,单线程平滑消费,以打满 IOPS
  4. 每 16 个队列共享一个写入缓冲区,这样控制写入缓冲区可以达到 64k,在刷盘时进行排序,将同一个 queue 的数据放置在一起。

但都以失败告终,没有 get 到写入优化的要领,算是本次比赛最大的遗憾了。

还有一个失误在于,评测环境使用的云盘 ssd 和我的本地 Mac 下的 ssd 存储结构差距太大,加上 mac os 和 Linux 的一些差距,导致本地成功的优化在线上完全体现不出来,还是租个阿里云环境比较靠谱。

另一方面的反思,则是对存储和 MQ 架构设计的不熟悉,对于 Kafka 和 RocketMQ 所做的一些优化也都是现学现用,不太确定用的对不对,导致走了一些弯路,而比赛中认识的一个 96 年的小伙子王亚普,相比之下对中间件知识理解的深度和广度实在令我钦佩,实在还有很多知识需要学习。

参赛感悟

第一感受是累,第二感受是爽。相信很多选手和我一样是工作党,白天工作,只能腾出晚上的时间去搞比赛,对于966 的我真是太不友好了,初赛时间延长了一次还算给缓了一口气,复赛一眨眼就过去了,想翻盘都没机会,实在是遗憾。爽在于这次比赛真的是汗快淋漓地实践了不少中间件相关的技术,初赛的 Netty,复赛的存储设计,都是难以忘怀的回忆,比赛中也认识了不少朋友,有学生党,有工作党,感谢你们不厌其烦的教导与发人深省的讨论,从不同的人身上是真的可以学到很多自己缺失的知识。

据消息说,阿里中间件大赛很有可能是最后一届,无论是因为什么原因,作为参赛者,我都感到深深的惋惜,希望还能有机会参加下一届的中间件大赛,也期待能看到更多的相同类型的赛事被各大互联网公司举办,和大佬们同台竞技,一边认识更多新朋友的感觉真棒。

虽然最终无缘决赛,但还是期待进入决赛的 11 位选手能带来一场精彩的答辩,也好解答我始终优化失败的写入方案。后续会考虑吸收下前几名 JAVA 的优化思路,整理成最终完善的方案。
目前方案的 git 地址,仓库已公开:https://code.aliyun.com/250577914/queuerace2018.git

欢迎关注我的微信公众号:「Kirito的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

分享到

选择Kong作为你的API网关

Kong(https://github.com/Kong/kong)是一个云原生,高效,可扩展的分布式 API 网关。 自 2015 年在 github 开源后,广泛受到关注,目前已收获 1.68w+ 的 star,其核心价值在于高性能和可扩展性。

查看更多

分享到

天池中间件大赛dubboMesh优化总结(qps从1000到6850)

天池中间件大赛的初赛在今早终于正式结束了,公众号停更了一个月,主要原因就是博主的空余时间几乎全花在这个比赛上,第一赛季结束,做下参赛总结,总的来说,收获不小。

最终排名

查看更多

分享到

设计RPC接口时,你有考虑过这些吗?

RPC 框架的讨论一直是各个技术交流群中的热点话题,阿里的 dubbo,新浪微博的 motan,谷歌的 grpc,以及不久前蚂蚁金服开源的 sofa,都是比较出名的 RPC 框架。RPC 框架,或者一部分人习惯称之为服务治理框架,更多的讨论是存在于其技术架构,比如 RPC 的实现原理,RPC 各个分层的意义,具体 RPC 框架的源码分析…但却并没有太多话题和“如何设计 RPC 接口”这样的业务架构相关。

查看更多

分享到

【千米网】从跨语言调用到dubbo2.js

dubbo2.js千米网 贡献给 dubbo 社区的一款 nodejs dubbo 客户端,它提供了 nodejs 对原生 dubbo 协议的支持,使得 nodejs 和 java 这两种异构语言的 rpc 调用变得便捷,高效。

微服务跨语言调用

微服务架构已成为目前互联网架构的趋势,关于微服务的讨论,几乎占据了各种技术大会的绝大多数版面。国内使用最多的服务治理框架非阿里开源的 dubbo 莫属,千米网也选择了 dubbo 作为微服务治理框架。另一方面,和大多数互联网公司一样,千米的开发语言是多样的,大多数后端业务由 java 支撑,而每个业务线有各自开发语言的选择权,便出现了 nodejs,python,go 多语言调用的问题。

查看更多

分享到

Spring Security(六)—SpringSecurityFilterChain加载流程深度解析

SpringSecurityFilterChain 作为 SpringSecurity 的核心过滤器链在整个认证授权过程中起着举足轻重的地位,每个请求到来,都会经过该过滤器链,前文《Spring Security(四)–核心过滤器源码分析》 中我们分析了 SpringSecurityFilterChain 的构成,但还有很多疑问可能没有解开:

查看更多

分享到

Spring揭秘--寻找遗失的web.xml

今天我们来放松下心情,不聊分布式,云原生,来聊一聊初学者接触的最多的 java web 基础。几乎所有人都是从 servlet,jsp,filter 开始编写自己的第一个 hello world 工程。那时,还离不开 web.xml 的配置,在 xml 文件中编写繁琐的 servlet 和 filter 的配置。随着 spring 的普及,配置逐渐演变成了两种方式—java configuration 和 xml 配置共存。现如今,springboot 的普及,java configuration 成了主流,xml 配置似乎已经“灭绝”了。不知道你有没有好奇过,这中间都发生了哪些改变,web.xml 中的配置项又是被什么替代项取代了?

查看更多

分享到

该如何设计你的 PasswordEncoder?

缘起

前端时间将一个集成了 spring-security-oauth2 的旧项目改造了一番,将 springboot 升级成了 springboot 2.0,众所周知 springboot 2.0 依赖的是 spring5,并且许多相关的依赖都发生了较大的改动,与本文相关的改动罗列如下,有兴趣的同学可以看看:Spring Security 5.0 New Features ,增强了 oauth2 集成的功能以及和一个比较有意思的改动—重构了密码编码器的实现(Password Encoding,由于大多数 PasswordEncoder 相关的算法是 hash 算法,所以本文将 PasswordEncoder 翻译成‘密码编码器’和并非‘密码加密器’)官方称之为

Modernized Password Encoding — 现代化的密码编码方式

查看更多

分享到