关注「Java视界」公众号,获取更多技术干货

【二】不要问我JVM !—— 虚拟机栈 + 本地方法栈 + 本地方法接口

在这里插入图片描述
JVM定义了若干运行时数据区,其中一些随着JVM的启动而创建,退出而销毁。

还有一些数据区域和线程一一对应,随着线程的开始而创建、结束而销毁。

上图中,红色为线程共享区域(堆、元空间),灰色是线程私有(计数器、栈、本地栈)。

一、JVM中的线程

每个JVM只有一个RunTime实例,即运行时环境。
在这里插入图片描述
JVM中,每个线程都和操作系统的线程一一对应,当一个Java线程准备执行时,此时操作系统本地线程也会对应创建。Java线程执行结束之后,操作系统本地线程也会回收。

操作系统把所有线程安排到一个可用的CPU上,一旦操作系统的一个线程准备完毕,就会调用Java线程中的run()方法。

二、程序计数器

JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。

作用:
用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
在这里插入图片描述

  • 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域
  • 线程私有,每个线程都有它自己的程序计数器,生命周期与线程的生命周期保持一致
  • 一个线程任何时间都只有一个方法在执行,即当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  • 是唯一没有任何outotMemoryError情况的区域在这里插入图片描述
    程序计数器存储的字节码指令地址是属于某个线程的,当有CPU的上下文切换时,当这个线程重现获得时间分片时,就知道从哪接着执行。这也是程序计数器为线程私有的原因,每个线程都有自己的程序计数器和栈帧就不会互相影响。

另外,JVM的字节码解释器就需要通过改变程序计数器的指令地址来确定下一条指令。

三、虚拟机栈

栈是运行时的单位,堆是存储的单位。
栈解决的是程序运行问题,即如何处理数据。堆解决的是数据存储的问题,即数据放哪的问题。

3.1 虚拟机栈基本内容

虚拟机栈是什么?

每个线程在创建时都会创建自己的虚拟机栈,栈中生成一个个栈帧,对应着一次次方法的调用。

虚拟机栈生命周期?

线程私有,和线程的一致。

虚拟机栈保存了哪些内容?

虚拟机栈中的数据以栈帧的形式存储。

栈帧是一块内存区域,是一个数据集,维系着方法执行需要的各种数据信息(栈帧的具体结构在本文后面会再详细介绍)。

每个方法对应一个栈帧。

正在执行的方法即当前方法,对应当前栈帧。方法执行完栈帧就会弹出虚拟机栈。

栈的优点:

  1. 执行速度快,效率仅次于程序计数器
  2. 栈不需要垃圾回收

3.2 栈运行过程

虚拟机栈的操作就是对栈帧的压栈和出栈,先进后出。

每个时刻只有一个活动的栈帧。即当前方法(对应的类为当前类)对应的栈帧,被称为当前栈帧。

执行引擎执行的所有字节码指令都只是针对当前栈帧进行的操作。

若当前方法中A中调用了B 方法,那执行到B时也会创建B的栈帧,这个新的栈帧成为当前栈帧,B方法执行完成后当前栈帧会把返回结果传给A的栈帧,然后B的栈帧会弹出虚拟机栈,接着执行A中剩余代码,A对应的栈帧重新成为当前栈帧。

Java中有两种返回函数的方式:

  • 一种是正常的函数返回,字节码指令是return
  • 一种是抛异常
    不管哪一种返回方式,都会导致它的栈帧弹出。
    在这里插入图片描述
    栈帧间是相互独立的,不同线程的栈帧不允许相互引用,即不可能在一个栈帧之中引用另外一个线程的栈帧。

3.3 栈帧

在这里插入图片描述
在这里插入图片描述

栈帧内部包含:

  1. 局部变量表
  2. 操作数栈(表达式栈)
  3. 动态链接(或指向运行时常量池的方法引用)
  4. 方法返回地址(方法正常退出或异常退出的定义)
  5. 一些附加信息

3.3.1 局部变量表

局部变量表是一个数组,元素主要是方法的参数和定义在方法内的局部变量(基本数据类型、对象引用、returnAddress类型)。

建立在虚拟机栈上,线程私有,因此安全。

局部变量表的大小是在编译成.class文件时就确定的,保存在Code属性的maximum local wariables数据项中,方法运行期间不会改变局部变量表的大小。

方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

Slot:
局部变量表是一个数组,参数值总是数组的索引为 0 的位置开始到数组最后的一个索引位置。

局部变量表中把整个数组又分成了多个Slot,即插槽。

每种类型(基本数据类型、对象引用、returnAddress类型)占用的Slot数量不同,不同JVM定义的插槽大小也不一样。

JVM会为局部变量表中的每个Slot都分配一个访问索引,通过索引可以访问到每个变量值。

