引言
随着JDK的发展以及JIT的不断优化,我们很多时候都可以写读起来易读但是看上去性能不高的代码了,编译器会帮我们优化代码。之前大学里面学单片机的时候,由于内存以及处理器性能都极其有限(可能很多时候考虑内存的限制优先于处理器),所以很多时候,利用位运算来节约空间或者提高性能,那么这些优秀的思想,放到目前的Java中,是否还有必要这么做呢?我们逐一思考与验证下(其实这也是一个关于Premature optimization的界定的思考)
1. 乘法与左移位
左移一位,相当于乘以2,左移n位,相当于乘以2的n次方。
1 << 1 == 1 * 2 //true 1 << n == 1 * pow(2, n) // true public int pow(int i, int n) { assert n >= 0; int result = 1; for (int i = 0; i < n; i++) { result *= i; } return result; }
看上去,移位应该比乘法性能快。那么JIT与JVM虚拟机是否做了一些优化呢?优化分为两部分,一个是编译器优化,另一个是处理器优化。我们先来看看字节码是否一致判断是否有编译优化,例如直接将乘以2优化成左移一位,来编写两个函数:
public void multiply2_1() { int i = 1; i = i << 1; } public void multiply2_2() { int i = 1; i *= 2; }
编译好之后,用javap -c
来看下编译好的class文件,字节码是:
public void multiply2_1(); Code: 0: iconst_1 1: istore_1 2: iload_1 3: iconst_1 4: ishl 5: istore_1 6: return public void multiply2_2(); Code: 0: iconst_1 1: istore_1 2: iload_1 3: iconst_2 4: imul 5: istore_1 6: return
可以看出左移是ishl
,乘法是imul
,从字节码上看编译器并没有优化。那么在执行字节码转换成处理器命令是否会优化呢?是会优化的,在底层,乘法其实就是移位,但是并不是简单地左移
我们来使用jmh验证下,添加依赖:
<dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-core</artifactId> <version>1.22</version> </dependency> <dependency> <groupId>org.openjdk.jmh</groupId> <artifactId>jmh-generator-annprocess</artifactId> <version>1.22</version> </dependency> <!-- https://mvnrepository.com/artifact/site.ycsb/core --> <dependency> <groupId>site.ycsb</groupId> <artifactId>core</artifactId> <version>0.17.0</version> </dependency>
实现思路:
- 被乘数的选择:被乘数固定为1,或者是一个极小值或者极大值或者是稀疏值(转换成2进制很多位是0),测试结果没啥太大的参考意义,所以我们选择2的n次方减某一数字作为被乘数
- 乘数生成的性能损耗:乘数是2的随机n次方,生成这个的方式要一致,我们这里要测试的仅仅是移位还有乘法运算速度,和实现复杂度没有关系。 实现代码:
@Benchmark @Warmup(iterations = 0) @Measurement(iterations = 300) public void multiply2_n_shift_not_overflow(Generator generator) { int result = 0; int y = 0; for (int j = 0; j < generator.divide.length; j++) { //被乘数x为2^n - j int x = generator.divide[j] - j; int ri = generator.divide.length - j - 1; y = generator.divide[ri]; result += x * y; //为了和移位测试保持一致所以加上这一步 result += y; } } @Benchmark @Warmup(iterations = 0) @Measurement(iterations = 300) public void multiply2_n_mul_not_overflow(Generator generator) { int result = 0; int y = 0; for (int j = 0; j < generator.divide.length; j++) { int x = generator.divide[j] - j; int ri = generator.divide.length - j - 1; //为了防止乘法多了读取导致性能差异,这里虽然没必要,也读取一下 y = generator.divide[ri]; result += x << ri; //为了防止虚拟机优化代码将上面的给y赋值踢出循环,加上下面这一步 result += y; } }
测试结果:
Benchmark Mode Cnt Score Error Units BitUtilTest.multiply2_n_mul_not_overflow thrpt 300 35882831.296 ± 48869071.860 ops/s BitUtilTest.multiply2_n_shift_not_overflow thrpt 300 59792368.115 ± 96267332.036 ops/s
可以看出,左移位相对于乘法还是有一定性能提升的
2. 除法和右移位
这个和乘法以及左移位是一样的.直接上测试代码:
@Benchmark @Warmup(iterations = 0) @Measurement(iterations = 300) public void divide2_1_1(Generator generator) { int result = 0; for (int j = 0; j < generator.divide.length; j++) { int l = generator.divide[j]; result += Integer.MAX_VALUE / l; } } @Benchmark @Warmup(iterations = 0) @Measurement(iterations = 300) public void divide2_1_2(Generator generator) { int result = 0; for (int j = 0; j < generator.divide.length; j++) { int l = generator.divide[j]; result += Integer.MAX_VALUE >> j; } }
结果:
Benchmark Mode Cnt Score Error Units BitUtilTest.divide2_n_div thrpt 300 10219904.214 ± 5787618.125 ops/s BitUtilTest.divide2_1_shift thrpt 300 44536470.740 ± 113360206.643 ops/s
可以看出,右移位相对于除法还是有一定性能提升的
3. “取余”与“取与”运算
对于2的n次方取余,相当于对2的n次方减一取与运算,n为正整数。为什么呢?通过下图就能很容易理解:
十进制中,对于10的n次方取余,直观来看就是:
其实就是将最后n位取出,就是余数。 对于二进制,是一样的: