为什么 Java 的 switch 枚举在第一次运行时比它的“if”等价物慢得多?

Mr *_*tor 1 java performance enums if-statement switch-statement

为什么 Java 的 switch 枚举在第一次运行时比它的“if”等价物慢得多?

我知道 JVM 需要“预热”才能可靠地测量性能。因此,每个第一次调用都比任何后续调用慢得多。这并不意味着我们无法根据每次首次运行来衡量性能。

测试标准是:

  1. 始终进行新的运行。
  2. 测量执行单个函数的时间(以纳秒为单位),该函数始终根据if语句或switch语句评估的传递值返回整数。
  3. 存储返回值并在最后打印它,这样它就不会在过程中被丢弃。

我首先测试了枚举,预计性能会略有不同。

相反,我得到的平均值是:

  • 77596纳秒 - 如果
  • 585232纳秒 - 开关

我想看看是否只有枚举具有这种不利的属性,因此我还使用整数和字符串对其进行了测试(从 Java 7 开始,可以在 switch 语句中使用字符串)

INTS

  • 2308纳秒 - 如果
  • 1950纳秒 - 开关

字符串

  • 8517纳秒 - 如果
  • 8322纳秒 - 开关

这两个测试都产生非常相似的结果,表明 if 和 switch 语句在每次运行中都是等效的、非常相似或同样好,但是枚举的情况并非如此。

我在 Windows 和 Linux 上使用 Java 8 和 Java 17 进行了测试。

这是开关枚举代码:

public class SwitchEnum{
    public static void main(String[] args){
        long st = System.nanoTime();
        int val = getValue(Day.FRIDAY);
        long en = System.nanoTime();
        System.out.println("SwitchEnum perf nano: " + (en - st));
        System.out.println("Sum: " + val);
    }

