学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

2022年9月3日12:13:58

大家可以去尚硅谷学习JVM,传送门

运行时数据区概述及线程

概述

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

当类的字节码文件加载进入到方法区后,就开始了运行时数据区的复习( ( ^ _ ^ ) )

内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。JVM 内存布局规定了 Java 在运行过程中内存申请,分配,管理的策略,保证了 JVM 的高效稳定运行;不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。

下图为 1.8 的结构图(1.8 去掉了 1.7 的永久代,使用了元数据区实现了方法区):

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

Java 虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁;

每个线程:独立包括程序计数器,虚拟机栈,本地栈;
线程间共享:堆,堆外内存(永久代或元空间,代码缓存);


学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

每个 JVM 只有一个 Runtime 实例,即为运行时环境,相当于内存结构的中间的那个运行时数据区:运行时环境;


线程

线程是一个程序里的运行单元, JVM 允许一个应用有多个线程并行的执行;在 Hotspot JVM 里,每个线程都与操作系统的本地线程直接映射;

  • 当一个 Java 线程准备好执行以后,此时一个操作系统的本地线程也同时创建, Java 线程执行终止后,本地线程也会回收;

操作系统负责所有线程的安排调度到任何一个可用的CPU上,一旦本地线程初始化成功,它就会调用 Java 线程中的 run() 方法;


程序计数器(PC寄存器)

程序计数器是什么?

  • JVM 中的程序计数寄存器(Program Counter Register) 中,Register 的命名源于 CPU 的寄存器,寄存器存储指令相关的现场信息;CPU 只有把数据装载到寄存器才能够运行;
  • 这里,并非是广义上所指的物理寄存器,或许将其翻译为 PC 计数器(或指令计数器)会更加贴切(也称为程序钩子),并且也不容易引起一些不必要的误会;JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟;

作用

  • PC 寄存器用来存储指向下一条指令的地址,也即,将要执行的指令代码。由执行引擎读取下一条指令;

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

特点

  • 它是一块很小的内存空间,几乎可以忽略不记;也是运行速度最快的存储区域;
  • 在 JVM 规范中,每个线程都有它自己的程序计数器,**是线程私有的,生命周期与线程的生命周期保持一致;**任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址;或者,如果是在执行 native 方法,则显示是未指定值(undefined,因为 native 方法是由 C/C++ 编写的);
  • 它是程序控制流的指示器;分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成;
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令;
  • 它是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域(也没有 GC);

示例

publicclassPCRegisterTest{publicstaticvoidmain(String[] args){int i=10;int j=20;int k= i+ j;}}

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

下图解释: PC 寄存器存储的是对应的指令地址,然后执行引擎获取出 PC 寄存器中的指令地址,然后再去获取出对应的操作指令,接着就会将其翻译成机器指令,最终让 CPU 进行计算执行;

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈


PC 寄存器中常见的面试问题

问:使用 PC 寄存器存储字节码指令地址有什么用呢?什么使用 PC 寄存器记录当前线程的执行地址呢?(一个问题)

答:因为 CPU 需要不停的切换各个线程,这时切换回来以后,就得知道接着从哪开始继续执行;

JVM 的字节码解释器就需要通过改变 PC 寄存器的值来明确下一条应该执行什么样的字节码指令。


问:PC 寄存器为什么会被设定为线程私有的?

答:我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU 会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?

为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个 PC 寄存器,这样一来各个线

程之间便可以进行独立计算,从而不会出现相互干扰的情况;


CPU 时间片

CPU 时间片即 CPU 分配给各个程序的时间,每个线程被分配一个时间段,称作它的时间片。

在宏观上:我们可以同时打开多个应用程序,每个程序并行不悖,同时运行;但在微观上:由于只有一个 CPU ,一次只能处理程序要求的一部分,如何处理公平,一种方法就是引入时间片,每个程序轮流执行


虚拟机栈

概述

Java 虚拟机栈是什么?

  • Java 虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈;
  • 每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方法调用;
    Java 虚拟机栈是线程私有的;

生命周期

  • 与线程一致;

作用

  • 主管 Java 程序的运行,它保存方法的局部变量,部分结果,并参与方法的调用和返回;

特点

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器;
  • JVM 直接对 Java 栈的操作只有两个:每个方法执行,伴随着进栈(入栈、压栈)和执行结束后的出栈工作;
  • 对于栈来说不存在垃圾回收问题(不存在 OOM,但是存在StackOverflowError);

如何设置栈的大小?

使用参数-Xss 来设置线程中的最大栈空间,栈的大小直接决定了函数的调用的最大可达深度,在 Windows 系统下,栈空间大小默认值取决于虚拟内存空间;

设置如: -Xss256k ,设置为 256kb ;


栈的存储单位

栈中存储的是什么?

  • 每个线程都有自己的栈,栈中的数据都是以栈帧(stack Frame)的格式存在;
  • 在这个线程上正在执行的每个方法都各自对应一个栈帧(Stack Frame);
  • 栈帧是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息;

