为什么“ while(i ++ <n){}”比“ while(++ i <n){}”显着慢


74

显然,在装有HotSpot JDK 1.7.0_45(并将所有编译器/ VM选项设置为默认值)的Windows 8笔记本电脑上,以下循环

final int n = Integer.MAX_VALUE;
int i = 0;
while (++i < n) {
}

比以下速度至少快两个数量级(〜10 ms与〜5000 ms):

final int n = Integer.MAX_VALUE;
int i = 0;
while (i++ < n) {
}

我在编写循环以评估另一个不相关的性能问题时碰巧注意到了这个问题。之间的差异++i < ni++ < n是巨大的,足以显著影响结果。

如果我们查看字节码,则较快版本的循环主体为:

iinc
iload
ldc
if_icmplt

对于较慢的版本:

iload
iinc
ldc
if_icmplt

因此,对于++i < n,它首先将局部变量i加1,然后将其压入操作数堆栈,同时i++ < n以相反的顺序执行这两个步骤。但这似乎无法解释为什么前者要快得多。在后一种情况下是否涉及任何临时副本?还是应该由字节码(VM实现,硬件等)之外的某些因素导致性能差异?

我已经阅读了有关++i和的其他讨论i++(尽管不是很详尽),但是没有找到任何特定于Java且与值比较涉及++ii++涉及的情况直接相关的答案。


23
10毫秒对于一个基准测试来说还不够长-更不用说Java基准测试了JVM预热效果。您可以发布确切的测试代码吗?另外,请尝试颠倒基准的顺序。
Mysticial

3
正如Mysticial所说,java需要预热时间。这是供即时(JIT)编译器执行的工作。如果将代码放在函数中并在执行测量之前多次调用它,则可能会得到不同的结果。
瑟勒2014年

12
@CaptainCodeman以这种通用形式表示,这只是胡说八道。性能远远超过(完善的)微型基准测试。我们从C ++转到大型项目,改用Java,并获得了一个数量级的性能。这取决于您要解决的问题,拥有的资源等等。始终选择最适合您的问题的语言以及您手头的人员(除其他因素外)。
Axel

4
@Axel我很好奇,对于从C ++切换到Java的哪种应用程序,性能提高了一个数量级?
CaptainCodeman 2014年

7
@Axel没有一种编译语言比其他语言快一个数量级。因此,更可能的情况是您有糟糕的C ++程序员或使用了非常慢的库。
CaptainCodeman

Answers:


119

正如其他人指出的那样,该测试在许多方面都有缺陷。

你没有告诉我们到底如何,你做了这个测试。但是,我试图实施这样的“幼稚”测试(无冒犯):

class PrePostIncrement
{
    public static void main(String args[])
    {
        for (int j=0; j<3; j++)
        {
            for (int i=0; i<5; i++)
            {
                long before = System.nanoTime();
                runPreIncrement();
                long after = System.nanoTime();
                System.out.println("pre  : "+(after-before)/1e6);
            }
            for (int i=0; i<5; i++)
            {
                long before = System.nanoTime();
                runPostIncrement();
                long after = System.nanoTime();
                System.out.println("post : "+(after-before)/1e6);
            }
        }
    }

    private static void runPreIncrement()
    {
        final int n = Integer.MAX_VALUE;
        int i = 0;
        while (++i < n) {}
    }

    private static void runPostIncrement()
    {
        final int n = Integer.MAX_VALUE;
        int i = 0;
        while (i++ < n) {}
    }
}

当使用默认设置运行它时,似乎有很小的差异。但是,当您使用该标志运行基准测试时,基准测试的真正缺陷就变得显而易见-server。在我的情况下,结果就像

...
pre  : 6.96E-4
pre  : 6.96E-4
pre  : 0.001044
pre  : 3.48E-4
pre  : 3.48E-4
post : 1279.734543
post : 1295.989086
post : 1284.654267
post : 1282.349093
post : 1275.204583

