JVM(一)

本文主要介绍Java虚拟机(默认为HotSpot虚拟机)中的几个重要模块,主要有:类加载子系统、PC寄存器、虚拟机栈和本地方法接口。适用于具备以下基础知识的Java开发人员:数据结构(本文主要涉及 )、JUC(多线程)、了解操作系统,能区分寄存器和内存即可。

一、对JVM的基础认知

虚拟机

虚拟机的本质是一款软件,用来执行一些虚拟的计算机指令。一般分为程序虚拟机和系统虚拟机。

  • 系统虚拟机:一般是对实际机器的一种仿真,主要是提供一个运行不同于本地操作系统的软件平台,比如熟知的VMware、Visual Box 就属于这类虚拟机产品。
  • 程序虚拟机:专门为执行单个计算机程序而设计,比如Java虚拟机,专门执行Java字节码指令(也就是java代码编译之后的 .class文件中的指令)。

JVM简介

Java虚拟机(Java Virtual Machine )是一台执行java字节码的虚拟计算机,它拥有独立的运行机制,其运行的Java字节码也未必仅仅由Java语言来编写,只要字节码文件满足一定的格式规范即可。所有的Java程序都是运行在Java虚拟机内部,JVM是运行在操作系统之上的,没有与硬件做直接的交互。
Java语言在设计之初的初衷是: “一次编译,到处运行”。靠的就是它发展至今强大的虚拟机架构。总结JVM的优点就是:跨语言的平台,全自动的内存管理,优秀的垃圾回收器,以及可靠的即时编译器

JVM整体结构

网上一张比较全面的图如下:
在这里插入图片描述
类加载器将编译器编译之后的字节码指令文件,也就是class文件加载进入内存。整个Java程序的内存空间被划分为5个部分,所有线程共享的是方法区、堆区,每个线程单独私有的是虚拟机栈(Java 栈)、本地方法栈和PC寄存器(也叫程序计数器)。
图中没有,本文章也不会说(篇幅所限,放到后面,为了方便清晰的认识了解JVM体系,在此简述一下)的是:执行引擎由三部分组成(解释器、即时编译器和垃圾回收器)。解释器将字节码指令翻译成为了机器指令交给计算机操作系统执行、执行引擎的JIT即时编译器会将反复执行的热点代码提前编译、垃圾回收器则负责对方法区域和堆区域进行内存空间资源的回收工作,即垃圾回收(GC)。
以下篇幅会对上图中的几个模块进行详述:类装载子系统、Java栈、本地方法栈、程序计数器和本地方法接口。
开始之前先要清楚几个知识点:

Java代码的执行流程

  1. Java源代码在在装载入内存之前会先进行编译,编译的过程主要是词法分析、语法分析、生成抽象语法树、语义分析、最终交给字节码生成器产生编译阶段的产物(.class字节码文件)。
  2. 字节码文件在JVM虚拟机中会交给类加载器,经过字节码校验器,由翻译器和即时编译器共同配合,完成翻译成为机器指令的过程,JVM测产物就是操作系统能够识别的机器指令。
  3. 最后操作系统将执行这些机器指令。

指令集架构
RISC(精简指令集计算机)和CISC(复杂指令集计算机)是当前CPU的两种架构。Java编译器输入的指令流基本上是一种基于栈结构的精简指令集架构
指令的一般格式有:
零地址指令:只有操作数,没有地址。
一地址指令:一个地址,另一个是操作数地址同时也是结果地址。
二地址指令:第一个操作数地址,第二个操作数的地址和结果地址。
三地址指令:第一操作数地址,第二操作数地址、结果地址,还有一个是下一条指令地址。
使用精简指令设计编译器的特点:
1、最大的好处就是避开了寄存器的分配难题:使用零地址指令的指令分配方式。栈只有对栈顶数据的操作,所以不需要地址。这也避免了和硬件的高度耦合,也是Java语言“一次编译,到处运行”的重要实现思路。
2、设计和实现更加简单,适用于一些系统资源本身不太好的场景。
3、指令流中的指令大部分是零地址指令,其执行过程依赖于操作栈。指令集更小,编译器容易实现。
4、不需要硬件支持,可移植性好,更好实现跨平台。
5、指令集合小。但是对比多地址指令,完成一项工作所需要的指令数量却多得多,性能也有所下降。