栈的运行原理

  • JVM 直接对 Java 栈的操作只有两个,就是对栈帧的压栈和出栈,遵循“先进后出”/“后进先出”原则;
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame) ,与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作;
  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧;
  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧;
  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧;
  • Java 方法有两种返回函数的方式:一种是正常的函数返回,使用 return 指令,另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出;

栈帧中的存储结构

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

  • 局部变量表(Local variables);
  • 操作数栈(operand Stack) (或表达式栈);
  • 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用);
  • 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义);
  • 一些附加信息;

局部变量表

  • 局部变量表也被称之为局部变量数组或本地变量表;
  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量,这些数据类型包括各类基本数据类型、对象引用(reference) ,以及 returnAddress 方法返回值类型;
  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的 Code 属性的 maximum local variables 数据项中。在方法运行期间是不会改变局部变量表的大小的;
  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少;
  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁

方法 Methods 属性

javap -v xxx.class 或使用 jclasslib 工具(插件)进行对字节码文件的解析(反汇编):

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

Code 属性

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

LineNumberTable 属性

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

LocalVariableTable 属性

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈


Slot 的理解

  • 参数值的存放总是在局部变量数组的 index0 开始,到 数组长度-1 的索引结束;
  • 局部变量表,最基本的存储单元是Slot (变量槽);
  • 局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress 类型的变量;
  • 在局部变量表里,32位以内的类型只占用一个 Slot (包括 returnAddress 类型),64位的类型(long 和 double)占用两个 Slot ;
    • byte,short,char ,float 在存储前被转换为int,boolean 也被转
      换为 int ,0 表示 false ,非 0 表示 true ;
    • long 和 double 则占据两个 Slot ;
  • JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值;
  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 Slot 上;
  • 如果需要访问局部变量表中一个 64bit 的局部变量值时,只需要使用前一个索引即可;(比如:访问 long 或 double 类型变量);
  • 如果当前栈帧是由构造方法或者实例方法创建的,那么该对象引用 this 将会存放在 index 为 0 的 Slot 处,其余的参数按照参数表顺序继续排列;

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

代码示例

