译-Java字节码:学会字节码,更上一层楼

下文中有关字节码的内容和字节码本身,是基于Java 2 SDK标准版 v1.2.1 javac compiler. 不同的编译器生成的字节码或略有不同

为何要了解字节码?

字节码作为 Java 程序的中间语言 正如汇编是 C/C++程序的中间语言。顶级的C/C++程序员是知道他们的程序编译出来的汇编指令集的。在做性能和内存调优的时候,这种技能至关重要的。了解你的代码编译出来的汇编指令,会对你实现性能和内存目标时有所启发。此外,当在追踪一个问题时,用调试器反汇编源码,一步一步执行汇编代码,通常很有用

Java 经常被忽视的部分就是 字节码,对于Java程序员,理解 字节码 和Java编译器可能生成的字节码 就像 C/C++程序员理解汇编一样有益

字节码就是你的程序。不管JIT 或者hotspot runtime (即时编译器),字节码在你的代码的执行速度和大小上占着重要的一部分。考虑一下,你生成越多的字节码,那么class文件就越大,JIT或者hotpot runtime就要编译更多的字节。下面就带你深入了解下Java字节码。

生成字节码

1
2
javac Employee.java
javap -c Employee > Employee.bc
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
Compiled from Employee.java
class Employee extends java.lang.Object {
public Employee(java.lang.String,int);
public java.lang.String employeeName();
public int employeeNumber();
}

Method Employee(java.lang.String,int)
0 aload_0
1 invokespecial #3 <Method java.lang.Object()>
4 aload_0
5 aload_1
6 putfield #5 <Field java.lang.String name>
9 aload_0
10 iload_2
11 putfield #4 <Field int idNumber>
14 aload_0
15 aload_1
16 iload_2
17 invokespecial #6 <Method void storeData(java.lang.String, int)>
20 return

Method java.lang.String employeeName()
0 aload_0
1 getfield #5 <Field java.lang.String name>
4 areturn

Method int employeeNumber()
0 aload_0
1 getfield #4 <Field int idNumber>
4 ireturn

Method void storeData(java.lang.String, int)
0 return

这个类十分简单,包含了两个成员变量,一个构造函数,和三个方法。字节码文件前五行列出了源代码文件名,类的定义以及它的基类(默认所有类继承与java.lang.Object),和构造函数和类方法,再之后列出了每一个构造函数的字节码,再后面是按照字母序列出来的类方法的字节码

你可能已经注意到某些操作码是以i或者a开头的。例如,在Employee类构造器,你能看到 aload_0iload_2 。这些前缀表示了操作码使用的类型。前缀 a 意味着 这个操作码 正在操作一个对象的引用。前缀 i 意味着这个操作码正在操作一个整数。其他操作码用 b 表示byte, c 表示char,d表示double,等等等等。操作码的前缀让你一眼看出来正在被操作的数据类型

注意:单个的代码通常被成为操作码,多个操作码通常被称作字节码

细节

为了理解字节码的细节,我们需要讨论下Java虚拟机(JVM)是如何处理字节码的执行。JVM是基于堆栈的。每个线程都有一个JVM存储栈帧的栈,每当一个方法被调用,一个栈帧就会被创建出来,一个栈帧包括了一个操作数栈,一个局部变量表,以及这个类的当前方法的运行时常量池的引用。从概念上来说,它类似于:

A frame

本地变量的列表,也被称为本地变量表,包含有这个方法的入参以及被用来保留本地变量的值。列表开始与0,最开始存储的是方法的入参。如果这是一个构造函数或者一个成员函数,存储在0位的是实例的引用(this),之后第一位保存着第一个正式的参数,第二位保留着第二个正式的参数,以此类推。对一个静态方法,0位存储的是第一个正式的入参,1位存储的第二个正式的入参,以此类推。

本地变量表的大小在编译时间就已经确定了,取决于本地变量的数量和方法的参数。操作数栈是一个用来PUSH/POP值的后进先出(LIFO)的堆栈。它的大小也依然是在编译的时候就确定了。某些操作码PUSH值到操作数栈;其余的从操作数栈中获取操作码,操作他们,并且PUSH进入结果。操作数栈同样用来接收方法的返回值

1
2
3
4
5
6
7
8
9
public String employeeName()
{
return name;
}

Method java.lang.String employeeName()
0 aload_0
1 getfield #5 <Field java.lang.String name>
4 areturn

