前阵子写过两篇东西介绍Java标准库里对ArrayList
,HashMap
做的小性能优化.
有一个小问题,那些作者是怎么知道,这么改了以后,性能就会变好的呢?
答案是,通过基准测试(BenchMark)
基准测试(Bench Mark)
那两个改动, 都是通过benchmark, 来测量改动带来的影响,衡量性能是否得到了优化.基准测试其实就是计算机里的一个标准化的跑分操作.
不过, 虽然Benchmark 现在是个计算机词汇, 他最早其实是,作为一个标尺存在[1]
https://my.theasianparent.com/height-and-weight-tracker
这玩意的原始用法, 大概类似上图,和在门框上划标记(mark)
来看孩子长了多少, 是一样的原理.
如何进行benchmark?
要进行benchmark, 当然是需要工具的.
基础版
日常我们会用for循环加上System.nanoTime()
来做,比如这样.
long start = System.nanoTime();
for (int i = 0; i < 100000; i++) {
dosth();
}
long end = System.nanoTime();
System.out.println("time= " + (end - start)/100000);
这么做是可以的, 不过有一个问题是, 过于不稳定了.
Java是解释执行的, 遇到热点会采用JIT来优化.这里面一上来就直接计入时间的话, 代码有没有经过JIT是无法知晓的.对性能对比来说,其实不太公平.
一般会先空跑几个循环, 来把代码热身(warmup)
.另外,作为一个统计指标,只跑一轮也是不准确的,要不要多跑几轮取平均?跑的话,具体跑几次呢?
这些都是基准测试里的常见问题, 因此, 理所当然,已经被人解决过了.前面提到的两个优化,用的都是官方的benchmark工具JMH
.
JMH
JMH 俗称(Java Microbenchmark Harness ), 是openjdk官方出的benchmark工具,详情可见Java Microbenchmark Harness [2].
这东西的使用流程要说明白比较费时间,就不提了.Intellij idea用户可以不求甚解地按照我这个方式来使用:
引入
-
先去插件里按照这个 JMH插件
-
在pom里加入
core
和注解处理器
.<!-- <jmh.version>1.34</jmh.version> -->
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</dependency>
</dependencies>
测试例子
有了这些之后, 我们编写这样一个类.
public class HelloWorld {
@Benchmark
@Fork(1)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 1,batchSize = 10000)
@BenchmarkMode({Mode.AverageTime})
@Measurement(iterations = 1, batchSize = 10000)
public static void dosth(){
}
}
执行按钮
如果插件安装正确, 左边会看到执行的按钮.
输出结果
输出很长,这是截取的部分.
Benchmark Mode Cnt Score Error Units
HelloWorld.dosth avgt 4633.679 ns/op
可以看到, 一次操作平均需要4633纳秒.
统计模式
前面输出结果的Mode
是avgt
,表示平均时间.对应上面的注解@BenchmarkMode({Mode.AverageTime})
.
如果把这个注解改变,变成@BenchmarkMode({Mode.Throughput})
,再跑一遍会得到新的结果,得到单位时间内,可以运行的次数Throughput
,俗称吞吐量
.
Benchmark Mode Cnt Score Error Units
HelloWorld.dosth thrpt ≈ 10⁻⁴ ops/ns
时间单位
前面的输出,每纳秒函数要跑 次…这好像有点太低了. 毕竟, 这是按照纳秒算的,对应注解@OutputTimeUnit(TimeUnit.NANOSECONDS)
.
把这个设置切换为@OutputTimeUnit(TimeUnit.SECONDS)
,重新跑可以得到输出.
Benchmark Mode Cnt Score Error Units
HelloWorld.dosth thrpt 204916.423 ops/s
其他设置
前面两个例子已经看出来了, 可设置的项目很多,就不一一演示输出结果了.
-
@Measurement(iterations = 1, batchSize = 10000)
跑1轮,1万次. -
@Warmup(iterations = 1,batchSize = 10000)
暖1轮,1万次. -
@Fork(1)
只跑一份测试
最后,前面说的这些注解,其实都是可以去掉的,只添加一个@Benchmark
就可以,别的会走默认值,可以自己试试看.
小试牛刀
既然有了这么好用的工具, 可以拿来验证一些东西试试看了.
for i 循环处理ArrayList比foreach快?
曾经在一个地方看过这个说法, ArrayList
是RandomAccess
的,foreach
是iterator
的语法糖. 迭代器用在ArrayList
这样的地方比较慢, 要用for i
才好. 那么这是真的吗?
我们准备这样3个测试:
@State(Scope.Benchmark)
public class ForLoop {
List<Integer> arr = IntStream.rangeClosed(1, 1000000).boxed().collect(Collectors.toList());
@Benchmark
public void foropt() {
long sum = 0;
for (int i = 0, size = arr.size(); i < size; i++) {
sum += arr.get(i);
}
}
@Benchmark
public void fori() {
long sum = 0;
for (int i = 0; i < arr.size(); i++) {
sum += arr.get(i);
}
}
@Benchmark
public void foreach() {
long sum = 0;
for (Integer i : arr) {
sum += i;
}
}
}
除了fori 和foreach外,外加一个fori 提取出size()
的分支, 结果如下:
Benchmark Mode Cnt Score Error Units
ForLoop.foreach thrpt 25 792.805 ± 53.592 ops/s
ForLoop.fori thrpt 25 830.634 ± 7.772 ops/s
ForLoop.foropt thrpt 25 819.431 ± 24.805 ops/s
fori确实比foreach快, 然而, 只快了 ,而且考虑到foreach的Error(误差)有 之大,这 有多大意义也不好说.
不过有一点让人意外的是,fori 提取出size()
方法, 居然比直接fori要慢, 看来以后就不需要在意是否动size了.
ArrayList构造器指定size可以减少扩容时间
这个应该是真的, 但是具体能减少多少,这事不好说,我们来测一下.
public class ArrayListInit {
@Benchmark
@Fork(1)
@Measurement(batchSize = 100)
@Warmup(batchSize = 100)
public void nosize() {
ArrayList<Integer> arr = new ArrayList<>();
for (int i = 0; i < Short.MAX_VALUE; i++) {
arr.add(i);
}
}
@Benchmark
@Fork(1)
@Measurement(batchSize = 100)
@Warmup(batchSize = 100)
public void withsize() {
ArrayList<Integer> arr = new ArrayList<>(Short.MAX_VALUE);
for (int i = 0; i < Short.MAX_VALUE; i++) {
arr.add(i);
}
}
}
(结果仅供娱乐, 这玩意跑着太慢了, 这里调小了轮次,不然今天这文写不完了)
Benchmark Mode Cnt Score Error Units
ArrayListInit.nosize thrpt 5 51.515 ± 1.052 ops/s
ArrayListInit.withsize thrpt 5 72.811 ± 3.868 ops/s
3万级别的添加, 看起来有构造器的快了40%左右.
循环中用StringBuilder拼接字符串比用+
快
To increase the performance of repeated string concatenation, a Java compiler may use the StringBuffer
class or a similar technique to reduce the number of intermediateString
objects that are created by evaluation of an expression.https://docs.oracle.com/javase/specs/jls/se7/html/jls-15.html#jls-15.18.1
大家都知道, 在Java7以后, 编译器会自动用StringBuilder
来优化+
拼接的字符串.但是有一个例外, for循环里拼接的会反复生成StringBuilder
导致速度偏慢.
今天来验证下是不是真的, 这个会慢多少.另外,同时试一下,用StrinBuffer
会慢还是会快.
public class StringJoin {
@Benchmark
@Fork(1)
@Measurement(iterations = 1,batchSize = 100)
@Warmup(iterations = 1, batchSize = 100)
public void add() {
String s= "";
for (int i = 0; i < Short.MAX_VALUE; i++) {
s+=i;
}
}
@Benchmark
@Fork(1)
@Measurement(iterations = 1,batchSize = 100)
@Warmup(iterations = 1, batchSize = 100)
public void sb() {
StringBuilder sb= new StringBuilder();
for (int i = 0; i < Short.MAX_VALUE; i++) {
sb.append(i);
}
}
@Benchmark
@Fork(1)
@Measurement(iterations = 1,batchSize = 100)
@Warmup(iterations = 1, batchSize = 100)
public void bf() {
StringBuffer sb= new StringBuffer();
for (int i = 0; i < Short.MAX_VALUE; i++) {
sb.append(i);
}
}
}
结果很明显,直接add
确实是挺慢的,这都有几个数量级的差别了.
Benchmark Mode Cnt Score Error Units
StringJoin.add thrpt 0.029 ops/s
StringJoin.bf thrpt 17.942 ops/s
StringJoin.sb thrpt 18.073 ops/s
StringBuilder 和StringBuffer居然一样快?
刚刚的实验里, StringBuffer基本和StringBuilder一个速度.
那就不对了,都知道StringBuffer因为有锁会慢,才出的StringBuilder这个单线程版本.前阵子还专门写过一篇 为什么StringBuilder是线程不安全的? 来聊这个.这是为什么?
其实是这样的,StringBuffer有锁, 跟锁很慢. 这是两件事
-
StringBuilder是从去掉锁这个角度解决问题的.这个改动发生在1.5 -
还有另一个思路,是让锁不再慢. synchronized
的优化,发生在1.6以后.
这两个优化,分别从两个方向解决了StringBuffer慢这个问题.
下面试一下,通过-UseBiasedLocking关掉优化,来对比下两种情况下StringBuffer的性能差别.
public class StringBufferLock {
@Benchmark
@Fork(value = 1,jvmArgsAppend = "-XX:+UseBiasedLocking")
@Measurement(iterations = 1,batchSize = 100)
@Warmup(iterations = 1, batchSize = 100)
public void biased() {
StringBuffer sb= new StringBuffer();
for (int i = 0; i < Short.MAX_VALUE; i++) {
sb.append(i);
}
}
@Benchmark
@Fork(value = 1,jvmArgsAppend = "-XX:-UseBiasedLocking")
@Measurement(iterations = 1,batchSize = 100)
@Warmup(iterations = 1, batchSize = 100)
public void nobiased() {
StringBuffer sb= new StringBuffer();
for (int i = 0; i < Short.MAX_VALUE; i++) {
sb.append(i);
}
}
}
这个结果不太准,不过基本能看出来, 关了偏向锁定的实现基本差了一半吞吐.
Benchmark Mode Cnt Score Error Units
StringBufferLock.biased thrpt 14.944 ops/s
StringBufferLock.nobiased thrpt 8.801 ops/s
现在的StringBuffer能做到和StringBuilder一样快,这里面就有锁优化和偏向锁定(biased locking)的功劳了.
对了, 如果不熟悉偏向锁定
这一技术的话,报告一个好消息.偏向锁定Java15以后JEP 374废除了[3] ,可以不用背相关面试题了.
伪共享(False Sharing)
最后一个实验,来玩下常见的伪共享问题.
计算机CPU访问内存,是非常慢的.所以中间加了L1,L2,L3
等多层缓存(Cache)
,来加快访问.然而有缓存,就有失效问题.
Cache
的读取粒度是缓存行(Cache Line)
.如果两个变量位于同一个cacheline
,一个变量就会遇到因另一个变量写入而导致的缓存失效, 拖慢访问.这个就是伪共享.
这个问题解决起来也很简单, 让两个变量分开住就好,俗称padding.
准备两组值a
和b
.
-
a
是只读的, 永远是0, 理论上读取应该非常快. -
b
是在不断变化的.
表面看,理论上a绝对不会被b的写入所影响.但是如果二者位于同一个缓存行,a
的读取会被伪共享所影响,明显变慢.
现在,给一组加上从这篇文章学到的@Contended.有了这个注解,JVM会自动进行padding(填充)一些字节
,让两个变量分开住,不再位于同一个缓存行.
下面可以来试试了,有和没有伪共享,性能差多少.
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 1, time = 1)
@Fork(jvmArgsAppend = "-XX:-RestrictContended")
public class FalseSharing {
@Benchmark
@Group("sharing")
public int r(Sharing s) {
return s.a;
}
@Benchmark
@Group("sharing")
public void w(Sharing s) {
s.b++;
}
@Benchmark
@Group("padding")
public int r(Padding s) {
return s.a;
}
@Benchmark
@Group("padding")
public void w(Padding s) {
s.b++;
}
@State(Scope.Group)
public static class Sharing {
int a;
int b;
}
@State(Scope.Group)
public static class Padding {
@Contended
int a;
@Contended
int b;
}
}
分两组来运行这个测试, padding组
和sharing组
,运行结果如下
Benchmark Mode Cnt Score Error Units
FalseSharing.padding thrpt 5 858755339.504 ± 20817646.761 ops/s
FalseSharing.padding:r thrpt 5 400798196.148 ± 15187778.989 ops/s
FalseSharing.padding:w thrpt 5 457957143.356 ± 6560755.220 ops/s
FalseSharing.sharing thrpt 5 550583294.132 ± 26454593.710 ops/s
FalseSharing.sharing:r thrpt 5 70995374.633 ± 14013970.477 ops/s
FalseSharing.sharing:w thrpt 5 479587919.499 ± 34979714.531 ops/s
这里可以看到,sharing组,读取速度明显慢的不像话,他可只是在读变量啊,确实发生了false sharing
.
padding组因为 @Contended
注解的存在, 自动进行了padding
操作, 两个变量不再共享同一个缓存行,明显读取速度提高了一个数量级.
后话
看到这, 基本上JMH
的基础用法应该就算是入门了.如果想进一步熟悉, 还是直接看github, 上面有很多详实专业的例子.
另外, 本文的测试参数都是瞎写的,严重不准,仅供娱乐.
参考资料
作为一个标尺存在: https://www.merriam-webster.com/dictionary/benchmark
[2]
Java Microbenchmark Harness : https://github.com/openjdk/jmh
[3]
偏向锁定Java15以后JEP 374废除了: https://openjdk.java.net/jeps/374
原文始发于微信公众号(K字的研究):如何使用JMH进行简单的benchmark?
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/24777.html