胡说八道JVM—运行时数据区(栈)

怼烎@ 2022-12-28 06:16 235阅读 0赞

  • 虚拟机栈是Java执行方法的内存模型。每个方法被执行的时候,都会创建一个栈帧,把栈帧压人栈,当方法正常返回或者抛出未捕获的异常时,栈帧就会出栈。
  • 然后又将栈内存分为pc寄存器、本地方法栈、Java方法栈
  • 需要注意的是,在jvm规范中运行时数据区包括,栈和本地方法栈,但是对于本地方法栈的实现没有严格的约束,所以hotspot 虚拟机将本地方法栈和虚拟机栈合二为一
  • 存储:线程执行的基本行为是函数调用,每次函数调用的数据都通过Java栈传递。
  • 结构:栈数据结构(先进后出),栈元素为栈帧。一个栈帧至少包括局部变量表、操作数栈和帧数据区。
  • 过程:每一次函数调用,都有一个对应的栈帧入栈,每一次函数调用结束(return指令或抛出异常),都有一个栈帧出栈。
  • 使用者:线程私有

栈理论——栈帧

  • 栈中的数据都是以栈帧(Stack Frame)的格式存在,栈帧是一个内存区块,是一个数据集,是一个有关方法和运行期数据的数据集,当一个方法A被调用时就产生了一个栈帧F1,并被压入到栈中,A方法又调用了B方法,于是产生栈帧F2也被压入栈,B方法又调用了C方法,于是产生栈帧F3也被压入栈…… 依次执行完毕后,先弹出后进…F3栈帧,再弹出F2栈帧,再弹出F1栈帧。 遵循“先进后出”/“后进先出”原则。
    image-20201126104605350
  • 每个线程都会有独立的栈,而栈的元素就是栈帧——对应一个方法,方法的调用就是入栈和出栈的过程
  • 每一个线程都有一个栈,栈中的基本元素我们称之为栈帧。栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。每个栈帧都包括了一下几部分:局部变量表、操作数栈、动态连接、方法的返回地址 和一些额外的附加信息。
  • 栈帧中需要多大的局部变量表和多深的操作数栈在编译代码的过程中已经完全确定,并写入到方法表的Code属性中在活动的线程中,位于当前栈顶的栈帧才是有效的,称之为当前帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令只针对当前栈帧进行操作。需要注意的是一个栈中能容纳的栈帧是受限,过深的方法调用可能会导致StackOverFlowError,当然,我们可以认为设置栈的大小。

栈帧的组成

  • 每个方法被执行的时候都会创建一个栈帧用于存储局部变量表,操作栈,动态链接,方法出口等信息。每一个方法被调用的过程就对应一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • 栈帧是一种数据结构,用于虚拟机进行方法的调用和执行。栈帧是虚拟机栈的栈元素,也就是入栈和出栈的一个单元
  • 栈帧在什么地方,内存 -> 运行时数据区 -> 某个线程对应的虚拟机栈 -> 栈里就是栈帧
  • 一个虚拟机栈对应一个线程,当前CPU调度的那个线程叫做活动线程;一个栈帧对应一个方法,活动线程的虚拟机栈里最顶部的栈帧代表了当前正在执行的方法,而这个栈帧也被叫做”当前栈帧”

局部变量表

  • 是变量值的存储空间,由方法参数和方法内部定义的局部变量组成,其容量用Slot作为最小单位。

是一片逻辑连续的内存空间,最小单位是Slot,用来存放方法参数和方法内部定义的局部变量。

  • 局部变量表是有索引的,就像数组一样。从0开始,到表的最大索引,也就是Slot的数量-1。要注意的是,方法参数的个数 + 局部变量的个数 ≠ Slot的数量。因为Slot的空间是可以复用的,当pc计数器的值已经超出了某个变量的作用域时,下一个变量不必使用新的Slot空间,可以去覆盖前面那个空间,然后虚拟机就是通过局部变量表的下标来使用局部变量表的

    1. 其中的变量只在当前函数调用有效,函数调用结束后销毁,如果不涉及到逃逸的话,可以直接在栈上分配
    2. 参数就涉及
  • 在编译期间,就在方法的Code属性的locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。
  • 在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。
  • 我们知道类变量表有两次初始化的机会,第一次是在“准备阶段”,执行系统初始化,对类变量设置零值,另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
  • 局部变量表的访问时通过索引完成的

image-20201126104630036

局部变量的声明过程

  • 基本局部变量的声明过程
    image-20201126104644901

