class test{
public static void main(String ...args){
int i = 5;
int j = i++;
int k = ++i;
}
}
Constant pool:
#1 = Methodref #3.#12 // java/lang/Object."<init>":()V
#2 = Class #13 // test
#3 = Class #14 // java/lang/Object
#4 = Utf8 <init>
#5 = Utf8 ()V
#6 = Utf8 Code
#7 = Utf8 LineNumberTable
#8 = Utf8 main
#9 = Utf8 ([Ljava/lang/String;)V
#10 = Utf8 SourceFile
#11 = Utf8 test.java
#12 = NameAndType #4:#5 // "<init>":()V
#13 = Utf8 test
#14 = Utf8 java/lang/Object
{
test();
descriptor: ()V
flags:
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public static void main(java.lang.String...);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC, ACC_VARARGS
Code:
stack=1, locals=4, args_size=1
0: iconst_5
1: istore_1
2: iload_1 //先load到操作数栈
3: iinc 1, 1 //自增
6: istore_2 //保存到j
7: iinc 1, 1 //直接自增
10: iload_1 //load到操作数栈
11: istore_3 //保存到k
12: return
LineNumberTable:
line 3: 0
line 4: 2
line 5: 7
line 6: 12
}
SourceFile: "test.java"
更多参考
引子
最近重新学习Java,在算术运算符章节里面发现了一个很奇怪的例子,代码如下:
class IncrementByteCode{
public static void main(String[] args){
int i = 0;
i = i++;
System.out.println(i);
}
}
按照正常的思路, 第4行代码中,变量i首先进行赋值运算,由于初始值为0,因此i依然为0;然后i自增一次,i由0变成1。第5行,显然输出结果应该为1。但是在实际运行之后得到的结果是0。我看的是某个java视频教程,他给出的解释是,在执行第4行代码时,内部的运行步骤是:先用一个临时的变量temp保存变量自增前的值,然后变量自增,最后自增表达式会把temp的值作为整个表达式的值返回,也就是把temp的值返回给了i,由于temp保存的是i自增前的值,所以i的值不会变。讲道理,这种解释太“任性”了,有种被“钦定”的感觉,所以我并不能接受。于是,我google了一下,看到一篇博客 [1],里面记录了和我一样的问题,并且从bytecode(字节码)的角度,给出了解释。我看了一下内容,不明觉厉,但是那些奇怪的操作码(opcode)并不能看懂,于是一个一个google。虽然每个操作码的含义弄明白了,但是那些个什么栈啊之类的还是不明白是怎么回事,后来发现了上面那篇博客的参考文章 [2],里面给出了Java字节码的简单解释,看完之后才算明白是怎么回事。
文章的余下部分,首先是我看完参考文章后的总结,然后是结合对字节码的理解给出上面例子运行结果的解释。也就说,整篇文章的内容基本来源于两篇博文(我会在最后给出参考列表),我只是在我理解的基础上给出心路历程。
字节码
什么是Java字节码?我们都知道,Java程序的运行步骤是这样的:
在JVM(Java虚拟机)里运行的实际上就是字节码,它就相当于C/C++编译后的汇编语言。为了对字节码有个直观的了解,我们先看一段简单的程序生成的字节码是什么样子的。
class Test{
public static void main(String[] args){
int a = 0;
System.out.println(a);
}
}
这段代码所在的文件是test.java,先编译Java文件,然后用javap命令即可从字节码文件中得到具体的字节码。如下:
上面那段代码里面没有构造函数,所以会默认有一个Test()的构造函数,关于这个构造函数的信息由"Test();"下的四行代码给出。接下来的“public static void main(java.lang.String[]);”下是main函数的具体内容。“Code:”后的代码就是操作码,每个操作码实现特定的功能。
为了理解字节码,我们首先需要了解一下随着字节码的执行,JVM是如何工作的。JVM基于栈。在代码的实际运行中,每个线程都用一个JVM栈存储frames。每当有方法调用时,frame就会被创建。一个frame由三部分组成:操作数栈(oprand stack)、局部变量数组、当前方法所在类的运行常量池的引用。局部变量数组存储的是方法的参数和局部变量的值。存储参数的索引从0开始,如果是构造方法或者实例化方法的frame,那么局部变量数组的“0”处存储的是“this”引用,然后再从“1”开始存储形参和局部变量;如果是静态方法的frame,局部变量表不会存储“this”引用,而是从“0”开始存储形参和局部变量。操作数栈临时存储参与运算的数值,然后进行相关操作。至于常量池,在类初始化的时候,会为给出的常量分配一个常量池,并且为每一个常量给出引用。盗用一下博客里面的图,一个frame的图形化描述如下:
下面,就上一段代码的main函数部分的字节码给出解释(为了大家看得方便,我把原始代码以及生成的main方法的字节码同时放在一个地方):由于main方法是一个静态方法,所以在局部变量表里面“0”处存储args,“iconst_0”将整数0压入操作数栈,“istore_1”将操作数栈的栈顶元素(这里是0),存储到局部变量表的“1”索引处。“getstatic”载入“System.out”域,“iload_1”将局部变量表索引“1”处的值压入操作数栈,然后“invokevirtual”调用println方法,将操作数栈的栈顶元素作为参数传入println方法,并且操作数栈的栈顶pop,最后“return”结束main方法。
// 原始代码
public static void main(String[] args){
int a = 0;
System.out.println(a);
}
// main方法的字节码
Code:
0: iconst_0
1: istore_1
2: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
5: iload_1
6: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
9: return
就我目前了解到的,像“istore_1”、“iload_1”这样的操作码,都是针对局部变量表的操作,下划线后面的数字代表局部变量表的索引位置,而“iconst_0”后面的数字代表实实在在的整数值。具体每个操作码的含义,请各位查阅Java字节码参考表 [3]。
还有一个问题:每行操作码前面的数字 0,1,2,5,6,9是什么意思?对于每个方法,都有一个字节码数组,数组的每个元素的大小是一个字节,每个操作码占据一个字节的位置。“iconst_0”占据一个字节的位置,所以“istore_1”到了“1”的位置。同理,“getstatic”到“2”的位置,由于此操作载入了一个域,这个域会占据特定空间(具体多大,自行查阅资料),所以“iload_1”到“5”的位置,依次类推。
自增运算的字节码解释
原始代码和主要的字节码如下:
// 原始代码
class IncrementByteCode{
public static void main(String[] args){
int i = 0;
i = ++i;
System.out.println(i);
}
}
// main方法的字节码
public static void main(java.lang.String[]);
Code:
0: iconst_0
1: istore_1
2: iload_1
3: iinc 1, 1
6: istore_1
7: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
10: iload_1
11: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
14: return
}
首先,由于是静态函数,初始时局部变量数组的“0”处存储的是args参数。“iconst_0”将整数0压入操作数栈,此时操作数栈为[0]。然后,“istore_1”将操作数栈的栈顶元素放入局部变量数组的“1”处,局部变量数组的内容是[args,0],此时操作数栈是[](也就是空栈)。接着,“iload_1”将局部变量数组里的“1”处的值压入操作数栈,操作数栈变成[0]。接下来注意了,“iinc”操作码是直接把局部变量数组的“1”处的值增加1,也就是说局部变量数组变成了[args,1]。接着,“istore_1”将操作数栈的栈顶元素,也就是0,放入局部变量数组的“1”处,局部变量数组又变成了[args,0]。“getstatic”载入“System.out”域,然后“iload_1”将局部变量表里“1”处的值(也就是0)压入操作数栈,作为参数传入“println”函数输出到命令行。也就是最终输出结果为0。
为了更加理解自增运算,我们看看下面这段代码:
// 原始代码
class IncrementByteCode{
public static void main(String[] args){
int i = 0;
i = i++;
System.out.println(i);
}
}
// main方法的字节码
public static void main(java.lang.String[]);
Code:
0: iconst_0
1: istore_1
2: iinc 1, 1
5: iload_1
6: istore_1
7: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
10: iload_1
11: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
14: return
}
与之前代码不同之处在于,源代码的i从先赋值再再增变成了先自增再赋值。对应的字节码中,先是在局部变量数组里面进行自增运算,然后将得到结果(1)压入操作数栈,最后再保存到局部变量表里面的就是1了,输出结果当然也是1。
其实,问题的关键在于自增运算发生的地方:局部变量数组!正常情况下,数值的操作应该都在操作数栈里完成。可能是为了减少运行时间吧。
总结
本来都没把自增运算当回事,因为学过太多语言了,几乎每一门语言都会有自增运算。由这么一个小小的例子,发现了字节码的用处,确实很有意思。
参考文献
https://zhuanlan.zhihu.com/p/22556307