如果使用复杂指令集去设计编译器,可以使用多地址指令的时候,就要考虑底层的硬件,寄存器数量。
基于寄存器的指令集架构会具备更加高效的执行效率和性能,对比使用零地址指令设计的编译器,可以使用很少的指令去完成一项工作,但是也有弊端,可移植性差,跟底层的硬件耦合程度高。

JVM生命周期
1、虚拟机的启动
Java虚拟机的启动是通过引导类加载器(bootstrap class loader)创建一个初始类(init class)来完成的,这个类是由虚拟机的具体实现决定的。
2、虚拟机的执行
一个运行中的Java虚拟机有着一个清晰的任务:执行java程序。程序开始执行的时候他才运行,程序结束时它就停止。执行一个所谓的Java程序的时候,真真正正在执行的是一个叫做Java虚拟机的进程。
3、虚拟机的退出
程序正常结束、程序在执行过程中遇到了异常或者是错误而导致异常终止、由于操作系统出现错误而导致Java虚拟机进程终止、由于线程调用RunTime类或System类的exit方法,或者Runtime类的halt方法,并且Java安全管理器也运行这样的操作。除此之外,JNI(Java Native Interface)规范描述用了JNI Invocation API 类加载或者是卸载Java虚拟机时,Java虚拟机退出的情况。

几款JVM产品(暂时先了解)
1、Sun Classic VM
是Sun公司在1996年Java 1.0 版本内置的世界上第一款商用的Java虚拟机。但是由于他的解释器和即时编译器不能互相配合工作,在JDK1.4的时候完全被淘汰。

2、HotSpot VM(三大主要商用虚拟机之一)
在jdk 1.3的时候,HotSpot VM成为默认的虚拟机,不管是jdk1.6,1.8默认的都是HotSpot。也是sun / oracle Jdk 和Open JDK的默认虚拟机。HotSpot 名称指的就是他的热点代码探测技术:通过计数器找到具有编译价值的代码,触发即时编译或栈上替换;通过编译器与解释器的协同工作,在最优化的程序响应时间与最佳执行性能中取得平衡。

3、BEA公司的JRockit(三大主要商用虚拟机之一)
这款虚拟机专注于服务器端应用,全部代码都依靠即时编译器编译后执行。大量的行业基准测试显示,JRockit VM是世界上最快的VM。JRockit能提供毫秒级别的JVM响应时间,适合财务、军事指挥、电信网络的需要。

4、IBM公司的 J9(三大主要商用虚拟机之一)
全称:IBM Techonlogy for Java Virtual Methine 简称 IT4J,内部代码是J9.
市场定位与HotSpot接近,服务器端、桌面应用、嵌入式等多用途VM,广泛应用于IBM的各种产品,号称是世界上运行最快的虚拟机。2017年左右,IBM发布了开源J9 VM ,命名为Open J9 交给Eclipse 基金会管理,也称为是Eclipse Open J9。

5、另外的几款高性能的JVM产品
微软(Microsoft JVM )、阿里巴巴(Taobao JVM)、谷歌(Dalvik VM)。他们的产品都和自己系统运行的硬件设施高度耦合,所以性能都很优秀。
作为国内开源产品最多的公司,淘宝的Java虚拟机针对他们的业务场景设计的JVM所具备的特性:

  • GCIH:GC invisible heap。将生命周期较长的Java对象从heap中移到heap外,并且GC
    不能管理GDIH内部的Java对象,以此达到降低GC的回收频率和提升GC回收效率的目的。
  • GCIH中的对象还能在多个Java虚拟机线程中实现共享。(要实现这个还是很不容易的)
  • 使用crc32指令实现JVM intrinsic 降低JNI的调用开销
  • PMU hardware 的Java profiling tool和 诊断功能。
  • 针对大数据场景的ZenGC。

接下来是本文的主题。

