JVM 程序计数器、虚拟机栈、本地方法栈 - 笔记 6

内存管理是虚拟机中的一个重要命题,当JVM接手了内存管理的事宜之后,相较于使用C++来开发时的手动控制内存,Java降低了开发者的门槛,也提高了程序的可维护性,那么,JVM究竟是如何对内存进行管理的?JVM首先需要对内存进行抽象和分区

注意:不要混淆内存区域和内存模型的概念

JVM内存管理分区

将连续的内存抽象为不同作用的内存区域,这并不是JVM的首创,在操作系统层面本身也已经将内存进行了抽象划分,而Java的内存区域划分,相当于是更加上层的,存在于用户空间内的封装,它的划分很简单,分为以下五个部分:

  • 程序计数器
  • 虚拟机栈
  • 本地方法栈
  • 方法区

按照线程共享、线程隔离进行分类:
因为从逻辑上可以将上面提到的五个部分,分为被所有线程共享的内存区域和仅被当前线程独占的内存区域,其中

线程共享区域包括:堆和方法区,
线程独占的区域包括:程序计数器、虚拟机栈、本地方法栈。

也就是说当你在写程序时,需要判断当前读写的数据是存在于哪类内存区域的,如果是存在共享区域,那么就要考虑是否存在线程安全问题。如果存在独占的程序区域,那么就可以打消这种顾虑,然而,学过Java的同学应该都知道,当我们在写代码的时候,对象都是在堆上分配的,所以当出现并发读写对象的情况,就需要考虑线程安全性,而比如方法内部局部变量,这些都是分配在虚拟机栈上的,仅供当前线程独占,所以就不用考虑线程安全性。

程序计数器

在硬件层面程序计数器是一种寄存器,他用来存放指令地址,提供给处理器执行,在JVM 这种软件层面,程序计数器也是一样的作用,它用来存储字节码的指令地址,提供给执行引擎去取指执行,可以这么认为,这两种程序计数器分别存在于硬件与软件中,实现方式不一样,但是设计思想是类似的。

问题:明白了JVM中程序计数器是做什么的,那么在程序运行时,我们能不能监控到程序计数器的值?

答案是不能。因为虚拟机没有向外暴露查询程序计数器值的接口,但是我们可以从侧面的角度去进行观察,比如下面这个demo,然后使用javap进行反编译得到可读性好一点的字节码

java源码

public class ProgramCounterTest {
    public static void main(String[] args) {
        int a = 0;
        while (a < 10) {
            a++;
        }
        System.out.println(a);
    }
}

字节码(javac + javap)

C:\Users\Administrator\Desktop>javap -c -l ProgramCounterTest.class
Compiled from "ProgramCounterTest.java"
public class com.example.demo0413.test.ProgramCounterTest {
  public com.example.demo0413.test.ProgramCounterTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."":()V
       4: return
    LineNumberTable:
      line 3: 0

  public static void main(java.lang.String[]);
    Code:
        stack=2,locals=2,args_size=1
       0: iconst_0
       1: istore_1
       2: iload_1
       3: bipush        10
       5: if_icmpge     14
       8: iinc          1, 1
      11: goto          2
      14: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
      17: iload_1
      18: invokevirtual #3                  // Method java/io/PrintStream.println:(I)V
      21: return
    LineNumberTable:
      line 5: 0
      line 6: 2
      line 7: 8
      line 9: 14
      line 10: 21
}

可以看到第一列的数字代表了字节码指令之间的偏移量,叫做 bytecode index,这其实就是程序计数器所需要读取的数据,看bytecode index 为11的这行,指令为goto,操作数为2,代表了回到index为2的那行指令,这里就体现出了源码中的循环逻辑,也体现出了程序计数器的工作方式。

虚拟机栈

虚拟机栈这个名字乍一听可能让人觉得有点难以理解,我们可以换一个称呼,叫做 Java方法栈,对应后面需要介绍的本地方法栈。

问题:什么是 Java方法栈?

