虚拟机栈
出现背景
由于跨平台性的设计,java的指令都是根据栈来设计的。不同平台的CPU架构不同,所以不能设计为基于寄存器的。
栈优点:跨平台,指令集小,编译器容易实现;缺点:性能下降,实现同样的功能需要更多的指令
简介
java虚拟机栈(java virtual machine stack),早期也叫java栈。每个线程在创建时都会创建一个虚拟机栈,去内部保存一个个的栈帧(stack frame),对应着一次次的java方法调用。
- 是线程私有的
- 生命周期:与线程一致
- 作用:主管java程序的运行,他保存方法的局部变量(8种基本数据类型,对象的引用地址)、部分结果,并参与方法的调用和返回
- 特点
- 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器
- JVM直接对java栈的操作只有两个:
- 每个方法执行,伴随着进栈
- 执行结束后出栈
- 对于栈来说不存在垃圾回收问题
可能出现的异常
java虚拟机规范允许java栈的大小是动态的或者是固定不变的
- 固定大小的虚拟机栈,每一个线程的虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过java虚拟机栈允许的最大容量,java虚拟机将会抛出一个StackOverflowError异常
- 动态扩展的JVM,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那java虚拟机将会抛出一个OutOfMemoryError异常。
设置栈内存大小
我们可以使用参数-Xss选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度
/**
*
* 默认情况下:count : 11420
* 设置栈的大小: -Xss256k : count : 2465
*/
public class StackErrorTest {
private static int count = 1;
public static void main(String[] args) {
System.out.println(count);
count++;
main(args);
}
}
栈的存储单位
- 每个线程都有自己的栈,栈中的数据都是以栈帧的格式存在的。
- 在这个线程上正在执行的每个方法都各自对应一个栈帧
- 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
栈的运行原理
-
JVM直接对java栈的操作只有两个,对栈帧的压栈和出栈,遵循“先进后出/后出先进”原则
-
在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即当前正在执行的方法的栈帧是有效的,被称为当前栈帧(Current Frame),与当前栈帧对应的方法就是当前方法,定义这个方法的类就是当前类
-
执行引擎运行的所有字节码指令只针对当前栈帧进行操作
-
如果在该方法中调用其他方法,对应的新的栈帧会被创建,放在栈顶,成为新的当前帧
-
不同线程中所包含的栈帧不允许存在相互引用。
-
当前方法调用其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
-
java方法有两种返回函数的方式:一种是正常的函数返回,使用retrun指令;另外一种是抛出异常。不管使用那种方式,都会导致栈帧被弹出。
栈帧的内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables)
- 操作数栈(Operand Stack)(或表达式栈)
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
- 一些附加信息
- 后三者有些地方统称为帧数据区
一.局部变量表(Local Variables)
- 也称之为局部变量数组或本地变量表
- 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量。这些数据类型包括各类基本数据类型,对象引用(reference)以及returnAddress类型
- 由于局部变量表是建立在线程的栈上的,是线程的私有数据,因此不存在数据安全问题
- 局部变量表所需的容量大小是编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法的运行期间是不会改变局部变量表的大小的。
- 方法嵌套调用的次数由栈的大小决定,栈越大,调用次数越多。参数和局部变量越多,栈帧越大,调用次数也少。
- 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁
字节码中方法内部结构的剖析
public class LocalVariablesTest {
private int count = 0;
public static void main(String[] args) {
}
//练习:
public static void testStatic(){
LocalVariablesTest test = new LocalVariablesTest();
Date date = new Date();
int count = 10;
System.out.println(count);
}
}
使用idea的jclasslib插件查看
局部变量表
关于slot的理解
-
参数值的存放总是在局部变量数组index0开始,数组长度-1的索引结束
-
局部变量表,最基本的存储单元是Slot(变量槽)
-
局部变量表中存储编译期可知的各种基本数据类型,对象引用(reference)以及returnAddress类型的变量
-
在局部变量表中,32位以内的类型只*+占用一个slot**(包括returnAdress类型),64位的类型(long和double)占用两个slot。
- byte,short,char存储前转为int,boolean也转换为int,0为false,1为true
- long和double占两个slot
-
每一个slot都分配一个访问索引,通过索引可访问指定的局部变量值
-
实例方法被调用时,方法参数和方法体内的局部变量将会按照顺序被复制到局部变量表中的每个slot
-
访问64bit的局部变量值,只需使用前一个索引
-
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的slot处,其余参数按顺序排列(静态方法无法调用this,是因为局部变量表中没有存储this)
-
栈帧中的局部变量表中的槽位是可以重用的,一个局部变量过了作用域,在其作用域之后申明的新的局部变量就很可能会复用过期局部变量的槽位,从而达到节省资源的目的
public void test4() { int a = 0; { int b = 0; b = a + 1; } //变量c使用之前已经销毁的变量b占据的slot的位置 int c = a + 1; }
静态变量和局部变量的对比
变量的分类:按照数据类型分:① 基本数据类型 ② 引用数据类型
按照在类中声明的位置分:① 成员变量:在使用前,都经历过默认初始化赋值
类变量: linking的prepare阶段:给类变量默认赋值 ---> initial阶段:给类变量显式赋 值即静态代码块赋值
实例变量:随着对象的创建,会在堆空间中分配实例变量空间,并进行默认赋值
② 局部变量:在使用前,必须要进行显式赋值的!否则,编译不通过
补充
- 在栈帧中,与性能调优关系最密切的部分就是前面提到的局部变量表。在方法执行时,虚拟机使用局部变量表完成方法的传递。
- 局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收
二.操作数栈(Operand Stack)
介绍
- 操作数栈,在方法执行过程中,根据字节码指令,往栈中写数据或提取数据,即入栈和出栈
- 主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间
- 操作数栈是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的
- 每个操作数栈有明确的栈深度用于存储数值,最大深度在编译期就定义好了
- 栈中的任何一个元素可以是任意的java数据类型,32bit一个栈深度,64bit两个栈深度
- 操作数栈并非采用访问索引的方式来进行数据访问的,而是入栈和出栈操作来完成(虽然底层是数组)
- 如果被调用的方法有返回值,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令
- 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译期在编译期间进行验证,同时在类加载过程中的类校验阶段的数据流分析阶段再次验证
- java虚拟机的解释引擎是基于栈的执行引擎,其中栈指的是操作数栈
代码追踪
栈顶缓存技术
三.动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
- 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接。比如invokedynamic指令
- 在java原文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外其他方法时,就是通过常量池中指向方法得到符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
- 为什么使用常量池?
- 就是为了提供一些符号和常量,便于指令的识别
方法的调用
JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关
-
静态链接(只是方法)
当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这样情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
-
动态链接
如果被调用的方法在编译期无法确定被下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用的转换过程具备动态性,因此也被称之为动态链接
对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Last Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
-
早期绑定:
就是被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用
-
晚期绑定:
如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定
class Animal{
public void eat(){
System.out.println("动物进食");
}
}
interface Huntable{
void hunt();
}
class Dog extends Animal implements Huntable{
@Override
public void eat() {
System.out.println("狗吃骨头");
}
@Override
public void hunt() {
System.out.println("捕食耗子,多管闲事");
}
}
class Cat extends Animal implements Huntable{
public Cat(){
super();//表现为:早期绑定
}
public Cat(String name){
this();//表现为:早期绑定
}
@Override
public void eat() {
super.eat();//表现为:早期绑定
System.out.println("猫吃鱼");
}
@Override
public void hunt() {
System.out.println("捕食耗子,天经地义");
}
}
public class AnimalTest {
public void showAnimal(Animal animal){
animal.eat();//表现为:晚期绑定
}
public void showHunt(Huntable h){
h.hunt();//表现为:晚期绑定
}
}
基于面向对象的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式
java中任何一个普通方法都具备虚函数特征。如果不希望方法拥有虚函数特征时,则可以使用关键字final来标记这个方法
方法的调用:虚方法(对于动态链接或晚期绑定)与非虚方法
- 非虚方法:
- 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法
- 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法
- 其他方法称为虚方法
子类对象多态性的使用前提:1. 类的继承关系 2. 方法的重写
虚拟机中提供了以下几条方法调用指令
- 普通调用指令
- invokestatic:调用静态方法,解析阶段确定唯一方法版本
- invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
- invokevirtual:调用所有虚方法
- invokeinterface:调用接口方法
- 动态调用指令
- invokedynamic:动态解析需要调用的方法,然后执行
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持用户确定方法版本,其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余(invokevirtual中final修饰的除外)称为虚方法。
/**
* 解析调用中非虚方法、虚方法的测试
*
* invokestatic指令和invokespecial指令调用的方法称为非虚方法
*/
class Father {
public Father() {
System.out.println("father的构造器");
}
public static void showStatic(String str) {
System.out.println("father " + str);
}
public final void showFinal() {
System.out.println("father show final");
}
public void showCommon() {
System.out.println("father 普通方法");
}
}
public class Son extends Father {
public Son() {
//invokespecial
super();
}
public Son(int age) {
//invokespecial
this();
}
//不是重写的父类的静态方法,因为静态方法不能被重写!
public static void showStatic(String str) {
System.out.println("son " + str);
}
private void showPrivate(String str) {
System.out.println("son private" + str);
}
public void show() {
//invokestatic
showStatic("atguigu.com");
//invokestatic
super.showStatic("good!");
//invokespecial
showPrivate("hello!");
//invokespecial
super.showCommon();
//invokevirtual
showFinal();//因为此方法声明有final,不能被子类重写,所以也认为此方法是非虚方法。
//虚方法如下:
//invokevirtual
showCommon();
info();
MethodInterface in = null;
//invokeinterface
in.methodA();
}
public void info(){
}
public void display(Father f){
f.showCommon();
}
public static void main(String[] args) {
Son so = new Son();
so.show();
}
}
interface MethodInterface{
void methodA();
}
invokedynamic指令
- java7增加了invokedynamic指令,这是java为了实现【动态类型语言】支持而做的一种改进
- 但在java7中没有提供直接生成invokedynamic指令的方法,需要借助ASM底层字节码工具来产生。知道java8Lambda表达式的出现,invokedynamic指令的生成,在java中才有了直接的生成方式
- java7中增加的动态语言类型支持的本质是对java虚拟机规范的修改,而不是对java语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中方法调用,最直接的受益者就是运行在java平台的动态语言编译器。
动态语言和静态语言
- 区别在于对类型的检查是在编译期还是运行期,满足前者就是静态类型语言,反之为动态类型语言
- 直白一点说,静态类型语言就是变量自身的类型信息;动态类型语言就是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
方法重写的本质
- 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C
- 如果类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,通过返回方法的直接引用,查找结束;如果不通过,则返回java.lang.IllegallAccessError异常
- 否则,按继承关系从下往上依次对C的各个父类进行第2步的搜索和校验
- 始终没有合适方法,则抛出java.lang.AbstractMethodError异常
IllegallAccessError
程序试图访问或修改一个属性或调用方法,这个属性或方法你没有权限访问。一般会引起编译器异常。如果错误发生在运行时,说明一个类型发生了不兼容的改变。
- 在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程都要重新在累的方法元数据中搜索合适的目标的话可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找
- 每个类有一个虚方法表,存放方法的实际入口
- 虚方法表在类加载的连接阶段(解析)被创建并开始初始化,类变量初始值准备完成后,JVM会把该类的方法表也初始化完毕
举例:
四.方法返回地址
- 存放调用该方法的pc寄存器的值
- 方法结束两种方式:1. 正常执行完成 2. 出现未处理异常,非正常退出
- 无论那种方法退出,在方法退出后都返回到该方法被调用的位置。方法正常退出,调用者的pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。异常退出,返回地址是要通过异常表来确定,栈帧一般不会保存这部分信息。
正常完成出口:执行引擎遇到任意一个方法返回的字节码指令(Return),会有返回值传递给上层的方法调用者。
异常完成出口:方法执行过程遇到异常,异常没有在方法内进行处理,即本方法的异常表没有匹配的异常处理,就会导致方法退出。
方法执行过程中抛出异常时的异常处理,存储在一个异常处理表中,方便发生异常时找到异常处理代码
- 本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表,操作数栈,将返回值压入调用者栈帧的操作数栈,设置PC寄存器值等,让调用者方法继续执行下去。
- 正常完成和异常完成区别:异常完成出口退出的不会给他的上层调用者产生任何返回值
在字节码指令中,返回指令包含ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn以及areturn,另外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用。
五. 一些附加信息
栈帧中还允许携带与java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。
关于虚拟机栈一些面试问题:
-
举例栈溢出的情况?(StackOverflowError)
- 通过-Xss设置栈的大小
-
调整栈大小,就能保证不出现溢出吗? 不能,只能延迟溢出的出现,不能保证你的代码在溢出出现之前就能完成,可能出现溢出
-
分配的栈内存越大越好吗? 不是,就栈来说可以延迟溢出出现,有益,但对内存中其他结构来说,挤占了他们的空间,无益
-
垃圾回收是否会涉及到虚拟机栈?不会,只存在入栈和出栈操作,出栈已经相当于销毁了
-
方法中定义的局部变量是否是线程安全的?具体问题具体分析
/**
* 面试题:
* 方法中定义的局部变量是否线程安全?具体情况具体分析
*
* 何为线程安全?
* 如果只有一个线程才可以操作此数据,则必是线程安全的。
* 如果有多个线程操作此数据,则此数据是共享数据。如果不考虑同步机制的话,会存在线程安全问题。
*/
public class StringBuilderTest {
int num = 10;
//s1的声明方式是线程安全的
public static void method1(){
//StringBuilder:线程不安全
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
//...
}
//sBuilder的操作过程:是线程不安全的
public static void method2(StringBuilder sBuilder){
sBuilder.append("a");
sBuilder.append("b");
//...
}
//s1的操作:是线程不安全的
public static StringBuilder method3(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1;
}
//s1的操作:是线程安全的
public static String method4(){
StringBuilder s1 = new StringBuilder();
s1.append("a");
s1.append("b");
return s1.toString();
}
public static void main(String[] args) {
StringBuilder s = new StringBuilder();
new Thread(() -> {
s.append("a");
s.append("b");
}).start();
method2(s);
}
}