关于java:为什么if(variable1%variable2 == 0)效率低下?

Why is if (variable1 % variable2 == 0) inefficient?

我是JAVA新手,昨晚运行了一些代码,这真的困扰着我。我正在构建一个简单的程序来显示for循环中的每个x输出,当我使用modules作为variable % variablevariable % 5000或什么时,我注意到性能有了很大的下降。有人能给我解释一下为什么会这样,是什么造成的吗?所以我可以更好…

这是"有效"代码(抱歉,如果我有一点语法错误,我现在不在有代码的计算机上)

1
2
3
4
5
6
7
8
long startNum = 0;
long stopNum = 1000000000L;

for (long i = startNum; i <= stopNum; i++){
    if (i % 50000 == 0) {
        System.out.println(i);
    }
}

这里是"低效代码"

1
2
3
4
5
6
7
8
9
long startNum = 0;
long stopNum = 1000000000L;
long progressCheck = 50000;

for (long i = startNum; i <= stopNum; i++){
    if (i % progressCheck == 0) {
        System.out.println(i);
    }
}

请注意,我有一个日期变量来测量差异,一旦它足够长,第一个变量需要50毫秒,而另一个变量需要12秒或类似的时间。如果您的PC比我的效率高或不高,您可能需要增加stopNum或减少progressCheck

我在网上寻找这个问题,但我找不到答案,也许我问得不对。

编辑:我没想到我的问题会如此受欢迎,我很欣赏所有的答案。我确实在所用的每一半时间上执行了一个基准测试,而效率低下的代码花费了相当长的时间,1/4秒对10秒。当然,他们使用的是println,但是他们都在做同样的工作,所以我不认为这会造成很大的偏差,特别是因为差异是可重复的。至于答案,既然我是爪哇人,我会让投票决定现在哪一个答案是最好的。我想在星期三之前挑一个。

编辑2:我今晚要做另一个测试,它不是取模,而是增加一个变量,当它到达progresscheck时,它将执行一个,然后将该变量重置为0。对于第三个选项。

编辑3.5:

我使用了此代码,下面我将显示我的结果。谢谢大家的帮助!我还尝试将long的短值与0进行比较,所以我所有的新检查都是"65536"次的,使其重复次数相等。

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
public class Main {


    public static void main(String[] args) {

        long startNum = 0;
        long stopNum = 1000000000L;
        long progressCheck = 65536;
        final long finalProgressCheck = 50000;
        long date;

        // using a fixed value
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if (i % 65536 == 0) {
                System.out.println(i);
            }
        }
        long final1 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        //using a variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                System.out.println(i);
            }
        }
        long final2 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();

        // using a final declared variable
        for (long i = startNum; i <= stopNum; i++) {
            if (i % finalProgressCheck == 0) {
                System.out.println(i);
            }
        }
        long final3 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        // using increments to determine progressCheck
        int increment = 0;
        for (long i = startNum; i <= stopNum; i++) {
            if (increment == 65536) {
                System.out.println(i);
                increment = 0;
            }
            increment++;

        }

        //using a short conversion
        long final4 = System.currentTimeMillis() - date;
        date = System.currentTimeMillis();
        for (long i = startNum; i <= stopNum; i++) {
            if ((short)i == 0) {
                System.out.println(i);
            }
        }
        long final5 = System.currentTimeMillis() - date;

                System.out.println(
               "
fixed ="
+ final1 +" ms" +"
variable ="
+ final2 +" ms" +"
final variable ="
+ final3 +" ms" +"
increment ="
+ final4 +" ms" +"
Short Conversion ="
+ final5 +" ms");
    }
}

结果:

  • 固定=874 ms(通常在1000 ms左右,但由于功率为2,所以速度更快)
  • 变量=8590 ms
  • 最终变量=1944 ms(使用50000时为~1000 ms)
  • 增量=1904 ms
  • 短转换=679 ms

不足为奇,由于缺乏划分,短转换比"快"转换快23%。这很有趣。如果您需要每隔256次(或大约256次)显示或比较某些内容,您可以这样做,并使用

1
if ((byte)integer == 0) {'Perform progress check code here'}

最后一个有趣的注意事项是,在"最终声明变量"的65536(不是一个漂亮的数字)上使用模数,其速度是固定值的一半(较慢)。以前的基准测试速度都差不多。


您正在测量OSR(堆栈替换)存根。

OSR存根是编译方法的一个特殊版本,专门用于在方法运行时将执行从解释模式传输到编译代码。

OSR存根不像常规方法那样优化,因为它们需要与解释帧兼容的帧布局。我已经在以下答案中显示了这一点:1,2,3。

这里也发生了类似的事情。虽然"低效代码"运行一个长循环,但该方法是专门为循环内部的堆栈替换而编译的。状态从解释帧传输到OSR编译方法,该状态包括progressCheck局部变量。此时,JIT不能用常量替换变量,因此不能应用某些优化,如强度降低。

特别是这意味着jit不会用乘法代替整数除法。(请看为什么gcc在实现整数除法时使用了一个奇怪数字的乘法?对于来自提前编译器的ASM技巧,如果启用了这些优化,则当值是内联/常量传播后的编译时常量时。%表达式中的整型文字权也会被gcc -O0优化,类似于这里,即使在OSR存根中,它也会被抖动优化。)

但是,如果您多次运行同一个方法,第二次和随后的运行将执行完全优化的常规(非OSR)代码。以下是证明该理论的基准(使用JMH进行基准测试):

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
@State(Scope.Benchmark)
public class Div {