显然,预增量版本已被完全优化。原因很简单:不使用结果。循环是否执行完全无关紧要,因此JIT只需将其删除即可。

通过查看热点反汇编可以确认这一点:预增量版本产生以下代码:

[Entry Point]
[Verified Entry Point]
[Constants]
  # {method} {0x0000000055060500} &apos;runPreIncrement&apos; &apos;()V&apos; in &apos;PrePostIncrement&apos;
  #           [sp+0x20]  (sp of caller)
  0x000000000286fd80: sub    $0x18,%rsp
  0x000000000286fd87: mov    %rbp,0x10(%rsp)    ;*synchronization entry
                                                ; - PrePostIncrement::runPreIncrement@-1 (line 28)

  0x000000000286fd8c: add    $0x10,%rsp
  0x000000000286fd90: pop    %rbp
  0x000000000286fd91: test   %eax,-0x243fd97(%rip)        # 0x0000000000430000
                                                ;   {poll_return}
  0x000000000286fd97: retq   
  0x000000000286fd98: hlt    
  0x000000000286fd99: hlt    
  0x000000000286fd9a: hlt    
  0x000000000286fd9b: hlt    
  0x000000000286fd9c: hlt    
  0x000000000286fd9d: hlt    
  0x000000000286fd9e: hlt    
  0x000000000286fd9f: hlt    

后递增版本产生以下代码:

[Entry Point]
[Verified Entry Point]
[Constants]
  # {method} {0x00000000550605b8} &apos;runPostIncrement&apos; &apos;()V&apos; in &apos;PrePostIncrement&apos;
  #           [sp+0x20]  (sp of caller)
  0x000000000286d0c0: sub    $0x18,%rsp
  0x000000000286d0c7: mov    %rbp,0x10(%rsp)    ;*synchronization entry
                                                ; - PrePostIncrement::runPostIncrement@-1 (line 35)

  0x000000000286d0cc: mov    $0x1,%r11d
  0x000000000286d0d2: jmp    0x000000000286d0e3
  0x000000000286d0d4: nopl   0x0(%rax,%rax,1)
  0x000000000286d0dc: data32 data32 xchg %ax,%ax
  0x000000000286d0e0: inc    %r11d              ; OopMap{off=35}
                                                ;*goto
                                                ; - PrePostIncrement::runPostIncrement@11 (line 36)

  0x000000000286d0e3: test   %eax,-0x243d0e9(%rip)        # 0x0000000000430000
                                                ;*goto
                                                ; - PrePostIncrement::runPostIncrement@11 (line 36)
                                                ;   {poll}
  0x000000000286d0e9: cmp    $0x7fffffff,%r11d
  0x000000000286d0f0: jl     0x000000000286d0e0  ;*if_icmpge
                                                ; - PrePostIncrement::runPostIncrement@8 (line 36)

  0x000000000286d0f2: add    $0x10,%rsp
  0x000000000286d0f6: pop    %rbp
  0x000000000286d0f7: test   %eax,-0x243d0fd(%rip)        # 0x0000000000430000
                                                ;   {poll_return}
  0x000000000286d0fd: retq   
  0x000000000286d0fe: hlt    
  0x000000000286d0ff: hlt    

对于我来说,还不是很清楚为什么它似乎没有删除后增量版本。(实际上,我认为这是一个单独的问题)。但是至少,这解释了为什么您可能会看到“数量级”差异。


编辑:有趣的是,从改变环路的上限时Integer.MAX_VALUEInteger.MAX_VALUE-1,那么这两个版本被优化掉,并且需要“零”的时间。这种限制(仍然会出现0x7fffffff在装配体中)以某种方式阻止了优化。据推测,这与将比较映射到(单个!)cmp指令有关,但是我不能给出更深层的理由。JIT以神秘的方式工作...


