http://www.importnew.com/13107.html
本文由 ImportNew - xiafei 翻译自 jamesdbloom。欢迎加入翻译小组。转载请见文末要求。
理解Java代码是如何被编译为字节码并在Java虚拟机(JVM)上执行是非常重要的,这将帮助理解你的程序是如何执行的。这样的理解不仅仅能够让你在逻辑上更好的掌握语言特性,而且能够有机会理解在做出重要决定时所需的权衡以及相应的副作用。
这篇文章解释了Java代码是如何被编译为字节码并在JVM上执行的,如果想要理解JVM的内部结构和以及字节码在运行过程中占用的不同的内存区域,请看我之前的深入JVM一文。
这篇文章被分为了3个部分,每个部分又分为若干个章节。你可以单独的阅读任何一个章节,但是如果按顺序来阅读,更容易建立起完整的概念体系。每个章节都会讲解Java代码结构的不同部分并解释各部分都是如何编译为字节码的,具体章节如下:
目录
这篇文章包含了很多的例子,展示了这些例子所对应生成的典型的字节码。字节码中在每条指令或者操作码之前的数字标识了字节的位置。举个例子,指令1: iconst_1
说明该指令由于没有操作数,所以只有1个字节的长度,因此接下来的字节码就在位置2;指令1: bipushu 5
就会占用两个字节,一个字节用于存储操作码bipush
,另一个存储操作数5
,这种情况下,因为操作数占用了位置2,所以下一条指令就会从位置3开始。
Java虚拟机(JVM)是基于栈结构的。对于最初的main方法产生的所有的方法调用,都会在栈中产生一个帧,这些帧各自包含一组局部变量,这组局部变量就是这个方法在执行过程中所需的所有变量,包括一个指向this
的引用、该方法的所有参数以及其他局部定义的变量。对于类方法(即static方法),其参数列表从0开始算起,而对于实例方法,位置0是用来存储this
引用。
局部变量可以是如下形式:
除了long和double类型是两倍的长度(占64个bit,而不是其他类型的32个bit)占用两个连续的位置之外,所有变量在局部变量表中都只占有一个位置。
当一个变量被创建时,操作数栈就会被用来存储这个新的变量的值,然后这个值就会被存储到局部变量表中的正确位置。如果这个变量不是基本数据类型,那么局部变量中仅仅会存储它的一个引用,这个引用指向堆中对应存储的对象。
举个例子:
1 |
|
被编译为:
1 2 |
|
操作码 | 说明 |
---|---|
bipush | 用于把一个byte作为一个int整型值放入操作数栈中,在这个例子中,5即被加入操作数栈中。 |
istore_0 | 形如istore_ 的一组操作码中的一个,这组操作码用于把int整型值存储到局部变量中。 指示了局部变量表中的存储位置,取值只能是0、1、2或者3。另外一个用于处于位置大于3位的操作码是istore ,这个操作码在使用的时候需要提供一个操作数用于表示需要存储到的局部变量表的位置。 |
当指令被执行的时候,内存里会发生这样的情况:
int i=69;对应的指令执行过程
类文件中同样为每一个方法包含了一个局部变量表,如果你的一个方法中包含了这样一句代码,那么你会在类文件的相应的方法的局部变量表中找到如下的条目:
局部变量表
Start(开始) Length (长度) Slot(位置个数) Name(名称) Signature(签名) 0 1 1 i I
成员变量(类变量)是作为类实例(对象)的一部分存储于堆之上的,其信息是被添加到类文件中的field_info
的数组中的,就像下面这样:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
|
另外,如果成员变量是被初始化了的,那么初始化操作会被放到构造方法中执行。
当下面的Java代码被编译时:
1 2 3 |
|
使用javap
工具查看字节码时,就会出现一个额外的片段,表明了成员变量被加入到了field_info
数组中:
1 2 3 |
|
用来初始化的字节码是被加入到了构造方法中的,就像下面这样(用粗体标志):
1 2 3 4 5 6 7 8 9 10 11 |
|
操作码 | 说明 |
---|---|
aload_0 | 从局部变量表的相应位置装载一个对象引用到操作数栈的栈顶。尽管示例中的代码并不包含构造函数,但是类变量的初始化代码实际上会在由编译器创建的默认的构造函数中执行。因此,第一个局部变量实际上指向this ,所以aload_0 把this 装载到了操作数栈中。实际上,aload_0 是一组格式为aload_ 的操作码中的一个,这一组操作码把对象的引用装载到操作数栈中。 标志了待处理的局部变量表中的位置,但取值仅可为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 | 用于把一个int整型值放入操作数栈中,这个例子中是把100放入操作数栈中(原文此处误写为5,译者注) |
putfield | 从运行时的常量池中取一个指向成员变量的引用,这个成员变量的值以及其对应的对象都会从操作数栈中弹出,本例中的成员变量即为simpleField 。例子中,aload_0 首先向操作数栈中添加了对象,然后bipush 向操作数栈中添加了100 这个值,最后putfield 从栈中弹出了这两个值,最终,这个对象的simpleField 的值被设置为100 。 |
当指令被执行的时候,内存里会发生这样的情况:
代码执行时,内存变化情况
putfield
只有一个指向常量池中的第二个位置的操作数。JVM为每一个类型都保持了常量池,尽管一个运行时数据结构包含了更多的数据,它还是非常类似一个符号表的结构。Java字节码需要运行时数据结构,但这些数据结构常常会比较大。如果直接放在字节码中,会占用太多的空间,所以Java把它存放在常量池中,而字节码中仅包含一个指向常量池的引用。当一个类文件被创建的时候,它拥有如下样式的常量池:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
|
一个用final
修饰的常量成员变量在类文件中是以ACC_FINAL
来标记的。
举个例子:
1 2 3 |
|
成员变量的描述中增加了ACC_FINAL
:
1 2 3 4 |
|
然而,构造方法的初始化却没有被影响:
1 2 3 |
|
一个用static
修饰的类的静态变量是在类文件中是以ACC_STATIC
标记的:
1 2 3 |
|
用于初始化静态变量的字节码并不存在于初始化构造函数中,而是作为类构造函数的一部分被初始化的,而且初始化时使用的是putstatic
操作符,而不是putfield
操作符。
1 2 3 4 5 6 7 8 |
|
条件选择语句,像if-else
和switch
语句,通过比较两个值的指令,控制字节码跳转到另一个分支上。
包括for循环
和while循环
在内的循环语句的工作原理和条件选择类似,只不过他们通常还要包含一个goto
指令,用来产生字节码的循环。do-while循环
不需要任何的goto
指令,这是由于它的条件分支判定就在字节码的尾部。更多的关于循环的内容,请阅读循环章节。
一些操作码可以在一条指令中完成比较两个int整型值或者两个引用类型,并执行一个分支的操作,但是比较其他一些类型的数据如double、long、float时,是分两步的操作,首先比较操作被执行,然后把1、0或-1放到操作数栈顶,然后根据操作数栈中的值是大于、小于或者等于0来执行下一步的分支操作。
我们首先用一个例子解释if-else
语句的编译执行过程,用于实现条件分支判定的不同指令将会在此之后做详细介绍。
接下来的代码示例展示了一个简单的用于比较两个int整型值的if-else
语句。
1 2 3 4 5 6 7 |
|
这个方法将会产生接下来的字节码:
1 2 3 4 5 6 7 |
|
首先两个参数使用iload_1
和iload_2
指令载入到操作数栈中,然后if_icmple
比较操作数栈顶的两个值,如果intOne
小于等于intTwo
,操作就会跳转到字节码的位置7所对应的分支。注意,这里的比较条件刚好和Java代码中的测试条件相反。因为,如果字节码中的测试条件成立,那么将会执行else
部分的代码。对应的,如果Java代码中的测试条件成立,那么将会执行if
部分的代码。换句话说,if_icmple
是测试if条件是否为假并跳过if
语句块(译者注:原文说的有点绕,其实就是在字节码中,默认顺序执行,只有条件不符合才跳转,所以才会出现判定条件刚好相反的情况,即判定的是不符合if语句的情况)。字节码中位置5和6的部分是if
代码块,而7和8的部分是else
代码块。
greaterThen
接下来的示例代码演示了一个稍微复杂一点的需要两步操作的比较的例子。
1 2 3 4 5 6 7 8 9 |
|
这个方法产生了接下来的字节码:
1 2 3 4 5 6 7 8 9 10 11 |
|
在这个例子中,首先使用fload_1
和fload_2
操作码将两个参数值放入操作数栈中,这个例子和之前的不同之处就是接下来的比较需要两步操作。第一步,先用fcmpl
比较floatOne
和floatTwo
,然后把比较的结果按照如下的方式放入操作数栈中:
floatOne > floatTwo -> 1
floatOne = floatTwo -> 1
floatOne < floatTwo -> 1
floatOne or floatTwo = NaN -> 1
第二步,使用ifle
判定前一步fcmpl
的结果,如果是小于等于0,则跳转到位置11处的字节码所对应的分支。
这个示例和前一个的不同之处还在于它只有在代码的尾部才出现一个return
语句,因此在if
代码块的尾部需要使用一个goto
语句来跳转,以防止继续执行else
代码段。这个goto
语句跳转到位置13的字节码处,该处的字节码使用iload_3
指令把存储在局部变量表的第三个位置的变量放入操作数栈中,以便于接下来的return
指令返回结果。
greaterThenFloat
除了比较数值的指令外,同样有比较引用的操作码,即==
,以及与null
比较的操作码,即== null
和!= null
,还有用于判定对象类型的操作码,即instanceof
。
操作码 | 说明 |
---|---|
if_icmp
eq ne lt le gt ge |
这组操作码适用于比较操作数栈顶的两个int整型值,然后跳转到相应的字节码。其中 可以是:eq – 等于 ne – 不等于 lt – 小于 le – 小于等于 gt – 大于 ge – 大于等于 |
if_acmp
eq ne |
这两个操作码是用于测试两个引用是否相同(eq )或者不相同(ne ),然后跳转到由操作数确定的对应的新的字节码的位置 |
ifnonnull ifnull |
这两个操作码是用来测试两个引用是否是null 或者不是null ,并跳转到由操作数确定的对应的新的字节码的位置 |
icmp | 这个操作码是用来比较操作数栈中的两个int整型值,然后按照以下的规则向操作数栈中放入一个值: 如果value1 > value2 -> 放入1 如果value1 = value2 -> 放入0 如果value1 < value2 -> 放入-1 |
fcomp
l g dcmp
l g |
这组操作码用于比较两个float 或者double 值,然后按照下面的规则,向操作数栈中放入一个值:如果value1 > value2 -> 放入1 如果value1 = value2 -> 放入0 如果value1 < value2 -> 放入-1 以 l 或g 结尾的操作码的区别在于如何处理NaN,fcmpg 和dcmpg 指令在遇到NaN时向操作数栈中放入int整型值1,而fcmpl 和dcmpl 指令在遇到NaN时向操作数栈中放入int整型值-1,后者保证了当待比较的数中的任何一个不是一个数字(NaN)时,比较都不会成功。举个例子,当测试是否x > y(x和y均为double类型)时,使用fcmpl 使得当其中任何一个值为NaN时,操作数栈中都会被放入-1,而接下来的指令永远是ifle ,用于判断值是否小于等于0。结果就是,只要x或者y中任何一个值为NaN,则ifle 指令会使分支跳过if代码段,从而阻止if代码块的执行 |
instancof | 这个操作码在操作数栈顶的对象是一个给定类的实例的时候,会向操作数栈中放入1,这个指令的操作数即使给定类在常量池中的索引位置。若果对象为null或者不是给定来的实例,则一个int整型值0将会被放入操作数栈中 |
if
eq ne lt le gt ge |
所有的这些操作码都是把操作数栈顶的值和0比较,并根据比较的结果将字节码跳转到给定的操作数所对应的位置。这些指令常常被用于实现复杂的条件逻辑,这些条件判定逻辑不能在一个指令中完成,例如测试一个方法的返回值。 |
switch
表达式中的类型必须为char、byte、short、int、Character、Byte、Short、Integer、String或enum类型。为了支持switch
语句,JVM提供了两个特殊的指令——tableswitch
和lookupswitch
,这两个指令都只能对int整型值进行操作,而char、byte、short和enmu类型都可以在内部转化为int类型,所以只能对int整型值操作不会是一个问题。Java 7中引入了对String类型的支持,这将在文章的后一部分做解释。
tableswitch
通常是较快的操作码,但同样通常需要更多的内存。tableswitch
的工作方式是列出位于最大和最小的case
值之间所有潜在的case
值,由于最大值和最小值是直接提供的。所以,一旦JVM发现switch的变量不在列出的case值的范围内,就会立即跳转到default
代码块。那些Java代码中不包含的case
值,只要位于最大值和最小值之间,都会被列出,只不过会指向default
代码块。这样就能保证所有在位于最大值和最小值之间的case都有对应的结果。下面的例子演示了switch
的代码:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
tableswitch
指令有0、1和4这三个case
值,每一个都对应了其预期的代码块,tableswitch
同时还包含了2和3的case
值,由于他们并未在Java代码中出现,所以指向默认的default
代码块。当这个指令被执行的时候,会检查操作数栈顶的值是否在最大值和最小值之间,如果不在,则直接跳转到default
代码块,也就是上面例子的位置42的字节码处。为了保证default
代码块能够被找到,在tableswitch
指令中,它总是出现在第一个字节(在字节码补齐之后)。如果操作数栈顶的值在最大值和最小值之间,则这个数就作为索引,在tableswitch
中查找需要跳转的正确的字节码的位置,举个例子来说,上一个例子中,当操作数为1时,就会被跳转到位置38的字节码处。下面的图展示了这个字节码是如何执行的:
tableswitch
如果各个case
值之间相隔比较远(即比较稀疏),这种做法就不可取了,因为这将消耗太多的内存。作为替代,当switch
中的case比较稀疏时,就会采用lookupswitch
指令。lookupswitch
只列出每个case
对应的字节码跳转,而非列出所有的可能值。当执行lookupswitch
指令时,操作数栈顶的值会和lookupswitch
中的每一个值进行比较,以决定跳转的地址。所以JVM执行该指令时,会在列表中搜索(查找)正确的匹配,因此lookupswitch
指令是慢于tableswitch
的,后者在执行时可以立即索引到对应的匹配。在编译switch
语句时,编译器必须在内存消耗和性能之间做一个权衡,以决定使用哪一个指令。接下来的代码解释了lookupswitch
的编译过程:
1 2 3 4 5 6 7 8 9 10 11 12 |
|
这将产生以下的字节码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
为了保证搜索算法的性能(优于线性搜索),带匹配的值是有序的,下图展示了这段代码是如何执行的:
lookupswitch
Java 7中的switch
语句增加了对String类型的支持。尽管现存的用于实现switch语句的操作码仅仅支持int整型值,但对String类型的支持并没有引入新的操作码,作为替代,String类型的switch
语句的执行分为两步。首先会比较操作数栈顶的值和case语句的哈希码(hashcode),这个可以用lookupswitch
或者tableswitch
指令实现(取决于哈希码的稀疏程度),然后会跳转到调用精确匹配的String.equals()的字节码,最后对String.equals()的结果使用tableswitch
指令,以跳转到正确的case
分支。
1 2 3 4 5 6 7 8 9 10 11 12 |
|
String类型的switch
将会产生以下的字节码:
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 |
|
包含这个字节码的类也要包含接下来的常量池,常量池中的数据被这段字节码引用。关于常量池的更多细节,请阅读深入JVM一文的运行时常量池章节。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
|
请注意,执行该switch
的字节码需要包含两个tableswitch
指令和多个用于调用String.equal()的invokevirtual
指令,关于invokevirtual
指令的更多内容,请阅读下一篇文章的关于方法调用的章节。下图展示了对于输入“b”,字节码是如何执行的。
java_string_switch_byte_code_1
java_string_switch_byte_code_2
java_string_switch_byte_code_3
如果不同的case
值对应的哈希码相同,如字符串”FB”和”Ea”的哈希码都是28,这种情况的处理方法是在执行equals方法时做一点小小的替换。请注意位置34处的字节码:ifeq 42
,它跳转到另外一个对String.equals()的调用处,而非像之前的例子一样在不存在哈希码冲突的情况下使用lookupswitch
操作码。
1 2 3 4 5 6 7 8 9 10 |
|
这将生成如下的字节码:
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 |
|
条件分支控制语句,如if-else
和switch
是通过使用操作码比较两个值,然后跳转到另外的字节码分支来实现的。更多的细节,请阅读条件选择这一章。
包括for循环
和while循环
在内的循环采用的是类似的处理方式,不同之处在于他们通常还包含一个goto
指令来产生字节码的循环,do-while循环
不需要任何的goto
指令,因为它的条件分支判定是放在字节码的最后。
一些操作码可以在同一条指令中比较两个int整型值或引用,并执行一个分支。而像double、long以及float类型的比较需要两步操作,首先是执行比较操作,把1、0或-1放入操作数栈中,然后根据操作数栈中的值是大于、小于或等于0来执行相应的分支。关于用于分支跳转的不同类型的指令的详细内容,请阅读本文前部的内容。
while循环
由一个条件分支指令if_icmpge
或if_icmplt
(如前文所述)和一个goto
语句组成。当条件不满足的时候,条件分支指令立即跳转到循环之后的指令,从而结束循环,循环的最后一句指令是goto
,会把字节码的执行跳转到循环的开始部分,从而确保循环的执行,除非循环的条件不满足。其过程如下所示:
1 2 3 4 5 6 |
|
编译为:
1 2 3 4 5 6 7 8 |
|
if_icmpge
指令测试位置1的局部变量(即i)是否大于等于2(此处原文误作10 译者注),如果是则跳到位置13(此处原文误作14 译者注)的字节码处,结束了循环。goto
指令保持了字节码的循环执行,直到if_icmpge
的条件被满足,这时就会立即执行到尾部的return
指令。iinc
是少见的直接更新一个局部变量而无需在操作数栈中进行读写的指令。在这个例子中,iinc
指令给局部变量表中的第一个位置的值(即i)加1。
java_while_loop_byte_code_1
for循环
和while循环
在字节码层面使用的相同的指令,这并不让人惊讶,因为所有的while循环
都可以很容易的被重写为相同的for循环
,前面提到的简单的while循环
可以被重写为以下的for循环
,他们产生的字节码是完全相同的。
1 2 3 4 5 |
|
do-while循环
同样和for循环
以及while循环
很相似,除了前者无需goto
指令,这是由于它的条件分支判定是最后一条指令,可以被用来跳转到循环的开始。
1 2 3 4 5 6 |
|
产生如下的字节码:
1 2 3 4 5 6 7 |
|
java_do_while_loop_byte_code_1
java_do_while_loop_byte_code_2
接下来的两篇文章是关于这些主题:
关于JVM内部架构和字节码执行期间的使用的不同内存区域的更多细节,请阅读我之前的一篇深入JVM的文章。
原文链接: jamesdbloom 翻译: ImportNew.com - xiafei
译文链接: http://www.importnew.com/13107.html
[ 转载请保留原文出处、译者和译文链接。]