本文共 6390 字,大约阅读时间需要 21 分钟。
虚拟机栈
背景
由于跨平台性的设计,JAVA的指令都是根据栈来设计的。
优点:跨平台,指令集小,编译器容易实现。
缺点:性能下降,实现同样的功能需要更多指令。
栈和堆
栈是运行时的单位,堆是储存的单位。
简介
每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧,对应着一次次的Java方法调用。
虚拟机栈主管Java程序的运行,它保存方法的局部变量,部分结果并参与方法的调用和返回。
特点
栈是一种快速有效的分配储存方式,访问速度仅次于程序计数器。
JVM直接对虚拟机栈只有两个操作。每个方法执行,伴随着进栈,方法执行结束伴随着出栈。
对于栈来说不存在垃圾回收问题,因为只有出栈入栈两个操作。
JVM规范允许虚拟机栈的大小是动态的或是固定不变的。
1.如果采用固定不变的,如果线程请求分配的栈容量超过了JVM允许的最大容量,JVM则会抛出一个StackOverflowError异常。
2.如果采用动态扩展,在线程请求分配的站容量超过允许的最大容量,虚拟机栈会尝试向JVM申请内存,但在申请时无法获得足够的内存,或者在创建新线程时没有足够的内存去创建对应的虚拟机栈,JVM则会抛出一个OutOfMemoryError异常。
设置栈内存大小
我们可以使用参数-Xss来设置虚拟机栈的大小。
IDEA可以在run -> Edit Configurations进行设置。
虚拟机栈的存储单位
栈帧:
每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在的。
在这个线程上正在执行的每个方法都各自对应一个栈帧。
栈帧是一个内存区块,是一个数据集。
栈帧运行原理:
JVM对虚拟机栈的操作只有入栈和出栈两个操作。
在一条活动的线程中,一个时间点只有一个活动的栈帧,只有当前正在执行方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧,与其对应的方法被称为当前方法,定义这个方法的类被称为当前类。
执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
如果在当前方法中调用了新方法,对应的新栈帧会创建出来并入栈,称为新的当前栈帧。
不同线程所包含的栈是不允许互相引用的。
当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,然后当前栈帧出栈,前一个栈帧称为当前栈帧。
JAVA有两种返回函数的方式,一种是正常的return指令返回。另一种是抛出未处理的异常。不管是哪种返回方式都会导致栈帧出栈。
入栈出栈测试代码
public class StackFrameTest {
public static void main(String[] args) {
StackFrameTest test = new StackFrameTest();
test.methon1();
}
private void methon1() {
System.out.println("方法一开始执行");
methon2();
System.out.println("方法一执行结束");
}
private void methon2() {
System.out.println("方法二开始执行");
methon3();
System.out.println("方法二执行结束");
}
private void methon3() {
System.out.println("方法三开始执行");
System.out.println("方法三执行结束");
}
}
测试代码中,每出现方法调用,都会有新栈帧入栈,直到当前方法执行结束之后,原方法重新称为当前方法后继续执行。在IDEA中的DEBUG的Frames窗口中,也形象的显示了方法入栈的操作。后附图。
代码中方法如果出现异常,且当前方法中没有处理,则会抛给前一个栈帧,如果前一个栈帧也没有处理则继续抛出,直到main函数,若仍未处理,则程序异常停止,由控制台打印异常信息。
栈帧的内部结构
每个栈帧中存储着以下内容:
局部变量表
操作数栈
动态链接
方法返回地址
一些其他的附加信息
局部变量表和操作数栈主要影响着栈帧的大小,栈帧的大小影响着虚拟机栈能存放多少栈帧。
局部变量表
局部变量表也被称为局部变量数组或本地变量表。
定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量。这些数据类型包括基本数据类型,对象引用和returnAdress类型。
局部变量表线程私有,不存在数据安全问题。
局部变量表所需的容量大小是在编译期就确定下来的。
在栈帧中,与性能调优关系最密切的就是局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。
局部变量表测试代码
public class LocalVariablesTest {
public static void main(String[] args) {
LocalVariablesTest test = new LocalVariablesTest();
int i = 10;
}
}
javap反编译后查看局部变量表,结果如图:
其中,
Start表示在执行指令该变量开始生效的指令行号。
Length表示该变量生效的行数。
Slot表示该变量的索引。
Name表示该变量的名称。
Signature表示该变量的类型。
[:表示是个数组。L表示是引用变量。I表示是int类型。
除了javap指令,我们也可以通过JClasslib插件查看局部变量表。
局部变量表内容同上,start和length两个属性决定变量的作用域,变量在start标志的下一行开始生效。
slot
局部变量表,最基本的单位是Slot(变量槽)
在局部变量表中,32位以内的类型只占一个slot,64位的类型占两个slot。
JVM会为局部变量表中的每一个slot分配一个访问索引,通过这个索引访问指定的额局部变量值。
当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量会按照顺序复制到局部变量表中的每一个slot上。
如果需要访问一个64位的局部变量值,只需要使用前一个索引即可。例:一个long类型的变量占据了4,5两个slot,我们使用4这个索引便可以找到这个变量。同样的,我们需要6这个索引来找到这个long类型之后的那个变量,而不是使用5。
如果当前帧是由构造方法或者实例方法创建的,那么该对象的引用变量this会存放在索引为0的slot处。
类方法为什么不能使用this关键字?
因为在类方法的局部变量表中不存在this变量。
slot的重复利用
栈帧中的局部变量表中的slot是可以重用的,如果一个局部变量过了其作用域,那么在其作用域后申请的新局部变量就很有可能复用这个过期变量的槽位。测试代码:
public void test() {
int a = 1;
{
int b = 0;
b = a + 1;
}
int c = a + 1;
}
测试代码的局部变量图如下:
通过索引,我们可以看出c复用了b的slot。
类变量和局部变量的对比
变量的分类:
根据数据类型分类:
基本数据类型
引用数据类型
根据在类中声明的位置分类:
成员变量
类变量:在类加载的链接准备阶段已经给类变量赋初值,并在初始化阶段显式赋值。
实例变量:随着对象的创建,在堆中分配实例变量空间,并进行默认赋值。
局部变量:在使用前必须显式赋值!否则编译不通过。
操作数栈
使用数组实现,在方法执行过程中,根据字节码指令执行,往栈中写入数据或提取数据。
主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈在一个方法刚开始执行的时候便创建出来了,这个方法的操作数栈是空的。
操作数栈有一个明确的栈深度用于储存数值,最大深度在编译器就确定好了。
操作数栈并非采用索引的方式来进行数据访问,而只能通过入栈和出栈操作完成一次数据访问。
测试代码
public void testadd() {
byte i = 15;
int j = 8;
int k = i + j;
}
反编译后的字节码指令如下:
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
字节码指令解析:
0:将数值15压入操作数栈。
2: 将操作数栈的当前栈帧压入局部变量表,索引为1。
3:将数值8压入操作数栈。
5:将操作数栈的当前栈帧压入局部变量表,索引为2。
6:读取局部变量表中索引为1的变量进操作数栈。
7:读取局部变量表中索引为2的变量进操作数栈。
8:执行加操作,并将结果压入操作数栈。
9:将操作数栈的当前栈帧压入局部变量表,索引为3。
10:方法正常返回结束。
栈顶缓存技术
基于栈式架构的虚拟机完成一项操作的时候需要使用更多的入栈和出栈操作,会出现更多的读写操作。
将栈顶元素全部缓存到物理的寄存器中,降低对内存的读写次数,提升执行引擎的执行效率。
动态链接
有些地方可能会将方法返回地址,动态链接和附加信息称为帧数据区。
每一个栈帧内部都包含一个指向运行时常量池中该站真所述方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。
在java源文件被编译成字节码文件时,所有变量和方法引用都作为符号引用保存在class文件的常量池里。动态链接的作用就是为了将这些符号引用转换为直接引用。
测试代码
public class DynamicLinkingTest {
int num = 1;
public void methonA() {
System.out.println("methonA");
}
public void methonB() {
System.out.println("methonB");
methonA();
num++;
}
}
反编译之后的执行指令如下:
Code:
stack=3, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String methonB
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: aload_0
9: invokevirtual #7 // Method methonA:()V
12: aload_0
13: dup
14: getfield #2 // Field num:I
17: iconst_1
18: iadd
19: putfield #2 // Field num:I
22: return
我们可以看到在指令后出现大量的#开头的符号引用,这些引用可以在编译后的常量池中找到对应的实际方法,测试代码编译后的常量池如下图:
为什么要有运行时常量池?
为了提供一些符号和常量,便于指令的识别,减少内存,提高复用。
方法的调用
在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
静态链接:当一个字节码文件被装载进JVM时,如果被调用的目标方法在编译器可知,且运行期保持不变,这种称为静态链接。
动态链接:如果被调用的方法在编译器无法确定,只有在程序运行期才能将其转换为直接引用,称为动态链接。
根据两种链接对应的绑定机制,绑定是指一个字段,方法或者类在符号引用被替换成直接引用的过程,这仅仅发生一次。
早期绑定:早期绑定指被调用的目标如果在编译器可知,且运行期保持不变。
晚期绑定:被调用的目标只能在运行时才能从符号绑定转为直接绑定,这称为晚期绑定。
例子
实例方法就是典型的晚期绑定,在程序运行之前,无法确定将要调用哪个实例的方法。
在子类构造器中显式调用super()则是早期绑定的例子,在编译期间,就可以确定该方法的直接引用。
虚方法和非虚方法
非虚方法:如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法。如静态方法,私有方法,final方法,实例构造器,父类方法都是非虚方法。其他方法都称为虚方法。
虚拟机中提供了一下几条方法调用指令:
普通调用指令
invokestatic 调用静态方法,解析阶段确定唯一方法版本。
invokespecial 调用方法,私有及父类方法,解析阶段确定唯一方法版本。
invokevirtual 调用虚方法。
invokeinterface 调用接口方法。
动态调用指令
invokedynamic 动态解析出需要调用的方法,然后执行。
前四条指令方法的调用执行不可人为干预,而动态调用支持由用户确定方法版本。其中invokestatic和invokespecial指令调用的方法称为非虚方法,其余的(final修饰的除外)称为虚方法。
没有显式使用super的方法调用也会被认为是虚方法,使用invokevirtual调用。
invokedynamic指令
JDK7中为了实现动态类型语言支持而做出的改进,出现了invokedynamic指令。但直到JDK8的Lambda表达式的出现,invokedynamic指令的生成,在JAVA种才有了直接的生成方式。
静态类型语言和动态类型语言
这两种语言最重要的区别在于对类型的检查是在编译期间还是在运行期间,前者为静态类型语言,后者为动态类型语言。直白的说,静态语言判断变量的类型,动态语言判断变量值的类型。
方法重写
方法重写的本质
找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
如果在类型C中找到与常量池中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,如果不通过则返回IllegalAccessError异常。
如果没有找到,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证操作。
如果始终没有找到,抛出AbstractMethodError异常。
IllegalAccessError异常介绍:
程序试图访问或修改一个属性或调用一个方法,这个属性或方法你没有权限访问。一般来说这个会引起编译器异常,如果发生在运行期间,就说明一个类发生了不兼容的改变。
虚方法表
为了提高性能,JVM在方法区建立一个虚方法表来实现,使用索引表来代替查找。
虚方法表主要在类加载的链接解析阶段创建。
方法返回地址
存放调用该方法的程序计数器的值。在方法结束之后都需要返回到该方法被调用的位置,方法正常退出时,调用者的程序计数器的值作为返回地址,即调用该方法指令的下一条指令的地址。而通过异常退出需要通过异常表确定,栈帧中不保存这部分信息。
所以,通过异常完成的方法退出不会给它的上层调用者任何的返回值。
补充:方法返回指令!
ireturn负责返回值是booleanbytecharshortint
lreturn负责返回值是long
freturn负责返回值是float
dreturn负责返回值是double
areturn负责返回值是引用类型
return负责生命为void的方法,实例初始化方法,类和接口的初始化方法。
栈帧中的附加信息
这个部分可能会有,也可能没有,主要看虚拟机的具体实现。
转载地址:http://mhltx.baihongyu.com/