二、类加载子系统

类加载的三个过程是:加载、链接、初始化。
在这里插入图片描述

2.1、加载 Loading
1、通过一个类的全限定名(全类名)获取定义此类的二进制字节流。
2、将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构。
3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

2.2、链接 Linking
验证(Verify)
目的在于确保class文件的字节流中包含信息是否符合虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身的安全。主要包括四种验证方式:文件格式验证、元数据验证、字节码验证、符号引用验证。
字节码文件都是以CA FE BA BY开头
准备(Prepare)
变量:为变量分配内存并且设置该类变量的默认初始值,即零值。
int类型是0, float类型是0.0 ,boolean 是false,char类型就是 -u0000,引用类型都是null。
常量:这里不包含用final修饰的static ,因为final在编译的时候就会分配了,准备阶段会显示初始化;
这里不会为实例变量分配初始化,类变量会分配到方法区中,而且实例变量会随着对象一起分配到Java堆中。
解析(Resolve)
将常量池内的符号引用转换为直接引用的过程。事实上,解析操作往往会伴随着JVM在执行完成之后再执行。符号引用就是一组符号来描述所引用的目标。符号引用的字面量形式明确定义在《Java虚拟机规范》的class文件 格式中。直接引用就是直接指向目标的指针、相对便宜量或一个间接定位到目标的句柄。
解析动作主要针对接口、字段、类方法、接口方法、方法类型等。

2.3、初始化Initialization

初始化阶段就是执行类构造器方法<clinit>()的过程。
此方法不需要定义,是Javac编译器自动收集类中所有类变量的赋值动作和静态代码块中的语句合并起来。
构造器中指令按语句在源文件中出现的顺序执行。
<clinit>()不同于类构造器。(关联:构造器是虚拟机视角下的<init>())
若该类具有父类,JVM会保证子类<clinit>()执行前,父类的<clinit>()已经执行完毕。
虚拟机必须保证一个类的<clinit>()方法在多线程下被同步加锁。

类加载器
JVM支持两种类型的加载器,分为引导类加载器(Bootstrap ClassLoader)和自定义类加载器(User-Defined ClassLoader) 从概念上来讲,自定义类加载器一般是指程序中由开发人员自定义的一类加载器,Java虚拟机规范将所有派生于抽象类ClassLoader的类加载器都划分为了自定义类加载器。
无论类加载器的类型如何划分,在程序中最常见的类加载器始终只有三个:
系统类加载器、扩展类加载器、和bootstrap类加载器。
对于用户自定义的类来说,默认使用的是系统类加载器。类似于String这样的类使用的是引导类加载器加载的。Java的核心类库都是使用引导类加载器进行加载的。

  1. 启动类加载器(Bootstrap ClassLoader)
    也叫引导类加载器,这个类加载使用C/C++语言编写的,嵌套在JVM内部。它用来加载Java的核心类库(rt.jar\resources.jar\sun.boot.class.path路径下的内容),用于提供JVM自身需要的类。并不继承自java.lang.ClassLoader,没有父加载器。加载扩展类和应用程序类加载器,并指定为他们的父类加载器。出于安全考虑,Bootstrap启动类加载器只加载包名为java,javax,sun等开头的类。
  2. 扩展类加载器(Exeension CLassLoader)
    是由Java语言编写的,由sun.misc.Luncher$ExtClassLoader实现。
    派生于ClassLoader类,父类加载器为启动类加载器,从java.ext.dirs系统属性所指定的目录中加载类库,或者从jdk的安装目录jre/lib/ext目录(扩展目录)下加载·类 库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
  3. 应用程序类加载器(AppClassLoader)
    也叫系统类加载器也是Java语言编写,由sun.misc.Luncher$ExtClassLoader实现,派生于ClassLoader类,父类加载器为启动类加 载器。它负责加载环境变量classpath或系统属性,java.class.path指定路径下的类库。该类加载器是程序中默认的类 加载器,一般来说,Java应用的类都是由它来完成加载。
    通过ClassLoader#getSystemClassLoader()方法可以获得该类加载器。