Slot还以重复利用:
若一个局部变量过了它的作用域,那么新生成的局部变量就可能复用过期局部变量的Slot,来节省资源。
在这里插入图片描述
类的变量有两次初始化的机会:

  • 第一次是在“准备阶段”,执行系统初始化,对类变量设置零值
  • 另一次则是在“初始化”阶段,赋予程序员在代码中定义的初始值。

和类变量初始化不同的是,局部变量表不存在系统初始化的过程,这意味着一旦定义了局部变量则必须人为的初始化,否则无法使用。
在这里插入图片描述
上面代码没有给局部变量 i 赋值,编译期间就会报错。

最后:
局部变量表是重要的垃圾回收根节点集合,只要是局部变量表里直接或间接引用的对象都不会被回收。

3.3.2 操作数栈

操作数栈也叫表达式栈。

即在方法执行过程中,根据字节码指令,向栈中写入或提取数据。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
若调用的方法有返回值,这个返回值会被压入当前栈帧的操作数栈中,并更新程序计数器中需要执行的下一条指令。

操作数栈保存计算过程中的中间结果,也作为计算过程中变量的临时存储空间。

操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,新建新的栈帧,这个栈帧的操作数栈是空的。操作数栈不像局部变量表采用索引来访问,而是通过标准的入栈和出栈来完成一次数据访问。

解释引擎就是基于操作数栈的。

3.3.3 动态链接

在这里插入图片描述
上面红框里的3个部分统称帧数据区。

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,即每个栈帧都有一个指向自己对方法的引用,这个方法信息在运行时常量池中。

持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。

Class文件的常量池中存有大量的符号引用,这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。

class文件里的常量池运行起来之后会保存在方法区,就变成了运行时常量池,当然运行时常量池还包含一些运行时才加入的常量。
在这里插入图片描述
关于方法的调用以及动态链接过程本文后面会接着详细介绍。

3.3.4 方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法:

  • 第一种方式是执行引擎遇到任意一个方法 返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用 者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种 退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion)。
  • 另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处 理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方 法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为“异常调用 完成(Abrupt Method Invocation Completion)”。一个方法使用异常完成出口的方式退出,是不会给它 的上层调用者提供任何返回值的。

无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继 续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。正常完成出口和异常完成出口的区别在于:通过异常完成出口退出的不会给他的上层调用者产生任何的返回值。

方法执行异常时,会有一个异常处理表,方便在异常时找到处理异常的代码。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

3.3.5 附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试、 性能收集相关的信息。

3.4 方法调用

3.4.1 静态链接 & 动态链接

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

静态链接: 当一个字节码文件被装载到JVM内部时,如果被调用的目标方法在编译器可知,且运行期保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。

动态链接: 如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接;

对应的方法绑定机制为:早期绑定和晚期绑定
绑定是一个属性、方法或者类的符号引用被替换成直接引用的过程,只发生一次。

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

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

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
上面在调用自己的及父类的构造器时都是能够编译期就知道的,因此是早期绑定(对应的字节码指令是invokespcial)。

而在传入父类引用以及接口并分别调用父类及接口方法时,是一种多态,在编译阶段是无法知道实现类到底是Cat还是Dog,只有在运行期间才能知道,因此是晚期绑定(对应的字节码指令是invokevirtual和invokeinterface)。

也正是Java的多态特性,才有了早期绑定和晚期绑定。

3.4.2 虚方法和非虚方法

非虚方法
方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法叫做非虚方法;
包括:静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。

其他方法称虚方法。
实际上,涉及到多态的就会有虚方法。父类引用指向子类对象。只有在运行时才能确定是哪个子类。
在这里插入图片描述
上面就是非虚方法。

虚拟机中提供了一下几种方法调用指令:

普通调用指令:
1.invokestatic:调用静态方法,解析阶段确定唯一方法版本
2.invokespecila:调用方法,私有及父类方法,解析阶段确定唯一方法版本
3.invokevirtual:调用所有虚方法
4.invokeinterface:调用接口方法

动态调用指令:
5.invokedynimic:动态解析出需要调用的方法,然后执行

普通调用指令固化在虚拟机内部,方法的调用不可人为干预,而 invokedynimic 指令则支持由用户确定方法版本;其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的称为虚方法(final修饰的方法除外,因为不能被子类重写,所以也是确定的,因此是非虚方法·)。
在这里插入图片描述在这里插入图片描述

3.4.3 方法重写的本质

  1. 找到操作数栈栈顶的第一个元素所执行的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限的校验,如果通过则返回这个方法的直接引用,如果不通过,则返回java.lang.IllegalAccessError异常(违法访问错误。当一个应用试图访问、修改某个类的域(Field)或者调用其方法,但是又违反域或方法的可见性声明,则抛出该异常)。
  3. 否则,按照继承关系从下往上以此对C的各个父类进行第2步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

