为什么x64 Java中long类型比int类型慢?

96

我在Surface Pro 2平板电脑上运行Windows 8.1 x64以及Java 7 update 45 x64 (没有安装32位Java)。下面的代码,在i是long类型时需要1688毫秒,在i是int类型时只需要109毫秒。为什么64位平台上使用64位JVM时,long(64位类型)比int慢了一个数量级呢?

我唯一的猜测是CPU对64位整数的加法需要更长的时间,但这似乎不太可能。我怀疑Haswell处理器没有使用波动进位加法器。

顺便说一句,我是在Eclipse Kepler SR1中运行这段代码的。

public class Main {

    private static long i = Integer.MAX_VALUE;

    public static void main(String[] args) {    
        System.out.println("Starting the loop");
        long startTime = System.currentTimeMillis();
        while(!decrementAndCheck()){
        }
        long endTime = System.currentTimeMillis();
        System.out.println("Finished the loop in " + (endTime - startTime) + "ms");
    }

    private static boolean decrementAndCheck() {
        return --i < 0;
    }

}

编辑:以下是使用VS 2013编译的C++代码的结果(如下),同一系统。长整型:72265毫秒 整型:74656毫秒 这些结果都是在32位调试模式下获得的。

在64位发布模式下:长整型:875毫秒 长长整型:906毫秒 整型:1047毫秒

这表明我观察到的结果是JVM优化怪异而不是CPU限制。

#include "stdafx.h"
#include "iostream"
#include "windows.h"
#include "limits.h"

long long i = INT_MAX;

using namespace std;


boolean decrementAndCheck() {
return --i < 0;
}


int _tmain(int argc, _TCHAR* argv[])
{


cout << "Starting the loop" << endl;

unsigned long startTime = GetTickCount64();
while (!decrementAndCheck()){
}
unsigned long endTime = GetTickCount64();

cout << "Finished the loop in " << (endTime - startTime) << "ms" << endl;



}

编辑:刚刚在Java 8 RTM中再次尝试了一下,没有显著的变化。


8
最有可能的嫌疑人是你的设置,而不是CPU或JVM的各个部分。你能够可靠地重现这个测量结果吗?如果不重复循环、不预热JIT、使用currentTimeMillis()、运行可以轻松优化的代码等,则容易产生不可靠的结果。 - user395760
1
我之前在进行基准测试时,必须使用long作为循环计数器,因为当我使用int时,JIT编译器会将循环优化掉。需要查看生成的机器代码的反汇编结果。 - Sam
7
这不是一个正确的微基准测试,我不认为它的结果以任何方式反映现实。 - Louis Wasserman
8
所有批评OP没有编写正确Java微基准测试的评论都是无法容忍的懒惰。如果你只是看一下JVM对代码的操作,就可以很容易地弄清楚这种情况。 - tmyklebu
2
@maaartinus:被接受的做法之所以被接受,是因为它可以避免一系列已知的陷阱。在 Proper Java Benchmarks 的情况下,您需要确保您正在测量适当优化的代码,而不是堆栈替换,并且您需要确保您的测量结果在最后是干净的。OP发现了一个完全不同的问题,他提供的基准测试充分证明了这一点。正如注意到的那样,将此代码转换为 Proper Java Benchmark 实际上并不能消除奇怪的现象。而阅读汇编代码并不难。 - tmyklebu
显示剩余14条评论
8个回答

84

当使用long时,我的JVM对内部循环执行非常简单的操作:

0x00007fdd859dbb80: test   %eax,0x5f7847a(%rip)  /* fun JVM hack */
0x00007fdd859dbb86: dec    %r11                  /* i-- */
0x00007fdd859dbb89: mov    %r11,0x258(%r10)      /* store i to memory */
0x00007fdd859dbb90: test   %r11,%r11             /* unnecessary test */
0x00007fdd859dbb93: jge    0x00007fdd859dbb80    /* go back to the loop top */

