虚拟机栈

概念

  • Java虚拟机栈线程私有,生命周期与线程相同。描述的是Java方法执行的线程内存模型。

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够内存会抛出OutOfMemoryError异常。

栈是运行时的单位,而堆是存储的单位。栈解决程序的运行问题,即程序如何执行,数据如何处理。堆解决的是数据的存储问题,即数据怎么放。

栈就好比你程序中的业务代码,而堆就是存储数据的数据库,数据一个程序是灵魂,如果数据不做处理、可视化,完全是死的数据,我认为是完全没有意义的。

设置栈的大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Test {
private static int i = 0;

public static void test1() {
test2();
}

public static void test2() {
i++;
System.out.println(i);
test1();
}

public static void main(String[] args) {
test1();
}
}

这个代码就会导致StackOverflowError异常。递归调用操作不当十分容易导致栈溢出。

test2()这个方法执行了10271次才溢出,可以通过-Xss选项来设置线程的最大栈空间,这个栈空间的大小直接决定了函数的调用的最大深度。

发现test2()方法只运行了541次,得出-Xss改变了栈空间的大小。

特点

  • 栈是一种快速有效的分配方式,访问速度仅次于程序计数器。
  • 对于栈来说不存在垃圾回收的问题。

栈帧

栈的存储单位就是栈桢。

每个方法被执行的时候,java虚拟机都会同步创建一个栈桢用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每个方法从被调用到执行完毕的过程,就对应着一个栈桢在虚拟机中从入栈到出栈的过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Test {
private static int i = 0;

public static void test1() {
test2();
}

public static void test2() {
test3();
}

public static void test3() {
System.out.println("结束");
}

public static void main(String[] args) {
test1();
}
}

这个程序main()方法中调用test1(),test1()调用test2(),test2()调用test3()。

将程序在test1()处打个断点,平时写代码调试的时候,经常接触栈栈桢,只是有可能不知道这是而已。

然后步入接着执行程序

继续步入执行

test3()方法执行完毕后开始出栈。

之后就是test2出栈test1出栈最后main出栈,程序执行结束。

这里如果调试的时候执行了下一步怎么返回上一步执行,退回去重新操作。idea提供了丢帧的操作。

抛弃test1()后就回到main()这个栈桢了。

这个就像你写的程序一个方法中调用另一个方法,就相当于妈妈让你爸爸去买醋,然后你爸爸让你去买醋,你买回来后高速你爸爸我买回来了,然后就没你的事情了,然后你爸爸高速你妈妈,买回来了。。。当然你也可以直接告诉你妈妈,,但是程序不行啊。这就是一个入栈出栈的过程,如果你爸爸告诉你去买,你告诉你爸爸去买,无限循环,不就导致错误了吗。

栈运行原理

  • 不同线程中包含的栈桢是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
  • java方法有两种返回函数的方式,一种是正常的函数返回,使用return命令;另外一种是抛出异常;不管使用哪种方式,都会导致栈帧被弹出。

栈帧的内部结构

  • 局部变量表(*)
  • 操作数栈(*)
  • 动态连接
  • 方法返回地址
  • 一些附加的信息

局部变量表

  • 局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数方法内部定义的局部变量这些数据类型包括各大基本数据类型、对象引用(reference),以及returnAddress类型。

    基本数据类型无需多言,reference就是一个对象实例的引用。returnAddress是为了字节码指令jsr、jsr_w和ret服务的,指向一条字节码指令的地址,目前已经很少使用。

  • 由于局部变量表在栈帧之中,因此,如果函数的参数和局部变量很多,会使得局部变量表膨胀,因此,每一次函数调用,其局部变量表会占用更多的栈空间,最终导致函数的嵌套调用的次数减少。

    栈大小固定,栈中存放栈帧,栈帧中有局部变量表,局部变量表存放局部变量,局部变量很多,占用空间就大,导致栈中可以存放的栈桢变少,自然函数嵌套的次数也就减少

  • 局部变量表是建立在线程的栈上,是线程的私有数据,不存在数据安全问题。

  • 局部变量表所需的容量大小是在编译期确定下来的,存储在方法的Code属性的maximum local variables数据项中。(后续会说明)在方法运行期间是不会改变局部变量表的大小的。

局部变量表中的容量的意思是你这个方法中有多少局部变量,和方法参数,这个肯定是确定的啊,在编译期间就确定的。你自己都能数一数一个方法中有多少变量和参数,更别说编译器了。

  • 局部变量表中的变量只在当前方法中有效。当在方法执行的时候,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也随之销毁。

例子

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void test1() {
byte a = 1;
short b = 1;
int c = 1;
long d = 1L;
float e = 11.2F;
double f = 1.1;
char g = 'a';
boolean i = true;

String h = "aa";
Student student = new Student();
}

分析test1()方法的局部变量表:

因为这是静态方法所以并不存在该对象的引用this,如果不是静态方法,index为0的位置就是隐藏的this。

变量槽Slot

  • 局部变量表,最基本的存储单元是变量槽
  • 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。

byte、short、char在存储前会被转换为int,boolean也被转换为int,0为false,非0表示true。

