作为一个资源整合工,我整合了网上对java虚拟机栈的大部分知识点,比较详细。由于我也不确定我查看和整合了网上哪些资源,因此在这里对这些大佬表示感谢。同时,文章也包含了我的许多个人理解,如有错误,欢迎指正。
详细讲解虚拟机栈
与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法返回等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
栈帧
栈帧(Frame)是用来存储方法的执行状态和临时数据的数据结构。
特点:
- 栈帧是线程私有的数据,不可能在一个栈帧之中引用另外一条线程的栈帧。
- 栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。
- 正在执行的栈帧被称之为当前栈帧。当方法返回的时候,当前栈帧会传回此方法的执行结果给前一个栈帧,当前栈帧会被遗弃,前一个栈帧变成当前栈帧。
-
-
-
- 动态链接与静态链接
-
-
概述
Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以指向常量池的引用作为参数。
- 部分符号引用在类加载阶段(解析)的时候就转化为直接引用,这种转化为静态链接。
- 部分符号引用在运行期间转化为直接引用,这种转化为动态链接。
QA:
所以说,什么是符号引用和直接引用呢?
答:
1) 符号引用就是字符串,这个字符串包含足够的信息,以供实际使用时可以找到相应的位置。比如说某个方法的符号引用,如:“java/io/PrintStream.println:(Ljava/lang/String;)V”。里面有类的信息,方法名,方法参数等信息。
为什么要使用符号引用呢?
答:个人理解:因为类加载之前,javac会将源代码编译成.class文件,这个时候javac是不知道被编译的类中所引用的类、方法或者变量他们的引用地址在哪里,所以只能用符号引用来表示
2) 直接引用在内存中能定位到地址,可以直接定位到要执行的方法。
直接引用可以是
(1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
(2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
(3)一个能间接定位到目标的句柄
举个例子:
private static int c = 0;
public static void main(String[] args) {
int a = 1;
System.out.println(add(a, c));
}
private static int add(int a, int b){
return a + b;
}
我们通过JClassLib分析它的class文件,下面是main方法的字节码:
我们就可以通过这些符号索引去常量池里面去获取他们的地址,进而获取他们的直接引用或值。
比如我们的add方法。
获取到这个方法的路径“<com/java/learn/ip/ipv6/ByteAndHex>”
再根据一定的规则,转为这个方法的直接引用(地址),通过它就可以访问add方法了。
局部变量表
局部变量表是一组局部变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
局部变量槽(slot)
局部变量槽是局部变量表中的基本单位,一个变量槽的大小完全由虚拟机自己定义,在最常使用的HotSpot中,一个局部变量槽是占用32个比特。
基本知识:
- 一个slot可以保存一个类型为 boolean、byte、char、short、float、reference和 returnAddress的数据,两个slot可以保存一个类型为 long 和 double 的数据。
QA:
- reference和 returnAddress 数据代表的是什么?
答:reference代表一个对象的引用,reference 类型的值可以想象成类似于一个指向对象的指针。returnAddress 类型:表示一条字节码指令的操作码(Opcode),为字节码指令jsr、jsr_w和ret服务,这几个指令在7及之后逐渐被废弃。
从上面这个表中,明显可以看到boolean、byte、char、short并没有占用4个字节,那么为什么在局部变量表中的时候还是占1个slot呢?
答:byte,short,char在存储前被转换位int,boolean也被转换位int(0表示false,非0表示true)。
局部变量表在编译期会初始化一个固定长度的数组,用于存储局部变量。从局部变量数组的index 0开始存放参数值,到数组长度-1的索引结束。值得注意的是,如果当前方法是由构造方法或实例方法创建的(非静态方法),那么该对象引用this将会存放在index为0的Slot处。
QA:这也解决了我的一些疑问
为什么static中不能调用this方法?
答:为this的索引只存在构造方法和实例方法的局部变量表中,而static是在初始化方法<clinit>中进行初始化的,里面没有保存this变量的索引
。
JVM会为局部变量表中的每一个Slot都会分配一个访问索引,通过这个索引可以访问到局部变量表中指定的局部变量值。方法参数和方法体内部的局部变量会按照顺序被复制到局部变量表中的每一个Slot上。
如果需要访问局部变量表的一个64bit的局部变量值时,只需要使用前一个访问索引即可。
注:long 和 double 类型的数据占用两个连续的局部变量,这两种类型的数据值采用两个局部变量之中较小的索引值来定位。例如我们讲一个 double 类型的值存储在索引值为 n 的局部变量中,实际上的意思是索引值为 n 和 n+1 的两个局部变量都用来存储这个值。索引值为 n+1 的局部变量是无法直接读取的。
局部变量表的特点:
- 局部变量表中的变量只在当前方法调用中有效。
- 局部变量表的长度由编译期决定,多大的局部变量表,多深的操作数栈都已经完全确定了,在方法运行期间时不会改变局部变量表的大小的。
- 局部变量表是建立在线程的栈上,是线程私有的数据,因此不存在数据安全问题。
操作数栈
主要用于计算。和局部变量表一样,操作数栈也是一个数组。但是和前者不同的是,操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。
特点:
- 操作数栈的最大深度也是编译的时候就确定了。
- 栈中的任何一个元素都是可以任意的Java数据类型,32位的类型占用一个栈单位深度,64位的类型占用两个栈单位深度。[A5]
- 数据运算的地方,大多数指令都在操作数栈先将数据弹出栈,再执行运算,然后将结果压栈。
在操作栈中,基本上都是通过字节码指令对栈进行数据的入栈和出栈操作。举例:
iload_0:将局部变量表中的第0个索引的局部变量压入栈。
iload_1:将局部变量表中的第1个索引的局部变量压入栈。
iadd:一次弹出栈顶两个元素,然后相加,再压入栈中。
istore_2:弹出栈顶元素,然后存入到局部变量表第二个索引的局部变量中。
字节码指令地址:
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5.getstatic
方法返回地址
- 存放调用该方法的PC寄存器的值。(PC寄存器是用来存储指向下一条指令的地址)
- 当一个方法开始执行后,只有两种方式可以退出这个方法:
- 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口。
- 方法执行过程中遇到异常: 无论是java虚拟机内部产生的异常还是代码中throw出的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器(catch),就会导致方法退出,这种退出的方式称为异常完成出口
- 一个方法若使用该方式退出,是不会给上层调用者任何返回值的。
- 无论使用那种方式退出方法,都要返回到方法被调用的位置,程序才能继续执行。
总结:本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。
虚拟机栈中的异常
在Java 虚拟机规范中,对这个区域规定了两种异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError 异常;
举个例子:
先设置一下栈大小为128K。
执行下面的递归方法,无线递归:
就会出现StackOverflowError 异常:
- 如果虚拟机栈可以动态扩展(当前大部分的Java 虚拟机都可动态扩展,只不过Java 虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError 异常。
举个例子:
(1)把内存设置为2M,-Xss2M方法和上面是相同的。
(2)跑代码,不断的建立线程。
(3)运行结果
抛出OutOfMemoryError异常。
总结
- 在单线程下,设置了栈内存为128k。无限递归下,导致了栈深度太大,抛出了StackOverflowError异常。
- 在多线程下,设置了栈内存为2M,由于虚拟机栈是线程独占的,就是说每个线程都有自己的虚拟机栈,那么假设有10000个线程,那么内存占用应该是2M*10000,约为20G,而可用的总内存大小是有限的,那么总有线程的虚拟机栈分配不到足够的内存,就会报出OOM异常。
虚拟机栈运行过程
简单的例子:
public class IOExce {
public static void main(String[] args) {
Math math = new Math();
System.out.println(math.add());
}
}
public class Math {
int add() {
int a = 10;
int b = 20;
int sum = a + b;
return sum * 3;
}
}
执行过程:
当执行main方法时,栈中会分配main()方法的栈帧,并存储局部变量a和b,紧接着执行相加后存入局部变量表。然后执行add()方法,那么栈又会分配add()的栈帧区域,add栈帧就变成了当前栈帧。
具体的信息如下:
字节码文件:
分析add方法执行流程:
- 压栈
将变量a压入操作数栈中
bipush 10 |
2)存储
将int类型的a存入局部变量表中
istore_0 |
变量b也重复1,2过程。
bipush 20 istore_1 |
3)装载
从局部变量表中取出变量a和b,压入操作数栈中
iload_0 iload_1 |
- 执行运算
执行加法运算
iadd |
iadd指令一执行,会将操作数栈中的10和20依次从栈底弹出并相加,然后把运算结果30在压入操作数栈底。
- 存储
将运算结果存入局部变量表中
istore_2 |
iload_2装载sum,重复第二步
- 压栈
iconst_3执行压栈,将3压入栈里面
7) 执行乘法
imul 将操作数栈中的3和30依次从栈底弹出并相乘,将结果压入栈中
8)方法返回
ireturn 从方法中返回结果。
add方法是被main方法中调用的,所以通过方法出口返回到mian方法中。add()栈帧中的方法出口就存储了当前要回到的位置,那么当add()方法执行完之后,会根据方法出口中存储的相关信息回到main()方法的相应位置。