这个方法的字节码包含了三个操作码。第一个是aload_0,它对应的操作是:把本地变量表的首位压入到操作数栈中。前面提到本地变量表是用来传递方法的参数。对于构造函数和成员函数,this引用总是存储在本地变量表的首位(下标为0)。this变量必须被压入栈中,因为这个方法正在访问这个类的数据,名称。

下一次操作码,getfield 用于获取类的实例域。当操作码被执行后,栈顶数据(this)会被取出来。那么,#5 用来构建这个运行时实例池中对应name的引用的序号。当这个引用被获取后,压入操作数栈中。

最后一个操作码,areturn,返回方法的引用。更详细点,areturn的执行会导致操作数堆栈的顶部数据,即name的引用被弹出,并压入调用方法的操作数堆栈。

employeeName的方法非常简单。在我们看更复杂的例子之前,我们需要检查下每个操作码左边的数值。在employeeName 方法的字节码中,这些值是0,1,4。每一个方法都有一个对应的操作码数组。这些值相当于这些存储操作码和它的参数的数组的下标。你可能会好奇为什么这些数据不是连续的。既然每个字节码每个指令占用一个字节,那么为什么序号不是0,1,2?原因是一些操作码是带有参数的,这些参数会占用一定的字节码的数组。例如 aload_0 指令没有参数,自然就只占用一个字节。因此,下一个操作码,getfield,在位置1。但是areturn是在位置4。这是因为getfield操作码和他的参数占了位置1,2,3。位置1用于getfield的操作码,位置2和位置3用来存储它的参数。这些参数用来构造在这个类中这个值在运行是常量池中存储的位置。下面的表中展示了employeeName 字节码数组 的样子:

Bytecode array for employeeName method

事实上,字节码数组包含的代表指令的字节。查看用十六进制的编辑器查看.class文件,你会在字节码数组中看到下面的值:

Values in the bytecode array

2A,B4,和B0 分别相当于 aload_0, getfield, areturn

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public Employee(String strName, int num)
{
name = strName;
idNumber = num;
storeData(strName, num);
}

Method Employee(java.lang.String,int)
0 aload_0
1 invokespecial #3 <Method java.lang.Object()>
4 aload_0
5 aload_1
6 putfield #5 <Field java.lang.String name>
9 aload_0
10 iload_2
11 putfield #4 <Field int idNumber>
14 aload_0
15 aload_1
16 iload_2
17 invokespecial #6 <Method void storeData(java.lang.String, int)>
20 return

第一个在位置0的操作码指令,aload_0 将this引用压入操作码栈。(记住,实例方法和构造函数的本地变量表中第一个条目是this引用)

下一个在位置1的操作码指令,invokespecial,调用了基类的构造函数。因为所有没有明确指定继承任意其他的类都隐式地继承java.lang.Object类,编译器提供了必要的字节码去调用基类的构造函数。在这个操作码执行过程中,操作数栈的栈顶数据,this,被弹出。

下面再位置4,5的两个操作码,压入本地变量表中最开始的两个条目到操作数堆栈中。第一个被压入的值是this,第二个是第构造函数一个正式的参数,strName。这些压入的值都是为了在位置6的putfield操作码做准备。

putfield 操作码 抛出栈顶的2个值,并且将strName的引用存储到被this引用的实例数据名。

下面三个在位置9,10,11的操作码指令,用构造函数第二个正式的参数num,和实例的变量,idNumber,执行着相同的操作。

下面三个在位置14,15,16的操作码指令,是在为调用storeDate方法准备数据。这些指令分别压入this引用,strName和num。this引入一定要被压入栈中,因为一个实例方法被调用。如果一个方式是静态的,那么this引用则不需要被压入。strName和num被压入是因为他们是storeData方法的参数。当storeData方法执行时,this,strName,num会分别占据那个方法的栈帧中的本地变量表中的序号为0,1,2的位置。

大小和速度问题

性能对于许多使用Java的桌面和服务器系统是一个关键问题。随着Java从这些系统转移到更小的嵌入式设备,大小问题也变得重要了。了解Java生成的字节码可以帮助你写出更精简,更有效的代码。举例来说,Java中的同步。下面的两个方法返回一个数组中第一位元素。两种方法使用同步方式,并且是功能等同的。

1
2
3
4
5
6
7
8
9
10
public synchronized int top1()
{
return intArr[0];
}
public int top2()
{
synchronized (this) {
return intArr[0];
}
}

