在循环内部或外部声明变量

280

为什么下面的代码可以正常工作?

String str;
while (condition) {
    str = calculateStr();
    .....
}

但是这个被认为是危险/不正确的:

while (condition) {
    String str = calculateStr();
    .....
}

在循环外声明变量是必要的吗?


1
这个回答解决了你的问题吗?在循环之前或之中声明变量的区别? - user202729
20个回答

330

局部变量的作用域应该尽可能小。

在您的示例中,我假设 str 并没有在 while 循环之外使用,否则您不会提出这个问题,因为在 while 循环内部声明它不是一个选项,因为它无法编译。

所以,由于 str 没有在循环之外使用,因此 str 的最小可能作用域在 while 循环 内部

所以,答案是非常明确的,str 绝对应该在 while 循环内部声明。别废话。

唯一违反此规则的情况是,如果每个时钟周期对代码来说至关重要,那么您可能希望在外部范围内实例化某些内容,并重复使用它,而不是在内部范围的每次迭代中重新实例化。然而,这不适用于您的示例,因为在 Java 中字符串是不可变的:在循环开始时将始终创建 str 的新实例,并且必须在循环结束时将其丢弃,因此没有可能进行优化。

编辑:(注入我的下面评论)

无论如何,正确的做法是编写所有代码,为您的产品建立性能要求,根据此要求测量最终产品,如果不满足要求,则去优化。而通常发生的情况是,您会找到一些漂亮且正式的算法优化方法,只需在几个地方就可以使我们的程序符合其性能要求,而不必在整个代码库中进行大量调整和修改以挤压时钟周期。


2
最后一段的查询:如果它是另一个不是不可变的字符串,那么会有影响吗? - Harry Joy
2
@HarryJoy 当然可以,以 StringBuilder 为例,它是可变的。如果你在循环的每次迭代中使用 StringBuilder 来构建一个新字符串,那么你可以通过在循环外分配 StringBuilder 来优化这个过程。但是,这仍然不是一个值得推荐的做法。如果没有非常充分的理由,这样做就是一种过早的优化。 - Mike Nakis
7
@HarryJoy 正确的做法是要把所有代码都写得“适当”,为你的产品建立性能要求,将最终产品与此要求进行测量,如果不能满足要求,那么就进行优化。而且你知道吗?通常只需要在几个地方提供一些好的正式算法优化,就能达到目的,而不必在整个代码库中进行调整和修改以挤压时钟周期。 - Mike Nakis
2
@MikeNakis,我认为你的思路太狭隘了。 - Siten
6
现代的多千兆赫、多核心、流水线、多级内存缓存的CPU让我们能够专注于遵循最佳实践而无需担心时钟周期。此外,只有在需要时,优化才是明智的,并且在必要时,一些高度局部化的调整通常就能达到所需的性能,因此没有必要为了性能而在我们的代码中添加各种小技巧。 - Mike Nakis
显示剩余11条评论

324

我比较了这两个(相似)示例的字节码:

让我们看一下第一个示例

package inside;

public class Test {
    public static void main(String[] args) {
        while(true){
            String str = String.valueOf(System.currentTimeMillis());
            System.out.println(str);
        }
    }
}

执行 javac Test.java 后,执行 javap -c Test 命令,你会得到以下结果:

public class inside.Test extends java.lang.Object{
public inside.Test();
  Code:
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return

public static void main(java.lang.String[]);
  Code:
   0:   invokestatic    #2; //Method java/lang/System.currentTimeMillis:()J
   3:   invokestatic    #3; //Method java/lang/String.valueOf:(J)Ljava/lang/String;
   6:   astore_1
   7:   getstatic       #4; //Field java/lang/System.out:Ljava/io/PrintStream;
   10:  aload_1
   11:  invokevirtual   #5; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   14:  goto    0

}

让我们来看看第二个例子

package outside;

