虚拟机字节码指令
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。
基本数据类型
1、除了long和double类型外,每个变量都占局部变量区中的一个变量槽(slot),而long及double会占用两个连续的变量槽。
2、大多数对于boolean、byte、short和char类型数据的操作,都使用相应的int类型作为运算类型。
加载和存储指令
1、将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
2、将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。
3、将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
4、扩充局部变量表的访问索引的指令:wide。
_<n>:_0、_1、_2、_3,
存储数据的操作数栈和局部变量表主要就是由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
运算指令
1、运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
2、算术指令分为两种:整型运算的指令和浮点型运算的指令。
3、无论是哪种算术指令,都使用Java虚拟机的数据类型,由于没有直接支持byte、short、char和boolean类型的算术指令,使用操作int类型的指令代替。
加法指令:iadd、ladd、fadd、dadd。
减法指令:isub、lsub、fsub、dsub。
乘法指令:imul、lmul、fmul、dmul。
除法指令:idiv、ldiv、fdiv、ddiv。
求余指令:irem、lrem、frem、drem。
取反指令:ineg、lneg、fneg、dneg。
位移指令:ishl、ishr、iushr、lshl、lshr、lushr。
按位或指令:ior、lor。
按位与指令:iand、land。
按位异或指令:ixor、lxor。
局部变量自增指令:iinc。
比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。
类型转换指令
1、类型转换指令可以将两种不同的数值类型进行相互转换。
2、这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
宽化类型转换
int类型到long、float或者double类型。
long类型到float、double类型。
float类型到double类型。
i2l、f2b、l2f、l2d、f2d。
窄化类型转换
i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。
对象创建与访问指令
创建类实例的指令:new。
创建数组的指令:newarray、anewarray、multianewarray。
访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic。
把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload。
将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore。
取数组长度的指令:arraylength。
检查类实例类型的指令:instanceof、checkcast。
操作数栈管理指令
直接操作操作数栈的指令:
将操作数栈的栈顶一个或两个元素出栈:pop、pop2。
复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
将栈最顶端的两个数值互换:swap。
控制转移指令
1、控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序。
2、从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。
条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne。
复合条件分支:tableswitch、lookupswitch。
无条件分支:goto、goto_w、jsr、jsr_w、ret。
在Java虚拟机中有专门的指令集用来处理int和reference类型的条件分支比较操作,为了可以无须明显标识一个实体值是否null,也有专门的指令用来检测null值。
方法调用和返回指令
invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
invokeinterface 指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
invokespecial 指令用于调用一些需要特殊处理的实例方法,包括实例初始化(<init>)方法、私有方法和父类方法。
invokestatic 调用静态方法(static方法)。
invokedynamic 指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面4条调用指令的分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。
关于方法调用
1、Class文件的编译过程中不包含传统编译中的连接步骤,所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,而不是方法在实际运行时内存布局中的入口地址。
2、在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这类方法(编译期可知,运行期不可变)的调用称为解析(Resolution)。
主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
3、只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。
4、动态语言支持
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期。
异常处理指令
在Java程序中显式抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显式抛出异常情况之外,Java虚拟机规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。
例如,在整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。
而在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现,现在已经不用了),而是采用异常表来完成的。
同步指令
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。
方法级同步
方法级的同步是隐式的,即无须通过字节码指令来控制
它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法。当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的管程将在异常抛到同步方法之外时自动释放。
方法内部一段指令序列的同步
同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持。
虚拟机运行活化的内存数据中的指令:程序的执行
前面我们说明了java源码被编译成为了二进制字节码,二进制字节码转为内存中方法区里存储的活化对象,那么最重要的程序执行就做好了基础:当方法区里的字段和方法按照虚拟机规定的数据结构排好,常量池中的符号引用数据在加载过程中最大限度地转为了直接引用,那么这个时候虚拟机就可以在加载主类后创建新的线程按步执行主类的main函数中的指令了。
java虚拟机执行程序的基础是特定的二进制指令集和运行时栈帧:
-
二进制指令集是java虚拟机规定的一些指令,在编译后二进制字节码的类方法里的字节码就是这种指令,所以只要找到方法区里的类方法就可以依照这套指令集去执行命令。
-
运行时栈帧是虚拟机执行的物理所在,在这个栈帧结构上,方法的局部变量、操作数栈、动态链接和返回地址依序排列,依照命令动态变换栈帧上的数据,最终完成所有的这个方法上的指令。
栈帧的进一步划分:
-
局部变量表:包括方法的参数和方法体内部的局部变量都会存在这个表中。
-
操作数栈:操作数栈是一个运行中间产生的操作数构成的栈,这个栈的栈顶保存的就是当前活跃的操作数。
-
动态链接:我们之前提到这个方法中调用的方法和类在常量池中的符号引用转换为的直接引用就保存在这里,只要访问到这些方法和类的时候就会根据动态链接去直接引用所指的地址加载那些方法。
-
返回地址:程序正常结束恢复上一个栈帧的状态的时候需要知道上一个指令的地址。
现在我们使用一个综合实例来说明运行的整个过程:
源代码如下,逻辑很简单:
public class TestDemo { public static int minus(int x){ return -x; } public static void main(String[] args) { int x = 5; int y = minus(x); } }
我们可以分析它的二进制字节码,当然这里我们借助javap工具进行分析:
jinhaoplus$ javap -verbose TestDemo Classfile /Users/jinhao/Desktop/TestDemo.class Last modified 2015-10-17; size 342 bytes MD5 checksum 4f37459aa1b3438b1608de788d43586d Compiled from "TestDemo.java" public class TestDemo minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #4.#15 // java/lang/Object."<init>":()V #2 = Methodref #3.#16 // TestDemo.minus:(I)I #3 = Class #17 // TestDemo #4 = Class #18 // java/lang/Object #5 = Utf8 <init> #6 = Utf8 ()V #7 = Utf8 Code #8 = Utf8 LineNumberTable #9 = Utf8 minus #10 = Utf8 (I)I #11 = Utf8 main #12 = Utf8 ([Ljava/lang/String;)V #13 = Utf8 SourceFile #14 = Utf8 TestDemo.java #15 = NameAndType #5:#6 // "<init>":()V #16 = NameAndType #9:#10 // minus:(I)I #17 = Utf8 TestDemo #18 = Utf8 java/lang/Object { public TestDemo(); descriptor: ()V flags: ACC_PUBLIC 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 int minus(int); descriptor: (I)I flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=1, args_size=1 0: iload_0 1: ineg 2: ireturn LineNumberTable: line 3: 0 public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=3, args_size=1 0: iconst_5 1: istore_1 2: iload_1 3: invokestatic #2 // Method minus:(I)I 6: istore_2 7: return LineNumberTable: line 6: 0 line 7: 2 line 8: 7 } SourceFile: "TestDemo.java"
这个过程是从固化在class文件中的二进制字节码开始,经过加载器对当前类的加载,虚拟机对二进制码的验证、准备和一定的解析,进入内存中的方法区,常量池中的符号引用一定程度上转换为直接引用,使得字节码通过结构化的组织让虚拟机了解类的每一块的构成,创建的线程申请到了虚拟机栈中的空间构造出属于这一线程的栈帧空间,执行主类的main方法:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=3, args_size=1 0: iconst_5 1: istore_1 2: iload_1 3: invokestatic #2 // Method minus:(I)I 6: istore_2 7: return LineNumberTable: line 6: 0 line 7: 2 line 8: 7 }
首先检查main的访问标志、描述符描述的返回类型和参数列表,确定可以访问后进入Code属性表执行命令,读入栈深度建立符合要求的操作数栈,读入局部变量大小建立符合要求的局部变量表,根据参数数向局部变量表中依序加入参数(第一个参数是引用当前对象的this,所以空参数列表的参数数也是1),然后开始根据命令正式执行:
0: iconst_5
将整数5压入栈顶
1: istore_1
将栈顶整数值存入局部变量表的slot1(slot0是参数this)
2: iload_1
将slot1压入栈顶
3: invokestatic #2 // Method minus:(I)I
二进制invokestatic方法用于调用静态方法,参数是根据常量池中已经转换为直接引用的常量,意即minus函数在方法区中的地址,找到这个地址调用函数,向其中加入的参数为栈顶的值
6: istore_2
将栈顶整数存入局部变量的slot2
7: return
将返回地址中存储的PC地址返到PC,栈帧恢复到调用前
现在我们分析调用minus函数的时候进入minus函数的过程:
public static int minus(int); descriptor: (I)I flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=1, args_size=1 0: iload_0 1: ineg 2: ireturn LineNumberTable: line 3: 0
同样的首先检查minus函数的访问标志、描述符描述的返回类型和参数列表,确定可以访问后进入Code属性表执行命令,读入栈深度建立符合要求的操作数栈,读入局部变量大小建立符合要求的局部变量表,根据参数数向局部变量表中依序加入参数,然后开始根据命令正式执行:
0: iload_0
将slot0压入栈顶,也就是传入的参数
1: ineg
将栈顶的值弹出取负后压回栈顶
2: ireturn
将返回地址中存储的PC地址返到PC,栈帧恢复到调用前
这个过程结束后对象的生命周期结束,因此开始执行GC回收内存中的对象,包括堆中的类对应的java.lang.Class对象,卸载方法区中的类。
方法的解析和分派
上面这个例子中main方法里调用minus方法的时候是没有二义性的,因为从二进制字节码里我们可以看到invokestatic方法调用的是minus方法的直接引用,也就说在编译期这个调用就已经决定了。这个时候我们来说说方法调用,这个部分的内容在前面的类加载时候提过,在能够唯一确定方法的直接引用的时候虚拟机会将常量表里的符号引用转换为直接引用,这样在运行的时候就可以直接根据这个地址找到对应的方法去执行,这种时候的转换才能叫做我们当时提到的在连接过程中的解析。
但是如果方法是动态绑定的,也就是说在编译期我们并不知道使用哪个方法(或者叫不知道使用方法的哪个版本),那么这个时候就需要在运行时才能确定哪个版本的方法将被调用,这个时候才能将符号引用转换为直接引用。这个问题提到的多个版本的方法在java中的重载和多态重写问题息息相关。
重载(override)
public class TestDemo { static class Human{ } static class Man extends Human{ } static class Woman extends Human{ } public void sayHello(Human human) { System.out.println("hello human"); } public void sayHello(Man man) { System.out.println("hello man"); } public void sayHello(Woman woman) { System.out.println("hello woman"); } public static void main(String[] args) { TestDemo demo = new TestDemo(); Human man = new Man(); Human woman = new Woman(); demo.sayHello(man); demo.sayHello(woman); } }
javap结果:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=4, args_size=1 0: new #7 // class TestDemo 3: dup 4: invokespecial #8 // Method "<init>":()V 7: astore_1 8: new #9 // class TestDemo$Man 11: dup 12: invokespecial #10 // Method TestDemo$Man."<init>":()V 15: astore_2 16: new #11 // class TestDemo$Woman 19: dup 20: invokespecial #12 // Method TestDemo$Woman."<init>":()V 23: astore_3 24: aload_1 25: aload_2 26: invokevirtual #13 // Method sayHello:(LTestDemo$Human;)V 29: aload_1 30: aload_3 31: invokevirtual #13 // Method sayHello:(LTestDemo$Human;)V 34: return LineNumberTable: line 21: 0 line 22: 8 line 23: 16 line 24: 24 line 25: 29 line 26: 34
重写(overwrite)
public class TestDemo { static class Human{ public void sayHello() { System.out.println("hello human"); } } static class Man extends Human{ public void sayHello() { System.out.println("hello man"); } } static class Woman extends Human{ public void sayHello() { System.out.println("hello woman"); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); } }
javap结果:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=1 0: new #2 // class TestDemo$Man 3: dup 4: invokespecial #3 // Method TestDemo$Man."<init>":()V 7: astore_1 8: new #4 // class TestDemo$Woman 11: dup 12: invokespecial #5 // Method TestDemo$Woman."<init>":()V 15: astore_2 16: aload_1 17: invokevirtual #6 // Method TestDemo$Human.sayHello:()V 20: aload_2 21: invokevirtual #6 // Method TestDemo$Human.sayHello:()V 24: return LineNumberTable: line 20: 0 line 21: 8 line 22: 16 line 23: 20 line 24: 24
我们可以看出来无论是重载还是重写,都是二进制指令invokevirtual调用了sayHello方法来执行的。
-
在重载中,程序调用的是参数实际类型不同的方法,但是虚拟机最终分派了相同外观类型(静态类型)的方法,这说明在重载的过程中虚拟机在运行的时候是只看参数的外观类型(静态类型)的,而这个外观类型(静态类型)是在编译的时候就已经确定的,和虚拟机没有关系。这种依赖静态类型来做方法的分配叫做静态分派。
-
在重写中,程序调用的是不同实际类型的同名方法,虚拟机依据对象的实际类型去寻找是否有这个方法,如果有就执行,如果没有去父类里找,最终在实际类型里找到了这个方法,所以最终是在运行期动态分派了方法。在编译的时候我们可以看到字节码指示的方法都是一样的符号引用,但是运行期虚拟机能够根据实际类型去确定出真正需要的直接引用。这种依赖实际类型来做方法的分配叫做动态分派。得益于java虚拟机的动态分派会在分派前确定对象的实际类型,面向对象的多态性才能体现出来。
对象的创建和堆内存的分配
前面我们提到的都是类在方法区中的内存分配:
在方法区中有类的常量池,常量池中保存着类的很多信息的符号引用,很多符号引用还转换为了直接引用以使在运行的过程能够访问到这些信息的真实地址。
那么创建出的对象是怎么在堆中分配空间的呢?
首先我们要明确对象中存储的大部分的数据就是它对应的非静态字段和每个字段方法对应的方法区中的地址,因为这些东西每个对象都是不一样的,所以必须通过各自的堆空间存储这些不一样的数据,而方法是所有同类对象共用的,因为方法的命令是一样的,每个对象只是在各自的线程栈帧里提供各自的局部变量表和操作数栈就好。
这样看来,堆中存放的是真正“有个性”的属于对象自己的变量,这些变量往往是最占空间的,而这些变量对应的类字段的地址会找到位于方法区中,同样的同类对象如果要执行一个方法只需要在自己的栈帧里面创建局部变量表和操作数栈,然后根据方法对应的方法区中的地址去寻找到方法体执行其中的命令即可,这样一来堆里面只存放有限的真正有意义的数据和地址,方法区里存放共用的字段和方法体,能最大程度地减小内存开销。