微信公众号搜"智元新知"关注
微信扫一扫可直接关注哦!

JVM系列-运行时数据区二

目录

4.4,操作数栈  

4.4.1,概述

4.4.2,操作数栈的作用

4.5,代码追踪

4.6,栈顶缓存技术ToS(Top-of-Stack Cashing)

4.7,动态链接(Dynamic Linking)

4.8,方法的调用,解析与分派

4.9,方法返回地址

4.10,相关题目

4.11,本地方法栈


接着上一次的运行时数据区一继续看,如果第一部分没有看的话,请在这里看:

4.4,操作数栈  

4.4.1,概述

  • 栈 :可以使用数组或者链表来实现
  •  每一个独立的栈帧中除了包含局部变量表以外,还包括一个后进先出(last-in-first-out)的操作数栈,也可以称为表达式栈(expression stack)。
  • 操作数栈,在方法执行的过程中,根据字节码指令,往栈中写入数据或者是提取数据,即入栈或者出栈。
  • 某一些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈,使用他们后再把结果压入栈。
  • 认用数组实现,因此在编译阶段大小已经确定。
  • 如果被调用方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并且更新pc寄存器中吓一跳需要执行的字节码指令。
  • 操作数栈中元素的数据类型必须和字节码指令的序列严格匹配,这是由编译器的编译期间进行验证的,同时在类加载的过程中类检验阶段的数据流分析阶段要再次验证。
  • 另外,我们说java虚拟机的解析引擎是基于栈的执行引擎,其中的栈指的是操作数栈

4.4.2,操作数栈的作用

  • 操作数栈,主要用于保存计算过程中的中间结果,同时作为计算过程中变量的临时存储位置。
  • 操作数栈就是jvm执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。
  • 一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需要的最大深度在编译期间就定义好,保存在方法的code属性中,为max_stack的值。
  • 栈中的数据类型可以是任何一个java数据类型。
    • 32bit的类型占用一个栈单位深度。
    • 64bit类型占据两个栈单位的深度。
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而只是通过标准的入栈和出栈操作来完成数据的访问。

4.5,代码追踪

public class OperantStackTest {
    public static void main(String[] args) {
        byte i=15;
        int j=8;
        int m=i+j;
    }
}
//编译结果
Classfile /D:/intellij/ideaWork/jvm/character01/target/classes/qq/com/rzf/OperantStackTest.class
  Last modified 2020-4-17; size 481 bytes
  MD5 checksum 775fd5fe9a4b1c367860391e1435daef
  Compiled from "OperantStackTest.java"
