java字节码执行
编译过程
指令简介
Java虚拟机的指令由一个字节长度的、 代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。Java虚拟机采用面向操作数栈而不是寄存器架构。
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶
类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作。
- Java虚拟机直接支持宽化类型转换(即小范围类型向大范围类型的安全转换)
- 处理窄化类型转换时,必须显式地使用转换指令来完成,窄化类型转换可能会导致转换结果产生不同的正负号、 不同的数量级的情况,转换过程很可能会导致数值的精度丢失。
对象创建和访问指令,类实例和数组都是对象
操作数栈管理指令,操作一个普通数据结构中的堆栈
控制转移指令,可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序
方法调用与返回指令
- invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派)
- invokeinterface指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用
- invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、 私有方法和父类方法。
- invokestatic指令用于调用类方法(static方法)。
- invokedynamic指令用于在运行时动态解析出调用点限定符所引用的方法并执行
异常处理指令
同步指令(synchronized语句),支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor)来支持的。
当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。 在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的管程将在异常抛到同步方法之外时自动释放。
指令执行
运行时栈
解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现
局部变量表(Local Variable Table)
是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。 在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,每个Slot都应该能存放一个boolean、 byte、 char、 short、 int、 float、 reference或returnAddress类型的数据(Java语言与Java虚拟机中的基本数据类型是存在本质差别的)
由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。 其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,不要认为Java中任何情况下都存在诸如整型变量默认为0。
操作数栈(Operand Stack)
也常称为操作栈,它是一个后入先出(Last In FirstOut,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。 例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。
方法退出的过程:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
方法调用
一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接引用)。 这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
静态解析
方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。 换句话说,调用目标在程序代码写好、 编译器进行编译时就必须确定下来。 这类方法的调用称为解析(Resolution)。
包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。(静态方法、 私有方法、 实例构造器、 父类方法4类)
final方法由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。
Human man=new Man();
把上面代码中的“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type)
虚拟机(准确地说是编译器)在重载(不是重写)时是通过参数的静态类型而不是实际类型作为判定依据的。 并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。 静态分派的典型应用是方法重载。 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
动态链接
动态分派和多态性的重写(Override)有着很密切的关联
invokevirtual指令的运行时解析过程大致分为以下几个步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
Java语言是一门静态多分派、 动态单分派的语言。
使用虚方法表索引来代替元数据查找以提高性能。虚方法表中存放着各个方法的实际入口地址。 如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。 如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。具有相同签名的方法,在父类、 子类的虚方法表中都应当具有一样的索引序号。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
运行时异常就是只要代码不运行到这一行就不会有问题。 与运行时异常相对应的是连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常,即使会导致连接时异常的代码放在一条无法执行到的分支路径上,类加载时(Java的连接过程不在编译阶段,而在类加载阶段)也照样会抛出异常。
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。