虚拟机栈

  • 根据《Java虚拟机规范》的规定,运行时数据区通常包括这几个部分:程序计数器(Program Counter Register)、Java栈(VM Stack)、本地方法栈(Native Method Stack)、方法区(Method Area)、堆(Heap)。

由此先来看看虚拟机栈的作用。

1、栈的异常

  • StackOverFlowError(栈溢出错误):如果采用固定大小的虚拟机栈,那么每一个线程的虚拟机栈容量可以在线程创建时独立选定,如果请求的栈容量超过虚拟机栈允许的最大容量,会抛出此异常。

例如:

1
2
3
4
5
public class StackTest {
public static void main(String[] args) {
main(args);
}
}

会抛出异常:

1
2
3
4
5
6
Exception in thread "main" java.lang.StackOverflowError
at com.jvm.demo.StackTest.main(StackTest.java:10)
at com.jvm.demo.StackTest.main(StackTest.java:10)
at com.jvm.demo.StackTest.main(StackTest.java:10)
at com.jvm.demo.StackTest.main(StackTest.java:10)
...
  • OutOfMemoryError(内存溢出):如果java虚拟机栈可以动态拓展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有对应的内存空间去创建对应的虚拟机栈,则会抛出此异常。

2、设置栈内存大小

  • 测试使用默认的栈内存大小:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class StackTest {
private static int count = 0;

public static void addCount(){
count++;
addCount();
}
public static void main(String[] args) {
try {
addCount();
}catch (Throwable e){
System.out.println("最大可达深度:" + count);
e.printStackTrace();
}
}
}

打印结果为:

1
2
3
4
5
6
最大可达深度:20818
java.lang.StackOverflowError
at com.jvm.demo.StackTest.addCount(StackTest.java:13)
at com.jvm.demo.StackTest.addCount(StackTest.java:13)
at com.jvm.demo.StackTest.addCount(StackTest.java:13)
...

通过修改idea编译器的Run–>Edit Configurations–>VM options为-Xss256k后,打印结果如下:

1
2
3
4
5
6
最大可达深度:2079
java.lang.StackOverflowError
at com.jvm.demo.StackTest.addCount(StackTest.java:12)
at com.jvm.demo.StackTest.addCount(StackTest.java:13)
at com.jvm.demo.StackTest.addCount(StackTest.java:13)
...

3、栈的存储结构与运行原理

  • 每个线程都有自己的栈,栈中的数据以栈帧为基本单位。
  • 在这个线程上执行的每个方法都有各自对应的一个栈帧。
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息。
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧,与当前栈帧相对应的方法就是当前方法,定义这个方法的类就是当前类
  • 不同线程中所包含的栈帧是不允许存在相互利用的,即不可能在一个栈帧中引用另一个线程的栈帧。
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。

4、栈帧的内部结构:Stack Frame

  • 每个栈帧中存储着:
    • 局部变量表
    • 操作数栈(或表达式栈)
    • 动态链接(或指向运行时常量池的方法引用)
    • 方法出口(或方法正常退出或者异常退出的定义)
    • 一些附加信息

4.1 局部变量表:Local Variable Table

  • 局部变量表又称为局部变量数组或本地变量表。
  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用以及返回地址类型。
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,所以不存在数据安全问题。
  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中,在方法运行期间是不会改变局部变量表的大小的。
  • 方法嵌套的调用次数由栈的大小决定。栈越大,方法嵌套调用次数越多。
  • 局部变量表中的变量只在当前方法调用中有效。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
  • 最基本的存储单元是Slot(变量槽)。
  • 在局部变量表里,32位以内的类型只占用一个slot(包括returnAddress类型),64位的long和double占用两个slot。
  • 每一个slot都会有一个访问索引,可通过索引访问变量值。
  • 当一个实例方法被调用时,它的方法参数和方法体内定义的局部变量会按照顺序复制到局部变量表的每一个slot上。
  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余的参数按照参数表顺序继续排列。
  • 栈帧中局部变量表中的slot是可以重复利用的,如果一个变量过了其作用域,那么在其后声明的局部变量可能会复用过期局部变量的slot,从而达到节省资源的目的。例如:
1
2
3
4
5
6
7
8
9
public void test(){
int a = 0;
{
int b = 0;
b = a + 1;
}
//变量c使用之前已经销毁的变量b占据的slot的位置
int c = a + 1;
}

上面方法的局部变量表里加上this变量一共有四个,但是编译出来发现只有三个,原因是变量c使用已经被销毁的变量b的slot,局部变量表中的信息如下:

