Java中对于位运算的优化以及运用与思考

引言

随着JDK的发展以及JIT的不断优化,咱们不少时候均可以写读起来易读可是看上去性能不高的代码了,编译器会帮咱们优化代码。以前大学里面学单片机的时候,因为内存以及处理器性能都极其有限(可能不少时候考虑内存的限制优先于处理器),因此不少时候,利用位运算来节约空间或者提升性能,那么这些优秀的思想,放到目前的Java中,是否还有必要这么作呢?咱们逐一思考与验证下(其实这也是一个关于Premature optimization的界定的思考)java

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>

实现思路:ide

  1. 被乘数的选择:被乘数固定为1,或者是一个极小值或者极大值或者是稀疏值(转换成2进制不少位是0),测试结果没啥太大的参考意义,因此咱们选择2的n次方减某一数字做为被乘数
  2. 乘数生成的性能损耗:乘数是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次方取余,直观来看就是: image 其实就是将最后n位取出,就是余数。 对于二进制,是同样的: image 这个运算至关于,对于n-1取与: image

这个是一个很经典的位运算运用,普遍用于各类高性能框架。例如在生成缓存队列槽位的时候,通常生成2的n次方个槽位,由于这样在选择槽位的时候,就能够用取与代替取余;java中的ForkJoinPool的队列长度就是定为2的n次方;netty中的缓存池的叶子节点都是2的n次方,固然这也是由于是平衡二叉查找树算法的实现。

咱们来看下性能会好多少:

@Benchmark
@Warmup(iterations = 0)
@Measurement(iterations = 300)
public void mod2_n_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 mod2_n_2(Generator generator) {
    int result = 0;
    for (int j = 0; j < generator.divide.length; j++) {
        int l = generator.divide[j];
        result += Integer.MAX_VALUE & (l - 1);
    }
}

结果:

Benchmark                                    Mode  Cnt         Score           Error  Units
BitUtilTest.mod2_n_1                        thrpt  300  10632698.855 ±   5843378.697  ops/s
BitUtilTest.mod2_n_2                        thrpt  300  80339980.989 ±  21905820.262  ops/s

同时,咱们从这里也能够引伸出,判断一个数是不是2的n次方的方法,就是看这个数与这个数减一取与运算看是不是0,若是是,则是2的n次方,n为正整数image

进一步的,奇偶性判断就是看对2取余是否为0,那么就至关于对(2-1)=1取与

4. 求与数字最接近的2的n次方

这个普遍运用于各类API优化,上文中提到,2的n次方是一个好东西。咱们在写框架的不少时候,想让用户传入一个必须是2的n次方的参数来初始化某个资源池,但这样不是那么灵活,咱们能够经过用户传入的数字N,来找出不大于N的最大的2的n次方,或者是大于N的最小的2的N次方。

抽象为比较直观的理解就是,找一个数字最左边的1的左边一个1(大于N的最小的2的N次方),或者是最左边的1(小于N的最大的2的N次方),前提是这个数字自己不是2的n次方。

image

那么,如何找呢?一种思路是,将这个数字最高位1以后的全部位都填上1,最后加一,就是大于N的最小的2的N次方。右移一位,就是小于N的最大的2的N次方。

如何填补呢?能够考虑按位或计算,咱们知道除了0或0=0之外,其余的都是1. 咱们如今有了最左面的1,右移一位,与原来按位或,就至少有了两位是1,再右移两位并按位或,则至少有四位为1。。。以此类推:

image

用代码表示是:

n |= n >>> 1; 
n |= n >>> 2; 
n |= n >>> 4; 
n |= n >>> 8; 
n |= n >>> 16;
n += 1;  //大于N的最小的2的N次方
n = n >>> 1; //小于N的最大的2的N次方

若是有兴趣,能够看一下Java的ForkJoinPool类的构造器,其中的WorkQueue大小,就是经过这样的转换得来的。

5. 交换两个数字

这个在单片机编程中常常会使用这个位运算性质:一个数字异或本身为零,一个数字异或0为本身自己。那么咱们就能够利用这个性质交换两个数字。

