40

代码可以用其中的断言进行编译,并且可以在需要时激活/停用

但是,如果我部署了一个带有断言的应用程序并且这些断言被禁用,那么它们存在并被忽略的惩罚是什么?

4

3 回答 3

71

与传统观点相反,断言确实会影响运行时并可能影响性能。平均而言,影响可能很小,在中位情况下可能为零,但当恒星正好对齐时,影响可能会很大。

在运行时断言减慢速度的一些机制是相当“平滑”和可预测的(通常很小),但下面讨论的最后一种方式(内联失败)是棘手的,因为它是最大的潜在问题(你可能有一个数量级回归)并且它不是平滑的1

分析

断言实现

在分析assertJava 中的功能时,一件好事是它们在字节码/JVM 级别上并不是什么神奇的东西。也就是说,它们是.class在(.java 文件)编译时使用标准 Java 机制在文件中实现的,并且它们没有得到 JVM 2的任何特殊处理,而是依赖于适用于任何运行时编译代码的通常优化。

让我们快速了解一下它们是如何在现代 Oracle 8 JDK 上实现的(但 AFAIK 并没有永远改变)。

使用单个断言采用以下方法:

public int addAssert(int x, int y) {
    assert x > 0 && y > 0;
    return x + y;
} 

...编译该方法并使用以下命令反编译字节码javap -c foo.bar.Main

  public int addAssert(int, int);
    Code:
       0: getstatic     #17                 // Field $assertionsDisabled:Z
       3: ifne          22
       6: iload_1
       7: ifle          14
      10: iload_2
      11: ifgt          22
      14: new           #39                 // class java/lang/AssertionError
      17: dup
      18: invokespecial #41                 // Method java/lang/AssertionError."<init>":()V
      21: athrow
      22: iload_1
      23: iload_2
      24: iadd
      25: ireturn

字节码的前 22 个字节都与断言相关联。就在前面,它检查隐藏的静态$assertionsDisabled字段,如果为真,则跳过所有的断言逻辑。否则,它只是以通常的方式进行两次检查,AssertionError()如果失败则构造并抛出一个对象。

因此,字节码级别的断言支持并没有什么特别之处——唯一的技巧是$assertionsDisabled字段,使用相同的javap输出——我们可以看到static final在类初始化时初始化:

  static final boolean $assertionsDisabled;

  static {};
    Code:
       0: ldc           #1                  // class foo/Scrap
       2: invokevirtual #11                 // Method java/lang/Class.desiredAssertionStatus:()Z
       5: ifne          12
       8: iconst_1
       9: goto          13
      12: iconst_0
      13: putstatic     #17                 // Field $assertionsDisabled:Z

所以编译器创建了这个隐藏static final字段,并根据公共desiredAssertionStatus()方法加载它。

所以根本没有魔法。事实上,让我们自己尝试做同样的事情,使用我们自己SKIP_CHECKS基于系统属性加载的静态字段:

public static final boolean SKIP_CHECKS = Boolean.getBoolean("skip.checks");

public int addHomebrew(int x, int y) {
    if (!SKIP_CHECKS) {
        if (!(x > 0 && y > 0)) {
            throw new AssertionError();
        }
    }
    return x + y;
}

在这里,我们只是简单地写出断言正在做什么(我们甚至可以组合 if 语句,但我们会尝试尽可能地匹配断言)。让我们检查一下输出:

 public int addHomebrew(int, int);
    Code:
       0: getstatic     #18                 // Field SKIP_CHECKS:Z
       3: ifne          22
       6: iload_1
       7: ifle          14
      10: iload_2
      11: ifgt          22
      14: new           #33                 // class java/lang/AssertionError
      17: dup
      18: invokespecial #35                 // Method java/lang/AssertionError."<init>":()V
      21: athrow
      22: iload_1
      23: iload_2
      24: iadd
      25: ireturn

嗯,它几乎与断言版本相同的字节码。

断言成本

因此,我们几乎可以将“断言有多昂贵”的问题简化为“基于static final条件的始终采用的分支跳过的代码有多昂贵?”。好消息是,如果方法被编译,这些分支通常会被 C2 编译器完全优化掉。当然,即使在这种情况下,您仍然需要支付一些费用:

  1. 类文件更大,JIT 的代码也更多。
  2. 在 JIT 之前,解释版本可能会运行得更慢。
  3. 该函数的完整大小用于内联决策,因此即使在 disabled 时,断言的存在也会影响该决策

