禁用时Java断言的性能拖累

asi*_*iar 27 java performance assertions

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

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

Bee*_*ope 38

与传统观点相反,断言确实会产生运行时影响并可能影响性能.在大多数情况下,这种影响可能很小,但在某些情况下可能很大.断言在运行时降低速度的一些机制相当"平滑"且可预测(通常很小),但下面讨论的最后一种方法(内联失败)很棘手,因为它是最大的潜在问题(你可能有一个数量级回归)并且它不平滑1.

分析

断言实施

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

让我们快速浏览一下究竟他们是如何在一个现代化的Oracle JDK 8实施(但据我所知还没有在几乎永远改变).

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

public int addAssert(int x, int y) {
    assert x > 0 && y > 0;
    return x + y;
} 
Run Code Online (Sandbox Code Playgroud)

...编译该方法并使用以下代码反编译字节码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
Run Code Online (Sandbox Code Playgroud)

字节码的前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
Run Code Online (Sandbox Code Playgroud)

因此,编译器已创建此隐藏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;
}
Run Code Online (Sandbox Code Playgroud)

这里我们只是简单地写出断言正在做什么(我们甚至可以组合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
Run Code Online (Sandbox Code Playgroud)

嗯,它与字节版本的字节相同.

断言成本

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

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

点(1)和(2)是在运行时编译(JIT)期间删除断言的直接结果,而不是在java文件编译时.这是与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;
}
Run Code Online (Sandbox Code Playgroud)

这编译为:

  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
Run Code Online (Sandbox Code Playgroud)

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

真正的编译时断言

最后,值得注意的是,如果需要,可以获得C/C++ - 就像编译时断言一样.这些是断言,其开/关状态被静态编译为二进制(在javac时间).如果要启用断言,则需要新的二进制文件.另一方面,这种类型的断言在运行时是真正自由的.

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

public static final boolean SKIP_CHECKS = true;
Run Code Online (Sandbox Code Playgroud)

然后addHomebrew汇编到:

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

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

public int addHomebrew2(int x, int y) {
    assert SKIP_CHECKS || (x > 0 && y > 0);
    return x + y;
}
Run Code Online (Sandbox Code Playgroud)

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


1这个,我的意思是这个问题可能没有效果,然后在对周围代码进行一次无害的改变后,它可能会突然产生很大的影响.基本上,由于"内联或不内联"决策的二元效应,各种惩罚级别被大量量化.

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

  • 这是迄今为止最好的答案.你可以在头上指出断言可以防止内联.任何其他答案都没有解决这个问题. (5认同)
  • 令人着迷的是,这种分析没有更多的投票……也许,这是对这个概念的最初抵制,因为它与即使是有经验的程序员所相信的也大不相同。 (2认同)
  • 很好的答案——你成功了。 (2认同)

Gar*_*vis 1

非常非常少。我相信它们在类加载期间被删除。

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

  • 该规范明确要求加载“assert”字节码:“启用在其类或接口完成初始化之前执行的断言语句。” (3认同)
  • 在有限的情况下,断言可以[即使禁用断言也执行](http://docs.oracle.com/javase/specs/jls/se7/html/jls-14.html#jls-14.10)。因此,VM 在加载类时无法去除断言,但抖动没有此限制(只要它在类初始化后运行)。 (2认同)