public Stringtest2(Date dateP, String name2){
        dateP= null;
        name2="songqixia";double weight=130;//占据两个slotchar gender='男';return dateP+ name2;}

对应的局部变量表


Slot 的重复利用问题

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

示例代码

publicvoidtest4(){int a=0;{int b=0;
            b= a+1;}//变量c使用的是之前已经销毁的变量b占据的slot的位置int c= a+1;}

注意:此处的行号不是 Java 代码中的行号,而是字节码指令的行号;

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈


说明

  • 在栈帧中,与性能调优关系最为密切的部分就是提到的局部变量表;在方法执行时,虚拟机使用局部变量表完成方法的传递;
  • 局部变量表中的变量也是重要的垃圾回收根节点(可达性分析算法),只要被局部变量表中直接或间接引用的对象都不会被回收;

操作数栈

  • 每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈
    (Expression Stack);
  • 操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈(push)/出栈(pop);
    • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈;使用它们后再把结果压入栈;
    • 比如:执行复制,交换,求和等操作;

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

  • 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间;
  • 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈就是空的;
  • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性中,为 max_ stack 的值;
  • 栈中的任何一个元素都可以是任意的 Java 数据类型:
    • 32bit 的类型占用一个栈单位深度;
    • 64bit 的类型占用两个栈单位深度;
  • 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop)操作来完成一次数据访问;
  • 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC寄存器 中下一条需要执行的字节码指令;
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证;
  • 另外, Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈;

代码追踪

示例代码

publicclassOperandStackTest{publicvoidtestAddOperation(){byte i=15;int j=8;int k= i+ j;}}

上述代码进行解析之后:

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

然后对上述的代码进行逐步分析有:

注意:字节码文件是在执行前已经编译好的,因此,其中的局部变量表和操作数栈的长度都已经确定好了

  • 将 15 压入操作数栈;

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

  • PC 寄存器调整到第 2 步,将 15 弹出操作数栈,然后将其存储到局部变量表索引为 1 的位置中(索引为 0 存储的是 this );

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

  • 接着将 8 压入操作数栈;

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

  • 接着将 8 弹出操作数栈,存入局部变量表的索引为 2 的位置中;

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

  • 接着 PC 寄存器就会调整到第 6 步和第 7 步, iload_1 和 iload_2 就是取出之前存入局部变量表中的 15 和 8,并且将取出的值再次压入操作数中,等待下一步的 iadd ,也就是相加;

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

  • 接着上一步,执行引擎就会将操作数栈中的值依次取出并将字节码指令翻译成机器码指令在 CPU 中进行对应的计算,这里是相加操作,计算出结果为 23,然后就会将 23 压入操作数栈中,最后根据 PC寄存器的调整,执行 istore_3 指令,将 23 存入局部变量表中索引为 3 的位置上;

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈


示例代码

publicintgetSum(){int n=1;int m=2;int res= n+ m;return res;}publicvoidtestGetSum(){int i=getSum();int j=10;}

会有如下结果:

aload_0 :从局部变量表的相应位置装载一个对象引用到操作数栈的栈顶;

invokevirtual:是一种调用方法的字节码指令(属于动态派分,该指令也是调用所有虚方法(如果有继承,可以被重写的方法都是虚方法),见方法的调用);

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈


动态链接

动态链接,也是指向运行时常量池的方法引用;

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

学习JVM-虚拟机栈,程序计数器和本地方法接口与本地方法栈

为什么需要常量池呢?

它的作用就是,为了提供一些符号引用和常量,便于指令的识别,并且节省了空间。


方法的调用

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

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

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

  • 早期绑定
    早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用;

  • 晚期绑定
    如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定;

  • 随着高级语言的横空出世,类似于 Java 一样的基于面向对象的编程语言如今越来越多,尽管这类编程语言在语法风格上存在一定的差别,但是它们彼此之间始终保持着一个共性,那就是都支持封装,继承和多态等面向对象特性,既然这一类的编程语言具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式;

  • Java 中任何一个普通的方法其实都具备虚函数的特征,它们相当于 C++ 语言中的虛函数(C++ 中则需要使用关键字 virtual 来显式定义)。如果在 Java 程序中不希望某个方法拥有虛函数的特征时,则可以使用关键字 final 来标记这个方法;


虚方法与非虚方法

  • 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法;
  • 静态方法,私有方法,final方法,实例构造器,父类方法都是非虚方法;
  • 其他方法称为虚方法;

虚拟机中提供了以下几条方法调用指令

  • 普通调用指令
    • invokestatic:调用静态方法,解析阶段确定唯一方法版本;
    • invokespecial:调用 < init > 方法、 私有及父类方法,解析阶段确定唯一方法版本;
    • invokevirtual:调用所有虛方法;
    • invokeinterface:调用接口方法;
  • 动态调用指令
    • invokedynamic:动态解析出需要调用的方法,然后执行;

方法返回地址

  • 存放调用该方法的 pc寄存器 的值;
  • 一个方法的结束,有两种方式:
    • 正常执行完成;
    • 出现未处理的异常,非正常退出;
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置;方法正常退出时,调用者的 pc寄存器 的值作为返回地址,即调用该方法的指令的下一条指令的地址,而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息;

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

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


  1. 执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口:
    • 一个方法在正常调用完成之后究竟需要使用哪一个返回指令还需要根据方法返回值
      的实际数据类型而定;
    • 在字节码指令中,返回指令包含 ireturn (当返回值是 boolean,byte,char,short 和 int 类型时使用),lreturn(long 类型),freturn(float 类型),dreturn(double 类型)以及 areturn(引用类型),另
      外还有一个return指令供声明为void的方法、实例初始化方法、类和接口的初始化方法使用
  2. 在方法执行的过程中遇到了异常(Exception) ,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口;方法执行过程中抛出异常时的异常处理,存储在一个异常处理表,方便在发生异常的时候找到处理异常的代码;

一些附加信息

栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息;

例如,对程序调试提供支持的信息;


虚拟机栈相关面试

  • 举例栈溢出的情况?

    StackOverflowError,可以通过设置-Xss 设置栈的大小;

  • 调整栈大小,就能保证不出现溢出吗?

    不能保证,只能说不容易出现,如果有失败的递归,那么肯定会 StackOverflowError;

  • 分配的栈内存越大越好吗?

    不是,如果栈内存很大,那么对其他的内存存储也是有影响的;

  • 垃圾回收是否会涉及到虚拟机栈?

    不会!

  • 方法中定义的局部变量是否线程安全?

    具体问题具体分析;如果是在方法内部申明的变量,那么就会使线程安全的;如果变量是从参数传入的,那么就不是线程安全的;


本地方法接口与栈

什么是本地方法?

简单地讲,一个 Native Method 就是一个 Java 调用非 Java 代码的接口,一个 Native Method 是这样一个 Java 方法: 该方法的实现由非 Java 语言实现,比如 C 语言;这个特征并非 Java 所特有,很多其它的编程语言都有这一机制,比如在 C++ 中,可以用extern “C” 告知 C++ 编译器去调用一个 C 的函数;

“A native method is a Java method whose implementation is provided by non-java code.”

在定义一个 Native Method 时, 并不提供实现体(有些像定义一个 Java interface),因为其实现体是由非 Java 语言在外面实现的。本地接口的作用是融合不同的编程语言为 Java 所用,它的初衷是融合 C/C++ 程序;

本地方法栈

  • Java 虚拟机栈用于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用;
  • 本地方法栈,也是线程私有的
  • 允许被实现成固定或者是可动态扩展的内存大小;(在内存溢出方面是相同的)
    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常;
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java 虚拟机将会抛出一个 OutofMemoryError 异常;
  • 本地方法是使用 C 语言实现的;
  • 它的具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库;
  • 作者:宋七夏
  • 原文链接:https://blog.csdn.net/qq_43816409/article/details/108024015
    更新时间:2022年9月3日12:13:58 ,共 9194 字。