双亲委派机制和沙箱安全机制
Java虚拟机对class文件采用的是按需加载的方式。也就是说当需要使用高类的时才会将它的class文件加载到内存生产class对象,而且加载某个类的class文件的时候,Java虚拟机采用的是双亲委派模式,也就是把请求交给父类处理,它是一种委派任务模式。
String的Demo 自己创建一个java.lang.String
工作原理:
1、如果一个类加载器收到了加载类的请求,它并不会自己先去加载,而是把这个请求委托给父类加载器去执行;
2、如果父类加载器还存在着父类加载器,则进一步向上委托,依次递归请求最终到达顶层的启动类加载器;
3、如果父类加载器可以完成类加载的任务,就返回成功。弱父类加载器无法完成加载任务,子加载器才会自己尝试去加载,这就是双亲委派模型。

优势:避免类的重负加载,保护程序安全,防止核心API被随意更改。

沙箱安全机制
自定义String类,但是在加载自定义String类的时候会率先使用引导类加载器加载,而引导类加载器在加载的过程中会先加载jdk自带的文件(rt.jar 中的 java.lang.String.class)报错信息说没有main方法就是因为加载的不是自定义的string类,这样可以保证对java核心代码的保护,这就是沙箱安全机制

类加载子系统的其他内容
在JVM中表示两个class对象是否为同一个类存在两个必要条件;1、类的完整类名一致,包括包名2、加载这个类的ClassLoader也必须相同。换句话说:在JVM中,即使这两个类对象来源于同一个Class文件,被同一个虚拟机所加载,但只要加载他们的ClassLoader对象实例对象不同,那么这两个类对象也是不相等的。
JVM必须知道一个类型是由启动类加载器还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么JVM会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。当解析一个类型到另一个类型的引用的时候,JVM需要保证这两个类型的加载器是相同的。
类的主动使用和被动使用
主动使用的七种情况:创建类的实例、访问某个类或接口的静态变量,或者对该静态变量赋值、调用类的静态方法、反射、初始化一个类的子类、Java虚拟机启动时被标明为启动类的类、动态语言。
除了上述七种情况,其他使用Java类的方式都被看做是类的被动使用,都不会导致类的初始化。

三、程序计数器

程序计数器(也叫做PC寄存器)Program Counter Register程序计数寄存器。
并非是广义上所指的物理寄存器,或许翻译成为PC计数器(指令计数器)会更加贴切一点。也有人叫程序钩子。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟,作用就是存储下一条指令的地址
在这里插入图片描述
程序计数器详解
它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。在JVM规范中,每个线程都有他自己程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。任何一个世间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址,或者,如果是在执行native方法,则是未指定值(undefined)。它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。它是唯一一不存在OOM的区域(OutOfMemoryError),也不存在GC(垃圾回收)

保证Java能够支持多线程
因为CPU要不停的切换各个线程,如果切换回来以后,就需要知道程序应该接着从哪里开始继续执行。
JVM字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

线程私有
PC寄存器设定为每个线程独占一份。多线程,其实在一个特定的时间只会执行其中某一个线程的方法,CPU会不停的做任务切换,这样必然导致基础中断或者恢复,要保证分毫不差,为了能够准确的记录各个线程正在执行的当前字节码指令的地址,最好的解决版本就是为每一个线程都分配一个PC寄存器,这样一来各个线程之间就可以独立计算,从而不会出现相互干扰的情况。
由于CPU的时间片轮转限制,众多线程在并发执行的过程当中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样会导致经常的中断或者恢复,所以就在每一个线程创建的时候,都产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。

四、虚拟机栈

Java Virtual Machine Stack 早期也叫Java栈。每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Fream),对应着一次次的Java方法调用,是线程私有的。生命周期短,和线程的生命周期一致。主管Java程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。
**在JVM中,栈管运行,堆管存储。**栈是运行时的单位,而堆是存储单位。栈解决的是程序运行的问题,程序如何运行,或者说如何处理数据。堆解决的是数据存储的问题,数据怎么放,放在哪。