4.2 操作数栈:Operand Stack

  • 在方法执行的过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈和出栈。
  • 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
  • 是JVM执行引擎的一个工作区,当一个方法开始执行的时候,一个新的栈帧会被建立出来,此时操作数栈是空的,其大小在编译器也被确定并保存在Code属性的max_stack值中。
  • 不能采用访问索引的方式访问数据,只能通过标准的入栈和出栈操作来完成一次数据访问。

例子1:

1
2
3
4
5
6
public void test1(){
//byte、short、char、boolean:都以int型来保存
byte i = 15;
int j = 8;
int k = i + j;
}

其方法对应的字节码指令为:

1
2
3
4
5
6
7
8
9
10
stack=2(操作数栈的大小), locals=4(局部变量表的大小), args_size=1
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

方法执行过程中对应程序计数器、局部变量表和操作数栈的变化如下所示:

例子2:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class OperandStackTest {
public int getSum(){
int m = 10;
int n = 20;
int k = m + n;
return k;
}
public void testGetSum(){
//获取上一个栈桢返回的结果,并保存在操作数栈中
int i = getSum();
int j = 10;
}
}

getSum()对应的字节码指令为:

1
2
3
4
5
6
7
8
9
10
 0 bipush 10
2 istore_1
3 bipush 20
5 istore_2
6 iload_1
7 iload_2
8 iadd
9 istore_3
10 iload_3
11 ireturn

testGetSum()对应的字节码指令为:

1
2
3
4
5
6
0 aload_0  //把局部变量表的开始位置中的this装载到操作数栈中,即aload_x是指从局部变量表的相应位置装载一个对象引用到操作数栈的栈顶
1 invokevirtual #2 <com/company/java03/OperandStackTest.getSum>
4 istore_1
5 bipush 10
7 istore_2
8 return

4.3 动态链接:Dynamic Linking

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

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class DynamicLinkingTest {

int num = 10;

public void methodA(){
System.out.println("methodA()....");
}

public void methodB(){
System.out.println("methodB()....");
methodA();
num++;
}
}

对应的部分字节码文件信息如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void methodB();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=3, locals=1, args_size=1
0: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #6 // String methodB()....
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: aload_0
9: invokevirtual #7 // Method methodA:()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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Constant pool://运行时常量池
#1 = Methodref #9.#23 // java/lang/Object."<init>":()V
#2 = Fieldref #8.#24 // com/company/java03/DynamicLinkingTest.num:I
#3 = Fieldref #25.#26 // java/lang/System.out:Ljava/io/PrintStream;
#4 = String #27 // methodA()....
#5 = Methodref #28.#29 // java/io/PrintStream.println:(Ljava/lang/String;)V
#6 = String #30 // methodB()....
#7 = Methodref #8.#31 // com/company/java03/DynamicLinkingTest.methodA:()V
#8 = Class #32 // com/company/java03/DynamicLinkingTest
#9 = Class #33 // java/lang/Object
#10 = Utf8 num
#11 = Utf8 I
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 LocalVariableTable
#17 = Utf8 this
#18 = Utf8 Lcom/company/java03/DynamicLinkingTest;
#19 = Utf8 methodA
#20 = Utf8 methodB
#21 = Utf8 SourceFile
#22 = Utf8 DynamicLinkingTest.java
#23 = NameAndType #12:#13 // "<init>":()V
#24 = NameAndType #10:#11 // num:I
#25 = Class #34 // java/lang/System
#26 = NameAndType #35:#36 // out:Ljava/io/PrintStream;
#27 = Utf8 methodA()....
#28 = Class #37 // java/io/PrintStream
#29 = NameAndType #38:#39 // println:(Ljava/lang/String;)V
#30 = Utf8 methodB()....
#31 = NameAndType #19:#13 // methodA:()V
#32 = Utf8 com/company/java03/DynamicLinkingTest
#33 = Utf8 java/lang/Object
#34 = Utf8 java/lang/System
#35 = Utf8 out
#36 = Utf8 Ljava/io/PrintStream;
#37 = Utf8 java/io/PrintStream
#38 = Utf8 println
#39 = Utf8 (Ljava/lang/String;)V
  • 为什么需要常量池?
    • 为了提供一些符号和常量,便于指令的识别。

4.4 方法返回地址:Return Address

  • 存放调用该方法的PC寄存器的值【保存了PC寄存器的值,当正常结束后,把这个值给到执行引擎,使线程继续工作】

  • 一个方法的结束有两种方式:(1)正常执行结束。(2)出现未处理的异常,非正常退出。

    • 无论哪种方式退出,此方法在退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法指令下一条指令的地址。而异常退出时,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
  • 返回指令包含ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。

  • 本质上,方法的退出就是当前栈帧出栈。此时,需要恢复上层方法的局部变量、操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

  • 正常完成的出口和异常完成的出口区别在于:通过异常完成出口退出的不会给它的上层调用者产生任何的返回值。