上一篇文章介绍了基准测试的基本概念以及 Java性能测试工具 JMH 的简单使用。这一篇文章将通过实例详细介绍 JMH 的使用方法,并验证几个提高程序性能的方式是否正确并给出作者的结论。
字符串拼接基准测试Java 中有这样一条优化建议,在循环中使用”+“号拼接字符串会带来很大的性能损失,应该使用StringBuilder。
这样的建议是否准确呢?我们可以设计基准测试来验证,使用的当然是我们的主角 JMH,下面是代码实现 (源码可以在 GitHub 上查看:地址 )
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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 import java.util.concurrent.TimeUnit;import org.openjdk.jmh.annotations.Benchmark;import org.openjdk.jmh.annotations.BenchmarkMode;import org.openjdk.jmh.annotations.Mode;import org.openjdk.jmh.annotations.OutputTimeUnit;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;@BenchmarkMode (Mode.AverageTime)@OutputTimeUnit (TimeUnit.MILLISECONDS)public class StringAppendBenchmarkTenK { public static final int TEN_K = 10000 ; @Benchmark public String stringAddBenchmark () { String targetString = "" ; for (int i = 0 ; i < TEN_K; i++) { targetString += "hello" ; } return targetString; } @Benchmark public String stringBuilderBenchmark () { StringBuilder sb = new StringBuilder(); for (int i = 0 ; i < TEN_K; i++) { sb.append("hello" ); } return sb.toString(); } @Benchmark public String stringBufferBenchmark () { StringBuffer sb = new StringBuffer(); for (int i = 0 ; i < TEN_K; i++) { sb.append("hello" ); } return sb.toString(); } public static void main (String[] args) throws RunnerException { Options opt = new OptionsBuilder() .include(StringAppendBenchmarkTenK.class.getSimpleName()) .forks(1 ) .build(); new Runner(opt).run(); } }
可以看到有几个注解:
@BenchmarkMode 代表基准测试模式,包含
Throughput
模式 :吞吐量模式,测试单位时间内可以执行测试方法的次数(默认1秒)Average Time
模式:平均时间模式,测试平均每次操作需要多长时间,它的值等于 Throughput
模式的倒数Sample Time
模式:时间取样模式,测试单位时间运行,自动取样执行的时间进行测量,其结果包含统计信息,比如 P99 、数据分布情况等等Single Shot Time
模式:测试单次方法运行的时间,没有 预热阶段,适合测试冷启动所需要的时间All
模式:同时使用以上所有模式@Benchmark 代表这个方法是一个基准测试用例,作为测试用例的入口,类似JUnit 中 @Test 注解
@OutputTimeUnit 设置输出数据的单位,此处是毫秒,加上AverageTime 模式,最后的单位是 ms/op:每次操作需要多少毫秒
测试结果下面是在我在本地运行的结果:
1 2 3 4 Benchmark Mode Cnt Score Error Units StringAppendBenchmarkTenK.stringAddBenchmark avgt 25 82.590 ± 14.824 ms/op StringAppendBenchmarkTenK.stringBufferBenchmark avgt 25 0.127 ± 0.005 ms/op StringAppendBenchmarkTenK.stringBuilderBenchmark avgt 25 0.146 ± 0.010 ms/op
我们可以看到 stringAddBenchmark 代表简单Sting 相加的方式,使用平均时间模式,平均每次执行需要82.590毫秒,误差在 ± 14.824 ms。其他两个的意义与之类似。
结果分析从结果中我们可以看到,
使用简单的字符串相加比使用 StringBuffer 和 StringBuilder 慢了两个数量级; StringBuilder 比 StringBuffer要慢一些; 结果 1 的原因是:String 在字符串拼接时,每次循环会创建新的 StringBuffer 对象(不是 String 对象),然后把原来的对象销毁,而 StringBuffer / StringBuilder 在初始化时预留了一定的空间,在调用 append 方法时只有在预留空间不足时才会发生数组拷贝。
结果 2 的原因是:StringBuffer 是线程安全的,append 方法是加锁的;StringBuilder 是非线程安全的。两个类除了在线程安全上的区别,其他几乎没有任何差别。
1 2 3 4 5 6 7 8 9 10 11 public StringBuilder append (String str) { super .append(str); return this ; }public synchronized StringBuffer append (String str) { toStringCache = null ; super .append(str); return this ; }
展望分析至此,我们还可以得出一个结论,**尽管在没有多线程竞争的情况下,加锁仍会损失一部分性能。**这个结论可以设计另一个基准测试来验证,读者有兴趣可以自己设计。
线程安全的 Long 类型说起线程安全的 long 类型的实现,不难想到有使用悲观锁锁synchronized 、乐观锁 AtomicLong 这两种实现,有没有性能更高的实现呢?肯定是有的,java 8 中发布的 LongAdder 就是为替代 AtomicLong 而存在的。
LongAdder 原理,简单来讲就是在多线程竞争激烈的情况下,LongAdder 将维护的值分散到多个段中,来减少CAS的重试,当需要获得结果时,只需要把各个段相加就可以了(类比 ConcurrentHashMap 分段锁的实现)。与 AtomicLong 多线程 CAS 更新单个值相比,理论上性能会有提升。
下面是我设计的基准测试的用例(源码可以在 GitHub 上查看:地址 ),用来验证线程安全Long类型的不同实现。
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 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 import java.util.concurrent.TimeUnit;import java.util.concurrent.atomic.AtomicLong;import java.util.concurrent.atomic.LongAdder;import org.openjdk.jmh.annotations.Benchmark;import org.openjdk.jmh.annotations.BenchmarkMode;import org.openjdk.jmh.annotations.Fork;import org.openjdk.jmh.annotations.Level;import org.openjdk.jmh.annotations.Measurement;import org.openjdk.jmh.annotations.Mode;import org.openjdk.jmh.annotations.OutputTimeUnit;import org.openjdk.jmh.annotations.Scope;import org.openjdk.jmh.annotations.Setup;import org.openjdk.jmh.annotations.State;import org.openjdk.jmh.annotations.TearDown;import org.openjdk.jmh.annotations.Threads;import org.openjdk.jmh.annotations.Warmup;import org.openjdk.jmh.runner.RunnerException;import com.technologiesinsight.jmh.helper.LunchHelper;@BenchmarkMode (Mode.AverageTime)@OutputTimeUnit (TimeUnit.MILLISECONDS)@State (Scope.Benchmark)@SuppressWarnings ("unused" )public class ThreadSafeLong { private static final Integer LOOP = 10000000 ; private final Object lock = new Object(); private AtomicLong atomicLong; private LongAdder longAdder; private long primitiveLong; @Setup (Level.Iteration) public void setUp () { this .atomicLong = new AtomicLong(); this .longAdder = new LongAdder(); this .primitiveLong = 0L ; } @Benchmark @Threads (2 ) public long testPrimitiveLongIncrement () { for (int i = 0 ; i < LOOP; i++) { synchronized (lock) { primitiveLong = primitiveLong + 1 ; } } return primitiveLong; } @Benchmark @Threads (2 ) public long testAtomicLongIncrement () { for (int i = 0 ; i < LOOP; i++) { atomicLong.incrementAndGet(); } return atomicLong.get(); } @Benchmark @Threads (2 ) public long testLongAdderIncrement () { for (int i = 0 ; i < LOOP; i++) { longAdder.increment(); } return longAdder.longValue(); } @TearDown (Level.Iteration) public void tearDown () { long atomicResult = atomicLong.get(); long longAdderResult = longAdder.longValue(); System.out.println(String.format("primitiveLongResult is %s,atomicResult is :%s;longAdderResult is %s" , primitiveLong, atomicResult, longAdderResult)); } public static void main (String[] args) throws RunnerException { LunchHelper.lunchBenchmark(ThreadSafeLong.class); } }
相比于上一个案例,我们发现了几个关于 JMH 的“新面孔”
@State(Scope.Benchmark) :有时候测试用例中需要维护一些”状态“(测试用不是一个”纯函数“),”状态“的变化可能会影响测试的结果。所以需要由JMH 管理这些状态,并显示的声明这些状态的声明周期(有效范围)。状态生命周期分为三类,由State的参数指定:Thread
每一个线程创建自己的状态对象。因此共享对象不会有线程安全问题。Group
组内共享状态对象。不同分组可以使用 @Group(“groupName”) 指定Benchmark
一次迭代运行中所有的线程共享状态对象。 @Setup(Level.Iteration) :用于执行基准测试前执行一些操作,比如初始化等等。参数 Level表示改方法说明时候执行:Trial
每次一个进程完整运行一遍测试用例之前执行:包括 warmUp 和 正式执行阶段Iteration
每次迭代前会执行。一次完整的运行包含多次迭代过程,每次迭代运行一次测试的方法。Invocation
每次方法调用前会执行。 @TearDown : 类似@Setup ,在测试后执行 @Thread :同时执行的线程数,用于线程下的基准测试 注释部分 @Measurement 等:用于测试用例开发阶段,提高执行速度,正式测试前需要注释掉 LunchHelper.lunchBenchmark(ThreadSafeLong.class)
JMH 启动助手 测试结果1 2 3 4 Benchmark Mode Cnt Score Error Units ThreadSafeLong.testAtomicLongIncrement avgt 25 383.194 ± 23.359 ms/op ThreadSafeLong.testLongAdderIncrement avgt 25 108.105 ± 2.911 ms/op ThreadSafeLong.testPrimitiveLongIncrement avgt 25 908.964 ± 29.782 ms/op
通过基准测试结果我们可以看到,testAtomicLongIncrement 代表使用 Atomic 类完成自增,运行 25 次后,所得到每次运行平均时间是383.194 ms,误差在± 23.359ms。其他结果的意义与之类似。
分析从结果中可以看到,在两个线程竞争同一个 long 类型的情况下,LongAdder 性能最好名副其实,AtomicLong 次之,使用 synchronized 加锁性能最差。
在日常开发中,我们可以尝试使用 LongAdder 代替 AtomicInteger 和加锁的方式
展望通过这个测试案例,我们可以分析出 JMH 测试框架本身的性能损耗,欢迎有兴趣的读者留言交流。
其他测试案例源码 中还有其他一些测试案例,比如
常见几个Map的测试:位于 com.technologiesinsight.jmh.MapBenchmark 展望很多公司编程对测试环节于不太重视,追求的是功能快速上线。这样的做法短期内可以提高开发速度,但如果从长远来看,单元测试,基准测试等测试,会减少后期维护、功能扩展的成本;对于开发者来说,掌握基本的测试理论和实践是一项基本的能力。