英文原文链接,译文链接,原文作者:James Bloom,译者:有孚
明白Java代码是如何编译成字节码并在JVM上运行的非常重要,这有助于理解程序运行的时候究竟发生了些什么。理解这点不仅能搞清语言特性是如何实现的,并且在做方案讨论的时候能清楚相应的副作用及权衡利弊。
本文介绍了Java代码是如何编译成字节码并在JVM上执行的。想了解JVM的内部结构以及字节码运行时用到的各个内存区域,可以看下我前面的一篇关于JVM内部细节的文章。
本文分为三部分,每一部分都分成几个小节。每个小节都可以单独阅读,不过由于一些概念是逐步建立起来的,如果你依次阅读完所有章节会更简单一些。每一节都会覆盖到Java代码中的不同结构,并详细介绍了它们是如何编译并执行的。
1. 第一部分, 基础概念
变量
局部变量
JVM是一个基于栈的架构。方法执行的时候(包括main方法),在栈上会分配一个新的帧,这个栈帧包含一组局部变量。这组局部变量包含了方法运行过程中用到的所有变量,包括this引用,所有的方法参数,以及其它局部定义的变量。对于类方法(也就是static方法)来说,方法参数是从第0个位置开始的,而对于实例方法来说,第0个位置上的变量是this指针。
局部变量可以是以下这些类型:
* char
* long
* short
* int
* float
* double
* 引用
* 返回地址
除了long和double类型外,每个变量都只占局部变量区中的一个变量槽(slot),而long及double会占用两个连续的变量槽,因为这些类型是64位的。
当一个新的变量创建的时候,操作数栈(operand stack)会用来存储这个新变量的值。然后这个变量会存储到局部变量区中对应的位置上。如果这个变量不是基础类型的话,本地变量槽上存的就只是一个引用。这个引用指向堆的里一个对象。
比如:
1 |
int i = 5 ; |
编译后就成了
1 |
0 : bipush 5 |
2 |
2 : istore_0 |
bipush | 用来将一个字节作为整型数字压入操作数栈中,在这里5就会被压入操作数栈上。 |
istore_0 | 这是istore_
|
这条指令执行的时候,内存布局是这样的:
class文件中的每一个方法都会包含一个局部变量表,如果这段代码在一个方法里面的话,你会在类文件的局部变量表中发现如下的一条记录。
1 |
LocalVariableTable: |
2 |
Start Length Slot Name Signature |
3 |
0 1 1 i I |
字段
Java类里面的字段是作为类对象实例的一部分,存储在堆里面的(类变量对应存储在类对象里面)。关于字段的信息会添加到类文件里的field_info数组里,像下面这样:
01 |
ClassFile { |
02 |
u4 magic; |
03 |
u2 minor_version; |
04 |
u2 major_version; |
05 |
u2 constant_pool_count; |
06 |
cp_info contant_pool[constant_pool_count – 1 ]; |
07 |
u2 access_flags; |
08 |
u2 this_class; |
09 |
u2 super_class; |
10 |
u2 interfaces_count; |
11 |
u2 interfaces[interfaces_count]; |
12 |
u2 fields_count; |
13 |
field_info fields[fields_count]; |
14 |
u2 methods_count; |
15 |
method_info methods[methods_count]; |
16 |
u2 attributes_count; |
17 |
attribute_info attributes[attributes_count]; |
18 |
} |
另外,如果变量被初始化了,那么初始化的字节码会加到构造方法里。
下面这段代码编译了之后:
1 |
public class SimpleClass { |
2 |
3 |
public int simpleField = 100 ; |
4 |
5 |
} |
如果你用javap进行反编译,这个被添加到了field_info数组里的字段会多出一段描述信息。
1 |
public int simpleField; |
2 |
Signature: I |
3 |
flags: ACC_PUBLIC |
初始化变量的字节码会被加到构造方法里,像下面这样:
01 |
public SimpleClass(); |
02 |
Signature: ()V |
03 |
flags: ACC_PUBLIC |
04 |
Code: |
05 |
stack= 2 , locals= 1 , args_size= 1 |
06 |
0 : aload_0 |
07 |
1 : invokespecial # 1 // Method java/lang/Object."<init>":()V |
08 |
4 : aload_0 |
09 |
5 : bipush 100 |
10 |
7 : putfield # 2 // Field simpleField:I |
11 |
10 : return |
aload_0 | 从局部变量数组中加载一个对象引用到操作数栈的栈顶。尽管这段代码看起来没有构造方法,但是在编译器生成的默认的构造方法里,就会包含这段初始化的代码。第一个局部变量正好是this引用,于是aload_0把this引用压到操作数栈中。aload_0是aload_指令集中的一条,这组指令会将引用加载到操作数栈中。n对应的是局部变量数组中的位置,并且也只能是0,1,2,3。还有类似的加载指令,它们加载的并不是对象引用,比如iload_,lload_,fload_,和dload_, 这里i代表int,l代表long,f代表float,d代表double。局部变量的在数组中的位置大于3的,得通过iload,lload,fload,dload,和aload进行加载,这些指令都接受一个操作数,它代表的是要加载的局部变量的在数组中的位置。 |
invokespecial | 这条指令可以用来调用对象实例的构造方法,私有方法和父类中的方法。它是方法调用指令集中的一条,其它的还有invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual.这里的invokespecial指令调用的是父类也就是java.lang.Object的构造方法。 |
bipush | 它是用来把一个字节作为整型压到操作数栈中的,在这里100会被压到操作数栈里。 |
putfield | 它接受一个操作数,这个操作数引用的是运行时常量池里的一个字段,在这里这个字段是simpleField。赋给这个字段的值,以及包含这个字段的对象引用,在执行这条指令的时候,都 会从操作数栈顶上pop出来。前面的aload_0指令已经把包含这个字段的对象压到操作数栈上了,而后面的bipush又把100压到栈里。最后putfield指令会将这两个值从栈顶弹出。执行完的结果就是这个对象的simpleField这个字段的值更新成了100。 |
上述代码执行的时候内存里面是这样的:
这里的putfield指令的操作数引用的是常量池里的第二个位置。JVM会为每个类型维护一个常量池,运行时的数据结构有点类似一个符号表,尽管它包含的信息更多。Java中的字节码操作需要对应的数据,但通常这些数据都太大了,存储在字节码里不适合,它们会被存储在常量池里面,而字节码包含一个常量池里的引用 。当类文件生成的时候,其中的一块就是常量池:
01 |
Constant pool: |
02 |
# 1 = Methodref # 4 .# 16 // java/lang/Object."<init>":()V |
03 |
# 2 = Fieldref # 3 .# 17 // SimpleClass.simpleField:I |
04 |
# 3 = Class # 13 // SimpleClass |
05 |
# 4 = Class # 19 // java/lang/Object |
06 |
# 5 = Utf8 simpleField |
07 |
# 6 = Utf8 I |
08 |
# 7 = Utf8 <init> |
09 |
# 8 = Utf8 ()V |
10 |
# 9 = Utf8 Code |
11 |
# 10 = Utf8 LineNumberTable |
12 |
# 11 = Utf8 LocalVariableTable |
13 |
# 12 = Utf8 this |
14 |
# 13 = Utf8 SimpleClass |
15 |
# 14 = Utf8 SourceFile |
16 |
# 15 = Utf8 SimpleClass.java |
17 |
# 16 = NameAndType # 7 :# 8 // "<init>":()V |
18 |
# 17 = NameAndType # 5 :# 6 // simpleField:I |
19 |
# 18 = Utf8 LSimpleClass; |
20 |
# 19 = Utf8 java/lang/Object |
常量字段(类常量)
带有final标记的常量字段在class文件里会被标记成ACC_FINAL.
比如
1 |
public class SimpleClass { |
2 |
3 |
public final int simpleField = 100 ; |
4 |
5 |
} |
字段的描述信息会标记成ACC_FINAL:
1 |
public static final int simpleField = 100 ; |
2 |
Signature: I |
3 |
flags: ACC_PUBLIC, ACC_FINAL |
4 |
ConstantValue: int 100 |
对应的初始化代码并不变:
1 |
4 : aload_0 |
2 |
5 : bipush 100 |
3 |
7 : putfield # 2 // Field simpleField:I |
静态变量
带有static修饰符的静态变量则会被标记成ACC_STATIC:
1 |
public static int simpleField; |
2 |
Signature: I |
3 |
flags: ACC_PUBLIC, ACC_STATIC |
不过在实例的构造方法中却再也找不到对应的初始化代码了。因为static变量会在类的构造方法中进行初始化,并且它用的是putstatic指令而不是putfiled。
1 |
static {}; |
2 |
Signature: ()V |
3 |
flags: ACC_STATIC |
4 |
Code: |
5 |
stack= 1 , locals= 0 , args_size= 0 |
6 |
0 : bipush 100 |
7 |
2 : putstatic # 2 // Field simpleField:I |
8 |
5 : return |
未完待续。