为什么会出现虚拟机栈?
Java语言诞生之初就本着“一次编译导出运行”的思想去设计,上文说过Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存机的架构(多地址指令)。优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

深入了解虚拟机栈
Oracle的Java虚拟机规范允许Java栈的大小是动态分配或者是固定不变的。
动态分配:意味着当栈的空间不足时,JVM会自动扩容。并且在尝试扩展的时候无法申请到足够的内存去创建对应线程的栈空间,则Java虚拟机将会抛出一个OutOfMemory的异常。
固定不变:每一个线程的Java虚拟机栈容量可以在线程创建的时候独立确定。如果线程请求分配的栈容量超过Java虚拟机允许的最大容量,则会抛出StackOverflowError的异常。

简单测试
最简单的栈报错是方法的递归调用。
在这里插入图片描述
在没有对JVM进行任何参数设置的时候,递归调用了大约9800次之后,虚拟机默认分配的栈空间被用光,报错。

使用参数 -Xss来设置线程的最大栈空间为128KB的时候,相同的代码在递归调用了大约1000次的时候报错。
所以,栈空间的大小决定了函数调用的最大可达深度。这也是跟栈的结构密切相关的。栈的内部是一个个栈帧,每一个栈帧的进栈,代表了一个方法的调用开始,出栈则代表了一个方法的返回。

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

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

栈帧的内部结构

栈帧一般有5部分组成,比较重要的是局部变量表操作数栈
1、局部变量表(Local Variables)
2、操作数栈(Operand Stack)(或表达式栈)
3、动态链接(Dynamic Linking)(或指向运行常量池的方法引用)
4、方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
5、一些附加信息
为了对栈帧的结构有最直观的感受,可以先看下图:
在这里插入图片描述

栈帧结构1:局部变量表(Local Variables )

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

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

栈帧结构2:操作数栈 (Operand Stack)

是用数组实现的。操作数栈的深度在编译期间就能确定。

每一个独立的栈帧当中除了包含局部变量表以外,还包含一个后进先出的操作数栈,也称之为表达式栈、
操作数栈,在方法执行的过程当中,根据字节码指令,往栈中写入数据或提取数据,push/pop
某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用他们后再把结果压入栈。复制交换求和等。主要用于保存计算过程中中间结果,同时作为计算过程中变量的临时存储空间。操作数栈就是JVM执行引擎的一个工作区域,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈式空的。每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期间就定义好了,保存在方法Code属性中,为max_stack的值。

栈中任何一个元素都是可以任意的Java数据类型。32bit的类型占用一个栈单位深度。64比特的类型占用两个栈单位深度。操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载的过程中的类检验阶段的数据流分析阶段要再次验证。

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

栈帧结构3:动态链接 (Dynamic Linking)

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

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

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

符号引用:引用的是运行时常量池。执行的时候把这些引用在常量池中一直找,找到需要执行方法的直接引用。如下:
在这里插入图片描述
Java作为一门面向对象语言,实现多态性的理解
以下不止是Java语言中有,在大部分面向对象语言中都有这些概念。以Java语言为例。
绑定时机
方法的绑定机制:早期绑定和晚期绑定。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这哥过程仅仅会发生一次。
早期绑定:invokespecial指被调用的目标方法如果在编译期间可知,且运行期间保持不变,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,就可以使用静态链接的方式将符号引用转换为直接引用。
晚期绑定:invokevirtual invokeinterface如果被调用的方法在编译期间无法被确定下来,只能在程序运行期间根据实际的类型绑定相关的方法,这种绑定方式就成为晚期绑定。

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

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

Java虚拟机调用指令
普通调用指令:
invokestatic 调用静态方法,解析阶段唯一方法版本
invokespecial 调用方法、私有及父类方法,解析阶段唯一方法版本
invokevirtual 调用所有虚方法
invokeinterface 调用接口方法
动态调用指令:
invokedynamic 动态解析出需要调用的方法,然后执行。

前四条指令固话在虚拟机内部,方法的调用执行不可人为干预,而invokedynamic指令则支持由用户确定方法版本。其中invokestatic指令和invokespecial指令调用的方法称为非虚方法,其余的(final修饰除外)称为虚方法。