当您使用整型变量时,它会进行欺骗性强的操作。首先,有一些我无法理解但看起来像是为了展开循环而设置的混乱操作:
0x00007f3dc290b5a1: mov    %r11d,%r9d
0x00007f3dc290b5a4: dec    %r9d
0x00007f3dc290b5a7: mov    %r9d,0x258(%r10)
0x00007f3dc290b5ae: test   %r9d,%r9d
0x00007f3dc290b5b1: jl     0x00007f3dc290b662
0x00007f3dc290b5b7: add    $0xfffffffffffffffe,%r11d
0x00007f3dc290b5bb: mov    %r9d,%ecx
0x00007f3dc290b5be: dec    %ecx              
0x00007f3dc290b5c0: mov    %ecx,0x258(%r10)   
0x00007f3dc290b5c7: cmp    %r11d,%ecx
0x00007f3dc290b5ca: jle    0x00007f3dc290b5d1
0x00007f3dc290b5cc: mov    %ecx,%r9d
0x00007f3dc290b5cf: jmp    0x00007f3dc290b5bb
0x00007f3dc290b5d1: and    $0xfffffffffffffffe,%r9d
0x00007f3dc290b5d5: mov    %r9d,%r8d
0x00007f3dc290b5d8: neg    %r8d
0x00007f3dc290b5db: sar    $0x1f,%r8d
0x00007f3dc290b5df: shr    $0x1f,%r8d
0x00007f3dc290b5e3: sub    %r9d,%r8d
0x00007f3dc290b5e6: sar    %r8d
0x00007f3dc290b5e9: neg    %r8d
0x00007f3dc290b5ec: and    $0xfffffffffffffffe,%r8d
0x00007f3dc290b5f0: shl    %r8d
0x00007f3dc290b5f3: mov    %r8d,%r11d
0x00007f3dc290b5f6: neg    %r11d
0x00007f3dc290b5f9: sar    $0x1f,%r11d
0x00007f3dc290b5fd: shr    $0x1e,%r11d
0x00007f3dc290b601: sub    %r8d,%r11d
0x00007f3dc290b604: sar    $0x2,%r11d
0x00007f3dc290b608: neg    %r11d
0x00007f3dc290b60b: and    $0xfffffffffffffffe,%r11d
0x00007f3dc290b60f: shl    $0x2,%r11d
0x00007f3dc290b613: mov    %r11d,%r9d
0x00007f3dc290b616: neg    %r9d
0x00007f3dc290b619: sar    $0x1f,%r9d
0x00007f3dc290b61d: shr    $0x1d,%r9d
0x00007f3dc290b621: sub    %r11d,%r9d
0x00007f3dc290b624: sar    $0x3,%r9d
0x00007f3dc290b628: neg    %r9d
0x00007f3dc290b62b: and    $0xfffffffffffffffe,%r9d
0x00007f3dc290b62f: shl    $0x3,%r9d
0x00007f3dc290b633: mov    %ecx,%r11d
0x00007f3dc290b636: sub    %r9d,%r11d
0x00007f3dc290b639: cmp    %r11d,%ecx
0x00007f3dc290b63c: jle    0x00007f3dc290b64f
0x00007f3dc290b63e: xchg   %ax,%ax /* OK, fine; I know what a nop looks like */

那么接下来是展开循环本身:
0x00007f3dc290b640: add    $0xfffffffffffffff0,%ecx
0x00007f3dc290b643: mov    %ecx,0x258(%r10)
0x00007f3dc290b64a: cmp    %r11d,%ecx
0x00007f3dc290b64d: jg     0x00007f3dc290b640

接下来是展开循环的拆除代码,其中包含一个测试和一个直接循环:

0x00007f3dc290b64f: cmp    $0xffffffffffffffff,%ecx
0x00007f3dc290b652: jle    0x00007f3dc290b662
0x00007f3dc290b654: dec    %ecx
0x00007f3dc290b656: mov    %ecx,0x258(%r10)
0x00007f3dc290b65d: cmp    $0xffffffffffffffff,%ecx
0x00007f3dc290b660: jg     0x00007f3dc290b654

对于整数(int),JIT编译器将循环展开16次,因此速度快了16倍;但对于长整数(long),JIT编译器没有展开循环。

为了完整起见,这里是我实际尝试的代码:

public class foo136 {
  private static int i = Integer.MAX_VALUE;
  public static void main(String[] args) {
    System.out.println("Starting the loop");
    for (int foo = 0; foo < 100; foo++)
      doit();
  }

  static void doit() {
    i = Integer.MAX_VALUE;
    long startTime = System.currentTimeMillis();
    while(!decrementAndCheck()){
    }
    long endTime = System.currentTimeMillis();
    System.out.println("Finished the loop in " + (endTime - startTime) + "ms");
  }

  private static boolean decrementAndCheck() {
    return --i < 0;
  }
}

汇编转储是使用选项 -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly 生成的。请注意,您需要调整JVM安装以使其适用于您;您需要将某些随机共享库放在恰当的位置,否则它将失败。