第 (1) 点和 (2) 点是在运行时编译 (JIT) 期间而不是在 java-file-compile 时删除断言的直接结果。这是与 C 和 C++ 断言的关键区别(但作为交换,您可以决定在每次启动二进制文件时使用断言,而不是在该决定中编译)。

第(3)点可能是最关键的,很少被提及,也很难分析。基本思想是,JIT 在做出内联决策时使用了几个大小阈值——一个小阈值(~30 字节)几乎总是内联,另一个更大的阈值(~300 字节)它从不内联。在阈值之间,是否内联取决于该方法是否是热的,以及其他启发式方法,例如它是否已经在其他地方内联。

由于阈值基于字节码大小,因此断言的使用会显着影响这些决策 - 在上面的示例中,函数中的 26 个字节中的全部 22 个字节都与断言相关。尤其是在使用许多小方法时,断言很容易将方法推到内联阈值之上。现在阈值只是启发式的,因此在某些情况下,将方法从内联更改为非内联可能会提高性能 - 但通常你想要更多而不是更少的内联,因为它是一个祖父优化,允许更多一次它发生。

减轻

解决此问题的一种方法是将大部分断言逻辑移至特殊函数,如下所示:

public int addAssertOutOfLine(int x, int y) {
    assertInRange(x,y);
    return x + y;
}

private static void assertInRange(int x, int y) {
    assert x > 0 && y > 0;
}

这编译为:

  public int addAssertOutOfLine(int, int);
    Code:
       0: iload_1
       1: iload_2
       2: invokestatic  #46                 // Method assertInRange:(II)V
       5: iload_1
       6: iload_2
       7: iadd
       8: ireturn

...因此将该函数的大小从 26 个字节减少到 9 个字节,其中 5 个与断言相关。当然,丢失的字节码刚刚移动到另一个函数,但这很好,因为它会在内联决策中单独考虑,并且当断言被禁用时 JIT 编译为无操作。

真正的编译时断言

最后,值得注意的是,您可以根据需要获得类似 C/C++ 的编译时断言。这些是其开/关状态被静态编译到二进制文件中的断言(在javac时间)。如果要启用断言,则需要一个新的二进制文件。另一方面,这种类型的断言在运行时是真正免费的。

如果我们将自制软件 SKIP_CHECKS 更改static final为在编译时已知,如下所示:

public static final boolean SKIP_CHECKS = true;

然后addHomebrew编译为:

  public int addHomebrew(int, int);
Code:
   0: iload_1
   1: iload_2
   2: iadd
   3: ireturn

也就是说,断言没有留下任何痕迹。在这种情况下,我们可以真正说运行时成本为零。您可以通过使用包装变量的单个 StaticAssert 类来使其在整个项目中更可行SKIP_CHECKS,并且您可以利用这个现有的assert糖制作一个 1 行版本:

public int addHomebrew2(int x, int y) {
    assert SKIP_CHECKS || (x > 0 && y > 0);
    return x + y;
}

同样,这在 javac 时间编译为字节码,没有任何断言的痕迹。不过,您将不得不处理有关死代码的 IDE 警告(至少在 Eclipse 中)。


1我的意思是这个问题可能是零影响,然后对周围的代码进行一个小的无害更改后,它可能会突然产生很大的影响。基本上,由于“内联或不内联”决策的二元效应,各种惩罚级别被高度量化。

2至少对于在运行时编译/运行断言相关代码的最重要部分。当然,JVM 中有少量支持接受-ea命令行参数和翻转默认断言状态(但如上所述,您可以通过属性以通用方式实现相同的效果)。

于 2016-12-01T19:44:24.117 回答
1

非常非常少。我相信它们在类加载过程中被删除。

我得到的最接近的证明是: Java 语言规范中的断言语句规范。它似乎是这样措辞的,以便可以在类加载时处理断言语句。

于 2011-01-07T11:20:11.027 回答
0

禁用断言完全消除了它们的性能损失。一旦禁用,它们在语义和性能上本质上等同于空语句

资源

于 2011-01-07T11:24:44.257 回答