本节将会介绍一下Java虚拟机栈中的栈帧,会对栈帧的组成部分(局部变量表、操作数栈、动态链接、方法出口)分别进行介绍,最后还会通过javap命令反解析编译后的.class文件,进行分析方法执行时的局部变量表、操作数栈等。
目录
Java虚拟机栈概述
局部变量表
操作数栈
动态连接
方法的返回地址
结合javap命令理解栈帧
Java虚拟机栈(Java Virtual Machine Stacks)是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:栈帧(Stack Frame)是用于支持Java虚拟机进行方法调用和执行的数据结构,它是虚拟机栈中的栈元素。每个方法在执行的同到都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写人到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟栈中从入栈到出栈的过程(说人话就是要执行一个方法,将该方法的栈帧压入栈顶,方法执行完成其栈帧出栈)。在JVM里面,栈帧的操作只有两种:出栈和入栈。正在被线程执行的方法称为当前线程方法,而该方法的栈帧就称为当前帧,执行引擎运行时只对当前栈帧有效。
下面对栈帧的每个组成部分分别介绍一下。
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时就在方法的code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个引都应该能存放一个boolean、byte、char、short,int,float、reference或returnAddress类型的数据,这8种数类都可以使用32位或更小的物理存来存放,但这种描述与明确指出 "每个Slot占用32位长度的内存空间" 是有一些差别的,它运行Slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。只要保证使在64位虚拟机中使用了64位的物理内存空间去实现一个Slot,虚拟机仍要使用对齐和补白的手段让Slot在外观上看起来与32位拟机中的一致。
一个Slot可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean、byte、char、short、float、reference和returnAddress8种类型。第7种reference类表示对一个对象实例的引用,虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但是一般来说,虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息。第8种即returnAddress类型目前已经很少见了,现在已经由异常表代替。
对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的引Slot空间。Java语言中明确的(reference类型则可能是32位也可能是64位),64位的数据类型只有long和double两种。虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果访问的是32位数据类型的变量,索引 n 就代表了使用第n个Slot,如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,Java虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。
如果是实例方法(非static的方法),那么局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用"this"。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot(比如方法method(int a1,inta2),参数表为a1和a2,则局部变量表索引0、1、2则分别存储了this指针、a1、a2,如果方法内部有其他内部变量,则在局部变量表中存在a2之后的位置)。
为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。
局部变量不像的类成员变量那样存在"准备阶段"。我们知道类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,不要认为Java中任何情况下都存在诸如整型变量默认为0,布尔型变量默认为false等这样的默认值。
操作数栈(Operand Stack)也常称为操作栈,它是一个后人先出(Last In First out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写人到code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在maxstacks数据项中设定的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写人和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存人了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。如果当前线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存人口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
Math math=new Math();
math.compute();//调用实例方法compute()
以上面两行代码为例,解释一下动态连接:math.compute()调用时compute()叫符号,需要通过compute()这个符号去到常量池中去找到对应方法的符号引用,运行时将通过符号引用找到方法的字节码指令的内存地址。
当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压人调用者栈帧的操作数栈中,调整pc计数器的值以指向方法调用指令后面的一条指令等。
上面进行了大段的文文字介绍,还是不太好理解,下面我们通过javap命令来分析一下方法中的操作指令、局部变量表、操作数栈等。
javap是jdk自带的反解析工具。它的作用就是根据class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。下面是其用法说明:
D:\wyonline\myworkspaces\framework\Test\bin\com\wkp\jvm>javap
用法: javap
其中, 可能的选项包括:
-help --help -? 输出此用法消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示公共类和成员
-protected 显示受保护的/公共类和成员
-package 显示程序包/受保护的/公共类
和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的
系统信息 (路径, 大小, 日期, MD5 散列)
-constants 显示最终常量
-classpath 指定查找用户类文件的位置
-cp 指定查找用户类文件的位置
-bootclasspath 覆盖引导类文件的位置
下面我们写一个简单的Java程序:
package com.wkp.jvm;
public class Math {
public static final Integer CONSTANT=666;
public int compute() {//一个方法对应一块栈帧内存区域
int a=3;
int b=5;
int c=(a+b)*10;
return c;
}
public static void main(String[] args) {
Math math=new Math();
math.compute();
}
}
然后进入到Math.class所在目录执行: javap -c Math.class > Math.txt 命令,将Math.class字节码文件反汇编然后输出到Math.txt文件中:
然后我们查看Math.txt的内容如下:我们重点分析下compute方法内的指令,其内部的指令后面我加了注释(这里我是参考上一节的《JVM字节码指令集大全及其介绍》,感兴趣的话可以看一看),注释中的栈就是指的栈帧中的操作数栈,本地变量表就是指的局部变量表。
Compiled from "Math.java"
public class com.wkp.jvm.Math {
public static final java.lang.Integer CONSTANT;
static {};
Code:
0: sipush 666
3: invokestatic #10 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
6: putstatic #16 // Field CONSTANT:Ljava/lang/Integer;
9: return
public com.wkp.jvm.Math();
Code:
0: aload_0
1: invokespecial #21 // Method java/lang/Object."":()V
4: return
public int compute();
Code:
0: iconst_3 //将int类型的3推送至栈顶
1: istore_1 //将栈顶int类型数值(上面的3)出栈并存入第二个本地变量
2: iconst_5 //将int类型的5推送至栈顶
3: istore_2 //将栈顶int类型数值(上面的5)出栈并存入第三个本地变量
4: iload_1 //将第二个int型本地变量(上面的3)推送至栈顶
5: iload_2 //将第三个int型本地变量(上面的5)推送至栈顶
6: iadd //将栈顶两int型数值出栈,然后相加并将结果压入栈顶
7: bipush 10 //将常量值10推送至栈顶
9: imul //将栈顶两int型数值出栈,然后相乘并将结果压入栈顶
10: istore_3 //将栈顶int类型数值(上面的乘积)出栈并存入第四个本地变量
11: iload_3 //将第四个int类型本地变量推送至栈顶
12: ireturn //从当前方法返回int类型值
public static void main(java.lang.String[]);
Code:
0: new #1 // class com/wkp/jvm/Math
3: dup
4: invokespecial #33 // Method "":()V
7: astore_1
8: aload_1
9: invokevirtual #34 // Method compute:()I
12: pop
13: return
}
下面我们通过图示简单表示一下上面compute方法中指令操作时关于本地变量表、操作数栈的情况:
我们先看下第一行 0: iconst_3 //将int类型的3推送至栈顶,可以看到下图3已经被入栈到操作数栈的栈顶。
我们再看下第二行 1: istore_1 //将栈顶int类型数值(上面的3)出栈并存入第二个本地变量,将上图中栈顶的3出栈然后存入本地表中第二个位置,如下图所示:
第三行、第四行跟上面的一二行指令类似,第四行指令执行后变成如下所示:
第五行、第六行中 4: iload_1 //将第二个int型本地变量(上面的3)推送至栈顶; 5: iload_2 //将第三个int型本地变量(上面的5)推送至栈顶,即将局部变量表中的3和5依次压入栈顶,如下图所示:
然后第七行执行iadd操作,将栈顶的两个int类型数据5和3出栈相加,将得到的和压入栈顶,得到如下结果:
后面的指令操作过程与上面类似,执行完第12行的iload_3指令之后,会得到如下图所示:
关于局部变量表的信息,还可以通过javap -l 命令查看如下图所示,另外还可以通过Idea中的jclasslib 查看。
LocalVariableTable表示的就是局部变量表的信息:
public int compute();
LineNumberTable:
line 8: 0
line 9: 2
line 10: 4
line 11: 11
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this Lcom/wkp/jvm/Math;
2 11 1 a I
4 9 2 b I
11 2 3 c I
我们还可以通过 javap -c Math.class > Math.txt 查看更多的信息如下:我们可以看到 9: invokevirtual #34 // Method compute:() 中的 #34 可以在常量池中找到 #34 = Methodref #1.#35 // com/wkp/jvm/Math.compute:()也就是方法的符号引用,运行时通过符号引用解析出来方法的执行指令的内存地址,这个其实就是动态连接。
Classfile /D:/wyonline/myworkspaces/framework/Test/bin/com/wkp/jvm/Math.class
Last modified 2019-8-24; size 761 bytes
MD5 checksum be0cdf4bcd037929d3fe0af86d44a837
Compiled from "Math.java"
public class com.wkp.jvm.Math
minor version: 0
major version: 52 //魔数
flags: ACC_PUBLIC, ACC_SUPER
Constant pool: //常量池
#1 = Class #2 // com/wkp/jvm/Math
#2 = Utf8 com/wkp/jvm/Math
#3 = Class #4 // java/lang/Object
#4 = Utf8 java/lang/Object
#5 = Utf8 CONSTANT
#6 = Utf8 Ljava/lang/Integer;
#7 = Utf8
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Methodref #11.#13 // java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
#11 = Class #12 // java/lang/Integer
#12 = Utf8 java/lang/Integer
#13 = NameAndType #14:#15 // valueOf:(I)Ljava/lang/Integer;
#14 = Utf8 valueOf
#15 = Utf8 (I)Ljava/lang/Integer;
#16 = Fieldref #1.#17 // com/wkp/jvm/Math.CONSTANT:Ljava/lang/Integer;
#17 = NameAndType #5:#6 // CONSTANT:Ljava/lang/Integer;
#18 = Utf8 LineNumberTable
#19 = Utf8 LocalVariableTable
#20 = Utf8
#21 = Methodref #3.#22 // java/lang/Object."":()V
#22 = NameAndType #20:#8 // "":()V
#23 = Utf8 this
#24 = Utf8 Lcom/wkp/jvm/Math;
#25 = Utf8 compute
#26 = Utf8 ()I
#27 = Utf8 a
#28 = Utf8 I
#29 = Utf8 b
#30 = Utf8 c
#31 = Utf8 main
#32 = Utf8 ([Ljava/lang/String;)V
#33 = Methodref #1.#22 // com/wkp/jvm/Math."":()V
#34 = Methodref #1.#35 // com/wkp/jvm/Math.compute:()I
#35 = NameAndType #25:#26 // compute:()I
#36 = Utf8 args
#37 = Utf8 [Ljava/lang/String;
#38 = Utf8 math
#39 = Utf8 SourceFile
#40 = Utf8 Math.java
{
public static final java.lang.Integer CONSTANT;
descriptor: Ljava/lang/Integer;
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: sipush 666
3: invokestatic #10 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
6: putstatic #16 // Field CONSTANT:Ljava/lang/Integer;
9: return
LineNumberTable:
line 5: 0
LocalVariableTable:
Start Length Slot Name Signature
public com.wkp.jvm.Math();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #21 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/wkp/jvm/Math;
public int compute();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: iconst_3
1: istore_1
2: iconst_5
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: bipush 10
9: imul
10: istore_3
11: iload_3
12: ireturn
LineNumberTable:
line 8: 0
line 9: 2
line 10: 4
line 11: 11
LocalVariableTable:
Start Length Slot Name Signature
0 13 0 this Lcom/wkp/jvm/Math;
2 11 1 a I
4 9 2 b I
11 2 3 c I
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #1 // class com/wkp/jvm/Math
3: dup
4: invokespecial #33 // Method "":()V
7: astore_1
8: aload_1
9: invokevirtual #34 // Method compute:()I
12: pop
13: return
LineNumberTable:
line 15: 0
line 16: 8
line 17: 13
LocalVariableTable:
Start Length Slot Name Signature
0 14 0 args [Ljava/lang/String;
8 6 1 math Lcom/wkp/jvm/Math;
}
SourceFile: "Math.java"
参考:《深入理解Java虚拟机第二版》、《Java虚拟机规范 JavaSE8版》