我已经几个月没有在这里写文章了,这种例外也会继续下去。 我计划在明年三月左右恢复写作。 本文末尾的说明。 等待! 不完全是最后,因为您可以向下滚动。 它在文章结尾处。 继续阅读!
三年前,我在写有关Java编译器如何优化其执行代码的文章。 或者更确切地说, javac
如何做到这一点,而JIT同时做到了。 我制定了一些基准,如Esko Luontola所提到的那样,确实有些糟糕。 这些基准旨在表明JIT甚至可以在收集有关代码执行的重要统计数据之前进行优化。
该文章创建于2013年1月。两个月后, JMH (Java Microbenchmark Harness)的第一个源代码上传就发生了。 从那时起,这个工具就发展了很多,并在明年成为Java下一个版本的一部分。 我有一份合同要写一本有关Java 9的书 ,其中的第5章应该涵盖Java 9进行微基准测试的可能性。 这是开始与JMH合作的好理由。
在详细介绍如何使用JMH及其好处之前,让我们先谈谈一些微基准测试。
微基准测试
微基准测试正在衡量某些小代码片段的性能。 它很少使用,在开始为实际的商业环境做微基准测试之前,我们必须三思。 请记住,过早的优化是万恶之源。 一些开发人员对此声明进行了概括,称优化本身是万恶之源,这也许是事实。 特别是如果我们指的是微基准测试。
微基准测试是一种诱使工具,可以在不知道是否值得优化代码的情况下优化一些小东西。 当我们有一个包含多个模块的庞大应用程序时,它可以在多个服务器上运行,我们如何确定改进应用程序的某些特殊部分可以大大提高性能? 它是否会偿还增加的收入,以产生如此多的利润,以弥补我们在性能测试和开发中花费的成本? 我不愿意说你不知道,只是因为这样的说法太笼统了。 从统计学上几乎可以肯定,这种包括微基准测试在内的优化在大多数情况下不会使您感到痛苦。 它会很疼,您可能不会注意到它,甚至不会享受它,但这是一个完全不同的故事。
何时使用微基准测试? 我可以看到三个方面:
- 您撰写了有关微基准测试的文章。
- 您确定了占用应用程序中大部分资源的代码段,并且可以通过微基准测试改进。
- 您无法确定将占用应用程序中大部分资源的代码段,但您怀疑它。
第一个笑话。 是否可以:您可以尝试使用微基准测试,以了解其工作原理,然后了解Java代码如何工作,哪些运行速度快,哪些运行速度不快。 去年, Takipi发表了一篇文章,他们试图测量Lambda的速度。 阅读这篇非常好的文章,并清楚地证明了博客相对于为印刷品写东西的主要优势。 读者评论并指出了错误,并在本文中进行了更正。
第二是通常的情况。 好的,在读者发表评论之前,纠正了我的观点:第二种应该是通常的情况。 第三是在开发库时,您只是不知道将使用该库的所有应用程序。 在这种情况下,您将尝试优化您认为对大多数可想象的应用程序最关键的部分。 即使在这种情况下,最好还是使用一些示例应用程序。
陷阱
微基准测试的陷阱是什么? 基准测试是作为实验完成的。 我编写的第一个程序是TI计算器代码,我可以算出该程序为分解两个大(当时为10位)素数的步数。 即使在那个时候,我仍在使用旧的俄罗斯秒表来测量懒惰的时间来计算步数。 实验和测量更加容易。
今天,您无法计算CPU执行的步骤数。 程序员无法控制的因素很多,它们可能会改变应用程序的性能,因此无法计算步骤。 我们将测量留给了我们,并且获得了所有测量的所有问题。
测量的最大问题是什么? 我们对某物感兴趣,比如说X,我们通常无法衡量。 因此,我们改为测量Y,并希望Y和X的值耦合在一起。 我们要测量房间的长度,但是要测量激光束从一端到达另一端所花费的时间。 在这种情况下,长度X和时间Y紧密耦合。 很多时候,X和Y仅或多或少地相关。 在大多数情况下,人们进行测量时,X和Y根本不相关。 人们仍然把钱和更多的钱花在这种测量支持的决策上。 以政治选举为例。
微基准测试没有什么不同。 很难做到这一点。 如果您对细节和可能的陷阱感兴趣, Aleksey Shipilev会提供一个不错的一小时视频。 第一个问题是如何衡量执行时间。 小代码运行时间短,并且在测量开始和结束时System.currentTimeMillis()
可能只是返回相同的值,因为我们仍处于同一毫秒内。 即使执行时间为10ms,纯粹由于我们测量时间的量化,测量误差仍然至少为10%。 幸运的是有System.nanoTime()
。 我们开心吗,文森特?
并不是的。 如文档所述, nanoTime()
返回正在运行的Java虚拟机的高分辨率时间源的当前值,以纳秒为单位。 什么是“当前”? 何时进行调用? 还是退回时? 还是介于两者之间? 选择所需的一个,您可能仍然会失败。 所有Java实现都应保证在最近1000ns内该当前值相同。
文档中使用nanoTime()
之前的另一个警告: 跨越大约292年(263纳秒)的连续调用中的差异由于数值溢出而无法正确计算经过时间。
292年? 真?
还有其他问题。 启动Java代码时,代码的前几千次执行将在没有运行时优化的情况下进行解释或执行。 与静态编译语言(如Swift,C,C ++或Golang)的编译器相比,JIT的优势在于,它可以从代码执行中收集运行时信息,并且当发现上次执行的编译基于最近的版本可能会更好运行时统计信息将再次编译代码。 对于也尝试使用统计信息调整其操作参数的垃圾收集可能同样如此。 因此,编写良好的服务器应用程序会随着时间的推移获得一些性能。 它们的启动速度稍慢,然后变得更快。 如果重新启动服务器,则整个迭代将再次开始。
如果您执行微型基准测试,则应注意这种行为。 您是要测量应用程序在预热期间的性能还是在操作过程中如何真正执行?
解决方案是尝试考虑所有这些警告的微型基准测试工具。 Java 9是JMH。
什么是JMH?
“ JMH是用于构建,运行和分析以Java和其他针对JVM的其他语言编写的nano / micro / milli / macro基准测试的Java工具。” (摘自JMH的官方网站 )
您可以将jmh作为独立于您测量的实际项目的独立项目运行,也可以仅将测量代码存储在单独的目录中。 该线束将根据生产类文件进行编译并执行基准测试。 如我所见,最简单的方法是使用Gradle插件执行JMH。 您将基准测试代码存储在名为jmh
的目录中(与main
和test
处于同一级别),并创建可以启动基准测试的main
。
import org.openjdk.jmh.annotations.*;
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.io.IOException;public class MicroBenchmark {public static void main(String... args) throws IOException, RunnerException {Options opt = new OptionsBuilder().include(MicroBenchmark.class.getSimpleName()).forks(1).build();new Runner(opt).run();}
有一个不错的构建器界面用于配置,还有一个可以执行基准测试的Runner
类。
玩一点
在《 Java 9编程实例》一书中,其中一个例子是Mastermind游戏 。 第五章是关于并行解决游戏以加快猜测速度的所有内容。 (如果您不了解该游戏,请在Wikipedia上阅读它,我不想在这里解释它,但是您需要它来理解以下内容。)
正常的猜测很简单。 有一个隐藏的秘密。 秘诀是从6种颜色中选择4种不同颜色的钉子。 当我们猜测时,我们一个接一个地考虑可能的颜色变化,并向表格提出问题:如果这种选择是秘密,所有答案都是正确的吗? 换句话说:这个猜测可以隐藏起来吗,或者以前的答案在答案中有矛盾吗? 如果可以将这种猜测作为秘密,那么我们将尝试将钉子放在桌子上。 答案可能是4/0(alleluia)或其他。 在后一种情况下,我们继续搜索。 这样,可以通过五个步骤解决6色4列表格。
为了简化和可视化,我们用数字命名颜色,例如01234456789
(在jmh基准中有10种颜色,因为6种颜色还不够)和6种钉子。 这个秘密,我们使用是987654
,因为这是最后的猜测,我们从去123456
, 123457
等。
1983年8月,当我在瑞典学校计算机(ABC80)上使用BASIC语言首次编写此游戏时,在运行于40MHz 6种颜色,4个位置的z80处理器上,每次猜测都花了20到30秒。 如今,我的MacBook Pro可以使用10种颜色和6种钉子,单线程大约每秒7次玩整个游戏。 但是,当我的机器中有4个处理器支持8个并行线程时,这还不够。
为了加快执行速度,我将猜测空间划分为相等的间隔,并启动了单独的猜测器,每个猜测器将猜测分散到阻塞队列中。 主线程从队列中读取并在猜测出现时将其放在表上。 万一某些线程创建一个猜测而主线程尝试将其用作猜测时已过时,则可能需要一些后期处理,但我们仍然希望可以大大提高速度。
真的加快了猜测速度吗? 那是JMH的目的。
为了运行基准测试,我们需要一些可以实际执行游戏的代码
@State(Scope.Benchmark)public static class ThreadsAndQueueSizes {@Param(value = {"1", "4", "8", "16", "32"})String nrThreads;@Param(value = { "1", "10", "100", "1000000"})String queueSize;}@Benchmark@Fork(1)public void playParallel(ThreadsAndQueueSizes t3qs) throws InterruptedException {int nrThreads = Integer.valueOf(t3qs.nrThreads);int queueSize = Integer.valueOf(t3qs.queueSize);new ParallelGamePlayer(nrThreads, queueSize).play();}@Benchmark@Fork(1)public void playSimple(){new SimpleGamePlayer().play();}
JMH框架将多次执行代码,以测量使用多个参数运行的时间。 将执行方法playParallel
来针对playParallel
和32个线程运行算法,每个线程的最大队列长度分别为playParallel
和一百万。 当队列已满时,各个猜测者将停止猜测,直到主线程从队列中拉出至少一个猜测为止。
我怀疑如果我们有很多线程,并且我们不限制队列的长度,那么工作线程将使用仅基于空表的初始猜测来填充队列,因此不会带来太多价值。 执行将近15分钟后,我们会看到什么?
Benchmark (nrThreads) (queueSize) Mode Cnt Score Error Units
MicroBenchmark.playParallel 1 1 thrpt 20 6.871 ± 0.720 ops/s
MicroBenchmark.playParallel 1 10 thrpt 20 7.481 ± 0.463 ops/s
MicroBenchmark.playParallel 1 100 thrpt 20 7.491 ± 0.577 ops/s
MicroBenchmark.playParallel 1 1000000 thrpt 20 7.667 ± 0.110 ops/s
MicroBenchmark.playParallel 4 1 thrpt 20 13.786 ± 0.260 ops/s
MicroBenchmark.playParallel 4 10 thrpt 20 13.407 ± 0.517 ops/s
MicroBenchmark.playParallel 4 100 thrpt 20 13.251 ± 0.296 ops/s
MicroBenchmark.playParallel 4 1000000 thrpt 20 11.829 ± 0.232 ops/s
MicroBenchmark.playParallel 8 1 thrpt 20 14.030 ± 0.252 ops/s
MicroBenchmark.playParallel 8 10 thrpt 20 13.565 ± 0.345 ops/s
MicroBenchmark.playParallel 8 100 thrpt 20 12.944 ± 0.265 ops/s
MicroBenchmark.playParallel 8 1000000 thrpt 20 10.870 ± 0.388 ops/s
MicroBenchmark.playParallel 16 1 thrpt 20 16.698 ± 0.364 ops/s
MicroBenchmark.playParallel 16 10 thrpt 20 16.726 ± 0.288 ops/s
MicroBenchmark.playParallel 16 100 thrpt 20 16.662 ± 0.202 ops/s
MicroBenchmark.playParallel 16 1000000 thrpt 20 10.139 ± 0.783 ops/s
MicroBenchmark.playParallel 32 1 thrpt 20 16.109 ± 0.472 ops/s
MicroBenchmark.playParallel 32 10 thrpt 20 16.598 ± 0.415 ops/s
MicroBenchmark.playParallel 32 100 thrpt 20 15.883 ± 0.454 ops/s
MicroBenchmark.playParallel 32 1000000 thrpt 20 6.103 ± 0.867 ops/s
MicroBenchmark.playSimple N/A N/A thrpt 20 6.354 ± 0.200 ops/s
(分数越高,越好。)它表明,如果启动16个线程并且在某种程度上限制了队列的长度,我们将获得最佳性能。 在一个线程(一个主线程和一个工作线程)上运行并行算法要比单线程实现慢一些。 这似乎没问题:我们有启动新线程以及线程之间通信的开销。 我们拥有的最大性能约为16个线程。 因为我们可以在这台机器上拥有8个内核,所以我们希望能看到8个内核。为什么?
如果我们用随机的东西替换标准的密码987654
(即使对于CPU来说也很无聊)会怎样?
Benchmark (nrThreads) (queueSize) Mode Cnt Score Error Units
MicroBenchmark.playParallel 1 1 thrpt 20 12.141 ± 1.385 ops/s
MicroBenchmark.playParallel 1 10 thrpt 20 12.522 ± 1.496 ops/s
MicroBenchmark.playParallel 1 100 thrpt 20 12.516 ± 1.712 ops/s
MicroBenchmark.playParallel 1 1000000 thrpt 20 11.930 ± 1.188 ops/s
MicroBenchmark.playParallel 4 1 thrpt 20 19.412 ± 0.877 ops/s
MicroBenchmark.playParallel 4 10 thrpt 20 17.989 ± 1.248 ops/s
MicroBenchmark.playParallel 4 100 thrpt 20 16.826 ± 1.703 ops/s
MicroBenchmark.playParallel 4 1000000 thrpt 20 15.814 ± 0.697 ops/s
MicroBenchmark.playParallel 8 1 thrpt 20 19.733 ± 0.687 ops/s
MicroBenchmark.playParallel 8 10 thrpt 20 19.356 ± 1.004 ops/s
MicroBenchmark.playParallel 8 100 thrpt 20 19.571 ± 0.542 ops/s
MicroBenchmark.playParallel 8 1000000 thrpt 20 12.640 ± 0.694 ops/s
MicroBenchmark.playParallel 16 1 thrpt 20 16.527 ± 0.372 ops/s
MicroBenchmark.playParallel 16 10 thrpt 20 19.021 ± 0.475 ops/s
MicroBenchmark.playParallel 16 100 thrpt 20 18.465 ± 0.504 ops/s
MicroBenchmark.playParallel 16 1000000 thrpt 20 10.220 ± 1.043 ops/s
MicroBenchmark.playParallel 32 1 thrpt 20 17.816 ± 0.468 ops/s
MicroBenchmark.playParallel 32 10 thrpt 20 17.555 ± 0.465 ops/s
MicroBenchmark.playParallel 32 100 thrpt 20 17.236 ± 0.605 ops/s
MicroBenchmark.playParallel 32 1000000 thrpt 20 6.861 ± 1.017 ops/s
由于我们不需要仔细研究所有可能的变化,因此性能得以提高。 如果是一个线程,则增加一倍。 在有多个线程的情况下,增益不是很多。 并请注意,这不会提高代码本身的速度,只能使用统计的随机机密来更实际地进行测量。 我们还可以看到,在8个线程中获得16个线程不再有意义。 仅当我们选择接近变体结尾的秘密时,这才有意义。 为什么? 根据您在此处看到的内容以及GitHub中提供的源代码,您可以给出答案。
摘要
计划于2017年2月发行《 Java 9示例编程 》一书。但是,由于我们生活在一个开放源码的世界中,因此您可以使发布者控制对1.xx-SNAPSHOT
版本的访问。 现在,我告诉了您在编写本书代码时使用的初步GitHub URL,您还可以预购eBook,并提供反馈以帮助我创建更好的书。
翻译自: https://www.javacodegeeks.com/2016/09/microbenchmarking-comes-java-9.html