public class Test {
    public static void main(String[] args) {
        String str;
        while(true){
            str =  String.valueOf(System.currentTimeMillis());
            System.out.println(str);
        }
    }
}

在执行javac Test.java之后,使用javap -c Test命令,你会得到以下结果:

public class outside.Test extends java.lang.Object{
public outside.Test();
  Code:
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return

public static void main(java.lang.String[]);
  Code:
   0:   invokestatic    #2; //Method java/lang/System.currentTimeMillis:()J
   3:   invokestatic    #3; //Method java/lang/String.valueOf:(J)Ljava/lang/String;
   6:   astore_1
   7:   getstatic       #4; //Field java/lang/System.out:Ljava/io/PrintStream;
   10:  aload_1
   11:  invokevirtual   #5; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
   14:  goto    0

}

观察结果表明,这两个示例中没有区别。 这是JVM规范的结果...

但出于最佳编码实践的考虑,建议在尽可能小的范围内声明变量(在此示例中,它位于循环内部,因为这是唯一使用变量的地方)。


7
这是JVM规范的结果,而不是“编译器优化”。方法所需的堆栈插槽在进入方法时全部分配。这就是字节码的规定。 - user207421
2
@Arhimed 还有一个将其放在循环内部(或仅在'{}'块中)的原因:如果您在另一个作用域中声明了一些其他变量,则编译器将重用堆栈帧中为该变量分配的内存。 - Serge
1
如果它正在循环遍历数据对象列表,那么对于大量数据会有什么影响吗?可能是四万个。 - Mithun Khatri
9
对于任何热爱使用final关键字的人:在inside包中将str声明为final也没有任何区别 =) - nikodaemus

34

最小范围内声明对象可以提高代码的可读性

对于今天的编译器来说,性能并不重要(在这种情况下)。
从维护的角度看,第二个选项更好。 在尽可能狭窄的范围内声明和初始化变量。

正如唐纳德·厄文·克努斯所说:

"我们应该忘记小的效率问题,大约97%的时间:过早的优化是万恶之源。"

即程序员让性能考虑影响代码的设计的情况。这可能会导致设计不如预期那样简洁,或者代码出现错误,因为代码被优化复杂化,程序员受到优化的干扰。


1
"第二个选项的性能稍微更快" => 你有测量过吗?根据其中一个答案,字节码是相同的,所以我不明白性能怎么可能会不同。 - assylias
很抱歉,但那真的不是测试Java程序性能的正确方式(而且你怎么测试一个无限循环的性能呢?) - assylias
我同意你的其他观点 - 只是我认为没有性能差异。 - assylias

14

如果你想在循环以外使用 str,那么就把它声明在循环外面。否则,第二个版本就可以了。


13

请跳转到更新的答案...

对于那些关心性能的人,请删除 System.out 并将循环限制为 1 字节。在 Windows 7 Professional 64 位和 JDK-1.7.0_21 上,使用 double(测试 1/2)和使用 String(3/4),以毫秒为单位给出了经过的时间。字节码(也在测试1和测试2下给出),并没有相同。我太懒了,不想用可变和相对复杂的对象进行测试。

double

Test1 耗时:2710 毫秒

Test2 耗时:2790 毫秒

String(只需在测试中将 double 替换为 string)

Test3 耗时:1200 毫秒

Test4 耗时:3000 毫秒

编译和获取字节码

javac.exe LocalTest1.java

javap.exe -c LocalTest1 > LocalTest1.bc


public class LocalTest1 {

    public static void main(String[] args) throws Exception {
        long start = System.currentTimeMillis();
        double test;
        for (double i = 0; i < 1000000000; i++) {
            test = i;
        }
        long finish = System.currentTimeMillis();
        System.out.println("Test1 Took: " + (finish - start) + " msecs");
    }

}

public class LocalTest2 {

    public static void main(String[] args) throws Exception {
        long start = System.currentTimeMillis();
        for (double i = 0; i < 1000000000; i++) {
            double test = i;
        }
        long finish = System.currentTimeMillis();
        System.out.println("Test1 Took: " + (finish - start) + " msecs");
    }
}