具体存储了什么

  1. 局部变量(运行过程中创建的变量和返回值)——基础数据类型 byte short int long float double char boolean 的变量
  2. 方法的形式参数,方法调用完后从栈空间回收

    • 对于instance方法,还要首先保存this类型
    • 其中方法参数按照声明顺序严格放置,局部变量可以任意放置
  3. 对象的引用(reference类型),引用完后,栈空间地址立即被回收,堆空间等待GC

    • 普通的对象
    • 字符串,内部也是char数组组
  4. 帧数据区(用来帮助支持常量池的解析,正常方法返回和异常处理)

存储单元——slot

slot 大小和存储
  1. 虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说明每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。
  2. 也就是说一个Slot应该能够存放一个32位以内的数据类型。reference类型表示对一个实例对象的应用,虚拟机规范没有说明它的长度,也没有明确指出这种引用应该具有的结构。
  3. 一般来说,虚拟机实现至少能应当通过这个引用做到两点:一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引,二是引用中直接或间接地查找到对象所属数据类型在方法区中的类型信息。
  4. 对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。

    public static void gcTest(){

    1. {
    2. byte[] placeholder = new byte[64 * 1024 * 1024];
    3. long x = 1l;
    4. long x2 = 1l;
    5. int m = 10;
    6. System.out.println(x);
    7. }
    8. }

image-20201126104706937

  1. 如果是实例方法,那局部变量表第0位索引的Slot存储的是方法所属对象实例的引用,因此在方法内可以通过关键字this来访问到这个隐含的参数。其余的参数按照参数表顺序排列,参数表分配完毕之后,再根据方法体内定义的变量的顺序和作用域分配。

image-20201126104721674

slot 重用
  • 为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体内定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。
  • slot 重用可能会给垃圾回收带来副作用,主要体现在变量已经超出了其在方法内的作用范围,但是整个方法执行时间又很长的情况下,将对象(引用)置为null,不失为一个好的选择。——其实这也反映了另外一个编码规则,方法不要太长。(这里仅仅从解释执行的角度出发,去理解)

操作数栈

  • 后入先出栈,由字节码指令往栈中存数据和取数据,栈中的任何一个元素都是可以任意的Java数据类型。
  • 和局部变量类似,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。当一个方法刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数中写入和提取内容,也就是出栈/入栈操作。
  • 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
  • 另外我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。在做算数运算的时候是通过操作数栈进行的,在调用方法的时候也是通过操作数栈进行传参的。
  • 操作数栈是区分类型的,操作数栈中严格区分类型,而且指令和类型也好严格匹配。

意义

  • java 是通过字节码指令的执行完成程序的运行逻辑,在这个过程中对变量的操作(操作之前是存储在局部变量表中的),都是基于栈的(操作数栈),也就是说对变量的读取或者赋值都是在栈上进行的。

到底存储了哪些东西

  • 要操作的变量
  • 方法调用时候的传参,就是通过操作数栈传的
  • 方法返回的结果,返回时返回结果也是存在操作数栈中

    public class MethodParameter {

    1. public static void main(String[] args) {
    2. int a = 1;
    3. int b = 1;
    4. int c = 1;
    5. sum(a,b,c);
    6. }
    7. public static void sum(int a,int b ,int c){
    8. }

    }

5c136d746c40c716bac8c41c6a273b58.png

  • load 指令就是将局部变量表中的变量load 到操作数栈中
  • iload_n(i 代表数据类型int,n 代表局部变量表的下标,就是哪个变量的意思)

例子

  • 其实栈这个数据结构在很多场景中都有用到,四则运算、括号匹配、递归、次幂、进制转换
整数的加法
  • 整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int类型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。

    public static void add(){

    1. int a = 10;
    2. int b = 11;
    3. int c = a + b;
    4. }

image-20201126104752642

  • 两条bipush 将对应的操作数10和11放入操作数栈(相当于数值的创建)
  • istore_0和istoore_1 完成堆创建的变量进行赋值,并且将其放入局部变量表的第0个位置和第1个位置。
  • 两条iload 指令将局部变量表中位置为0和1(上面放入的)的数放入操作数栈
  • iadd指令把栈顶的两个值出栈,相加,然后把结果放回栈顶
  • 最后istore_2把栈顶的值放到局部变量表的第3个Slot中(第2个位置)。

备注:

  1. 操作数栈中元素的数据类型必须和字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
  2. 再以上面的iadd指令为例,这个指令用于整型数加法,它在执行时,最接近栈顶的两个元素类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况
方法的调用
  1. public static int add(int a,int b){
  2. int c = a + b;
  3. return c;
  4. }
  5. public static void invokeMethod(){
  6. int a = 1;
  7. int b = 2;
  8. int c = add(a, b);
  9. }

image-20201126104848921

操作数栈的共享

  • 从概念模型上,两个栈帧是相互独立的,但是大多数时候,虚拟机都会进行优化处理,让当前栈和下一个栈帧出现部分重叠,这样进行方法调用的时候,就无须进行额外的参数复制。

