在Java中,这些代码片段哪一个更快?

11

a) for(int i = 100000; i > 0; i--) {}

b) for(int i = 1; i < 100001; i++) {}

答案在此网站上(第3个问题)。我只是不明白为什么? 网站上的解释如下:

3. a


11
你真的尝试过验证第一个版本是否确实更快吗?因为我相当怀疑它是否是。 - Michael Myers
2
有些问题由于使用了质量较差的英语而难以阅读和理解。 - Blessed Geek
9
在这份面试问题清单中缺少的一项是:“回答完所有问题后,你还想在这里工作吗?”只有一个答案。 - Jed Smith
5
这些问题实在太愚蠢了,答案最多也只是误导,最糟糕的情况下就是错误的。 - Konrad Rudolph
1
哇,这个网站真是太专业了。它应该被称为“面向C程序员的Java 1.0”。 - IAdapter
显示剩余4条评论
16个回答

68

当你深入到最低层(机器码,但我将使用汇编语言,因为它大多数时候是一对一的映射)时,一个从零递减到0的空循环和一个从零递增到50(例如)的循环之间的区别通常在以下方面:

      ld  a,50                ld  a,0
loop: dec a             loop: inc a
      jnz loop                cmp a,50
                              jnz loop
那是因为大多数合理的 CPU 中,当您达到零时,减量指令会设置零标志位。而当增量指令达到50时通常不能说有同样的情况(因为50没有像零一样的特殊性)。因此,您需要将寄存器与50比较以设置零标志位。

然而,询问哪个循环:

for(int i = 100000; i > 0; i--) {}
for(int i = 1; i < 100001; i++) {}

在几乎任何环境下(包括Java),比较哪个更快是没有意义的,因为它们都没有做任何有用的事情。这两个循环中最快的版本是根本不需要循环的。我挑战任何人想出比这更快的版本 :-)

只有在你开始在大括号内执行一些有用的工作时,它们才会变得有用,此时工作将决定你应该使用哪个顺序。

例如,如果你需要从1计数到100,000,你应该使用第二个循环。这是因为倒计数的优点(如果有的话)可能会被每次需要使用它时都要评估100000-i的事实淹没。在汇编方面,这将是以下代码之间的区别:

     ld  b,100000             dsw a
     sub b,a
     dsw b

(dsw 当然是臭名昭著的 do something with 汇编助记符)。

既然每次迭代只需进行一次递增循环的耗时,而至少每次迭代都需要进行一次减法(假设你将使用 i,否则根本不需要循环),那么你应该选择更自然的版本。

如果你需要计数,就计数。如果你需要倒序计数,就倒序计数。


好建议。我还要指出的是,通过分支预测,计数向上和计数向下的汇编指令在性能上几乎没有区别(但我同意您这种微观优化不值得污染源代码)。 - Drew Hall
2
回答问题时请注意,问题明确要求使用Java语言。考虑到在许多层VM之间存在的情况,机器码中发生的事情是无关紧要的。 - Kevin Bourrillion
你会发现这个问题在第二部分有答案,即迭代方向应该是最有意义的方向。即使使用Java,形式为100000-i的计算也几乎肯定会淹没您从反转循环中获得的任何小优势。 - paxdiablo
paxdiablo,任何优化编译器都可以更快地完成它(即反向方式)。实际上,任何优化编译器都应该展开循环(在Java中,您肯定会得到它们的展开,但在这种情况下,它们只是无操作并完全被忽略)。 - bestsss
1
Kevin,任何一个像样的Java环境最终都会将代码JIT编译成机器码,所以这是相关的。 - paxdiablo

23

在许多编译器上,向后循环的机器指令更有效率,因为测试是否为零(并因此将寄存器清零)比加载常量值要快。

另一方面,一个好的优化编译器应该能够检查循环内部,并确定向后循环不会产生任何副作用...

顺便说一句,我认为这是一个糟糕的面试问题。除非你在谈论一个运行10百万次并且已确定向后循环不会因多次重新创建前向循环值(n-i)而被权衡掉的循环,否则任何性能收益都是微不足道的。

一如既往地,不要在牺牲代码易读性的情况下进行微观优化,务必进行性能基准测试。


12
这种微观优化可能在C或C++中有一点点用处,但对于Java来说则没有。 - Michael Myers
4
虽然这是对的,性能提升非常微小,不值得花费这种努力。如果有人告诉我应该使用递减的for循环以获得更好的性能,则他们正在过度尝试,因此我同意这是一个糟糕的面试问题。 - Brett Ryan

17

这类问题往往只是某些人过度纠结的无关紧要的分心。称其为微优化崇拜或其他任何你喜欢的名字吧,但是向上还是向下循环更快?说真的吗?你应该根据当前情况使用适合的方式,而不是为了节省两个时钟周期而编写代码。