Compiled from "LocalTest1.java"
public class LocalTest1 {
  public LocalTest1();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]) throws java.lang.Exception;
    Code:
       0: invokestatic  #2                  // Method java/lang/System.currentTimeMillis:()J
       3: lstore_1
       4: dconst_0
       5: dstore        5
       7: dload         5
       9: ldc2_w        #3                  // double 1.0E9d
      12: dcmpg
      13: ifge          28
      16: dload         5
      18: dstore_3
      19: dload         5
      21: dconst_1
      22: dadd
      23: dstore        5
      25: goto          7
      28: invokestatic  #2                  // Method java/lang/System.currentTimeMillis:()J
      31: lstore        5
      33: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
      36: new           #6                  // class java/lang/StringBuilder
      39: dup
      40: invokespecial #7                  // Method java/lang/StringBuilder."<init>":()V
      43: ldc           #8                  // String Test1 Took:
      45: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      48: lload         5
      50: lload_1
      51: lsub
      52: invokevirtual #10                 // Method java/lang/StringBuilder.append:(J)Ljava/lang/StringBuilder;
      55: ldc           #11                 // String  msecs
      57: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      60: invokevirtual #12                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      63: invokevirtual #13                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      66: return
}


Compiled from "LocalTest2.java"
public class LocalTest2 {
  public LocalTest2();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]) throws java.lang.Exception;
    Code:
       0: invokestatic  #2                  // Method java/lang/System.currentTimeMillis:()J
       3: lstore_1
       4: dconst_0
       5: dstore_3
       6: dload_3
       7: ldc2_w        #3                  // double 1.0E9d
      10: dcmpg
      11: ifge          24
      14: dload_3
      15: dstore        5
      17: dload_3
      18: dconst_1
      19: dadd
      20: dstore_3
      21: goto          6
      24: invokestatic  #2                  // Method java/lang/System.currentTimeMillis:()J
      27: lstore_3
      28: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
      31: new           #6                  // class java/lang/StringBuilder
      34: dup
      35: invokespecial #7                  // Method java/lang/StringBuilder."<init>":()V
      38: ldc           #8                  // String Test1 Took:
      40: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      43: lload_3
      44: lload_1
      45: lsub
      46: invokevirtual #10                 // Method java/lang/StringBuilder.append:(J)Ljava/lang/StringBuilder;
      49: ldc           #11                 // String  msecs
      51: invokevirtual #9                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      54: invokevirtual #12                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      57: invokevirtual #13                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      60: return
}

更新的答案

要比较所有JVM优化后的性能并不容易,但是还是有一定的可能性。更好的测试和详细结果请查看Google Caliper

  1. 有关变量在循环内部或循环之前声明的一些细节:你应该在循环内部还是循环之前声明变量?
  2. GitHub存储库:https://github.com/gunduru/jvdt
  3. 双精度情况和100M循环的测试结果(是的,包含所有JVM细节):https://microbenchmarks.appspot.com/runs/b1cef8d1-0e2c-4120-be61-a99faff625b4

DeclaredBefore 1,759.209 DeclaredInside 2,242.308

  • 在循环之前声明:1,759.209纳秒
  • 在循环内部声明:2,242.308纳秒

双重声明的部分测试代码

这段代码与上面的代码不完全相同。如果你只编写一个虚拟循环,JVM会跳过它,因此至少需要分配和返回一些内容。这也是Caliper文档中推荐的做法。

@Param int size; // Set automatically by framework, provided in the Main
/**
* Variable is declared inside the loop.
*
* @param reps
* @return
*/
public double timeDeclaredInside(int reps) {
    /* Dummy variable needed to workaround smart JVM */
    double dummy = 0;

    /* Test loop */
    for (double i = 0; i <= size; i++) {

        /* Declaration and assignment */
        double test = i;

        /* Dummy assignment to fake JVM */
        if(i == size) {
            dummy = test;
        }
    }
    return dummy;
}