动态连接

  • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有该引用是为了支持方法调用过程中的动态连接。
  • 一个方法调用另一个方法,或者一个类使用另一个类的成员变量时,总得知道被调用者的名字吧?(你可以不认识它本身,但调用它就需要知道他的名字)。符号引用就相当于名字,这些被调用者的名字就存放在Java字节码文件里。名字是知道了,但是Java真正运行起来的时候,真的能靠这个名字(符号引用)就能找到相应的类和方法吗?需要解析成相应的直接引用,利用直接引用来准确地找到。
  • 就相当于我在0X0300H这个地址存入了一个数526,为了方便编程,我把这个给这个地址起了个别名叫A, 以后我编程的时候(运行之前)可以用别名A来暗示访问这个空间的数据,但其实程序运行起来后,实质上还是去寻找0X0300H这片空间来获取526这个数据的

    我们知道,Class文件的常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数,这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

方法返回地址

  • 存放调用调用该方法的pc计数器的值。当一个方法开始之后,只有两种方式可以退出这个方法:

    1. 执行引擎遇到任意一个方法返回的字节码指令,也就是所谓的正常完成出口。
    2. 在方法执行的过程中遇到了异常,并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种方式成为异常完成出口。

      1. 正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。
      2. 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置,程序才能继续执行;方法正常退出时,调用者的pc计数器的值作为返回地址,栈帧中需要保存这个计数器值
      3. 而通过异常退出的,返回地址是要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。本质上,方法的退出就是当前栈帧出栈的过程。
  • 方法调用结束后,对于有返回值的要放入调用者的操作数栈,然后调整程序计数器的值,使其指向方法调用指令的后面一条指令。

栈理论——方法调用

  1. 方法调用的主要任务就是确定被调用方法的版本(即调用哪一个方法),该过程不涉及方法具体的运行过程。按照调用方式共分为两类:
  • 解析调用是静态的过程,在编译期间就完全确定目标方法。
  • 分派调用即可能是静态,也可能是动态的,根据分派标准可以分为单分派和多分派。两两组合有形成了静态单分派、静态多分派、动态单分派、动态多分派
  • 编译阶段,也就是class 文件中,存的仅仅是符号引用(目标方法都是常量池中的符号引用),java的方法调用需要在类加载,甚至运行期间才能确定目标方法的直接引用。
  • 在类加载的解析阶段会有一部分方法的符号引用会被转成直接引用,但是这种解析的前提是——在运行之前,就有一个可确定的调用版本,并且这个版本在运行期间是不可变的。
  • 在类加载的解析阶段,会将一部分符号引用转为直接引用,也就是在编译阶段就能够确定唯一的目标方法,这类方法的调用称为解析调用。此类方法主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可访问,因此决定了他们都不可能通过继承或者别的方式重写该方法,符合这两类的方法主要有以下几种:

    静态方法、私有方法、实例构造器、父类方法

解析期可确定的方法

  • 不可能通过继承或者重写,使其有其他版本,也就是说被 invokestatic和 invokespecial指令调用的方法——非虚方法,是在编译期间可以被确定的,注意被final 修饰的方法除外,它虽然被invokevirtula 修饰,但是它却是编译器可知的,因为不会有其他版本。

静态方法

  • 和类绑定

私有方法

  • 在外部不可访问

实例构造器方法

父类方法

被final修饰的方法

解释执行

  • 在jdk 1.0时代,Java虚拟机完全是解释执行的,随着技术的发展,现在主流的虚拟机中大都包含了即时编译器(JIT)。因此,虚拟机在执行代码过程中,到底是解释执行还是编译执行,只有它自己才能准确判断了,但是无论什么虚拟机,其原理基本符合现代经典的编译原理
  • 在Java中,javac编译器完成了词法分析、语法分析以及抽象语法树的过程,最终遍历语法树生成线性字节码指令流的过程,此过程发生在虚拟机外部。
  • Java编译器输入的指令流基本上是一种基于栈的指令集架构,指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。

静态分派

  • 所有依赖静态类型来确定要执行那个版本的方法的分派动作——静态分配,典型应用——方法的重载
  • 静态分配发生在编译阶段,所以静态分配的动作不是由虚拟机确定的。所以方法的重载其实也是通过参数的静态类型进行判断的。

    public class MethodInvoke {

    1. static abstract class Human{
    2. }
    3. static class Man extends Human{
    4. }
    5. static class WoMan extends Human{
    6. }
    7. public void sayHello(Human guy){
    8. System.out.println("hello guy!");
    9. }
  1. public void sayHello(WoMan guy){
  2. System.out.println("hello lady!");
  3. }
  4. public void sayHello(Man guy){
  5. System.out.println("hello gentleman!");
  6. }
  7. public static void main(String[] args) {
  8. MethodInvoke invoke = new MethodInvoke();
  9. /**
  10. * Human 是静态类型,Man 和 Woman 是实际类型
  11. */
  12. Human m1 = new WoMan();
  13. Human m2 = new Man();
  14. invoke.sayHello(m1);
  15. invoke.sayHello(m2);
  16. }
  17. }

