💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
### [JMH 的引入](https://lingcoder.gitee.io/onjava8/#/book/16-Validating-Your-Code?id=jmh-%e7%9a%84%e5%bc%95%e5%85%a5) 截止目前为止,唯一能产生像样结果的 Java 微基准测试系统就是 Java Microbenchmarking Harness,简称 JMH。本书的**build.gradle**自动引入了 JMH 的设置,所以你可以轻松地使用它。 你可以在命令行编写 JMH 代码并运行它,但是推荐的方式是让 JMH 系统为你运行测试;**build.gradle**文件已经配置成只需要一条命令就能运行 JMH 测试。 JMH 尝试使基准测试变得尽可能简单。例如,我们将使用 JMH 重新编写**BadMicroBenchmark.java**。这里只有**@State**和**@Benchmark**这两个注解是必要的。其余的注解要么是为了产生更多易懂的输出,要么是加快基准测试的运行速度(JMH 基准测试通常需要运行很长时间): ~~~ // validating/jmh/JMH1.java package validating.jmh; import java.util.*; import org.openjdk.jmh.annotations.*; import java.util.concurrent.TimeUnit; @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) // Increase these three for more accuracy: @Warmup(iterations = 5) @Measurement(iterations = 5) @Fork(1) public class JMH1 { private long[] la; @Setup public void setup() { la = new long[250_000_000]; } @Benchmark public void setAll() { Arrays.setAll(la, n -> n); } public void parallelSetAll() { Arrays.parallelSetAll(la, n -> n); } } ~~~ “forks” 的默认值是 10,意味着每个测试都运行 10 次。为了减少运行时间,这里使用了**@Fork**注解来减少这个次数到 1。我还使用了**@Warmup**和**@Measurement**注解将它们默认的运行次数从 20 减少到 5 次。尽管这降低了整体的准确率,但是结果几乎与使用默认值相同。可以尝试将**@Warmup**、\*\*@Measurement\*\* 和**@Fork**都注释掉然后看使用它们的默认值,结果会有多大显著的差异;一般来说,你应该只能看到长期运行的测试使错误因素减少,而结果没有多大变化。 需要使用显式的 gradle 命令才能运行基准测试(在示例代码的根目录处运行)。这能防止耗时的基准测试运行其他的**gradlew**命令: **gradlew validating:jmh** 这会花费几分钟的时间,取决于你的机器(如果没有注解上的调整,可能需要几个小时)。控制台会显示**results.txt**文件的路径,这个文件统计了运行结果。注意,**results.txt**包含这一章所有**jmh**测试的结果:**JMH1.java**,**JMH2.java**和**JMH3.java**。 因为输出是绝对时间,所以在不同的机器和操作系统上结果各不相同。重要的因素不是绝对时间,我们真正观察的是一个算法和另一个算法的比较,尤其是哪一个运行得更快,快多少。如果你在自己的机器上运行测试,你将看到不同的结果却有着相同的模式。 我在大量的机器上运行了这些测试,尽管不同的机器上得到的绝对值结果不同,但是相对值保持着合理的稳定性。我只列出了**results.txt**中适当的片段并加以编辑使输出更加易懂,而且内容大小适合页面。所有测试中的**Mode**都以**avgt**展示,代表 “平均时长”。**Cnt**(测试的数目)的值是 200,尽管这里的一个例子中配置的**Cnt**值是 5。**Units**是**us/op**,是 “Microseconds per operation” 的缩写,因此,这个值越小代表性能越高。 我同样也展示了使用 warmups、measurements 和 forks 默认值的输出。我删除了示例中相应的注解,就是为了获取更加准确的测试结果(这将花费数小时)。结果中数字的模式应该仍然看起来相同,不论你如何运行测试。 下面是**JMH1.java**的运行结果: **Benchmark Score** **JMH1.setAll 196280.2** **JMH1.parallelSetAll 195412.9** 即使像 JMH 这么高级的基准测试工具,基准测试的过程也不容易,练习时需要倍加小心。这里测试产生了反直觉的结果:并行的版本**parallelSetAll()**花费了与非并行版本的**setAll()**相同的时间,两者似乎都运行了相当长的时间。 当创建这个示例时,我假设如果我们要测试数组初始化的话,那么使用非常大的数组是有意义的。所以我选择了尽可能大的数组;如果你实验的话会发现一旦数组的大小超过 2亿5000万,你就开始会得到内存溢出的异常。然而,在这么大的数组上执行大量的操作从而震荡内存系统,产生无法预料的结果是有可能的。不管这个假设是否正确,看上去我们正在测试的并非是我们想测试的内容。 考虑其他的因素: C:客户端执行操作的线程数量 P:并行算法使用的并行数量 N:数组的大小:\**10^(2*k)**,通常来说,**k=1..7\*\* 足够来练习不同的缓存占用。 Q:setter 的操作成本 这个 C/P/N/Q 模型在早期 JDK 8 的 Lambda 开发期间浮出水面,大多数并行的 Stream 操作(**parallelSetAll()**也基本相似)都满足这些结论:\**N*Q\*\*(主要工作量)对于并发性能尤为重要。并行算法在工作量较少时可能实际运行得更慢。 在一些情况下操作竞争如此激烈使得并行毫无帮助,而不管 \**N*Q\*\* 有多大。当**C**很大时,**P**就变得不太相关(内部并行在大量的外部并行面前显得多余)。此外,在一些情况下,并行分解会让相同的**C**个客户端运行得比它们顺序运行代码更慢。 基于这些信息,我们重新运行测试,并在这些测试中使用不同大小的数组(改变**N**): ~~~ // validating/jmh/JMH2.java package validating.jmh; import java.util.*; import org.openjdk.jmh.annotations.*; import java.util.concurrent.TimeUnit; @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) @Warmup(iterations = 5) @Measurement(iterations = 5) @Fork(1) public class JMH2 { private long[] la; @Param({ "1", "10", "100", "1000", "10000", "100000", "1000000", "10000000", "100000000", "250000000" }) int size; @Setup public void setup() { la = new long[size]; } @Benchmark public void setAll() { Arrays.setAll(la, n -> n); } @Benchmark public void parallelSetAll() { Arrays.parallelSetAll(la, n -> n); } } ~~~ **@Param**会自动地将其自身的值注入到变量中。其自身的值必须是字符串类型,并可以转化为适当的类型,在这个例子中是**int**类型。 下面是已经编辑过的结果,包含精确计算出的加速数值: | JMH2 Benchmark | Size | Score % | Speedup | | --- | --- | --- | --- | | **setAll** | 1 | 0.001 | | | **parallelSetAll** | 1 | 0.036 | 0.028 | | **setAll** | 10 | 0.005 | | | **parallelSetAll** | 10 | 3.965 | 0.001 | | **setAll** | 100 | 0.031 | | | **parallelSetAll** | 100 | 3.145 | 0.010 | | **setAll** | 1000 | 0.302 | | | **parallelSetAll** | 1000 | 3.285 | 0.092 | | **setAll** | 10000 | 3.152 | | | **parallelSetAll** | 10000 | 9.669 | 0.326 | | **setAll** | 100000 | 34.971 | | | **parallelSetAll** | 100000 | 20.153 | 1.735 | | **setAll** | 1000000 | 420.581 | | | **parallelSetAll** | 1000000 | 165.388 | 2.543 | | **setAll** | 10000000 | 8160.054 | | | **parallelSetAll** | 10000000 | 7610.190 | 1.072 | | **setAll** | 100000000 | 79128.752 | | | **parallelSetAll** | 100000000 | 76734.671 | 1.031 | | **setAll** | 250000000 | 199552.121 | | | **parallelSetAll** | 250000000 | 191791.927 | 1.040 | | 可以看到当数组大小达到 10 万左右时,**parallelSetAll()**开始反超,而后趋于与非并行的运行速度相同。即使它运行速度上胜了,看起来也不足以证明由于并行的存在而使速度变快。 | | | | **setAll()/parallelSetAll()**中工作的计算量起很大影响吗?在前面的例子中,我们所做的只有对数组的赋值操作,这可能是最简单的任务。所以即使**N**值变大,\**N*Q\*\* 也仍然没有达到巨大,所以看起来像是我们没有为并行提供足够的机会(JMH 提供了一种模拟变量 Q 的途径;如果想了解更多的话,可搜索**Blackhole.consumeCPU**)。 我们通过使方法**f()**中的任务变得更加复杂,从而产生更多的并行机会: ~~~ // validating/jmh/JMH3.java package validating.jmh; import java.util.*; import org.openjdk.jmh.annotations.*; import java.util.concurrent.TimeUnit; @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @OutputTimeUnit(TimeUnit.MICROSECONDS) @Warmup(iterations = 5) @Measurement(iterations = 5) @Fork(1) public class JMH3 { private long[] la; @Param({ "1", "10", "100", "1000", "10000", "100000", "1000000", "10000000", "100000000", "250000000" }) int size; @Setup public void setup() { la = new long[size]; } public static long f(long x) { long quadratic = 42 * x * x + 19 * x + 47; return Long.divideUnsigned(quadratic, x + 1); } @Benchmark public void setAll() { Arrays.setAll(la, n -> f(n)); } @Benchmark public void parallelSetAll() { Arrays.parallelSetAll(la, n -> f(n)); } } ~~~ **f()**方法提供了更加复杂且耗时的操作。现在除了简单的给数组赋值外,**setAll()**和**parallelSetAll()**都有更多的工作去做,这肯定会影响结果。 | JMH2 Benchmark | Size | Score % | Speedup | | --- | --- | --- | --- | | **setAll** | 1 | 0.012 | | | **parallelSetAll** | 1 | 0.047 | 0.255 | | **setAll** | 10 | 0.107 | | | **parallelSetAll** | 10 | 3.894 | 0.027 | | **setAll** | 100 | 0.990 | | | **parallelSetAll** | 100 | 3.708 | 0.267 | | **setAll** | 1000 | 133.814 | | | **parallelSetAll** | 1000 | 11.747 | 11.391 | | **setAll** | 10000 | 97.954 | | | **parallelSetAll** | 10000 | 37.259 | 2.629 | | **setAll** | 100000 | 988.475 | | | **parallelSetAll** | 100000 | 276.264 | 3.578 | | **setAll** | 1000000 | 9203.103 | | | **parallelSetAll** | 1000000 | 2826.974 | 3.255 | | **setAll** | 10000000 | 92144.951 | | | **parallelSetAll** | 10000000 | 28126.202 | 3.276 | | **setAll** | 100000000 | 921701.863 | | | **parallelSetAll** | 100000000 | 266750.543 | 3.455 | | **setAll** | 250000000 | 2299127.273 | | | **parallelSetAll** | 250000000 | 538173.425 | 4.272 | 可以看到当数组的大小达到 1000 左右时,**parallelSetAll()**的运行速度反超了**setAll()\*\*。看来 \*\*parallelSetAll()**严重依赖数组中计算的复杂度。这正是基准测试的价值所在,因为我们已经得到了关于**setAll()**和**parallelSetAll()**间微妙的信息,知道在何时使用它们。 这显然不是从阅读 Javadocs 就能得到的。 大多数时候,JMH 的简单应用会产生好的结果(正如你将在本书后面例子中所见),但是我们从这里知道,你不能一直假定 JMH 会产生好的结果。 JMH 网站上的范例可以帮助你开始。