/**
* Variable is declared before the loop.
*
* @param reps
* @return
*/
public double timeDeclaredBefore(int reps) {

    /* Dummy variable needed to workaround smart JVM */
    double dummy = 0;

    /* Actual test variable */
    double test = 0;

    /* Test loop */
    for (double i = 0; i <= size; i++) {

        /* Assignment */
        test = i;

        /* Not actually needed here, but we need consistent performance results */
        if(i == size) {
            dummy = test;
        }
    }
    return dummy;
}

总结:declaredBefore 表示更好的性能 -真的非常微小- 但与最小作用域原则相悖。JVM 应该为您实现此操作。


1
@EJP 对于那些对这个主题感兴趣的人来说,这应该是非常清晰的。方法论取自PrimosK的答案,以提供更有用的信息。老实说,我不知道如何改进这个答案,也许你可以点击编辑并展示给我们如何正确地做? - Onur Günduru
2
  1. Java字节码在运行时会被优化(重新排序、折叠等),因此不要过于关注.class文件中的内容。
  2. 为了获得2.8秒的性能提升,需要运行10亿次,因此每次运行大约需要2.8纳秒,相比安全和正确的编程风格,这是一个明显的胜利者。
  3. 由于您没有提供有关预热的信息,因此您的计时几乎没有用处。
- Hardcoded
不仅需要热身,还需要进行多次运行,记录所有运行之间的标准偏差,并查看差异与标准偏差相比如何。如果差异接近标准偏差,则可以非常确定您只是在查看噪声。 - Mike Nakis
@MikeNakis Calipher会处理这些问题,代码在Github上,请随意使用。需要注意的一点是,您必须从方法中返回某些内容,否则JVM将跳过赋值代码...因此,您需要有一个虚拟返回变量。顺便说一句,我并不认为Calipher是完美的,它旨在进行微基准测试,如果您有更好的选择,请分享以找到合理的基准线。长话短说,declaredBefore表示更好的性能-非常微小-并且违反了最小范围原则。JVM实际上应该为您执行此操作。 - Onur Günduru
@OnurGunduru 好的,抱歉,我评论之前应该查一下资料。很酷的东西。我仍然觉得很难理解为什么会有差别,甚至不相信实际上真的有差别。 - Mike Nakis
显示剩余8条评论

8

在代码中,变量的可见范围越小越好。


8

解决这个问题的一个方法是提供一个封装while循环的变量作用域:

{
  // all tmp loop variables here ....
  // ....
  String str;
  while(condition){
      str = calculateStr();
      .....
  }
}

当外部作用域结束时,它们将自动取消引用。

7
如果您在 while 循环后不需要使用 str(与作用域有关),则第二个条件即
  while(condition){
        String str = calculateStr();
        .....
    }

如果您只在condition为真时在堆栈上定义对象,则性能会更好。也就是说,仅在需要时使用它。


3
请注意,即使在第一种情况下,如果条件为false,也不会构建任何对象。 - Philipp Wendler
@ Phillip:是的,你说得对。我的错。我当时想的是现在这样。你觉得呢? - Cratylus
1
“在Java世界中,“在堆栈上定义对象”这个术语有点奇怪。此外,在运行时在堆栈上分配变量通常是无操作的,那么为什么要费心呢?作用域有助于帮助程序员解决实际问题。” - Philipp Wendler

4
我认为回答你的问题最好的资源是下面这篇文章: 在循环中声明变量和在循环之前声明变量的区别是什么? 根据我的理解,这取决于编程语言。我记得Java会对此进行优化,所以没有任何区别,但JavaScript(例如)将在每次循环中进行整个内存分配。特别是在Java中,我认为第二种方法在进行分析时会更快。

3

将字符串str声明在while循环外,可以让其在while循环内外被引用。将字符串str声明在while循环内部,只能在该while循环内被引用。


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