【问题标题】:Difference between JVM's LookupSwitch and TableSwitch?JVM 的 LookupSwitch 和 TableSwitch 的区别?
【发布时间】:2012-05-04 11:41:18
【问题描述】:

我在理解 Java 字节码中的 LookUpSwitch 和 TableSwitch 时有些困难。

如果我理解的很好,LookUpSwitch和TableSwitch都对应Java源码的switch语句?为什么一个 JAVA 语句会生成 2 个不同的字节码?

每个 Jasmin 文档:

【问题讨论】:

    标签: java bytecode jasmin


    【解决方案1】:

    区别在于

    • lookupswitch 使用带有键和标签的表格
    • tableswitch 使用只有标签的表格

    在执行tableswitch时,直接将栈顶的int值作为table的索引来获取跳转的目的地并立即执行跳转。整个查找+跳转过程是一个O(1)操作,这意味着它非常快。

    当执行 lookupswitch 时,堆栈顶部的 int 值会与表中的键进行比较,直到找到匹配项,然后使用该键旁边的跳转目标执行跳。由于一个lookupswitch表总是必须排序使得keyX O(log n)操作作为key将使用二分搜索算法进行搜索(不必将 int 值与所有可能的键进行比较以找到匹配项或确定没有任何键匹配)。 O(log n) 比 O(1) 慢一些,但它仍然可以,因为许多众所周知的算法都是 O(log n) 并且这些通常被认为是快的;甚至 O(n) 或 O(n * log n) 仍然被认为是一个非常好的算法(慢/坏算法有 O(n^2)、O(n^3) 甚至更糟)。

    编译器根据switch语句的紧凑程度来决定使用哪条指令,例如

    switch (inputValue) {
      case 1:  // ...
      case 2:  // ...
      case 3:  // ...
      default: // ...
    }
    

    上面的开关非常紧凑,没有数字“洞”。编译器会像这样创建一个 tableswitch:

     tableswitch 1 3
        OneLabel
        TwoLabel
        ThreeLabel
      default: DefaultLabel
    

    Jasmin 页面的伪代码很好地解释了这一点:

    int val = pop();                // pop an int from the stack
    if (val < low || val > high) {  // if its less than <low> or greater than <high>,
        pc += default;              // branch to default 
    } else {                        // otherwise
        pc += table[val - low];     // branch to entry in table
    }
    

    这段代码非常清楚地说明了这种 tableswitch 的工作原理。 valinputValuelow 将是 1(开关中的最低值),high 将是 3(开关中的最高值)。

    即使有一些孔,开关也可以很紧凑,例如

    switch (inputValue) {
      case 1:  // ...
      case 3:  // ...
      case 4:  // ...
      case 5:  // ...
      default: // ...
    }
    

    上面的开关“几乎紧凑”,它只有一个孔。编译器可以生成以下指令:

     tableswitch 1 6
        OneLabel
        FakeTwoLabel
        ThreeLabel
        FourLabel
        FiveLabel
      default: DefaultLabel
    
      ; <...code left out...>
    
      FakeTwoLabel:
      DefaultLabel:
        ; default code
    

    如你所见,编译器必须为 2 添加一个 fake caseFakeTwoLabel。由于 2 不是开关的实际值,FakeTwoLabel 实际上是一个标签,它会准确地更改默认情况所在的代码流,因为值 2 实际上应该执行默认情况。

    因此,编译器创建 tableswitch 时,switch 不必非常紧凑,但至少应该非常接近紧凑。现在考虑以下开关:

    switch (inputValue) {
      case 1:    // ...
      case 10:   // ...
      case 100:  // ...
      case 1000: // ...
      default:   // ...
    }
    

    这个开关远非紧凑,它的孔比值多一百倍。人们会称之为稀疏开关。编译器必须生成几乎一千个假案例才能将此开关表示为表格开关。结果将是一个巨大的表,大大增加了类文件的大小。这是不切实际的。相反,它会生成一个查找开关:

    lookupswitch
        1       : Label1
        10      : Label10
        100     : Label100
        1000    : Label1000
        default : DefaultLabel
    

    此表只有 5 个条目,而不是超过一千个。该表有 4 个实数值,O(log 4) 为 2(这里的 log 是 2 BTW 的底数,而不是 10 的底数,因为计算机对二进制数进行操作)。这意味着 VM 最多需要两次比较才能找到 inputValue 的标签或得出结论,该值不在表中,因此必须执行默认值。即使该表有 100 个条目,VM 最多需要 7 次比较才能找到正确的标签或决定跳转到默认标签(7 次比较远少于 100 次比较,你不觉得吗?)。

    所以说这两条指令可以互换或者说两条指令的原因有历史原因是无稽之谈。有两种指令用于两种不同的情况,一种用于具有紧凑值的开关(用于最大速度),另一种用于具有稀疏值的开关(不是最大速度,但仍然具有良好的速度和非常紧凑的表格表示,无论数字孔如何)。

    【讨论】:

    • n*log(n) 大于n 对于任何大于日志基数的n,我相信这通常会明显小于我们正在评估的n 的大小;即O(n) 将被视为O(n log n) 更好
    • @FauxFaux:感谢您提供的信息,我已相应地更正了回复。
    • “这里的日志是以 2 BTW 为底,而不是以 10 为底,因为计算机操作二进制数” - 我认为二进制数系统在这里没有任何作用。只是搜索到的集合每次减半,因此日志的基数为 2。
    • 只是想说tableswitch 不是 O(1),至少在实践中不是,根据一些测试它的行为是线性的。见这里github.com/frostwire/frostwire-jlibtorrent/pull/…
    • @Gubatron 抱歉,您的基准测试方法无效。您甚至没有使用查找结果,导致 JIT 编译器部分优化整个查找。如果你做得正确,查找 0-9 和查找 0-99 之间几乎没有任何区别。表查找速度更快的原因也不足为奇:内存查找甚至可以放入 CPU 的一级缓存的表自然是超快的。代码跳转从来没有那么快,尤其是在 CPU 无法预测它们的情况下(切换对于 CPU 来说通常是不可预测的,这与代码中的 if/else 测试相反)。
    【解决方案2】:

    javac 1.8.0_45 如何决定将switch 编译成什么?

    要决定何时使用哪个,您可以使用javac 选择算法作为基础。

    我们知道javac 的来源在langtools 存储库中。

    然后我们grep:

    hg grep -i tableswitch
    

    第一个结果是langtools/src/share/classes/com/sun/tools/javac/jvm/Gen.java:

    // Determine whether to issue a tableswitch or a lookupswitch
    // instruction.
    long table_space_cost = 4 + ((long) hi - lo + 1); // words
    long table_time_cost = 3; // comparisons
    long lookup_space_cost = 3 + 2 * (long) nlabels;
    long lookup_time_cost = nlabels;
    int opcode =
        nlabels > 0 &&
        table_space_cost + 3 * table_time_cost <=
        lookup_space_cost + 3 * lookup_time_cost
        ?
        tableswitch : lookupswitch;
    

    地点:

    • hi:最大案例值
    • lo: 最小大小写值

    因此我们得出结论,它同时考虑了时间和空间复杂度,时间复杂度的权重为 3

    TODO 我不明白为什么lookup_time_cost = nlabels 而不是log(nlabels),因为tableswitch 可以在O(log(n)) 中通过二分搜索完成。

    奖励事实:C++ 编译器也在 O(1) 跳转表和 O(long(n)) 二进制搜索之间做出类似选择:Advantage of switch over if-else statement

    【讨论】:

    • +1 因为这帮助我弄清楚如何在我自己的 JVM 语言编译器中实现 switch 指令
    • O(log(n)) 时间永远不会更好,总有一些乘数,因此 c1*n =java 选择索引。但是索引可能会浪费空间。
    【解决方案3】:

    Java Virtual Machine Specification 描述差异。 “当 switch 的情况可以有效地表示为目标偏移表中的索引时,使用 tableswitch 指令。”规范描述了更多细节。

    【讨论】:

      【解决方案4】:

      我怀疑这主要是历史原因,因为 Java 字节码与底层机器代码(例如 Sun 自己的 CPU)的某些特定绑定。

      tableswitch 本质上是一个计算跳转,其中目标是从查找表中获取的。相比之下,lookupswitch 需要比较每个值,基本上是遍历表元素直到找到匹配值。

      显然,这些操作码是可互换的,但根据值,一个或另一个可能更快或更紧凑(例如,比较之间有较大间隙的一组键和一组顺序键)。

      【讨论】:

      • Scala 2.13 将一些 Match-Case 语句编译为tableswitch,一些编译为lookupswitch,还有一些编译为“嵌套”If 语句。
      猜你喜欢
      • 2017-12-12
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2021-01-05
      • 1970-01-01
      • 2011-03-27
      • 2011-12-12
      相关资源
      最近更新 更多