禁用时 Java 断言的性能拖累分析

2022-09-01 02:00:01

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

但是,如果我部署了一个包含断言的应用程序,并且这些断言被禁用,那么在热在那里并被忽略所涉及的惩罚是什么?


答案 1

与传统观点相反,断言确实会对运行时产生影响,并可能影响性能。平均而言,这种影响可能很小,在中位数情况下为零,但是当星星对齐得恰到好处时,它也可能很大。

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

分析

断言实现

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

让我们快速看一下它们是如何在现代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 个字节都与断言相关联。在前面,它会检查隐藏的静态字段,如果它是真的,它会跳过所有断言逻辑。否则,它只是以通常的方式执行两个检查,并在它们失败时构造并抛出一个对象。$assertionsDisabledAssertionError()

因此,在字节码级别断言支持没有什么特别之处 - 唯一的诀窍是字段,它 - 使用相同的输出 - 我们可以看到在类init时初始化:$assertionsDisabledjavapstatic 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

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

所以根本没有任何魔力。实际上,让我们尝试自己做同样的事情,使用我们自己的静态字段,我们基于系统属性加载: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

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

断言成本

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

  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;
}

这将编译为:

  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编译为no-op时,当断言被禁用时,它将被单独考虑。

真正的编译时断言

最后,值得注意的是,如果需要,您可以获得类似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 类,使其在整个项目中更加可行,并且您可以利用此现有糖来创建一个 1 行版本:SKIP_CHECKSassert

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

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


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

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


答案 2

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

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


推荐