invokedynamic指令
JVM字节码指令集一直都是比较稳定的,直到Java7中增加了一个invokedynamic指令,这是Java7为了实现【动态类型语言】支持而做的一种改进。但是在Java7中并没有提供直接生成invokedynamic指令的方法,需要借助ASM这种底层字节码工具来产生invokedynamic指令。直到Java8的lambda表达式的出现,才有invokedenamic指令的直接生成方式。
Java7中增加动态语言类型支持本质是对Java虚拟机规范的修改,而不是对Java语言规则的修改,增加了虚拟机中的方法调用,最直接的受益者就是运行在Java平台的动态语言的编译器。

动态类型的语言和静态类型的语言
动态类型语言和静态类型语言两者的区别就在于对类型的检查是否是在编译期还是在运行期进行的,在编译期进行类型检查的语言就是静态类型语言,在运行期进行类型检查的语言就是动态语言。
静态类型的语言是判断变量自身的类型信息,动态类型的语言是判断变量值的信息变量没有类型信息,值才有类型信息。Java语言是静态类型的语言。JS能使用一个var声明很多种类型的变量,它就是一种动态类型的语言,而Java声明或使用了错误类型的变量之后,在编译期间就会报错。

Java语言中方法重写的本质
1、找到操作数栈顶的第一个元素所执行的对象的实际类型,记为C
2、如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果校验通过则返回这个方法的直接引用,查找过程结束,如果不通过,则返回java.lang.IllegalAccessError
3、否则,则按照继承关系从上往下依次对C的各个父类进行2的搜索和验证过程。
4、若始终没有找到合适的方法,则抛出啊java.lang.AbstractMethodError异常

IllegalAccessError
程序试图访问或修改一个属性或调用一个方法,这个属性或方法,你没有权限访问。一般的,这个会引起编译异常。这个错误如果发生在运行时,就说明一个类发生了不兼容的改变。

在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提升性能,JVM采用在类的方法区建立一个虚方法表 virtual method table 来实现。使用索引表来代替查找。类一个类中都有一个虚方法表,表中存放着各个方法的实际入口。在类加载的链接阶段被创建并且开始初始化,类的变量初始值准备完毕之后,JVM会把该类的方法表也初始化完毕。
C++语言中也具有虚函数表指针的概念,目的也是为了在运行期间快速的找到需要执行的是父类的方法还是子类的方法。实现面向对象语言的多态性。

栈帧结构4:方法返回地址(ReturnAddress)

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

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

栈帧结构5:一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。

总结
1、若设置为可变大小的栈,当扩容时无法分配到需要的空间,JVM会报OOM异常。
2、若虚拟机栈设置为固定大小,当栈帧入栈的空间分配日不足时,JVM会报StackOverFlowError异常。
3、虚拟机栈这种Java程序运行时内存区域不存在GC的过程。

五、本地方法接口

“A native method is a Java method whose implementation is provided by non-java code”
是一个Java方法,只是这个方法的实现是由其他语言完成的。本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C++程序。
Java应用需要与Java外面的环境交互,这就是本地方法存在的主要原因。
目前本地方法使用的越来越少,除非是与硬件有关的应用。
其实Sun公司的解释器本身就是由C语言编写的,所以Java与其他语言多多少少都是存在一些联系的,JVM就提供了native method 的方式去使用其他语言。
比如在Object类中,Thread底层需要让操作系统运行一个独立的线程单元,这些操作都离不开本地方法库。

六、本地方法栈

Java虚拟机栈用于管理Java方法的调用,而本地方法栈用于管理本地方法的调用。本地方法栈,也是线程私有的。在内存溢出方面和Java虚拟机栈是相同的。

如果线程请求分配的栈绒里写那个超过了本地方法栈允许的最大容量,则JVM会抛出StackOverFlow异常。
如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新线程的时候没有足够的内存去创建对应的本地方法栈,那么JVM会抛出一个OutOfMemoryError异常。

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

在这里插入图片描述
注:本文是宋红康老师的笔记整理。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值