10
好的,所以"净净"的结果并不是long版本较慢,而是int版本更快。这有道理,很可能 JIT 没有投入太多精力来优化 long 表达式。 - Hot Licks
1
请原谅我的无知,但是什么是“funrolled”?我甚至似乎无法正确地在谷歌上搜索这个词,这使得这是我第一次在互联网上询问某个单词的含义。 - BrianH
1
@BrianDHall gcc使用-f作为命令行开关的“标志”,并且通过使用-funroll-loops来打开unroll-loops优化。我只是使用“展开”来描述这种优化。 - chrylis -cautiouslyoptimistic-
4
Java编译器无法做到,但JIT(即时编译器)可以。 - tmyklebu
1
只是为了明确,它没有“展开”它。它展开了它并将展开的循环转换为i-=16,这当然快了16倍。 - Aleksandr Dubinsky
显示剩余6条评论

22

JVM栈是以“单词”为单位定义的,其大小是一项实现细节,但必须至少为32位宽。JVM实现者可以使用64位单词,但字节码不能依赖于此,因此对于具有longdouble值的操作必须特别小心处理。特别是,JVM整数分支指令仅定义在类型int上。

就您的代码而言,反汇编是有益的。以下是由Oracle JDK 7编译的int版本的字节码:

private static boolean decrementAndCheck();
  Code:
     0: getstatic     #14  // Field i:I
     3: iconst_1      
     4: isub          
     5: dup           
     6: putstatic     #14  // Field i:I
     9: ifge          16
    12: iconst_1      
    13: goto          17
    16: iconst_0      
    17: ireturn       

请注意,JVM将加载静态变量i的值(0),减去1(3-4),在堆栈上复制该值(5),然后将其推回变量中(6)。然后它执行一个与零比较的分支并返回。

使用long的版本稍微复杂一些:

private static boolean decrementAndCheck();
  Code:
     0: getstatic     #14  // Field i:J
     3: lconst_1      
     4: lsub          
     5: dup2          
     6: putstatic     #14  // Field i:J
     9: lconst_0      
    10: lcmp          
    11: ifge          18
    14: iconst_1      
    15: goto          19
    18: iconst_0      
    19: ireturn       

首先,当JVM在栈上复制新值(5)时,它必须复制两个栈字。在您的情况下,这可能不比复制一个更昂贵,因为JVM可以自由地使用64位字。但是,您会注意到这里的分支逻辑更长。 JVM没有指令可以将 long 与零进行比较,因此它必须将常量 0L 推送到堆栈上(9),进行一般的 long 比较(10),然后根据计算的值进行分支。

以下是两种合理的场景:

  • JVM 正在精确地遵循字节码路径。在这种情况下,在 long 版本中需要做更多的工作,推送和弹出几个额外的值,而这些值在虚拟管理堆栈上,而不是在实际的硬件辅助CPU堆栈上。如果是这种情况,在预热后仍将看到显着的性能差异。
  • JVM 現實它可以优化这段代码。在这种情况下,它需要额外的时间来优化掉一些实际上不必要的推送/比较逻辑。如果是这种情况,在预热后会看到非常少的性能差异。

我建议您编写正确的微基准测试,以消除JIT启动的影响,并尝试使用不为零的最终条件,迫使JVM执行与 long 相同的比较与 int 比较。


1
@Katona 不一定。尤其是,客户端和服务器端的HotSpot JVM完全是不同的实现方式, 而Ilya并没有指明选择服务器端 (客户端通常是32位默认值)。 - chrylis -cautiouslyoptimistic-
1
@tmyklebu 这个问题在于基准测试同时测量了几个不同的东西。使用非零终止条件可以减少变量的数量。 - chrylis -cautiouslyoptimistic-
1
@tmyklebu 的观点是,原帖的意图是比较 int 和 long 类型的增量、减量和比较速度。但是(假设这个答案是正确的),他们只测量了比较操作,并且只与 0 进行了比较,这是一个特殊情况。如果没有其他因素,这会使原始基准测试结果具有误导性——它看起来像是测量了三种一般情况,而实际上只测量了一种特定情况。 - yshavit
1
@tmyklebu 不要误会,我点赞了这个问题、这个答案和你的答案。但是我不同意你的说法,即 @chrylis 调整基准测试以停止测量它试图测量的差异。如果我错了,OP 可以纠正我,但看起来他们并不是只/主要测量 == 0,这似乎是基准测试结果中不成比例的一部分。我认为 OP 更有可能是在尝试测量更广泛范围的操作,而这个答案指出基准测试高度偏向其中一个操作。 - yshavit
2
@tmyklebu 一点也不是。我完全支持理解根本原因。但是,既然已经确定一个主要的根本原因是基准测试被扭曲了,那么更改基准测试以消除扭曲并深入了解该扭曲(例如,它可以实现更高效的字节码,可以使展开循环更容易等)并不无效。这就是为什么我给这个回答(指出了扭曲)和你的回答(更详细地挖掘了扭曲)都点了赞的原因。 - yshavit
显示剩余6条评论

