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++;
}
相關文章
相關標籤/搜索