让编译器发挥作用,明确你的意图(同时让编译器和读者都能理解)。另一个常见的Java慢化行为是:

public final static String BLAH = new StringBuilder().append("This is ").append(3).append(' text").toString();

因为过多的字符串拼接会导致内存碎片,但对于常量,编译器可以(并且会)进行优化:

public final static String BLAH = "This is a " + 3 + " test";

其中第一个不会被优化,第二个更易读。

(a>b)?a:bMath.max(a,b) 呢?我知道我宁愿读第二个,所以我并不在意第一个没有引入函数调用的开销。

这个列表中有一些有用的东西,比如知道finally块不会在System.exit()上调用是可能有用的。知道浮点数除以0.0不会抛出异常也很有用。

但是除非真的很重要(我打赌99.99%的情况下都不是),不要费力地去猜测编译器。


1
...但在 Gentoo 上,我有一个 USE 标志可以神奇地反转所有应用程序的 for 循环,并为我赢得每 GHz 218 ips,宝贝。 - Jed Smith
你确定 Math.max(..) 这个东西吗?如果我没记错,JVM通常会优化很多Math* - 将它们转换为直接代码,而不是方法调用等 - 因为它是不可更改的...也就是说,如果我没记错,任何一个合适的JVM/javac组合实际上都会以相同的方式实现 Math.max()。 - Adam
1
@Adam:如果你看一下链接的网站,它声称 Math.max() 更慢。这可能是因为函数调用开销、装箱/拆箱(虽然有针对原始类型的 max() 版本,所以我不确定这是否真的是情况)或两者都有。无论如何,这都是微观优化。 - cletus

12

更好的问题是:

哪个更容易理解/使用?

这比性能上的概念差异更为重要。个人认为,性能不应成为确定两者区别的标准。如果他们不喜欢我对此做出挑战,我也不会因未得到工作而感到不满。 ;)


10
在现代的Java实现中,这种情况并不是真的。以十亿为基准进行求和的结果如下:
Java(TM) SE Runtime Environment 1.6.0_05-b13
Java HotSpot(TM) Server VM 10.0-b19
up 1000000000: 1817ms 1.817ns/iteration (sum 499999999500000000)
up 1000000000: 1786ms 1.786ns/iteration (sum 499999999500000000)
up 1000000000: 1778ms 1.778ns/iteration (sum 499999999500000000)
up 1000000000: 1769ms 1.769ns/iteration (sum 499999999500000000)
up 1000000000: 1769ms 1.769ns/iteration (sum 499999999500000000)
up 1000000000: 1766ms 1.766ns/iteration (sum 499999999500000000)
up 1000000000: 1776ms 1.776ns/iteration (sum 499999999500000000)
up 1000000000: 1768ms 1.768ns/iteration (sum 499999999500000000)
up 1000000000: 1771ms 1.771ns/iteration (sum 499999999500000000)
up 1000000000: 1768ms 1.768ns/iteration (sum 499999999500000000)
down 1000000000: 1847ms 1.847ns/iteration (sum 499999999500000000)
down 1000000000: 1842ms 1.842ns/iteration (sum 499999999500000000)
down 1000000000: 1838ms 1.838ns/iteration (sum 499999999500000000)
down 1000000000: 1832ms 1.832ns/iteration (sum 499999999500000000)
down 1000000000: 1842ms 1.842ns/iteration (sum 499999999500000000)
down 1000000000: 1838ms 1.838ns/iteration (sum 499999999500000000)
down 1000000000: 1838ms 1.838ns/iteration (sum 499999999500000000)
down 1000000000: 1847ms 1.847ns/iteration (sum 499999999500000000)
down 1000000000: 1839ms 1.839ns/iteration (sum 499999999500000000)
down 1000000000: 1838ms 1.838ns/iteration (sum 499999999500000000)
请注意,时间差异是脆弱的,循环中的任何小变化都可能导致时间差异。 编辑: 基准循环为:
        long sum = 0;
        for (int i = 0; i < limit; i++)
        {
            sum += i;
        }

        long sum = 0;
        for (int i = limit - 1; i >= 0; i--)
        {
            sum += i;
        }

使用 int 类型的 sum 变量会快大约三倍,但是可能会发生溢出。 使用 BigInteger 则会慢超过 50 倍:

BigInteger up 1000000000: 105943ms 105.943ns/iteration (sum 499999999500000000)