8
在Java虚拟机中,数据的基本单位是字。选择正确的字大小取决于JVM的实现。JVM实现应该选择最小的32位字大小。它可以选择更高的字大小以获得效率。64位JVM没有任何限制只能选择64位字。
底层架构并不规定字大小也应该相同。JVM逐字地读取/写入数据。这就是为什么一个long类型可能比int类型花费更长时间的原因。
这里你可以找到更多相关的内容。

4

我刚刚使用caliper编写了一个基准测试。

结果与原始代码非常一致:对于使用int而言,速度提升了约12倍,而对于long则没有。看起来肯定是进行了tmyklebu所报告的循环展开或类似的操作。

timeIntDecrements         195,266,845.000
timeLongDecrements      2,321,447,978.000

这是我的代码;请注意,它使用了最新构建的 caliper 快照,因为我无法弄清如何针对他们现有的 beta 版本进行编码。
package test;

import com.google.caliper.Benchmark;
import com.google.caliper.Param;

public final class App {

    @Param({""+1}) int number;

    private static class IntTest {
        public static int v;
        public static void reset() {
            v = Integer.MAX_VALUE;
        }
        public static boolean decrementAndCheck() {
            return --v < 0;
        }
    }

    private static class LongTest {
        public static long v;
        public static void reset() {
            v = Integer.MAX_VALUE;
        }
        public static boolean decrementAndCheck() {
            return --v < 0;
        }
    }

    @Benchmark
    int timeLongDecrements(int reps) {
        int k=0;
        for (int i=0; i<reps; i++) {
            LongTest.reset();
            while (!LongTest.decrementAndCheck()) { k++; }
        }
        return (int)LongTest.v | k;
    }    

    @Benchmark
    int timeIntDecrements(int reps) {
        int k=0;
        for (int i=0; i<reps; i++) {
            IntTest.reset();
            while (!IntTest.decrementAndCheck()) { k++; }
        }
        return IntTest.v | k;
    }
}

1

记录一下,这个版本进行了粗略的“热身”:

public class LongSpeed {

    private static long i = Integer.MAX_VALUE;
    private static int j = Integer.MAX_VALUE;

    public static void main(String[] args) {

        for (int x = 0; x < 10; x++) {
            runLong();
            runWord();
        }
    }

    private static void runLong() {
        System.out.println("Starting the long loop");
        i = Integer.MAX_VALUE;
        long startTime = System.currentTimeMillis();
        while(!decrementAndCheckI()){

        }
        long endTime = System.currentTimeMillis();

        System.out.println("Finished the long loop in " + (endTime - startTime) + "ms");
    }

    private static void runWord() {
        System.out.println("Starting the word loop");
        j = Integer.MAX_VALUE;
        long startTime = System.currentTimeMillis();
        while(!decrementAndCheckJ()){

        }
        long endTime = System.currentTimeMillis();

        System.out.println("Finished the word loop in " + (endTime - startTime) + "ms");
    }

    private static boolean decrementAndCheckI() {
        return --i < 0;
    }

    private static boolean decrementAndCheckJ() {
        return --j < 0;
    }

}

总体时间提高了约30%,但两者之间的比率仍大致相同。

@TedHopp - 我尝试在我的程序中更改循环限制,但实际上并没有发生什么变化。 - Hot Licks
@Techrocket9:使用这段代码,我得到了类似的数字(int快了20倍左右)。 - tmyklebu

1
记录如下:

如果我使用

boolean decrementAndCheckLong() {
    lo = lo - 1l;
    return lo < -1l;
}

将“l--”修改为“l = l - 1l”,可使性能提高约50%。


1

0

我没有64位机器进行测试,但是相差很大的结果表明,不仅仅是稍微长一点的字节码在起作用。

在我的32位1.7.0_45上,long/int的时间非常接近(4400 vs 4800ms)。

这只是一个猜测,但我强烈怀疑这是内存未对齐惩罚的影响。为了确认/否认这种怀疑,请尝试在i的声明之前添加public static int dummy = 0; 。这将使i在内存布局中向下移动4个字节,并可能使其正确对齐以获得更好的性能。已确认不会导致问题。

编辑:背后的原因是VM可能不会随意重新排序字段并添加填充以实现最佳对齐,因为那可能会干扰JNI(不是这种情况)。


虚拟机确实被允许重新排序字段并添加填充。 - Hot Licks
JNI必须通过那些讨厌、缓慢的访问器方法访问对象,这些方法需要一些不透明的句柄,因为GC可能会在本地代码运行时发生。重排字段和添加填充是完全自由的。 - tmyklebu

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