多个“if”语句和“if else if”语句在互斥条件下是否存在性能差异?

12

我对Java如何针对互斥条件的多个 "if" 语句进行优化很好奇,但我自己没有分析的知识。这个问题基本上是这个问题的Java版本 "if if" vs "if else if" 的性能差异

我已经看到了针对returnif语句的答案,但是这个问题是关于有互斥条件但不会returnif语句的。

1. 多个if语句

if (x == 0) doSomething();
if (x == 2) doSomething();
if (x == 5) doSomething();

2. 嵌套的if-else语句

if (x == 0) doSomething();
else if (x == 2) doSomething();
else if (x == 5) doSomething();

问题
#1和#2在编译后执行相同的操作吗?
(如果是这样,Java可以优化多复杂的条件语句?)


4
它们生成的Java字节码是不同的,您可以在http://javabytes.io/上进行验证。然而,我怀疑它们会生成相同的JITted代码。 - Brennan Vincent
基于两种方法的时间,似乎if-else即使使用JIT编译器和分支预测也更快。请参见我的答案。 - Daniel Williams
4个回答

4

没有什么比一个经典的时间测试更有说服力了:

long total = 0;
long startTime;
long endTime;

for (int j = 0; j < 10; j++) {
    startTime = System.currentTimeMillis();

    for (int i = 0; i < 100000000; i++) {
        if (i % 3 == 0) total += 1;
        if (i % 3 == 1) total += 2;
        if (i % 3 == 2) total += 3;
    }

    endTime = System.currentTimeMillis();
    System.out.println("If only: " + (endTime - startTime));

    startTime = System.currentTimeMillis();

    for (int i = 0; i < 100000000; i++) {
        if (i % 3 == 0) total += 1;
        else if (i % 3 == 1) total += 2;
        else if (i % 3 == 2) total += 3;
    }

    endTime = System.currentTimeMillis();
    System.out.println("If-else: " + (endTime - startTime));
}
System.out.println(total);

(“total”值是必要的,以防编译器删除整个循环!)
输出:
If only: 215
If-else: 137
If only: 214
If-else: 121
If only: 210
If-else: 120
If only: 211
If-else: 120
If only: 211
If-else: 121
If only: 210
If-else: 121
If only: 210
If-else: 121
If only: 211
If-else: 120
If only: 211
If-else: 120
If only: 211
If-else: 121
3999999980

正如我们所见,即使if条件明显是互斥的,if-else块运行速度仍然更快。由于两个循环需要不同的时间长度,编译后的代码必须针对每个循环进行不同的处理。显然编译器没有对此进行优化。即使JIT或CPU分支预测也无法完全解决这个问题。差距仍然很大。
我的建议:尽可能使用if-else 编辑:我还尝试交换了两个循环,并得到了相同的结果。if-else要快得多。
编辑2:我在整个测试周围添加了一个for循环,以消除任何初始化或热身方面的差异。结果是一样的。

我在我的答案中提到,这种性能问题只在doSomething()是一些简单的操作时才会有影响。我可以说你选择的测试案例就是“简单”的典型代表,所以这种性能问题确实非常严重! - Cort Ammon
1
这回答了这个问题,因为它显示最终的机器码不同(与字节码相对,后者可以通过JIT编译器进一步优化),至少是针对@DanielWilliams正在使用的特定JIT编译器实现。 - Jeck
@sprinter 正确。请参见此链接 - Eugene
@sprinter,相关的问题很有帮助,我没有意识到微基准测试是多么敏感。我取消了对答案的接受,因为我不确定它是否是一个健壮的微基准测试。考虑到它只是比较两个代码块而不是试图获取时间/迭代次数,那么它到底缺少什么呢? - Jeck
交换两个循环,可以解决另一篇关于微基准测试的文章中的规则1、4和5。无论Java代码的初始化、顺序或热身如何,或任何其他编译理论,if-else在公平的实现中始终运行更快。关于JMH测试的帖子显示几乎相同的计时结果。我会用更强大的微基准测试来更新这篇文章。 - Daniel Williams
显示剩余5条评论

2

好的,只有一个适当的JMH测试才能证明某个方法的快慢。当然,前提是如果您真的想知道为什么数字是这样的,您也应该了解底层机器代码。我将把这留给您,并在此测试中仅向您展示一些细节。

最初的回答:

package com.so;

import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Warmup;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

@Warmup(iterations = 5)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Measurement(iterations = 2, time = 2, timeUnit = TimeUnit.SECONDS)
public class IfElseCompare {

    public static void main(String[] args) throws Exception {
        Options opt = new OptionsBuilder()
            .include(IfElseCompare.class.getName())
            .jvmArgs("-ea")
            .build();

        new Runner(opt).run();
    }

    private int resolveValueMultipleIfs(IfElseExecutionPlan plan) {

        int x = -1;

        if (plan.value() == 0) {
            x = 0;
        }

        if (plan.value() == 1) {
            x = 1;
        }

        if (plan.value() == 2) {
            x = 2;
        }

        assert x != -1;
        return x;
    }