    public static int getValue(Day day){
        switch (day){
            case MONDAY:
                return 7;
            case TUESDAY:
                return 3;
            case WEDNESDAY:
                return 5;
            case THURSDAY:
                return 2;
            case FRIDAY:
                return 1;
            case SATURDAY:
                return 6;
            case SUNDAY:
                return 4;
            default:
                throw new RuntimeException();
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

这是 if 枚举代码:

public class IfEnum{
    public static void main(String[] args){
        long st = System.nanoTime();
        int val = getValue(Day.FRIDAY);
        long en = System.nanoTime();
        System.out.println("IfEnum perf nano: " + (en - st));
        System.out.println("Sum: " + val);
    }

    public static int getValue(Day day){
        if (day == Day.MONDAY){
            return 7;
        }else if (day == Day.TUESDAY){
            return 3;
        }else if (day == Day.WEDNESDAY){
            return 5;
        }else if (day == Day.THURSDAY){
            return 2;
        }else if (day == Day.FRIDAY){
            return 1;
        }else if (day == Day.SATURDAY){
            return 6;
        }else if (day == Day.SUNDAY){
            return 4;
        }else{
            throw new RuntimeException();
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

和枚举:

public enum Day{
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
}
Run Code Online (Sandbox Code Playgroud)

我还在 C 和 C# 中对此进行了测试,以查看枚举上的 switch 语句与其 if 等效项相比是否具有显着的性能缺陷 - 没有。我还注意到,如果我们在“默认”或等效的“其他”中提供指令,性能也会提高,因此我将其包含在所有测试中。

这个问题不是关于典型的“if vs switch”之战,而是关于枚举和 switch 语句发生了什么。

无论如何,为什么带有枚举的 switch 平均比同等的 switch 慢 7 倍?这可能是什么原因造成的?

看来我被误解了。事实上,原来的枚举是完全不同的,因为我试图找到“不合理的开销”的罪魁祸首,所以我提出了这个基准。

有趣的是,预热 JVM 根本无法提高该函数的性能。

您可以在相关方法之前放置一些嵌套循环:

public static void main(String[] args) throws InterruptedException{
        for (int i = 0; i < 1000; i++){
            for (int j = 0; j < 1000; j++){
                System.out.println(j);
            }
            System.out.println(i);
        }
        Thread.sleep(100);
        for (int i = 0; i < 1000; i++){
            System.out.println(i);
        }
        long st = System.nanoTime();
        int val = getValue(Day.FRIDAY);
        long en = System.nanoTime();
        System.out.println("SwitchEnum perf nano: " + (en - st));
        System.out.println("Sum: " + val);
    }
Run Code Online (Sandbox Code Playgroud)

唯一重要的是它是否已经被调用。随后的每个调用都会得到优化。无论是构造函数、函数还是对象的方法。事实上,如果您正在初始化一个框架,您只会调用“initialize()”方法一次(该方法将依次调用其他方法)。在这种特殊情况下,您唯一关心的是函数第一次调用的性能。假设您的框架在首次启动时调用了 8000 个方法。每个方法需要 1 毫秒执行,因此每次运行都会传播 8 秒。Java 社区只是会说“你对它进行了错误的基准测试”?不。这是启动并运行该特定框架所需的时间。自然,性能会到处丢失。你总是可以让它更快更好。鉴于 switch 枚举语句的 'if' 等效项需要0.1ms ,所以switch 枚举语句没有理由向时钟添加0.6ms

那么这里我想问一下,这个开销的来源是什么?

Hol*_*ger 8

当您有像这样的符号引用时case FRIDAY:,您希望它针对枚举常量执行FRIDAY,而不管该类中的其他常量如何,或者换句话说,即使在常量之前添加或删除常量或声明顺序发生更改,它也能继续工作。

\n

这是规范所强制规定的:

\n
\n

在枚举类中添加或重新排序枚举常量不会破坏与预先存在的二进制文件的兼容性。

\n
\n

该规范没有告诉编译器应该如何实现这一点,并且现有编译器之间存在有趣的差异。

\n

编译器javac和 都ecj将生成用于创建int[]数组的代码,将 enum\xe2\x80\x99s 运行时序号映射到编译后的字节码中使用的数字(因为字节码指令仅切换int值)。由于两个编译器都以首次创建数组、switch执行语句然后重用的方式生成代码,因此这是首次执行开销较高的常见原因。

\n

当您查看javac编译后的代码时,您\xe2\x80\x99 会注意到SwitchEnum$1.class您的类旁边有另一个类文件,例如 。该类将保存在类初始值设定项中创建的上述数组。这遵循线程安全延迟初始化的已知模式,无需后续访问的同步原语。但当然,它会增加第一次初始化的开销。在最坏的情况下,根据 JVM,您需要在第一次执行语句时加载、验证和初始化第二个类的成本switch

\n

另一方面,Eclipse 将一个private static volatile字段添加到包含该switch语句的类中,并根据null测试延迟初始化它。因此,不存在第二个类的初始化开销,而是写入volatile,这在第一次执行中可能会更快。volatile但是这段代码会付出每次读取的代价,该switch语句随后被执行。不过,我们应该感谢您的阅读volatile,因为十多年来,Eclipse 只是生成了非线程安全的代码,如果您偶然发现了这个问题,那么识别问题的机会很小,因为\xe2\x80\x99s 没有源代码中可见的可变状态。

\n

即使在初始化之后,由于代码依赖于int[]运行时创建的数组的内容,因此可能会出现性能损失,因此比语句序列更难预测if。这取决于周围的代码,通常\xe2\x80\x99并不重要。但 JDK 开发人员自己至少偶然发现过这个问题一次,并创建了此票证。该报告包含指向与性能真正相关的场景的链接。

\n

\xe2\x80\x99s 是一个将来使用的讨论invokedynamicswitch,这不会改变第一次开销,但会改善后续执行,因为链接后,代码对于 JIT 来说将是完全可预测的,尤其是对于 99%映射是恒等函数的所有情况。

\n

JDK\xc2\xa021\xe2\x80\x99s 进行了一项改进javac:当语句位于枚举类型本身内时,它不会使用此数组间接寻址switch,在这种情况下,常量可以\xe2\x80\x99t 获取重新排序而不重新编译switch语句。这也正在修复这个错误

\n

请注意,该数组在一个类中同一枚举类型的所有语句之间共享switch,因此即使一个语句仅执行一次,其他语句仍可能从已初始化的数组中受益。如果此类中确实只有一条switch语句,最多执行一次,例如在类初始化程序中,并且初始化时间非常重要,或者您确实在一个特定switch语句处遇到性能瓶颈,您可以求助于Elliott Frisch\xe2\ x80\x99s 答案并将预期值放入枚举类型本身(如果您可以选择更改枚举类型)。否则,您可以if在该特定位置使用语句。

\n

对于多次执行的代码,EnumMap如果您可以\xe2\x80\x99t 修改枚举类型本身,也可能会有所帮助。

\n