2
我不是Java的人,但我确实对编译器的机制有浓厚的兴趣。如果您(或任何人)碰巧在另一篇文章中问您的后续问题,请发布链接。谢谢!
RLH

26
实际上,这是我想到的第一件事:while (i++ < Integer.MAX_VALUE)退出循环时,已经发生了溢出i。当可能发生溢出时,证明代码转换的正确性要困难得多,毕竟,带有溢出的循环并不常见,所以热点为什么要对它们进行优化……
Holger 2014年


@Holger:是的,这听起来像是一种避免在违反安全性约束的优化过程中遇到麻烦的方法-这种情况很少发生,因此不值得检查所有可能出错的事情(例如,缓冲区溢出)。
a安

@Holger,但您如何解释,如果将限制从Integer.MAX_VALUE减少到Integer.MAX_VALUE-1,则两者均已优化,因此使用i ++时,案例仍然会发生溢出,但同时会优化!!!
Sumit Kumar Saha 2014年

19

++ i和i ++之间的区别在于++ i有效地增加了变量并“返回”了新值。另一方面,i ++有效地创建了一个临时变量来保存i中的当前值,然后递增变量“返回”临时变量的值。这是额外开销的来源。

// i++ evaluates to something like this
// Imagine though that somehow i was passed by reference
int temp = i;
i = i + 1;
return temp;

// ++i evaluates to
i = i + 1;
return i;

在您的情况下,由于您在表达式中使用结果,因此看来JVM无法优化增量。另一方面,JVM可以优化这样的循环。

for( int i = 0; i < Integer.MAX_VALUE; i++ ) {}

这是因为从未使用过i ++的结果。在这样的循环中,您应该能够同时使用++ i和i ++并获得与使用++ i相同的性能。


当明确提到热点编译器时,可能会更清楚一点。
乔普·艾根

10
如OP所述,两个版本导致相同数量的字节码指令。您在这里谈论的间接费用在哪里?您谈到的JVM优化有哪些++i,而其他版本则无法实现?
arne.b 2014年

想知道iload的工作原理...它实际上是否将变量从局部变量表复制到操作数堆栈?如果是,则对于i ++,首先将i压入(复制)到操作数堆栈,然后iinc将局部变量表中的原始i递增。++ i的执行顺序完全相反。在这两种情况下,都没有其他临时变量。但我可能完全错了:)
sikan 2014年

如果您查看Eugene的附加基准测试结果,您会发现差异很小,甚至根本没有差异。JVM大部分时间可以将i ++优化为++ i。这样一来,它将删除temp变量,而仅对变量进行递增。我唯一的猜测是,通过在比较中使用i ++,是在将字节码编译为机器代码时,JVM分配了一个额外的寄存器供循环使用。
Smith_61

18

编辑2

您应该真正看一下这里:

http://hg.openjdk.java.net/code-tools/jmh/file/f90aef7f1d2c/jmh-samples/src/main/java/org/openjdk/jmh/samples/JMHSample_11_Loops.java

编辑 我考虑得越多,我意识到该测试在某种程度上是错误的,JVM将对循环进行认真的优化。

我认为您应该放下,@Param然后放开n=2

这样,您将测试其while本身的性能。我在这种情况下得到的结果:

o.m.t.WhileTest.testFirst      avgt         5        0.787        0.086    ns/op
o.m.t.WhileTest.testSecond     avgt         5        0.782        0.087    ns/op

几乎没有区别

您应该问自己的第一个问题是如何测试和衡量这一点。这是微基准测试,在Java中这是一门艺术,几乎总是一个简单的用户(例如我)都会错误地得出结果。您应该依靠基准测试和非常好的工具。我使用JMH进行了测试:

    @Measurement(iterations=5, time=1, timeUnit=TimeUnit.MILLISECONDS)