public class qq.com.rzf.OperantStackTest
  minor version: 0
  major version: 49
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#21         // java/lang/Object."<init>":()V
   #2 = Class              #22            // qq/com/rzf/OperantStackTest
   #3 = Class              #23            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               LocalVariableTable
   #9 = Utf8               this
  #10 = Utf8               Lqq/com/rzf/OperantStackTest;
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               args
  #14 = Utf8               [Ljava/lang/String;
  #15 = Utf8               i
  #16 = Utf8               I
  #17 = Utf8               j
  #18 = Utf8               m
  #19 = Utf8               SourceFile
  #20 = Utf8               OperantStackTest.java
  #21 = NameAndType        #4:#5          // "<init>":()V
  #22 = Utf8               qq/com/rzf/OperantStackTest
  #23 = Utf8               java/lang/Object
{
  public qq.com.rzf.OperantStacktest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lqq/com/rzf/OperantStackTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=4, args_size=1
         0: bipush        8
         2: istore_1
         3: bipush        9
         5: istore_2
         6: iload_1
         7: iload_2
         8: iadd
         9: istore_3
        10: return
      LineNumberTable:
        line 5: 0
        line 6: 3
        line 7: 6
        line 8: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  args   [Ljava/lang/String;
            3       8     1     i   I
            6       5     2     j   I
           10       1     3     m   I
}
SourceFile: "OperantStackTest.java"
//byte,short,char,boolean像数组中存放都以int类型保存

通过分析上面的代码,我们来看一个方法(栈帧)的执行过程,局部变量表是从1开始的,因为0位置存储的是this指针。

  • 1:pc寄存器的值为0,标示执行的是第一条指令。15入栈--->15存储到局部变量表。

  • pc寄存器是3,标示当前执行第三条指令。8入栈操作--->8存储到局部变量表。

  • 执行第6,7条指令:从局部变量表中把索引为1和2的数据加载出来,然后放到操作数栈中,然后执行第八条指令,iadd指令,把两个数据相加。

  • 执行第九条指令:把相加的结果入栈,再把相加的结果存储到局部变量表索引为3的位置。

bipush:表示存储`byte`类型数据。Istore_1表示存储的是int类型的数据。

  • i++和++i的区别:

4.6,栈顶缓存技术ToS(Top-of-Stack Cashing)

  • 基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数
  • 由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存技术,将栈顶元素全部缓存在cpu的寄存器中,以此降低对内存的读/写次数,提升执行疫情的执行效率

4.7,动态链接(Dynamic Linking)

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

  • 为什么需要常量池呢:常量池的作用,就是为了提供一些符号和常量,便于指令的识别。节省存储空间。在class字节码文件中常量池中的符号引用被加载到内存时就会放到方法区的常量池中,然后栈帧中需要符号引用时就直接引用方法区中的符号引用。方法区的常量池称作运行时的常量池。动态连接的目的就是将这些符号的引用转换为调用方法的直接引用。也就是说方法区的常量池信息就是字节码文件中常量池的信息。
  • 符号引用:

  • 运行时常量池:

4.8,方法调用,解析与分派

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。

  • 静态链接
    • 一个 字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接
  • 动态链接
    • 如果被调用方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接
  • 总结
    • 方法中的符号引用根据符号在常量池中找到具体对应的方法就叫做符号引用到直接引用的转换。

    • 符号引用转换为直接引用:如果在编译期间确定下来,就是静态链接,如果是在运行期间确定下来,就是动态链接

对应的方法的绑定机制为:早起绑定(Early Binding)和晚期绑定(Late Bingding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

  • 早期绑定
    • 早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。 
  • 晚期绑定
    • 如果被调用方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定。
  • 小结:
    • 编译期间决定---->早期绑定

    • 运行期间决定---->晚期绑定

    • 晚期绑定体现在多态处。构造器表现为早期绑定。

随着高级语言的横空出世,类似于java一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装,集成和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法

/**
 * 解析调用中非虚方法、虚方法的测试
 */
class Father {
    public Father(){
        System.out.println("Father认构造器");
    }

    public static void showStatic(String s){
        System.out.println("Father show static"+s);
    }

    public final void showFinal(){
        System.out.println("Father show final");
    }

    public void showCommon(){
        System.out.println("Father show common");
    }

}

public class Son extends Father{
    public Son(){
        super();
    }

    public Son(int age){
        this();
    }

    public static void main(String[] args) {
        Son son = new Son();
        son.show();
    }

    //不是重写的父类方法,因为静态方法不能被重写
    public static void showStatic(String s){
        System.out.println("Son show static"+s);
    }

    private void showPrivate(String s){
        System.out.println("Son show private"+s);
    }

    public void show(){
        //invokestatic
        showStatic(" 大头儿子");
        //invokestatic
        super.showStatic(" 大头儿子");
        //invokespecial
        showPrivate(" hello!");
        //invokespecial
        super.showCommon();
        //invokevirtual 因为此方法声明有final 不能被子类重写,所以也认为该方法是非虚方法
        showFinal();
        //虚方法如下
        //invokevirtual
        showCommon();//没有显式加super,被认为是虚方法,因为子类可能重写showCommon
        info();

        MethodInterface in = null;
        //invokeinterface  不确定接口实现类是哪一个 需要重写
        in.methodA();

    }

    public void info(){

    }

}

interface MethodInterface {
    void methodA();
}
  • 关于invokedynamic指令:
    • jvm字节码指令集一直比较稳定,一直到java7才增加一个invokedynamic指令,这是java为了实现动态类型语言支持做的一种改进。
    • 但是在java7中并没有提供直接生成invokedynamic指令的方法,需要借助底层ASM这种底层字节码工具来产生invokedynamic指令,直到java8的lambda表达式出现,invokedynamic指令的生成,在java中才有了直接生成的方式。
  • 动态类型语言和静态类型语言
    • 动态类型语言和静态类型语言两者的却别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之则是动态类型语言。
    • 直白来说 静态语言是判断变量自身的类型信息;动态类型预言师判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。根据变量值来确定类型。
    • Java是静态类型语言(尽管lambda表达式为其增加了动态特性),js,python是动态类型语言.
  • java语言中方法重写的本质。
    • 找到操作数栈的第一个元素所执行的对象的实际类型,记作C。
    • 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
    • .否则,按照继承关系从下往上依次对c的各个父类进行第二步的搜索和验证过程。
    • 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。 IllegalAccessError介绍 程序视图访问或修改一个属性调用一个方法,这个属性方法,你没有权限访问。一般的,这个会引起编译器异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

  • 方法
    • 在面向对象的编程中,会很频繁的使用动态分派,如果每一次动态分派的过程中都需要重新在类的方法元数据中搜索合适的目标的话就很可能影响到执行效率,因此,为了提高性能,jvm采用在类的方法区建立一个方法表(非虚方法不会出现在表中)来实现,使用索引表来代替查找。
    • 每个类中都有一个方法表,表中存放着各个方法的实际入口。
    • 那么虚方法表什么时候被创建? 虚方法表会在类加载的链接阶段被创建 并开始初始化,类的变量初始值准备完成之后,jvm会把该类的方发表也初始化完毕。
  • 方法表:friendly是一个接口。

  • dog的虚方法表,其中dog重写了object类的tostring()和sayHello()方法

也就是说dog类实现了父类的那些方法,dog类的虚方法表中的函数地址就直接指向dog类,调用的时候直接调用dog实现的方法,如果dog没有实现,就调用dog父类方法

具体狗的实现类,没有重写父类dog的tostring()方法,那么此方法就指向父类的tostring方法,实现了父类sayHello()和sayGoodbye方法,那么这两个方法就指向具体的实现类,如果用其他的方法,都是调用object()中的方法

  • cat实现类:cat实现类重写的方法全部指向cat类,其余指向object类。这就是cat的虚方法表。

4.9,方法返回地址

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

  • 存放调用方法的pc寄存器的值。

  • 一个方法的结束有两种方式

    • 正常执行完毕,执行return语句。
    • 出现未处理的异常,非正常退出
  • 无论通过哪种方式退出,在方法退出后都会返回到该方法调用的位置,方法正常退出时,调用者的pc计数器的值作为返回地址,即调用方法的指令的下一条指令的地址,而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。
  • 一个方法开始执行后,只有两种方式可以退出这个方法
    • 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,(正常完成的出口)。
      • 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值的实际数据类型而决定。
      • 在字节码指令中,返回指令包含ireturn(当返回值是boolean,byte,char,short ,int),String类型和Data类型都是作为引用类型进行返回,即areturn返回指令。lreturn,freturn,dreturn,areturn另外还有一个return指令供声明为void的方法,实例初始化方法,类和接口的初始化方法使用。(也就是没有返回类型的函数,在字节码文件中最后用return语句返回)构造器使用return指令返回。
    • 方法执行的过程中遇到了异常(exception),并且这个异常并没有在这方法内部进行处理,也就是只要在本地方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,(异常完成出口)。
    • 方法执行过程中抛出异常时的异常处理,存储在一个异常处理表中,方便在发生异常的时候找到处理异常的代码

前面两行数字都是字节码指令的地址,也就是说如果指令从4-16出现异常,那么就按照19行处进行处理。

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

4.10,相关题目

  • 开发中遇到的异常有哪些?(高级一点的异常:java虚拟机中的异常)

    • 递归函数调用通常易发生栈溢出的情况。stackoverflowerror,一个一个添加栈帧,当栈空间不足的时候,会发生栈溢出情况,通过-Xss来设置java栈的大小。栈空间可以是固定大小,也可以是动态变化的,当设置固定大小时,可能发生栈溢出情况,当设置动态变化时,可能发生内存溢出情况。

  • 调整栈大小,就可以保证不出现溢出情况么?
    • 不可以,调整栈空间的大小,可以让stackoverflowerror出现的时间晚一点,但是不能完全避免。就比如给你500元你一周可以用完,但是给你1000元,你还会把钱用完,只不过用完时间晚一点而已。
/**
 * 栈内存溢出
 * 认不设置栈内存大小:num最大值是9654
 * 设置栈的大小:-Xss256k,num值是:2469
 */
public class StackerrorFlow {
    private static  int num=0;
    public static void main(String[] args) {
        System.out.println(num);
        num++;
        main(args);//主函数递归调用自己
    }
}
  • 垃圾回收机制是否会涉及到虚拟机栈?不会。

内存块区域

栈溢出或者内存溢出

GC

Pc寄存器

不存在

不存在

虚拟机栈

存在

不存在

本地方法栈(调用本地c函数使用的栈)

存在

不存在

存在

存在

方法

存在

存在

  • 分配栈内存空间越大越好么?
    • 不一定,应为栈的总容量大小是一定的,如果给某一个线程的栈容量空间分配的很大,会导致其他线程的栈空间容量变小发生溢出情况,对于当前的线程,分配更大的栈内存。可以让stackoverflowerror出现的时间晚一点,并不能完全避免。
  • 方法中定义的局部变量是否是线程安全的?
    • 这个问题要具体问题具体分析。线程安全:如果只有一个线程才可以操作此数据,则必定是线程安全的,如果是多个线程操作此数据,则此数据是共享数据,如果不考虑同步机制的话,会存在线程安全问题。
 //但是stringBuilder的声明方式是线程安全的
//因为method1()归某一个栈帧单独所有,其他线程不能够访问,所以是线程安全的,变量多个线程共享的时候存在线程安全问题。
    public static void mothod1(){
        //StringBuilder类本身线程不安全
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("aa");
        stringBuilder.append("bb");
    }
//method2()的stringBuilder声明方式是不安全的,参数是从外面传进来,不归一个线程单独所有,可能多个线程共享,所以不安全
    public static void mothod2( StringBuilder stringBuilder){
        //StringBuilder类本身线程不安全
        stringBuilder.append("aa");
        stringBuilder.append("bb");
    }
//method3()的stringBuilder声明方式是不安全的,虽然stringBuilder是在方法里面创建的,但是有返回值,可能被其他线程所调用修改,所以不安全。
    public static StringBuilder mothod3(){
        //StringBuilder类本身线程不安全
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("aa");
        stringBuilder.append("bb");
        return stringBuilder;
    }
//method4()的stringBuilder声明方式是安全的,Method3不安全,应为直接将stringBuilder进行返回,但是method4是安全的,应为在方法里面,stringBuilder已经不存在了,方法里面的StringbUILDER已经消亡,在这里stringBuilder安全,但是string可能不安全,应为tostring()方法有创建一个方法返回,可能多个线程共享string
    public static String mothod4(){
        //StringBuilder类本身线程不安全
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.append("aa");
        stringBuilder.append("bb");
        return stringBuilder.toString();
    }

小结:对象如果内部产生,内部消亡,就是安全的。不是内部产生的,或者内部产生,但又返回生命周期没有结束就是不安全的。

4.11,本地方法

  • Java虚拟机栈用于管理Java方法调用,而本地方法栈用于管理本地方法调用
  • 本地方法栈,也是线程私有的。
  • 允许被实现成固定或者是可动态拓展的内存大小。(在内存溢出方面是相同的)
    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出一个StackOverFlowError异常。
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么java虚拟机将会抛出一个OutOfMemoryError异常。

  • 本地方法是使用C语言实现的
  • 它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。
  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限
    • 本地方法可以通过本地方法接口来 访问虚拟机内部的运行时数据区
    • 它甚至可以直接使用本地处理器中的寄存器。
    • 直接从本地内存的堆中分配任意数量的内存。
  • 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。
  • 在hotSpot JVM中,直接将本地方法栈和虚拟机栈合二为一。

也就是说本地方法栈主要和本地方法库,本地方法接口打交道。通过本地方法接口,本地方法栈可以访问jvm的运行时数据区。


参考资料:

[1] 深入理解java虚拟机。周志明


 

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 [email protected] 举报,一经查实,本站将立刻删除。

相关推荐