3.4.4 虚方法表

在面向对象的编程中,会很频繁地用到动态分派(invokedynamic),如果每次都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率,因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表(非虚方法不会出现在表中)来实现。使用索引表来代替查找。

每个类都有一个虚方法表,表中存着各个方法的实际入口。

虚方法是会在类加载的链接阶段(解析)被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。看下面图片举的例子,son继承自father类,father继承自Object,father和son中都重写了hardChoice和hardChoice 方法,实际在实现过程中,未被重写的方法都是直接指向Object,比如说son.toString()未被重写过,那直接指向Object,而不是一层层的,先到father,再到Object,重写过的方法,在虚方法表中都是指向自己数据类型(左右两个分别是Father和Song的虚方法表)。
在这里插入图片描述

3.5 虚拟机栈可能出现的异常

栈的大小可以根据 -Xss 来设置。栈的大小决定了方法嵌套调用的深度。

public class StackError {
    private static int count = 1;
    public static void main(String[] args) {
        Console.log(count);
        count ++;
        main(args);
    }
}

在这里插入图片描述在这里插入图片描述
在栈内存吃紧时会报上面的栈溢出错误。

3.6 栈顶缓存技术

在栈中,完成一项操作会频繁出栈、入栈,带来就是频繁的内存读写。

由于操作数是存储在内存中,频繁读写也会影响效率。

于是有了栈顶缓存技术,它将栈顶元素全部缓存在 CPU 的寄存器,来降低对内存的读写次数,提升执行引擎的执行效率。

四、本地方法接口

为什么要使用本地方法接口?

Java使用起来非常方便,然而有些层次的任务用Java实现起来不容易,或者我们对程序的效率很在意时,就依靠本地方法接口来完成了。

1、与Java环境外交互

有时Java应用需要与Java外面的环境交互,这是本地方法存在的主要原因。本地方法为我们提供了一个非常简洁的接口,比如Java需要与一些底层系统像操作系统或某些硬件交换信息时的情况,我们是不需要去了解Java应用之外的繁琐细节。

2、与操作系统交互

JVM支持着Java语言本身和运行时库,它是Java程序赖以生存的平台,它由一个解释器(解释字节码)和一些连接到本地代码的库组成。但它不是一个完整的系统,它经常依赖于一些底层系统的支持。这些底层系统常常是强大的操作系统。通过使用本地方法,我们可以用Java实现jre与底层系统的交互甚至JVM的一些部分就是用C写的。如果我们要用一些Java语言本身没有提供封装的操作系统的特性时,也需要用本地方法。

3、Sun’s Java

Sun的解释器是用C实现的,这使得它能像一些普通的C一样与外部交互。JRE大部分是用Java实现的,它也通过一些本地方法与外界交互。比如类Java.lang.Thread的setPriority()方法是用Java实现的,但它实现调用的是该类里的本地方法setPriority0()。这个本地方法是用C实现的,并被植入JVM内部,在Windows95的平台上,这个本地方法最终将调用win32 setPriority()API。这是一个本地方法的具体实现,由JVM直接提供,更多的情况是本地方法由外部的动态链接库(external dynamic link library)提供,然后被JVM调用。

目前使用该方法的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业中应用很少。因为现在的异构领域间的通信很发达,比如可以使用socket通信,也可以使用web service等。

五、本地方法栈

在这里插入图片描述
Java虚拟机栈管理Java方法的调用,本地方法栈管理本地方法的调用。

本地方法栈,也是线程私有的。

允许被实现成固定或者是可动态扩展的内存大小。(在内存溢出方面是相同的)

  • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,JVM将会抛出stackoverflowError 异常。
  • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OOM异常。
  • 本地方法是使用C语言实现的。
  • 它的具体做法是本地方法栈中登记native方法,在Execution Engine 执行时加载本地方法库。

当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机拥有同样的权限。

  • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区。
  • 它甚至可以直接使用本地处理器中的寄存器
  • 直接从本地内存的堆中分配任意数量的内存。
  • 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。如果JVM产品不打算支持native方法,也可以无需实现本地方法栈。

在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一。

补充一、栈溢出的情况有哪些?

补充二、调整栈的大小就不会出现栈溢出吗?

补充三、分配的栈内存越大越好吗?

补充四、方法中定义的局部变量是否是线程安全的?

若这个局部变量在方法内部产生又在内部消亡的,那就是线程安全的;若内部产生后又返回到外面,供外面使用,或者外部产生的变量,那就有可能会产生线程安全题。
在这里插入图片描述
在这里插入图片描述
toString 方法又重新 new 了一个新的对象,但是s1是没有返回的,因此s1是线程安全的。

posted @ 2022-06-25 14:01  沙滩de流沙  阅读(128)  评论(0编辑  收藏  举报

关注「Java视界」公众号,获取更多技术干货