禁用时 Java 断言的性能拖累分析
代码可以使用其中的断言进行编译,并且可以在需要时激活/停用。
但是,如果我部署了一个包含断言的应用程序,并且这些断言被禁用,那么在热在那里并被忽略所涉及的惩罚是什么?
代码可以使用其中的断言进行编译,并且可以在需要时激活/停用。
但是,如果我部署了一个包含断言的应用程序,并且这些断言被禁用,那么在热在那里并被忽略所涉及的惩罚是什么?
与传统观点相反,断言确实会对运行时产生影响,并可能影响性能。平均而言,这种影响可能很小,在中位数情况下为零,但是当星星对齐得恰到好处时,它也可能很大。
在运行时断言减慢速度的一些机制是相当“平滑”和可预测的(并且通常很小),但是下面讨论的最后一种方法(无法内联)很棘手,因为它是最大的潜在问题(你可能有一个数量级的回归),它不是平滑的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 个字节都与断言相关联。在前面,它会检查隐藏的静态字段,如果它是真的,它会跳过所有断言逻辑。否则,它只是以通常的方式执行两个检查,并在它们失败时构造并抛出一个对象。$assertionsDisabled
AssertionError()
因此,在字节码级别断言支持没有什么特别之处 - 唯一的诀窍是字段,它 - 使用相同的输出 - 我们可以看到在类init时初始化:$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
因此,编译器创建了这个隐藏字段,并根据公共所需的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) 和 (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_CHECKS
assert
public int addHomebrew2(int x, int y) {
assert SKIP_CHECKS || (x > 0 && y > 0);
return x + y;
}
同样,这在javac时间编译为字节码,没有断言的痕迹。不过,您将不得不处理有关死代码的IDE警告(至少在eclipse中)。
1 我的意思是,这个问题可能没有任何影响,然后在对周围代码进行一次小的无害更改之后,它可能会突然产生很大的影响。基本上,由于“内联或不内联”决策的二元效应,各种惩罚级别被严重量化。
2 至少对于在运行时编译/运行与断言相关的代码的所有重要部分。当然,JVM 中有少量支持接受命令行参数和翻转默认断言状态(但如上所述,您可以使用属性以通用方式实现相同的效果)。-ea