HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构,当虚拟机启动的时候,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间。并且随着程序运行时间的推移,即时编译器逐渐发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。
在Java虚拟机规范中,Java栈(Java Stack)同PC寄存器一样都是线程私有的,并且生命周期与线程的生命周期保持一致。Java栈主要用于存储栈帧(Stack Frame),而栈帧中则负责存储局部变量表、操作数栈、动态链接和方法返回值等信息。
什么是栈帧?
栈帧是一种用于支持JVM调用/执行程序方法的数据结构,它是方法的执行环境,每一个方法被调用时都会创建一个独立的栈帧以便维系所需的各种数据信息,栈帧伴随着方法的调用而创建,伴随着方法的执行结束而销毁,那么每一个方法从调用到执行结束的过程,就对应着Java栈中一个栈帧从入栈到出栈的过程,并且无论方法的调用状态是否正常都算作方法结束。在此大家需要注意,不同线程中所包含的栈帧是不允许存在相互引用的。
在栈帧中,局部变量表和操作数栈所需的容量大小在编译期就可以完全被确定下来,并保存在方法的Code属性中,也就是说,栈帧究竟需要分配多大的内存空间完全取决于具体的JVM实现和方法调用时分配的实际内存。在一条活动线程中,只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧也被称之为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
然一个线程中只有当前正在执行的方法的栈帧才是当前栈帧,那么如果当前方法在执行过程中调用了另外一个新的方法时,当前栈帧会发生变化吗?如下所示:
/**
* 谁是当前栈帧?
*/
public class CurrentFrameTest {
public void methodA() {
System.out.println("当前栈帧对应的方法->methodA");
methodB();
System.out.println("当前栈帧对应的方法->methodA");
}
public void methodB() {
System.out.println("当前栈帧对应的方法->methodB");
}
}
在上述程序示例中,如果与methodA()方法相对应的栈帧是当前栈帧,那么当methodA()方法内部调用了methodB()方法时,则会有一个与methodB()方法相对应的新栈帧作为当前栈帧被创建,也就是说,程序的控制权将会移交给methodB()方法。不过当methodB()方法执行完成并返回后,当前栈帧随之被丢弃,前一个栈帧又重新变为当前栈帧。
局部变量表
局部变量表(Local Variables Table)也可以称之为本地变量表,它包含在一个独立的栈帧中。局部变量表主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类原始数据类型、对象引用(reference),以及returnAddress类型。局部变量表所需的容量大小在编译期就可以被完全确定下来,并保存在方法的Code属性中。
既然方法体内定义的局部变量是存储在栈帧中的局部变量表里的,那么原始数据类型的成员变量的值是否也存储在局部变量表中呢?
其实如果是定义在方法体外的成员变量,不止是作用域发生了变化,更重要的是,其值也并非还是存储在局部变量表里,而是存储在对象内存空间的实例数据中,整体来看即存储在Java堆区内。简单来说,与线程上下文相关的数据存储在Java栈中,反之则存储在Java堆区内。
局部变量表中最小的存储单元是Slot(变量槽),一个Slot可以存储一个类型为boolean、byte、char、short、float、reference以及returnAddress小于或等于32bit的数值,2个Slot可以存储一个类型为long或double的64bit数值。JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值,访问索引从0开始到小于局部变量表最大的Slot长度,由于long和double类型的二进制位数是64bit,那么当使用这2个类型存储数据时,理论上占用的是2个连续的Slot,如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用前一个索引即可。这就好比一个double类型的值存储在局部变量表中其Slot的访问索引为n,当我们需要取出这个局部变量值时,只需要根据索引n便可以成功取出n和n+1的值,也就是一个完整的64bit的数据值。当然关于是否一定需要使用2个连续的Slot来存储一个64bit的值,Java虚拟机规范其实并没有明确要求,这主要还需要根据JVM的具体实现而定。
除此之外,一个Slot究竟应该占用多大的内存空间Java虚拟机规范同样也没有明确的要求,但最好使用32bit的内存空间用于存储boolean、byte、char、short、float、reference及returnAddress等类型的值,当然这并不会意味着Slot的内存大小就一定会固定为32bit,因为Slot的内存大小允许根据处理器、操作系统或JVM实现的不同而产生相应的变化。
JVM使用局部变量表来完成方法调用时的参数传递,当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上。访问索引为0的Slot一定存储的是与被调用实例方法相对应的对象引用(通过Java语法层面的“this”关键字便可访问到这个参数),而后续的其他方法参数和方法体内定义的成员变量则会按照顺序从局部变量表中索引为1的Slot位置处展开复制。
操作数栈
每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack)。操作数栈和局部变量表在访问方式上存在着较大差异,操作数栈并非采用访问索引的方式来进行数据访问的,而是通过标准的入栈和出栈操作来完成一次数据访问。每一个操作数栈都会拥有一个明确的栈深度用于存储数值,一个32bit的数值可以用一个单位的栈深度来存储,而2个单位的栈深度则可以保存一个64bit的数值,当然操作数栈所需的容量大小在编译期就可以被完全确定下来,并保存在方法的Code属性中。
操作数栈就是JVM执行引擎的一个工作区,当一个方法被调用的时候,一个新的栈帧也会随之被创建出来,但这个时候栈帧中的操作数栈却是空的,只有方法在执行的过程中,才会有各种各样的字节码指令往操作数栈中执行入栈和出栈操作。比如在一个方法内部需要执行一个简单的加法运算时,首先需要从操作数栈中将需要执行运算的两个数值出栈,待运算执行完成后,再将运算结果入栈。如下所示: public void testAddOperation();
Code:
0: bipush 15
2: istore_1
3: bipush 8
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: return
1.首先会由“bipush”指令将数值15从byte类型转换为int类型后压入操作数栈的栈顶(对于byte、short和char类型的值在入栈之前,会被转换为int类型)
2.当成功入栈之后,“istore_1”指令便会负责将栈顶元素出栈并存储在局部变量表中访问索引为1的Slot上
3.接下来再次执行“bipush”指令将数值8压入栈顶后
4.通过“istore_2”指令将栈顶元素出栈并存储在局部变量表中访问索引为2的Slot上
5.“iload_1”和“iload_2”指令会负责将局部变量表中访问索引为1和2的Slot上的数值15和8重新压入操作数栈的栈顶
6.紧接着“iadd”指令便会将这2个数值出栈执行加法运算后再将运算结果重新压入栈顶
7.“istore_3”指令会将运算结果出栈并存储在局部变量表中访问索引为3的Slot上
8.最后“return”指令的作用就是方法执行完成之后的返回操作。在操作数栈中,一项运算通常由多个子运算(subcomputation)嵌套进行,一个子运算过程的结果可以被其他外围运算所使用。
在此大家需要注意,在操作数栈中的数据必须进行正确的操作。比如不能在入栈2个int类型的数值后,却把它们当做long类型的数值去操作,或者入栈2个double类型的数值后,使用iadd指令对它们执行加法运算等情况出现。
动态链接
每一个栈帧内部除了包含局部变量表和操作数栈之外,还包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。在一个字节码文件中,描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用(Symbolic Reference)来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,那么在这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。相反如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。
早期绑定:早期绑定就是指被调用的目标方法如果在编译器可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定:相反如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
在Java中,开发人员并不需要在程序中显式指定某一个方法需要在运行期支持晚期绑定,因为除了final方法外,几乎所有的方法都是默认基于晚期绑定的。如下所示:
/**
* 晚期绑定(Late Binding)示例
*/
public class LateBinding {
public static void getName(Animal animal) {
animal.name();
}
public static void main(String[] args) {
getName(new Tiger());
getName(new Pig());
}
}
interface Animal {
public void name();
}
class Tiger implements Animal {
@Override
public void name() {
System.out.println("我是Tiger,我派生于Animal");
}
}
class Pig implements Animal {
@Override
public void name() {
System.out.println("我是Pig,我派生于Animal");
}
}
在上述程序示例中,接口Animal包含Tiger和Pig两个派生类,并且这两个派生类还重写了它的name()方法。由于在编译期并不明确LateBinding类中的getName()方法究竟需要调用哪一个name()方法,也就无法使用静态链接的方式将符号引用转换为直接引用,因此这一类的方法就是基于晚期绑定的虚函数,其实虚函数的存在就是为了支持多态特性。与动态链接相反,如果程序中能够使用静态链接的方式将符号引用转换为直接引用的话,这一类的方法就是基于早期绑定的非虚函数。在此大家需要注意,从严格意义上来说,在Java中其实并不存在虚函数的概念,因为开发人员并不需要显式使用任何关键字去标示Java中的一个虚函数。如果在程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。
方法返回值
一个方法在执行的过程中将会产生两种调用结果:
1.方法正常调用完成:如果是方法正常调用完成,那么这就意味着,被调用的当前方法在执行的过程中将不会有任何的异常被抛出,并且方法在执行的过程中一旦遇见字节码返回指令时,将会把方法的返回值返回给它的调用者,不过一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而定。在字节码指令中,返回指令包含ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
2.方法异常调用完成:方法异常调用完成意味着当前方法在执行的过程中可能会因为某些错误的指令导致JVM抛出了异常,并且这些异常在当前方法中没有办法进行处理,或者方法在执行的过程中遇见了athrow指令显式抛出的异常,并且在当前方法内部没有捕获这个异常。总之,如果一个方法在执行的过程中抛出了异常,那么这个方法在调用完成之后将不会再有任何的返回值返回给它的调用者。
无论当前方法的调用结果是正常还是异常,都需要在执行完成之后返回到之前被调用的位置上,那么这个时候当前栈帧就承担着恢复调用者状态的责任。之前曾经提及过,在方法内部调用了另外一个方法时,将会有一个与当前方法相对应的新栈帧被创建出来,当方法调用完成之后,当前栈帧随之被丢弃,前一个栈帧又重新变为了当前栈帧,而被调用的方法如果带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。