那么,计算“sum 499999999500000000”时,您使用了longs还是BigIntegers?后者特别耗费资源,会淹没不同的循环。请注意,从范围的上限开始会使数字非常快地变得非常大,由于添加BigIntegers的速度取决于它们的大小,这将使它成为一个非常不公平的测试。请注意,我并不争论性能问题,我只是说,除非详细说明您的方法,否则基准测试是没有用的,因此其他人可以仔细检查它们是否存在偏见,并为自己重现结果。 - Artelius

6

回答这个问题实际上只有两种方法。

  1. 告诉你这真的不重要,你甚至在浪费时间思考。

  2. 告诉你唯一的方法是在你真正关心的生产硬件、操作系统和JRE安装上运行可信赖的基准测试来了解。

因此,我为您制作了一个可运行的基准测试,您可以在此处尝试:

http://code.google.com/p/caliper/source/browse/trunk/test/examples/LoopingBackwardsBenchmark.java

这个 Caliper 框架还不够成熟,所以可能不是完全明显该怎么做,但如果你真的很关心,你可以弄清楚。以下是它在我的 Linux 系统上给出的结果:

     max benchmark        ns
       2  Forwards         4
       2 Backwards         3
      20  Forwards         9
      20 Backwards        20
    2000  Forwards      1007
    2000 Backwards      1011
20000000  Forwards   9757363
20000000 Backwards  10303707

往后倒带看起来像是赢了吗?

1
完全可以理解,你只循环两次会发生什么?!如果你有三个这样的东西,那么你就可以节省3纳秒。三个该死的纳秒啊!我猜你只是还不够强硬。是的,我在开玩笑。 - rball
1
我们已经破坏了你的链接。祈祷我们不会进一步破坏它。实际上,链接又断了。也许,如果不太大的话,你可以在这里发布它,这样它就不会再遭受进一步的破坏了。 - paxdiablo

6
通常情况下,正向计数的真实代码会运行得更快。原因如下:
  • 处理器被优化为向前读取内存。
  • HotSpot(以及其他字节码到本地编译器)会严重优化正向循环,但不会费心去处理反向循环,因为它们发生的频率较低。
  • 正向通常更明显,而干净的代码通常更快。
因此,高兴地做正确的事情通常会更快。不必要的微观优化是有害的。自从编写6502汇编语言以来,我就没有刻意编写过反向循环。

3
你确定提问者期望得到一个直截了当的答案(例如“第一个更快”或“第二个更快”),还是这个问题只是为了引发讨论,就像人们在回答中所做的那样?
一般来说,不可能确定哪个更快,因为它严重依赖于Java编译器、JRE、CPU和其他因素。仅仅因为你认为其中一个更快而在程序中使用它们之一,而没有理解最低层次的细节,这是迷信式编程。即使其中一个版本在你特定的环境中比另一个版本更快,差异也很小,可以忽略不计。
写清晰的代码,而不是试图聪明地去做。

在引用的页面中,作者说第二个更快,但没有给出原因。因此,产生了这个问题。 - rball

3
这些问题基于旧的最佳实践建议。这是一个比较的过程:与0进行比较被认为更快。多年前,这可能被视为非常重要。现在,特别是对于Java而言,我宁愿让编译器和虚拟机完成它们的工作,我会专注于编写易于维护和理解的代码。
除非有其他原因需要这样做。请记住,Java应用程序并不总是在HotSpot和/或快速硬件上运行。

2
关于在JVM中测试零的问题:可以使用ifeq来完成,而测试其他值则需要使用if_icmpeq,这也需要在堆栈上放置一个额外的值。
如问题中所述,测试> 0可以使用ifgt,而测试< 100001则需要使用if_icmplt

1
只有在JVM解释字节码时才适用,一旦优化为本机代码,就没有区别,而在空循环的情况下可能会被替换为“nothing”。 - Peter Lawrey
即使在本地代码中,大多数架构都有一条指令与零进行比较,还有一种或两种其他方式与其他所有东西进行比较,这会慢上一两个时钟周期。理论上,这可能会有所区别,即使我说这种差异不值得计算,机会是你将不得不在循环内执行其他愚蠢的“技巧”,只是因为你计算错误了。典型的微观优化。 - Fredrik
@Fredrik:大多数架构在执行增量/减量时都可以测试零。因此,您根本不需要比较指令。x86会在任何算术指令的一部分中更新“零标志”(以及其他标志),而ARM则允许您指定是否要更新特定算术指令的标志。但是,由于更好的流水线和超标量操作,这种影响要小得多。 - Artelius
@Artelius:我知道(即使我不同意它对“大多数架构”有效,但我想这取决于你在计数时画线的位置)。然而,仅测试零标志通常比执行其他操作更快。事实上,您可以在一条指令中同时执行两个操作并不重要,因为并非所有指令都在相等数量的时钟周期内执行。尽管如此,这仍然是无关紧要的,在现实中并没有太大的区别。 - Fredrik

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