假设有数字x,y。 咱们有x^y^y = x^(y^y)= x^0 = x 还有x^y^y^x^y = 0^y = y 那么咱们能够利用:

x = x ^ y;
y = x ^ y; //代入后就是x^y^y
x = x ^ y; //代入后就是x^y^y^x^y

这个方法虽然很巧妙,可是是一种时间换空间的方式; 咱们经常使用的利用另外一个变量实现交换是一种空间换时间的方式,来对比下性能:

@Benchmark
@Warmup(iterations = 0)
@Measurement(iterations = 300)
public int swap_1() {
    int x = Integer.MAX_VALUE, y = Integer.MAX_VALUE / 2;
    int z = x;
    x = y;
    y = z;
    return x + y;
}


@Benchmark
@Warmup(iterations = 0)
@Measurement(iterations = 300)
public int swap_2() {
    int x = Integer.MAX_VALUE, y = Integer.MAX_VALUE / 2;
    x ^= y;
    y ^= x;
    x ^= y;
    return x + y;
}

结果:

Benchmark            Mode  Cnt          Score           Error  Units
BitUtilTest.swap_1  thrpt  300  267787894.370 ± 559479133.393  ops/s
BitUtilTest.swap_2  thrpt  300  265768807.925 ± 387039155.884  ops/s

测试来看,性能差别并不明显,利用位运算减小了空间占用,减小了GC,可是交换减小了cpu运算,可是GC一样是消耗cpu计算,因此,很难界定。目前仍是利用中间变量交换的更经常使用,也更易读一些

6. bit状态位

咱们为了节省空间,尝尝利用一个数字类型(例如long类型)做为状态数,每一位表明一个状态是true仍是false。假设咱们使用long类型,则一个状态数能够最多表示64个属性。代码上通常这么写:

public static class Test {
    //若是你的field是会被并发修改访问,那么最好仍是加上缓存行填充防止false sharing
    @jdk.internal.vm.annotation.Contended
    private long field;

    private static final long SWITCH_1_MASK = 1;
    private static final long SWITCH_2_MASK = 1 << 1;
    private static final long SWITCH_3_MASK = 1 << 2;

    public boolean isSwitch1On() {
        return (field & SWITCH_1_MASK) == 1;
    }

    public void turnOnSwitch1() {
        field |= SWITCH_1_MASK;
    }

    public void turnOffSwitch1() {
        field &= ~SWITCH_1_MASK;
    }
}

这样能节省大量空间,在实际应用中,不少地方作了这种优化。最直接的例子就是,Java对象的对象头:

|-------------------------------------------------------|--------------------|
|                  Mark Word (32 bits)                  |       State        |
|-------------------------------------------------------|--------------------|
| identity_hashcode:25 | age:4 | biased_lock:1 | lock:2 |       Normal       |
|-------------------------------------------------------|--------------------|
|  thread:23 | epoch:2 | age:4 | biased_lock:1 | lock:2 |       Biased       |
|-------------------------------------------------------|--------------------|
|               ptr_to_lock_record:30          | lock:2 | Lightweight Locked |
|-------------------------------------------------------|--------------------|
|               ptr_to_heavyweight_monitor:30  | lock:2 | Heavyweight Locked |
|-------------------------------------------------------|--------------------|
|                                              | lock:2 |    Marked for GC   |
|-------------------------------------------------------|--------------------|

7. 位计数

基于6,有时候咱们想某个状态数里面,有多少个状态是true,就是计算这个状态数里面多少位是1.

比较朴素的方法就是:先判断n的奇偶性,为奇数时计数器增长1,而后将n右移一位,重复上面的步骤,直到移位完毕。

高效一点的方法经过:

  1. n & (n - 1) 能够移除最后一位1 (假设最后一位原本是0, 减一后必为1,0 & 1为 0, 最后一位原本是1,减一后必为0,0 & 1为 0)
  2. 移除了最后一位1以后,计数加1,若是结果不为零,则用结果继续第一步。
int n = Integer.MAX_VALUE;
int count = 0;
while(n != 0) {
    n &= n -1;
    count++;
}
相关文章
相关标签/搜索