    private int resolveValueIfElse(IfElseExecutionPlan plan) {
        int x = -1;
        if (plan.value() == 0) {
            x = 0;
        } else if (plan.value() == 1) {
            x = 1;
        } else if (plan.value() == 2) {
            x = 2;
        }

        assert x != -1;
        return x;
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(1)
    public int multipleIf(IfElseExecutionPlan plan) {
        return resolveValueMultipleIfs(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(1)
    public int ifElse(IfElseExecutionPlan plan) {
        return resolveValueIfElse(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-Xint")
    public int multipleIfsfNoJIT(IfElseExecutionPlan plan) {
        return resolveValueMultipleIfs(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-Xint")
    public int ifElseNoJIT(IfElseExecutionPlan plan) {
        return resolveValueIfElse(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:-TieredCompilation")
    public int multipleIfsC2Only(IfElseExecutionPlan plan) {
        return resolveValueMultipleIfs(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:-TieredCompilation")
    public int ifElseC2Only(IfElseExecutionPlan plan) {
        return resolveValueIfElse(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:TieredStopAtLevel=1")
    public int multipleIfsC1Only(IfElseExecutionPlan plan) {
        return resolveValueMultipleIfs(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1, jvmArgsAppend = "-XX:TieredStopAtLevel=1")
    public int ifElseC1Only(IfElseExecutionPlan plan) {
        return resolveValueIfElse(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1,
        jvmArgsAppend = {
            "-XX:+UnlockExperimentalVMOptions",
            "-XX:+EagerJVMCI",
            "-Dgraal.ShowConfiguration=info",
            "-XX:+UseJVMCICompiler",
            "-XX:+EnableJVMCI"
        })
    public int multipleIfsGraalVM(IfElseExecutionPlan plan) {
        return resolveValueMultipleIfs(plan);
    }

    @Benchmark
    @BenchmarkMode(Mode.AverageTime)
    @Fork(value = 1,
        jvmArgsAppend = {
            "-XX:+UnlockExperimentalVMOptions",
            "-XX:+EagerJVMCI",
            "-Dgraal.ShowConfiguration=info",
            "-XX:+UseJVMCICompiler",
            "-XX:+EnableJVMCI"
        })
    public int ifElseGraalVM(IfElseExecutionPlan plan) {
        return resolveValueIfElse(plan);
    }
}

最初的回答:

以下是结果:

IfElseCompare.ifElse              avgt    2    2.826          ns/op
IfElseCompare.multipleIf          avgt    2    3.061          ns/op

IfElseCompare.ifElseC1Only        avgt    2    3.927          ns/op
IfElseCompare.multipleIfsC1Only   avgt    2    4.397          ns/op

IfElseCompare.ifElseC2Only        avgt    2    2.507          ns/op
IfElseCompare.multipleIfsC2Only   avgt    2    2.428          ns/op

IfElseCompare.ifElseGraalVM       avgt    2    2.587          ns/op
IfElseCompare.multipleIfsGraalVM  avgt    2    2.854          ns/op

IfElseCompare.ifElseNoJIT         avgt    2  232.418          ns/op   
IfElseCompare.multipleIfsfNoJIT   avgt    2  303.371          ns/op

如果您反编译带有多个if条件的版本:最初的回答。
  0x000000010cf8542c: test   %esi,%esi
  0x000000010cf8542e: je     0x000000010cf8544f             ;*ifne {reexecute=0 rethrow=0 return_oop=0}
                                                            ; - com.so.IfElseCompare::resolveValueMultipleIfs@3 (line 21)

  0x000000010cf85430: cmp    $0x1,%esi
  0x000000010cf85433: je     0x000000010cf8545e             ;*if_icmpne {reexecute=0 rethrow=0 return_oop=0}
                                                            ; - com.so.IfElseCompare::resolveValueMultipleIfs@10 (line 25)

  0x000000010cf85435: cmp    $0x2,%esi
  0x000000010cf85438: je     0x000000010cf8546e             ;*if_icmpne {reexecute=0 rethrow=0 return_oop=0}
                                                            ; - com.so.IfElseCompare::resolveValueMultipleIfs@17 (line 29)

一系列的cmp/je- 比较并跳转,这是非常可预期的。

if/else的反编译代码也是相同的(我会让你自己反编译并亲眼看到);使用(java-12)生成的汇编代码:

Original Answer翻译成"最初的回答"

java -XX:+UnlockDiagnosticVMOptions  
     -XX:CICompilerCount=2 
     -XX:-TieredCompilation  
     "-XX:CompileCommand=print,com/so/IfElseCompare.resolveValueMultipleIfs"  
     com.so.IfElseCompare

由于机器码似乎相同,时间上的约10%的变化可能只是噪音吗? - Jeck
@Jeck 很有可能是的。我可能会运行更长时间和更多堆,也许这样可以稍微平衡一下结果。这仍然只是纳秒级别的差异... - Eugene

2
尽管差别微小,但仍然有区别。关键问题是任何步骤是否由能够推断如果x==0,则x==2x==5必须为false的软件来完成。
在Java的字节码级别上,它们通常会产生不同的结果。编译器无需分析差异。 (尤金在相关问题的答案中表明 Sun 的 Java 12 编译器确实足够聪明以在某些情况下优化代码)
即时编译器倾向于非常激进。 它们更可能意识到代码只能通过三个分支中的一个流动并将其优化掉。 但这仍然是工具相关的陈述。 Java语言本身将它们视为不同的。
现实情况是,除非您进行非常紧密的循环,否则这根本不重要。 优化的第一条规则是“剖析,然后再优化”。 至少99%的情况下,没有理由优化这些细节。
具体而言,在您给出的示例中,即使编译器和JIT未能为您优化代码,性能成本也可以忽略不计。 在“平均”CPU上,成功预测的分支大约只有调用函数的十分之一的成本,因此您对这些分支进行了doSomething()调用的事实将使其望尘莫及。 如果额外的调用导致一些额外的分支错误预测,则可能会看到更糟糕的效果,但没有像调用函数那样昂贵的东西。
现在,假设doSomething()实际上是类似x += 1之类的快速占位符,则需要进行剖析以确定正确性。
因此,我的建议是基于哪一个是正确的来编写if/if/ifif/else if/else if。 哪个对于您想要使用的逻辑类型最有意义,哪个就是正确答案。 如果这是一个仅采取一条路径的分支,则建议使用else if。 如果这是一个函数可能在未来执行许多分支的情况,但当前的分支列表恰好是互斥的,则使用if/if/if来传达给读者预期结果。
然后进行剖析。 总是进行剖析。 如果发现此函数是热点,则考虑是否担心if语句的成本。
作为旁注,编译器很难证明它们可以将if转换为else if。它必须对x进行分析,以查看另一个线程是否可能修改它。如果它是局部变量,则没有其他线程可以修改它。但是,如果它是成员变量,则有可能在您的if/if/if块中间修改x,导致它采取两条路径。您可能知道没有人会像这样修改x,但编译器必须在进行此类优化之前证明它,或者至少证明其写入与Java内存模型规则的实现一致。"最初的回答"

@Eugene 对于你运行的测试,它们是相同的。结果是编译器特定的并且取决于x所在的位置。在你的测试中,x始终是一个局部变量。在这种情况下,执行闭包分析以证明另一个线程无法修改x很容易,因为Java保证没有其他线程可以看到线程的局部变量。如果x在别处,那可能不是真的(OP的代码片段没有指定x在哪里)。 - Cort Ammon
@Eugene 我已经编辑过了,指出你的 Java 12 版本在这种情况下足够聪明,可以进行优化。 - Cort Ammon
我不明白你的意思,读取x如何影响if/else语句,当然这是编译器特定的。我也不知道为什么要把内存模型带入这个讨论中。 - Eugene
说实话,我不明白你在谈论什么转换。有cmp/je和可能是cmp/cmove。你脑海中还有其他想法吗? - Eugene
@Eugene 要将 if (cond1) ... if (cond2) 转换为 if(cond1) ... else if (cond2),您必须能够证明假设 cond1cond2 是互斥的是安全的。这涉及到证明在这些条件的评估之间,可以安全地假设 x 不会改变。在某些情况下,这很容易(例如如果 x 是一个局部变量)。在其他情况下,这需要编译器进行更多的工作。 - Cort Ammon
显示剩余3条评论

-1
让我告诉你条件运算符“if()”的工作原理。当你编写if()语句时,它会检查你在“()”中提供的条件的真实性。如果条件失败,则编译器会寻找可用于if()条件失败时的备用语句或代码块。现在,对于这个备用内容,我们使用“else”块。
现在根据你的问题,答案很容易理解。两种方法之间有很大的区别。
1). 多个If语句
if (x == 0) doSomething();
if (x == 2) doSomething();
if (x == 5) doSomething();

在上面的代码中,所有的 if 语句都会被编译器解析,无论是否满足其中任何一个条件。因为它们是分别使用的,没有任何备选部分。
2). 链式 If-else 语句
if (x == 0) doSomething();
else if (x == 2) doSomething();
else if (x == 5) doSomething();

现在在上面的代码中有一个主要的条件检查器(x==0),如果这个失败了,那么就会有其他的替代方案,编译器会检查它们直到找到令人满意的解决方案。

性能问题

在第一种情况下,编译器必须检查每个条件,因为它们都是单独的,这可能需要更多的时间。但在第二种情况下,当if()语句未满足条件时,它只会编译"else if"部分。所以在性能方面它们之间可能会有一点差异。

希望对你有所帮助。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接