long和double占据两个变量槽。

  • JVM会为局部变量表中的每一个slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量的值。
  • 如果要访问局部变量表中的一个64bit(long、double)的局部变量值时,只需要使用前一个索引即可。这也是JVM规定必须的。
  • 如果一个方法不是静态方法,那么该对象的引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。
  • 变量槽(Slot)可以重复利用
变量槽的重复利用

栈帧中的局部变量表中的槽位是可以重复利用的,如果一个局部变量出了它的作用域,那么在其作用域之后申明的局部变量就会复用过期局部变量的槽位,从而达到节省资源的目的。

1
2
3
4
5
6
public void test2() {
{
int b = 1;
}
int a = 1;
}

这个代码不是静态代码,所以肯定存在一个this这个对象的引用,为什么是2个槽位,而不是3个槽位,代码中b这个变量出了代码块后,这个变量槽就没必要存储它了,然后a也没必要在重开一个槽位,就相当于占用了b的槽位,把b替换了。

这是你可能会想,如果a是long或者double类型的占用2个槽位,但是b只空出一个槽位,那该怎么算?如果这样的话,槽位就会变成3,而不是4,照样重用那个槽位,然后在添加上一个槽位就可以了。

操作数栈

  • 后入先出(Last In First Out)栈,也常被称作操作栈。

  • 同局部变量表一样,操作数栈的最大深度也在编译的时候被写入Code属性的max_stacks数据项之中。

  • 栈中的任何一个元素都是可以任意的Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。

  • 操作数栈不是采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop)操作来完成一次数据访问。

  • 操作数栈中元素类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。

  • Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。

例子

1
2
3
4
5
public static void test1() {
int a = 1;
int b = 2;
int c = a + b;
}

编译的时候局部变量表和操作数栈的大小和深度就是确定的。

动态连接

  • 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态连接。
  • 在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在class文件的常量池里。比如:描述一个方法调用另外的其他方法时候,就是通过常量池中指向方法的符号引用来表示的,那么动态连接的作用就是为了将这些符号引用转换为调用方法的直接引用。

具体描述在方法调用中说明。

方法调用

  • 方法调用阶段的唯一任务就是确定被调用方法到底是哪个方法。一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在运行时内存布局中入口地址(这个就是直接引用)
  • 某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

例如

1
2
3
4
5
6
7
8
9
public class Test {
public static void test1() {
System.out.println("琦玉");
}

public static void main(String[] args) {
test1();
}
}

main()方法中调用了test1()方法,它的字节码为

在java虚拟机中支持以下5条方法调用字节码指令:

指令 作用
invokestatic 用于调用静态方法
invokespecial 用于调用实例构造方法<init>()、私有方法和父类中的方法
invokevirtual 用于调用所有的虚方法
invokeInterface 用于调用接口方法,会在运行时再确定一个实现该接口的对象
invokedynamic 这个最为特殊,具体说明。

虚方法、非虚方法

  • 只要是能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,java语言里符合这个条件的方法有静态方法、私有方法、实例构造器、父类方法这4种,再加上被final修饰的方法(尽管它是使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为非虚方法与之相反就是虚方法

静态方法、私有方法、实例构造器、父类方法还有被final修饰的方法有一个共同的特点就是它们都不能通过继承或别的方式重写,之所以叫做非虚我认为就是十分确定的,你调用这些方法能特别肯定的,你的程序中只有一个地方有该方法,是十分确定的,不像有些方法可以重写,重载一样,你调用一个方法系统不知道调用哪个,必须等到程序真正运行调用的时候才能知道,虚无缥缈,所以就叫做虚方法了。

分派

通过分派彻底理解重载和重写

invokedynamic

  • jdk7中增加的一个指令,这个指令是为了实现动态类型语言支持而做出的改进。

  • 每一处含有invokedynamic指令的位置都被称作“动态调用点”,虽然这个指令是jdk7引用的,但是并没有提供直接生成该指令的方法。直到java8中的Lambda表达式的出现,invokedynamic指令的生成,在java中才有了直接的生成方式。

动态类型、静态类型语言

动态语言的特征是它的类型检查的主体过程是在运行期间而不是编译期进行的(例如js、php、python等)相对的在编译期间就进行类型检查过程的语言,例如c++、java就是最常用的静态类型语言。

a = 1; 这个语句在python中就可以执行,但是在java中就会报错,静态语言是对a变量的检查,而动态语言是对1这个值的检查,根据这个值来确定变量a的类型。一个运行时确定,一个编译期确定。

这两个类型的语言各自优点?

静态语言:能够在编译期确定变量类型,十分严谨,利于系统的稳定性。

动态语言:运行时才确定类型,十分灵活,并且静态类型花费很多代码才能实现的功能,动态语言可能很简洁就能实现,提高了开发的效率。

方法返回地址

当一个方法开始执行后只有两种方法可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层方法的调用者,这种退出的方式为“正常调用完成”。

ireturn(返回值是boolean,byte,char,short和int时使用)、lreturn、freturn、dreturn、areturn(引用类型)、return(void类型)

  1. 方法在执行的过程中遇到了异常,并且这个异常并未在方法内进行处理,这种退出方法的方式称为“异常调用完成”。

一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。

总结

方法退出的过程就等同于当前栈帧出栈。这是需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。