@Fork(1)
@Warmup(iterations=5, time=1, timeUnit=TimeUnit.SECONDS)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@State(Scope.Benchmark)
public class WhileTest {
    public static void main(String[] args) throws Exception {
        Options opt = new OptionsBuilder()
            .include(".*" + WhileTest.class.getSimpleName() + ".*")
            .threads(1)
            .build();

        new Runner(opt).run();
    }


    @Param({"100", "10000", "100000", "1000000"})
    private int n;

    /*
    @State(Scope.Benchmark)
    public static class HOLDER_I {
        int x;
    }
    */


    @Benchmark
    public int testFirst(){
        int i = 0;
        while (++i < n) {
        }
        return i;
    }

    @Benchmark
    public int testSecond(){
        int i = 0;
        while (i++ < n) {
        }
        return i;
    }
}

对JMH更有经验的人可能会纠正此结果(我真的希望如此!,因为我在JMH中还没有那么多才行),但是结果表明,差别很小:

Benchmark                        (n)   Mode   Samples        Score  Score error    Units
o.m.t.WhileTest.testFirst        100   avgt         5        1.271        0.096    ns/op
o.m.t.WhileTest.testFirst      10000   avgt         5        1.319        0.125    ns/op
o.m.t.WhileTest.testFirst     100000   avgt         5        1.327        0.241    ns/op
o.m.t.WhileTest.testFirst    1000000   avgt         5        1.311        0.136    ns/op
o.m.t.WhileTest.testSecond       100   avgt         5        1.450        0.525    ns/op
o.m.t.WhileTest.testSecond     10000   avgt         5        1.563        0.479    ns/op
o.m.t.WhileTest.testSecond    100000   avgt         5        1.418        0.428    ns/op
o.m.t.WhileTest.testSecond   1000000   avgt         5        1.344        0.120    ns/op

分数字段是您感兴趣的字段。


据我所知,如果我错了,请纠正我,使用结果后,JVM似乎并没有将i ++优化为++ i。还是仅仅是因为i ++循环了额外的时间?
Smith_61

0

可能该测试不足以得出结论,但是我想说,如果是这种情况,JVM可以通过将i ++更改为++ i来优化此表达式,因为i ++的存储值(pre value)从未在此循环中使用。


-3

我建议您(尽可能)始终使用++c而不是使用c++前者,因为前者绝对不会变慢,因为从概念上讲,c在后一种情况下必须获取的深层副本才能返回前一个值。

确实,许多优化器会优化掉不必要的深层副本,但是如果您使用表达式值,他们将不容易做到这一点。而您只是在这种情况下这样做。

但是,许多人不同意:他们将其视为微优化。


6
这在非平凡的C ++迭代器世界中可能是正确的,但对于原始类型却不是如此……
Mysticial

3
@Bathsheba我同意您应该了解您的编译器以及它将为您做什么样的优化。在有限的情况下,您将必须自己进行此类优化。如果您使用的编译器不适合您,则可能会知道。由于大多数这些编译器是针对嵌入式系统的,或者用户数量较少。
Smith_61

4
我站在@Bathsheba一边。我确实知道在99%的情况下(尤其是在Java中),在编写++ i和i ++时没有区别。但是,我宁愿养成写++ i的习惯,因为在某些情况下它会有所作为(特别是C ++等)。鉴于++ i比i ++更难阅读,为什么不编写一种可能更安全的形式?就像我们写的东西一样if (CONSTANT == var),以及if (CONSTANT.equals(var))
Adrian Shum 2014年

5
虚报错误信息。无法对Java中可能使用的“ ++”运算符进行任何“深度复制”,并且指出优化器无法在比较中使用时优化该操作,这也是错误的信息。
Score_Un14年

4
在使用增量运算符的结果的情况下,应该使用一种更适合自己正在执行的语义的运算符,因为任何性能差异都可能会因选择导致的其他地方的代码更改而被抵消。如果不使用运算符的结果,我更喜欢后置运算符,因为它与其他地方使用的名词-动词模式更加一致。
超级猫2014年
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.