程序执行的过程对应方法的调用,而方法的调用实际上对应着 栈帧的入栈出栈。比如我们写这样一段代码

    public void funcA() {
        int a = 1;
        funcB(1);
    }
    
    public void funcB(int arg) {
        
    }

运行时,程序会先调用 A方法,那么 A方法封装成“栈帧”入栈,由于 A方法调用了 B方法,那么 B方法接着被封装为栈帧,入栈,然后,先执行 B方法中的逻辑,等于 B栈帧出栈,然后执行 A方法,等于 A方法出栈,可能有的同学在写递归的时候稍不留神,将会出现栈溢出的异常情况,原因是没有编写适当的递归退出条件,导致无限量的栈帧入栈,超出了方法栈的最大深度。所以就抛出了 StackOverFlow的异常,我觉得这里有三点需要注意:

  • 栈帧
  • 栈帧的生成时机
  • 栈帧的构成

栈帧这个概念,目前可以简单地将其当作方法调用的一种封装。
栈帧的生成时机,在编译期间,无法确定 Java方法栈的深度,因为栈帧的生成,是根据程序运行时的实际情况来决定的。这是动态的,比如你写了藏有 StackOverFlow的递归代码,编译器是无法检查出这种异常的。
栈帧的组成,在编译期间,由于每一个方法的源码都是确定的,而栈帧是根据方法调用来产生的,那么可以猜想栈帧内部的一些元素是可以确定的。比如说有多少个局部变量,存储局部变量所需要的空间,而有一些元素的是无法确定的,比如说改方法与其他方法之间的动态链接。

栈帧

栈帧中主要存在四种结构,局部变量表,操作数栈,动态链接,返回地址,这几种结构和我们上面的猜想也差不多,详细介绍下这四种结构。

  • 局部变量表
  • 操作数栈
  • 动态链接
  • 返回地址
局部变量表:

1.局部变量表的完整概念

  • 主要存储方法的参数、定义在方法内的局部变量,包括基本数据类型(8大),对象的引用类型,返回地址。
  • 局部变量表中存储的基本单元为变量槽(Slot),32位(4字节)以内的数据类型占一个slot,64位的占两个slot。
  • 局部变量表是一个数字数组,byte、short、char都会被转化为int,boolean类型也会被转化为int,0代表false,1代表true
  • 局部变量表的大小是在编译期间决定下来的,所以在运行时它的大小是不会变的。
  • 局部变量表中含有直接或者间接只想的引用类型变量时,不会被垃圾回收处理。

栈帧是通过方法源码来生成的,当调用该方法时呢,传入方法的参数类型,局部变量类型,这些在源码中都是已经确定的,既然数量与类型能够确定,那么需要占用的存储空间也就能够确定,怎么进行存储呢?这里在局部变量表中,通过4字节的slot来存储。

示例:

    public static void main(String[] args) {
        int a = 0;
        int b = 1;
        int c = a + b;
    }

字节码 (javac+javap)

javac -g:vars ProgramCounterTest.java

javap -verbose ProgramCounterTest.class

  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: iconst_0
         1: istore_1
         2: iconst_1
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: istore_3
         8: return
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
            2       7     1     a   I
            4       5     2     b   I
            8       1     3     c   I

在LocalVariableTable这一栏中,我们可以看到局部变量表,其中参数args占用了index为0的slot,并且声明了签名为String,剩下的三个局部变量abc,分别占用其余三个slot,签名都为int,接下来我们来看操作数栈,

操作数栈

在操作系统层面的操作数是计算机指令的一部分。而这里的操作数栈 是JVM层面的,但作用是相似的,顾名思义,这里的操作数栈就是一个用来存储操作数的栈,这里的操作数大部分就是方法内的变量,那为什么需要使用操作数栈堆操作数进行入栈出栈操作,主要有两个作用,第一点就是存储操作数,这里的操作数指的是,变量以及中间结果。第二点就是操作数栈能够方便指令顺序读取操作数,虚拟机的执行引擎在执行字节码指令的时候呢,会通过当前指令类型,从操作数栈中取出栈顶的操作数进行计算,然后再将计算结果入栈,继续执行后续的指令,我们写一个简单的例子检验一下。