image-20201126104916514

静态分配的”更合适”理论

动态分配

  1. public class MethodInvokeDynamicDispatch {
  2. static abstract class Human{
  3. public void sayHello(){
  4. System.out.println("hello guy!");
  5. }
  6. }
  7. static class Man extends Human{
  8. public void sayHello(){
  9. System.out.println("hello gentleman!");
  10. }
  11. }
  12. static class WoMan extends Human{
  13. public void sayHello(){
  14. System.out.println("hello lady!");
  15. }
  16. }
  17. public static void main(String[] args) {
  18. /**
  19. * Human 是静态类型,Man 和 Woman 是实际类型
  20. */
  21. Human m1 = new WoMan();
  22. Human m2 = new Man();
  23. m1.sayHello();
  24. m2.sayHello();
  25. m2 = new WoMan();
  26. m2.sayHello();
  27. }
  28. }

image-20201126104932167

  • 可以看出方法的调用,根据实际类型的变化而变化

栈理论——方法的执行(基于栈的解释执行)

  • java 虚拟机在执行java 代码的时候,都有解释执行和编译执行两种选择。这里我们只看解释执行的过程

栈的内存结构

  • 对于每个线程,都会分配一个栈,其中存放本地变量、方法参数和返回值(更准确的说法是每个栈帧存储了各自方法的参数,本地变量,返回值)这组局部变量包含了方法运行过程中用到的所有变量,包括this引用,所有的方法参数,以及其它局部定义的变量。对于类方法(也就是static方法)来说,方法参数是从第0个位置开始的,而对于实例方法来说,第0个位置上的变量是this指针。
  • 除了long和double类型外,每个变量都只占局部变量区中的一个变量槽(slot),而long及double会占用两个连续的变量槽,因为这些类型是64位的。
  • 当一个新的变量创建的时候,操作数栈(operand stack)会用来存储这个新变量的值。然后这个变量会存储到局部变量区中对应的位置上。如果这个变量不是基础类型的话,本地变量槽上存的就只是一个引用。这个引用指向堆的里一个对象。
  • 在java里面除去基本数据类型的其它类型都是引用数据类型,所以String不是基本类型而是引用类型。

栈内存解析

  • 代码片段

    public static long test3(String s,Long m,String s2){

    1. long x = 10L;
    2. System.out.println(sss);
    3. return x;

    }

  • 字节码片段

    image-20201126104948237

指令集

  • 代码片段

    public static void add(){

    1. int a = 10;
    2. int b = 11;
    3. int c = a + b;
    4. }

基于栈的实现

image-20201126105002718

基于寄存器的实现

image-20201126105019200

  • mov指令把EAX寄存器的值设为10,然后add指令把这个值加上11,结果就保存在EAX寄存器里面。

优缺点

基于栈的指令集

优点
  • 基于栈的指令集的主要优点是可移植,因为它不直接依赖于寄存器,所以不受硬件和操作系统约束。
  • 代码相对紧凑,字节码中每个字节就是一个指令,并且不需要存放操作数的地址信息
  • 编译实现起来,也相对简单一些,不需要考虑空间分配的问题,所有的空间都是在栈上操作。
缺点
  • 它的主要缺点是执行速度相对会稍慢一些。
  • 虚拟机会将一些频繁操作的数据(程序计数器、栈顶缓存等)放到寄存器,以获取更好的性能。
  • 之所以速度慢,原因有两点:一是基于栈的指令集需要更多的指令数量,因为出栈和入栈本身就产生了相当多的指令;二是因为执行指令时会有频繁的入栈和出栈操作,频繁的栈访问也就意味着频繁的内存访问,相对于处理器而言,内存始终是执行速度的瓶颈

基于寄存器的指令集

  • 另外一种指令集架构则是基于寄存器的指令集架构,典型的应用是x86的二进制指令集,比如传统的PC以及Android的Davlik虚拟机,基于寄存器的指令集架构则完全依赖硬件,这意味基于寄存器的指令集架构执行效率更高
  • 所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。

发表评论

表情:
评论列表 (有 0 条评论,235人围观)

还没有评论,来说两句吧...

相关阅读

    相关 JVM运行数据

    程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值