    @Benchmark
    public void divConst(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % 50000 == 0) {
                blackhole.consume(i);
            }
        }
    }

    @Benchmark
    public void divVar(Blackhole blackhole) {
        long startNum = 0;
        long stopNum = 100000000L;
        long progressCheck = 50000;

        for (long i = startNum; i <= stopNum; i++) {
            if (i % progressCheck == 0) {
                blackhole.consume(i);
            }
        }
    }
}

结果是:

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
# Benchmark: bench.Div.divConst

# Run progress: 0,00% complete, ETA 00:00:16
# Fork: 1 of 1
# Warmup Iteration   1: 126,967 ms/op
# Warmup Iteration   2: 105,660 ms/op
# Warmup Iteration   3: 106,205 ms/op
Iteration   1: 105,620 ms/op
Iteration   2: 105,789 ms/op
Iteration   3: 105,915 ms/op
Iteration   4: 105,629 ms/op
Iteration   5: 105,632 ms/op


# Benchmark: bench.Div.divVar

# Run progress: 50,00% complete, ETA 00:00:09
# Fork: 1 of 1
# Warmup Iteration   1: 844,708 ms/op          <-- much slower!
# Warmup Iteration   2: 105,893 ms/op          <-- as fast as divConst
# Warmup Iteration   3: 105,601 ms/op
Iteration   1: 105,570 ms/op
Iteration   2: 105,475 ms/op
Iteration   3: 105,702 ms/op
Iteration   4: 105,535 ms/op
Iteration   5: 105,766 ms/op

由于OSR存根的编译效率很低,因此divVar的第一次迭代确实要慢得多。但只要方法从头重新运行,就会执行新的无约束版本,它利用了所有可用的编译器优化。


在@phuclv comment的后续工作中,我检查了jit1生成的代码,结果如下:

对于variable % 5000(按常量除):

1
2
3
4
5
6
7
8
9
10
11
mov     rax,29f16b11c6d1e109h
imul    rbx
mov     r10,rbx
sar     r10,3fh
sar     rdx,0dh
sub     rdx,r10
imul    r10,rdx,0c350h    ; <-- imul
mov     r11,rbx
sub     r11,r10
test    r11,r11
jne     1d707ad14a0h

对于variable % variable

1
2
3
4
5
6
7
8
9
10
11
mov     rax,r14
mov     rdx,8000000000000000h
cmp     rax,rdx
jne     22ccce218edh
xor     edx,edx
cmp     rbx,0ffffffffffffffffh
je      22ccce218f2h
cqo
idiv    rax,rbx           ; <-- idiv
test    rdx,rdx
jne     22ccce218c0h

因为除法总是比乘法花费更长的时间,所以最后一个代码段的性能要差一些。

Java版本:

1
2
3
java version"11" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11+28)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11+28, mixed mode)

1-使用的虚拟机选项:-XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,src/java/Main.main


正如其他人所指出的,一般的模量运算需要进行除法。在某些情况下,除法可以用乘法代替(由编译器)。但与加减法相比,这两种方法都比较慢。因此,可以通过以下几方面来期望最佳性能:

1
2
3
4
5
6
7
8
9
10
long progressCheck = 50000;

long counter = progressCheck;

for (long i = startNum; i <= stopNum; i++){
    if (--counter == 0) {
        System.out.println(i);
        counter = progressCheck;
    }
}

(作为一个小的优化尝试,我们在这里使用一个预减量计数器,因为在许多体系结构中,与0相比,在算术运算之后立即花费0个指令/CPU周期,因为前面的运算已经适当地设置了ALU的标志。但是,即使编写if (counter++ == 50000) { ... counter = 0; },一个合适的优化编译器也会自动进行优化。

注意,通常你并不真正想要/不需要模,因为你知道你的循环计数器(i或任何只增加了1的东西,你真的不关心模给你的实际余数,只是看看增加一个计数器是否达到某个值。

另一个"诀窍"是使用两个值/限值的幂,例如progressCheck = 1024;。模A的2次幂可以通过按位的and快速计算,即if ( (i & (1024-1)) == 0 ) {...}。这也应该相当快,并且在某些体系结构上可能比上面的显式counter更好。


看到上述代码的性能,我也感到惊讶。所有这些都是关于编译器根据声明的变量执行程序所花费的时间。在第二个(低效)示例中:

1
2
3
4
5
for (long i = startNum; i <= stopNum; i++) {
    if (i % progressCheck == 0) {
        System.out.println(i)
    }
}

您正在两个变量之间执行模数运算。在这里,编译器必须检查stopNumprogressCheck的值,以便在每次迭代后转到这些变量的特定内存块,因为它是一个变量,其值可能会更改。

这就是为什么在每次迭代之后,编译器都会去内存位置检查变量的最新值。因此,在编译时,编译器无法创建有效的字节代码。

在第一个代码示例中,您在变量和常量数值之间执行模数运算符,该常量数值在执行过程中不会更改,编译器不需要从内存位置检查该数值的值。这就是编译器能够创建高效字节代码的原因。如果您声明progressCheckfinalfinal static变量,则在运行时/编译时编译器知道它是最终变量,其值不会更改,则编译器将progressCheck替换为50000代码:

1
2
3
4
5
for (long i = startNum; i <= stopNum; i++) {
    if (i % 50000== 0) {
        System.out.println(i)
    }
}

现在您可以看到这个代码看起来也像第一个(高效的)代码示例。第一个代码的性能和前面提到的这两个代码都将有效地工作。任何一个代码示例的执行时间都不会有太大的差异。