字节码提供了应用程序性能的线索
级别:中级
Jack Shirazi (jack@JavaPerformanceTuning.com),董事,JavaPerformanceTuning.com
Kirk Pepperdine (kirk@JavaPerformanceTuning.com),首席技术官,JavaPerformanceTuning.com
热衷于 Java 性能的 Jack Shirazi 和 Kirk Pepperdine ?? JavaPerformanceTuning.com 的董事和 CTO ?? 跟踪遍布 Internet 上的性能讨论,探究是什么在困扰着开发人员。在浏览 Usenet 新闻组 comp.lang.java 时,他们碰到了几个有意思的底层性能调整问题。在 关注性能 的这篇文章中,他们对字节码作了一些分析,检验并回答了其中的一些问题。
尽管没有专门针对 Java 性能的 Usenet 讨论组,但是有许多关于性能调整和优化的讨论。这些讨论中很大一部分基于从宏性能基准测试中得到的结果,所以在本月的专栏中我们也预备谈论有关宏基准测试(microbenchmarking)的好处和不足之处。
前置还后置?
有一个问题非凡引起我们注重:哪一种运算更快:i++ 还是 ++i?在我们浏览过的几乎每一个论坛中都可以看到以不同的形式提出的这个问题。虽然这个问题很简单,但是看来没有一个绝对的答案。
首先介绍一下它们的区别,++i 使用前置增量运算符,而 i++ 使用后置增量运算符。虽然它们都增加变量 i,但是前置增量运算符在增量运算之前返回 i 的值,而后置增量运算符在增量运算之后返回值 i。一个简单的测试程序展示了这种区别:
public class Test {
public static void main(String[] args) {
int pre = 1;
int post = 1;
System.out.println("++pre = " + (++pre));
System.out.println("post++ = " + (post++));
}
}
运行 Test 类生成以下的输出:
++pre = 2
post++ = 1
宏基准测试
难道不能试着反复运行每次运算,并观察哪一种运算有更快的运行时吗?简单的回答是能,但是危险在于宏基准测试并不总是测量您想要它们测量的内容。相当多的时候,即时(JIT)编译器的优化和变化掩盖了底层性能中所有可检测的差异。例如,一个这种测试显示第二个 i++ 运算比第一个 ++i 测试更快。但是改变测试顺序显示正好相反的结果!从这里我们只能得出测试方法有缺陷的结论。进一步的调查表明,这种令人困惑的结果来源于在第一次测试时发生的 HotSpot 优化。这些优化有双重效果:使第一次运行增加了额外的开销,并去掉了第二次运行时的解释成本。
宏基准测试的其他变化,如在发生了 JIT 启动成本后重复测试,在反复运行时只能给出不确定的结果。它可能告诉我们两种运算符在速度上没有区别,但是我们对此不能确定。
iinc 字节码运算符
Heinz Kabutx 博士在其新闻信 The Java Specialists Newsletter 中,问他的读者哪一个更快: i++、++i 还是 i+=1?在 Issue 64 中,他报告说有一位读者用一种简单的技术回答了他的问题:查看编译的字节码。事实上,他考察了四种增量语句:
++i;
i++;
i -= -1;
i += 1;
可以用 Java SDK 所带的反汇编程序 javap 很轻易地分析编译的字节码。这四种增量语句的每一种得到的字节码都是 iinc 1 1。
iinc 运算符有两个参数。第一个参数指定变量在 JVM 的局部变量表中的索引,第二个参数指定变量的增量值。
这是不是给了我们一个明确的回答?无论如何,假如不同的源代码编译为同样的字节码,那么在速度上没有区别,是不是?
运算符上下文
那么,假如代码片断都编译为同样的字节码,使用不同的运算符的意义何在?好,让我们回过头看前置增量符和后置增量符。要害的一点是什么时候访问变量。假如不访问变量,那么这些运算符之间就没有什么区别。语句 i++ 和 ++i 本身在功能上是一样的。不过,语句 j=i++ 和 j=++i 在功能上是 不一样的。我们需要分析在额外的赋值上下文中的字节码。考虑这两个类似的方法:
public static int preIncrement() {
int i = 0, j;
j = ++i;
return j;
}
public static int postIncrement() {
int i = 0, j;
j = i++;
return j;
}
反汇编 preIncrement() 和 postIncrement() 得到下面的字节码:
Method int preIncrement()
0 iconst_0
1 istore_0
2 iinc 0 1
5 iload_0
6 istore_1
7 iload_1
8 ireturn
Method int postIncrement()
0 iconst_0
1 istore_0
2 iload_0
3 iinc 0 1
6 istore_1
7 iload_1
8 ireturn
现在我们 可以 看到这两种方法间的区别:preIncrement() 返回 1,而 postIncrement() 返回 0。让我们分析字节码,更好地理解这种区别。首先,我们将解释在反汇编的代码中可以看到的不同字节码运算符。
字节码运算:i=0
iconst_0 运算符将整数 iconst_0 推到堆栈上。要完全理解这一点,请记住 JVM 模拟一个基于堆栈的 CPU(假如您以前没接触过堆栈,请参阅 java.util.Stack 类文档)。JVM 在需要以后对某些东西进行操作时,先将它们推到堆栈中,在预备对它们进行操作时弹出它们。
在 Java 语言中有几种不同的数据类型,对于不同的数据类型有不同的字节码运算符。对于某些特定的优化,值 -1、0、1、2、3、4 和 5 都有专门的字节码。假如我们不是处理这些值,那么编译器会生成 bipush 字节码运算,将一个特定的整数推到堆栈上(例如,假如方法的第一条语句是 int i = -2,那么第一个字节码将会 bipush -2)。
下一条语句 istore_0 看上去可能像另一个处理整数 -1 到 5 的非凡字节码,但是事实上,这次 _0 指向一个到局部变量表的索引。JVM 维护一个局部于方法的变量表,字节码 istore 在堆栈的顶部弹出这个值,并将这个值储存到局部变量表中。在这里我们用的是 istore_0,所以这个值储存在表的索引 0 处。
所有这些解释针对的是“i=0”的Java 字节码,它被转换为字节码:
0 iconst_0
1 istore_0
更多的字节码运算
现在我们知道了堆栈和局部变量表,我们可以更快地讨论其他字节码。正如我们前面说的,字节码 iinc 0 1 在局部变量表索引 0 处增量值 1,iload_0 将局部变量表索引 0 处的值推到椎栈中,而 ireturn 从堆栈中弹出这个值,并将它推到调用方法的操作数堆栈上。下面的表 1 概括了字节码。
表 1. 字节码
字节码 描述
iconst_0 将 0 推到堆栈中
iconst_1 将 1 推到堆栈中
istore_0 从堆栈中弹出这个值,并将它存储到局部变量表的索引 0 处
istore_1 从堆栈中弹出这个值,并将它存储到局部变量表的索引 1 处
iload_0 将局部变量表索引 0 处的值推到堆栈中
iload_1 将局部变量表索引 1 处的值推到堆栈中
iadd 从操作数堆栈中弹出两个整数并让它们相加。将得到的整数推回堆栈中
iinc 0 1 局部变量表索引 0 处的变量加 1
ireturn 从堆栈中弹出值并将它推到调用方法的操作数栈中。退出方法
比较方法
现在,让我们再看一下这些反汇编的字节码。我们将用 lvar 表示局部变量表,就像它是一个 Java 数组,并对字节码加上注释:
Method int preIncrement()
0 iconst_0 //push 0 onto the stack
1 istore_0 //pop 0 from the stack and store it at lvar[0], i.e. lvar[0]=0
2 iinc 0 1 //lvar[0] = lvar[0]+1 which means that now lvar[0]=1
5 iload_0 //push lvar[0] onto the stack, i.e. push 1
6 istore_1 //pop the stack (value at top is 1) and store at it lvar[1], i.e. lvar[1]=1
7 iload_1 //push lvar[1] onto the stack, i.e. push 1
8 ireturn //pop the stack (value at top is 1) to the invoking method i.e. return 1
Method int postIncrement()
0 iconst_0 //push 0 onto the stack
1 istore_0 //pop 0 from the stack and store it at lvar[0], i.e. lvar[0]=0
2 iload_0 //push lvar[0] onto the stack, i.e. push 0
3 iinc 0 1 //lvar[0] = lvar[0]+1 which means that now lvar[0]=1
6 istore_1 //pop the stack (value at top is 0) and store at it lvar[1], i.e. lvar[1]=0
7 iload_1 //push lvar[1] onto the stack, i.e. push 0
8 ireturn //pop the stack (value at top is 0) to the invoking method i.e. return 0
现在,希望您能更清楚地了解所发生的事情,以及方法之间的一些功能差别。惟一的差别是两个方法的第三个和第四个字节码交换了。注释的字节码清楚表明,在 postIncrement() 方法中,iinc 运算完全是多余的,因为从这一点起,不再使用被更新的局部变量元素 lvar[0]。对于这个特定的方法,一个优化 JIT 编译程序可以完全去掉这种字节码运算。所以在这种特定情形中,postIncrement() 方法可能有比 preIncrement() 操作更少的字节码运算,从而使它更加高效。但是在大多数使用后置增量运算符的情况下,增量运算是不能优化的。
那么谁更快呢?
我们学到了什么?是的,假如语句只有 ++i 和 i++ ,那么它们之间没有区别。只有在存在额外的赋值时,编译的字节码才会有区别。
在赋值的上下文中,比较前置增量运算符或者后置增量运算符的使用有可能得到不同的运行时。但是使用哪种运算的功能结果都不太可能是一样的。记住,在我们这里的例子里,方法实际上返回不同的值,它取决于我们是使用前置增量运算符还是后置增量运算符。在一个普通程序中,其中一种变化可能会成为一个缺陷。
结束语
在过去,我们可以根据一组运算的语言表达对它们的成本进行测量。这是因为这些运算到底层运行时环境的转换总是静态的,这在 Java 运行时中是不成立的。Java 运行时可以动态优化运行的代码,这是一种非凡强大的功能。尽管这种功能还没有使我们完全不能进行宏性能基准测试,但是它导致我们在使用这种技术时需要更加当心。
参考资料
阅读 Jack Shirazi 和 Kirk Pepperdine 的全部 关注性能 系列。
Greg Travis 的“如何封锁您的(或打开别人的) Java 代码”(developerWorks,2001 年 5 月)提供了有关反编译一个 Java 类文件的信息。
利用 The Jikes Research Virtual Machine (developerWorks,2000 年 2 月)了解更多有关 IBM 对高性能 JVM 的研究。
Click 博士在 JavaOne 2003 上展示了 High Performance Computing with HotSpot Server Compiler (PDF)。
The Java HotSpot Virtual Machine, v1.4.1 (java.sun.com,2002 年 9 月)是有关 JVM HotSpot 技术的官方白皮书。
Jack Shirazi 的“Micro-Tuning Step-by-Step”(ONJava,2002 年 3 月)提供了对宏性能基准测试的实用建议。
在 developerWorks Java 技术专区 可以找到数百篇有关 Java 编程各个方面的文章。
关于作者
Jack Shirazi 是 JavaPerformanceTuning.com 的董事,也是 Java Performance Tuning (O′Reilly)一书的作者。
Kirk Pepperdine 是 Java Performance Tuning.com 的首席技术官,并且在过去 15 年一直关注对象技术和性能调优。Kirk 是 ANT Developer′s Handbook 一书的合著者。