尽管这些方法使用的同步是不同的,但是是功能等同的。但是,不宜察觉出来,两段代码在性能和大小上是不同的。在这个例子中,top1大概比top2快13个点,并且更小。检查下生成的字节码就明白这些方法有何不同了。字节码上加的注释是为了帮助更好的理解操作码做了什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
Method int top1()
0 aload_0 //Push the object reference(this) at index 将this压入本地变量表首位
//0 of the local variable table.
1 getfield #6 <Field int intArr[]>
//Pop the object reference(this) and push 弹出this,从常量池中压入intArr对象引用
//the object reference for intArr accessed
//from the constant pool.
4 iconst_0 //Push 0. 压入0
5 iaload //Pop the top two values and push the 弹出最顶部两个数据,压入intArr[0]
//value at index 0 of intArr.
6 ireturn //Pop top value and push it on the operand 弹出顶部值,压入调用方法操作数堆栈中,退出
//stack of the invoking method. Exit.

Method int top2()
0 aload_0 //Push the object reference(this) at index 将this压入本地变量表首位
//0 of the local variable table.
1 astore_2 //Pop the object reference(this) and store 弹出this,存储到本地变量表下标为2的位置
//at index 2 of the local variable table.
2 aload_2 //Push the object reference(this). 压入this
3 monitorenter //Pop the object reference(this) and 弹出this,并获取对象monitor
//acquire the object's monitor.
4 aload_0 //Beginning of the synchronized block. 开始同步块。压入this到本地变量表首位
//Push the object reference(this) at index
//0 of the local variable table.
5 getfield #6 <Field int intArr[]>
//Pop the object reference(this) and push 弹出this,压入intArr的引用
//the object reference for intArr accessed
//from the constant pool.
8 iconst_0 //Push 0. 压入0
9 iaload //Pop the top two values and push the 弹出最顶部两个数据,压入intArr[0]
//value at index 0 of intArr.
10 istore_1 //Pop the value and store it at index 1 of 弹出这个值并存储在本地变量表下标为1的位置
//the local variable table.
11 jsr 19 //Push the address of the next opcode(14) 压入下一个操作码地址,跳到位置19
//and jump to location 19.
14 iload_1 //Push the value at index 1 of the local 压入本地变更量表中下标为1的值
//variable table.
15 ireturn //Pop top value and push it on the operand 弹出顶部的值,并压入调用方法的操作数堆栈,退出
//stack of the invoking method. Exit.
16 aload_2 //End of the synchronized block. Push the 同步块结束。压入this到本地变量表下标为2的位置
//object reference(this) at index 2 of the
//local variable table.
17 monitorexit //Pop the object reference(this) and exit 弹出this,并退出monitor
//the monitor.
18 athrow //Pop the object reference(this) and throw 弹出this,并抛出一个异常
//an exception.
19 astore_3 //Pop the return address(14) and store it 弹出返回地址(14)并存储在本地变量表下标为3的位置
//at index 3 of the local variable table.
20 aload_2 //Push the object reference(this) at 压入this
//index 2 of the local variable table.
21 monitorexit //Pop the object reference(this) and exit 弹出this,推出monitor
//the monitor.
22 ret 3 //Return to the location indicated by 返回本地变量表中下标为3的值对应的地址(14)
//index 3 of the local variable table(14).
Exception table: //If any exception occurs between 如果在位置4(含位置4)和位置16(不含位置16)中出现异常
from to target type //location 4 (inclusive) and location 跳到位置16
4 16 16 any //16 (exclusive) jump to location 16.

top2方法更大更慢,原因在于同步以及异常处理。注意到top1方法使用的是synchronized方法修饰符,这样没有生成多余的字节码。相比之下,top2使用了在方法中使用了synchronized同步块。

在方法中使用synchronized同步块会生成monitorenter和monitorexit的操作码,以及生成附加的代码去处理异常。如果在执行同步块中的代码时抛出了异常,锁需要保证在退出同步块的之前被释放。top1的实现略微比top2高效;这可以产生非常小的性能增益。

当存在synchronized方法修饰符时,就像top1,获取锁和后续释放锁就不同是通过monitorenter和monitorexit来完成的了。不同的是,当JVM调用一个方法,它会检查ACC_SYNCHRONIZED属性标记。如果存在这个表标记,执行的线程会获取锁,调用方法,之后当方法返回时释放锁。如果在这个同步的方法中有异常抛出,锁会在异常离开方法时候自动释放。

注意:如果synchronized方法修饰符存在,ACC_SYNCHROINZED属性标记包含在方法的method_info结构中。

无论使用synchronized方法修饰符还是synchronized方法块,在大小上都会有影响。仅仅当你的代码要求同步,并且你了解因此带来的消耗的时候,使用同步方法。如果整个方法都需要同步,为了产生更小的和稍微快点的方法,相比同步块,我推荐方法修饰符。