虚拟机栈,本身就是一个普通的栈,栈中的元素叫做栈帧。
虚拟机栈是线程私有的,每有一个线程,虚拟机就会创建一个虚拟机栈,线程与虚拟机栈一一对应。线程每调用一个方法,虚拟机就会创建一个栈帧,并将此栈帧压入虚拟机栈中,当方法调用结束后,此栈帧又从虚拟机栈中弹出。
线程每调用一个方法,都会起一个栈帧,因此栈帧的容量偏小,栈帧虽小,却五脏俱全。栈帧包含:
- 局部变量表
- 操作数栈
- 动态链接
- 方法返回地址
局部变量表
可以将局部变量表理解为一个数组,数组中的内容为所在的栈帧对应的方法上参数以及方法体内的局部变量,可以有基本数据类型、对象引用(真正的对象在堆上)。
既然是数组,而数组的长度是固定的,局部变量表的长度也是固定的,在编译期就确定下来了。虽然栈帧是在运行期创建的,但在运行期间,局部变量表的长度不会改变。
这里我们使用jclasslib来观察生成的class文件,需要先安装此插件。如果idea抽风,查不到这个插件,那直接就去这里下载吧,下载后不用解压,最后直接从磁盘导入该zip包。
以这样的代码为例:
public class Main {
private Object test() {
Object obj = new Object();
double a = 0.12d;
int b = 1;
return obj;
}
}
找到test方法,我们可以在Misc(杂项)中可以看到,Maximun local variables,即最大的本地变量个数为5,其实就是局部变量表的最大长度为5。
我们点到LocalVariable Table(局部变量表)中看看,到底有什么元素:
- Nr是编号
- Start PC是字节码指令的行号
- length是元素作用域的长度(Start PC+Length=Misc.Code Length)
- Index则是元素的下标,或者说slot号(槽位号)
- name是元素的名称
- Descriptor则是元素的描述符。
第1个slot是this引用,对于一个实例方法或者构造方法,局部变量表的第一个位置都是存放着this引用。
第2个slot则是Object类型的引用obj
第3、4个slot则是Double类型的变量a,long和double时64位的,因此占用两个槽
第5个slot则是int类型的变量b
int占用1个槽,byte、short、char都会转化为int,boolean也会转化为int,false转化为0,true转化为1。
当然,局部变量表为了节省空间,对出了作用域的变量,其所占据的slot会被复用。
private void method() {
int a = 1;
{
int b = 2;
b = a + b;
}
int c = 3;
}
可以看到,此次的流程为:
- 第1个slot依然保存this引用,作用域一直持续到PC=11(Start PC+Length)
- 第2个slot给了a,作用域也是持续到PC=11
- 第3个slot给了b,但b的作用域在PC>8时就结束了,因此下一个在PC=8以后才开始分配slot的变量直接可以复用b的slot
- 因此接下来的c直接复用了b的slot
操作数栈
操作数栈就是一种普通的栈,遵循后进先出的原则。操作数栈和局部变量表在访问数据的方式上存在差异,局部变量表是通过索引,而操作数栈则是通过一系列的入栈-出栈的操作来访问数据的。
但操作数栈和局部变量表有一个相同的地方,就是操作数栈的最大深度与局部变量表的最大长度,都是在编译期间就确定。
现在用一个简单的例子,来理解入栈出栈操作:
public static void main(String[] args) {
int a = 1;
int b = 2;
int c = a + b;
System.out.println(c);
}
通过jclasslib得到字节码后:
0 iconst_1 将数值1压入栈中
1 istore_1 将栈顶元素1出栈,并保存到局部变量表中索引为1的slot
2 iconst_2 将数值2压入栈中
3 istore_2 将栈顶元素2出栈,并保存到局部变量表中索引为2的slot
4 iload_1 将局部变量表中索引为1的元素入栈
5 iload_2 将局部变量表中索引为2的元素入栈
6 iadd 将栈顶的2个元素出栈,执行相加后再入栈
7 istore_3 将栈顶元素2出栈,并保存到局部变量表中索引为2的slot
8 getstatic #2 <java/lang/System.out> 调用静态变量System.out,PrintStream类型
11 iload_3 将局部变量表中索引为3的元素入栈
12 invokevirtual #3 <java/io/PrintStream.println> 调用PrintStream的println方法输出栈顶元素
15 return 方法结束
当然,int类型在-1~5、-128~127、-32768~32767、-2147483648~2147483647范围,执行入栈操作时,分别对应的指令是iconst、bipush、sipush、ldc(常量池中)。
动态链接
首先需要了解class常量池与运行时常量池
class常量池:
class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool ),在java文件被编译成class文件后,class常量池就会被生成,用于存放编译器生成的各种字面量和符号引用。
字面量就是我们所说的常量概念,如字符串、被声明为final的常量值等。 符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。
符号引用一般包括下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
说白了,字面量就是我们随便写的一个字符串,而符号引用更像是类的姓名,但是光靠符号引用是不能直接找到这个类。
运行时常量池:
首先,运行时常量池处于方法区,关于方法区在java7/8中又有不同的实现,可以先参考我的另外一篇文章
为什么用元空间替换永久代?
class文件在经过加载后(类加载机制,可以参考我的另外一篇文章类的奇幻漂流——类加载机制探秘),会进入内存,准确的说,是进入了方法区,我们可以在方法区中看到该class文件对应的魔数,版本号,常量池(这个常量池是class常量池在方法区的一个副本,为了避免和运行时常量池混淆,我们称这个常量池为静态常量池),类,父类和接口数组,字段,方法等信息,可以将这些信息统称为类的元数据。
接着,再经过连接中的解析阶段后,元数据中的静态常量池会进入运行时常量池,静态常量池中的一部分符号引用会被翻译成直接引用。直接引用就像是一个身份证,上面有你的姓名(符号引用),还有你的地址,通过地址可以直接定位到本人。
上文中,提到了“一部分”,这里的一部分指的是在编译期间就能确定方法的调用者,且在整个运行期间保持不变,可以直接称为静态链接。对于那些在运行时才能确定方法调用者的情况,需要在运行时动态地将符号引用转化为直接引用,即动态地将符号引用链接到直接引用,简称为动态链接。
方法返回地址
假设有这样的一个场景:
在线程t中,首先调用A方法,然后在A方法中的第p行(字节码中的第p行)调用B方法。则虚拟机首先创建一个虚拟机栈,将A方法的栈帧压入栈,接着将B方法的栈帧压入栈。
那么当B方法在正常结束(没有产生异常),B方法的栈帧在出栈后,会给方法A返回一个值,这个值就是p+1,即A方法继续执行时程序计数器的值。
但是,如果B方法出现异常时,则不会给方法A的栈帧返回任何值。
正是有了方法返回地址,方法A才知道,调用完B方法后,自己下一步需要从哪里开始执行。