源码:

    public static void main(String[] args) {
        int n = 1;
        int m = 2;
        int sum = n + m;
    }

字节码:

  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: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: istore_3
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 2
        line 7: 4
        line 8: 8

可以看到bytecode index为 4 和5的两行,对应的字节码指令是iload,iload的含义就是将int类型的操作数压栈,所以4和5两行,其实就是将m和n两个变量压栈,接着是iadd这个指令,它就是取出栈顶的两个操作数,进行求和计算并将计算结果压入栈中,接着就是istore这个指令,它就是将栈顶的操作数存入局部变量表中。

字节码指令对照表

指令 含义
iload int型变量进栈
istore 栈顶int数值存入局部变量表
iadd 弹出栈顶两个操作数,并将求和的int值压入栈中

对于操作数栈,还隐藏着另外一个小问题,上面演示的例子中只有一个栈帧,如果虚拟机栈中存在多个栈帧,我们可以想象,先执行完的方法的返回值,需要被当作后执行方法的变量。

源码:

    public void funcA() {
        int a=funcB();
    }
    
    public int funcB() {
        int n = 1;
        int m = 2;
        return n+m;
    }

在运行的时候,虚拟机栈中应该会出现两个栈帧,我们这里称为a和b, 首先执行栈帧B,我们可以想象n和m将会作为两个操作数入栈,通过求和字节码指令计算结果,并将计算结果存入局部变量表,那这个中间结果又将会成为栈帧A的操作数,所以,需要再从栈帧B的局部变量表中将该值复制进入栈帧A的操作数栈,这样做当然可以,但是JVM做了一些优化。在JVM的实现中,将两个栈帧的一部分重叠,让下面栈帧的操作数栈和上面栈帧的部分局部变量表重叠在一起,这样,在进行方法调用时可以共享一部分数据,而无需进行额外的参数复制传递,这算是一个优化的细节。

动态链接

动态链接其实我们在类加载那部分说过,大家知道OOP的主要特性之一就是多态,而Java中的多态就是通过栈帧中的动态链接来实现的,一句话概念:

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接。

Java在类加载过程中,有一个步骤叫做‘连接’,在这个步骤中,JVM将会将class对象(存储在方法去的运行时常量池中)中的部分符号以用替换为直接引用。连接是将部分符号引用替换为直接引用,为什么是部分?

因为对于有些方法JVM能判断这些方法所在的具体类型,所以就可以放心大胆的对方法进行连接,这叫做静态解析

而对于有些方法因为多态的存在,无法在类加载阶段就确定被调用的具体类型,只能在运行时真正产生调用的时候,根据实际的类型信息来进行连接,这就叫做动态连接。

我们用一个简单的例子来说明一下:

源码:

public class A {
    private B b;

    public void funcA() {
        b.funcB();
    }
}

public abstract class B {
    
    public abstract int funcB() {

    }
}

由于B为抽象类,所以类A的加载阶段无法确定B的具体实现类,在运行时呢,当方法a中调用方法b时,需要先查询栈帧B在运行时常量池中的符号引用,然后根据当前具体的类型信息进行动态链接。

返回地址

第一种,方法正常执行完成返回,

第二种,方法执行期间遇到了异常情况,返回

正常返回的情况:若方法正常返回,代表栈帧执行完成,栈帧在退出虚拟机的时候,需要把返回信息共享给其上层的操作数栈,同时修改程序计数器的值,让程序能够继续执行下去,

若异常返回:需要通过额外的异常处理器表来进行处理,其他和程序计数器相关的逻辑与正常情况类似。

栈帧是虚拟机栈中的主要内容。

  1. 局部变量表
  2. 操作数栈
  3. 动态链接
  4. 返回地址
  5. 其他附加信息

本地方法栈

Java方法栈和本地方法栈的区别:

本地方法栈,不是使用java实现的函数。往往是由C、C++来编写。和操作系统相关性比较强的底层函数。
本地方法栈就是用来支持本地方法的调用逻辑的。

你可能感兴趣的:(JVM 程序计数器、虚拟机栈、本地方法栈 - 笔记 6)