怎么理解java虚拟机执行子系统
本篇内容主要讲解"怎么理解java虚拟机执行子系统",感兴趣的朋友不妨来看看。本文介绍的方法操作简单快捷,实用性强。下面就让小编来带大家学习"怎么理解java虚拟机执行子系统"吧!
类文件结构
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式- 字节码( ByteCode ) 是构成平台无关性的基石。
图-Java虚拟机提供的语言无关性
虚拟机类加载机制
虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
类加载的时机
Java语言中类型的加载、连接以及初始化过程都是在程序运行期间完成的,这种策略虽然会使类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性。Java里天生就可以动态扩展语言特性就是依赖运行期间动态加载和动态连接这个特点实现的。比如,如果编写一个面向接口的程序,可以等到运行时再指定其具体实现类;用户可以通过Java预定义的和自定义类加载器,让一个本地的应用程序可以在运行时从网络或其它地方加载一个二进制流作为程序代码的一部分,这种组装应用程序的方式目前已广泛应用于Java程序之中。从最基础的JSP到相对复杂的OSGI技术,都使用了Java语言运行类加载的特性。
类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中验证、准备、解析3个部分统称为连接(Linking),这7个阶段的发生顺序如图:
图-类的生命周期
加载、验证、准备、初始化和卸载这5个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。
什么情况下需要开始类加载过程的第一个阶段:加载?Java虚拟机规范中并没有进行强制约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行"初始化"(而加载、验证、准备自然需要在此之前开始):
1)遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
5)当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
类加载的过程
加载
"加载"是"类加载"(Class Loading)过程的一个阶段。在加载阶段,虚拟机需要完成以下3件事情:
1)通过一个类的全限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
通过类型的完全限定名,产生一个代表该类型的二进制数据流的几种常见形式:
1)从zip包中读取,成为日后JAR、EAR、WAR格式的基础;
2)从网络中获取,这种场景最典型的应用就是Applet;
3)运行时计算生成,这种场景最常用的就是动态代理技术了;
4)由其他文件生成,比如我们的JSP;
相对于类加载过程的其他阶段,一个非数组类(数组类比较特殊,有虚拟机直接创建的)的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的,因为加载阶段既可以使用系统提供的引导类加载器来完成,也可以由用户自定义的类加载器去完成,开发人员可以通过定义自己的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass()方法)。
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义,虚拟机规范未规定此区域的具体数据结构。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面),这个对象将作为程序访问方法区中的这些类型数据的外部接口。
验证
验证是链接阶段的第一步,这一步主要的目的是确保class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。
验证阶段主要包括四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。
1.文件格式验证
验证class文件格式规范,例如class文件是否已魔术0xCAFEBABE开头 , 主、次版本号是否在当前虚拟机处理范围之内等。
2.元数据验证
这个阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合java语言规范要求。验证点可能包括:这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)、这个类是否继承了不允许被继承的类(被final修饰的)、如果这个类的父类是抽象类,是否实现了起父类或接口中要求实现的所有方法。
3.字节码验证
进行数据流和控制流分析,这个阶段对类的方法体进行校验分析,这个阶段的任务是保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如:保证访法体中的类型转换有效,例如可以把一个子类对象赋值给父类数据类型,这是安全的,但不能把一个父类对象赋值给子类数据类型、保证跳转命令不会跳转到方法体以外的字节码命令上。
4.符号引用验证
对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的知识点,首先是这时候进行内存分配的仅包括类变量(static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在java堆中。其次是这里所说的初始值"通常情况"下是数据类型的零值,假设一个类变量定义为:
public static int value = 12;
那么变量value在准备阶段过后的初始值为0而不是12,因为这时候尚未开始执行任何java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器()方法之中,所以把value赋值为12的动作将在初始化阶段才会被执行。
上面所说的"通常情况"下初始值是零值,那相对于一些特殊的情况,如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,建设上面类变量value定义为:
public static final int value = 123;
编译时javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value设置为123。
解析
解析阶段是虚拟机常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用是一组符号来描述所引用的目标对象,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标对象并不一定已经加载到内存中。
直接引用:直接引用可以是直接指向目标对象的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机内存布局实现相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同,如果有了直接引用,那引用的目标必定已经在内存中存在。
初始化
类的初始化阶段是类加载过程的最后一步,在准备阶段,类变量已赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器
1.遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需先触发其初始化。生成这4条指令的最常见的java代码场景是:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用类的静态方法的时候。
2.使用java.lang.reflect包的方法对类进行反射调用的时候
3.当初始化一个类的时候,如果发现其父类还没有进行过初始化、则需要先出发其父类的初始化
4.jvm启动时,用户指定一个执行的主类(包含main方法的那个类),虚拟机会先初始化这个类
在上面准备阶段 public static int value = 12; 在准备阶段完成后 value的值为0,而在初始化阶调用了类构造器
类加载器
虚拟机设计团队把类加载阶段中的"通过一个类的全限定名来获取描述此类的二进制字节流"这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为"类加载器"。
类与类加载器
对于任何一个类,都需要由加载它的类加载器和这个类来确立其在JVM中的唯一性。也就是说,两个类来源于同一个Class文件,并且被同一个类加载器加载,这两个类才相等。比如同一个类采用不同的类加载器去加载,在判断对象所属类型检查(instanceof)时会出现不同。
双亲委派模型
从虚拟机的角度来说,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),该类加载器使用C++语言实现,属于虚拟机自身的一部分。另外一种就是所有其它的类加载器,这些类加载器是由Java语言实现,独立于JVM外部,并且全部继承自抽象类java.lang.ClassLoader。
从Java开发人员的角度来看,大部分Java程序一般会使用到以下三种系统提供的类加载器:
1)启动类加载器(Bootstrap ClassLoader):负责加载存放在%JAVA_HOME%\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且被java虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库,即使放在指定路径中也不会被加载)类库到虚拟机的内存中,启动类加载器无法被java程序直接引用。
2)扩展类加载器(Extension ClassLoader):由sun.misc.Launcher$ExtClassLoader实现,负责加载%JAVA_HOME%\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
3)应用程序类加载器(Application ClassLoader):由sun.misc.Launcher$AppClassLoader实现,负责加载用户类路径classpath上所指定的类库,是类加载器ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称为系统类加载器,开发者可以直接使用应用程序类加载器,如果程序中没有自定义过类加载器,该加载器就是程序中默认的类加载器。
我们的应用程序都是由这三类加载器互相配合进行加载的。
另外还有自定义类加载器。
4)自定义类加载器(必须继承 ClassLoader)。
图-类加载器双亲委派模型
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的父加载器都是如此,因此所有的请求最终都应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。双亲委派模型对于保证JAVA程序的稳定运作很重要。例如可以尝试去编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但永远无法被加载运行。
如何破坏双亲委派模型
虚拟机字节码执行引擎
执行引擎是Java虚拟机最核心的组成部分之一。虚拟机是一个相对于物理机的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在Java虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现里面,执行引擎在执行Java代码的时候会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至可能会包含几个不同级别的编译器执行引擎。
方法调用
解析
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。我们知道,Class文件的编译过程中并不包括传统编译中的连接步骤,一切方法调用在Class文件调用里面存储的都只是符号引用,而不是方法在实际运行时的内存布局入口地址(相当于之前说的直接引用),也就是说符号引用解析成直接引用的过程。这个特性使得Java 具有强大的动态扩展能力,但也使得Java方法调用过程变得复杂起来,需要在类加载器件,甚至是运行期间才确定目标方法的直接引用。
在类加载的解析阶段,会将其中一部分符号引用直接转化为直接引用,前提是:方法在程序真正运行之前就有一个可确定的版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好,编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。
在Java语言中符合"编译期可知,运行期不可变"这个要求的方法,主要包括:静态方法和私有方法。前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了他们都不可能通过继承或别的方式重写其它版本,因此它们适合在类加载阶段进行解析。
与之相对应的,Java 虚拟机里面提供了5条方法调用字节码指令,分别如下:
invokestatic:调用静态方法
invokespecial:调用
invokevirtual:调用所有的虚方法
invokeinterface:调用接口方法,会在运行时在确定一个实现此接口的对象
invokedynamic:会在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
只要能被invokestatic和invokespecial调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在加载的时候就会把符号引用解析为该方法的直接引用,这些方法称为非虚方法,由于final修饰的方法不能被覆盖,也属于非虚方法。与之相反,其他的方法称为虚方法。
解析调用一定是静态的过程,在编译期间完全确定,在类装载的解析阶段就会把涉及的符号引用全部转换为可确定的直接引用,不会延迟到运行期再去完成。这和后边谈到的分派是完全不同的。
分派
作为一门面向对象的程序语言,Java具备面型对象的3个特征:继承、封装和多态。下面我们将会讲解多态性特征的一些最基本的体现,如"重写"和"重载"在Java虚拟机中是怎么实现的。
静态分派
依赖于静态类型来定位方法执行版本的分派动作(如重载)称为静态分派。虚拟机(准确说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的,并且静态类型是编译器可知的,因此在编译期,Javac编译器会根据参数的静态类型决定使用哪个重载版本。
动态分派
运行时期依赖于实际类型来定位方法执行的分派动作(重写Override)属于动态分派。
单分派与多分派
方法的接受者与方法的参数统称为方法的宗量。根据分派基于多少宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
在静态分派的过程中,选择目标方法的依据有两点,对象的静态类型以及方法参数的类型和数量。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
在动态分派的过程中,由于编译器已经决定了目标方法的签名,因此只需要找到方法的接受者就可以了。因为是根据一个宗量进行选择,所以Java语言的动态分派属单分派类型。
虚拟机动态分派的实现
由于动态分配是非常频繁的动作,而且动态分配的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中,基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。最常用的手段就是为类在方法区中建立一个虚方法表(Virtual Method Table , 也称为vtable ,与此对应的在invokeinterface执行时也会用到接口方法表-Inteface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口。方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值之后,虚拟机会把该类的方法表也初始化完毕。
到此,相信大家对"怎么理解java虚拟机执行子系统"有了更深的了解,不妨来实际操作一番吧!这里是网站,更多相关内容可以进入相关频道进行查询,关注我们,继续学习!