Java虚拟机类加载器与虚拟机字节码执行引擎

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/qq_18870127/article/details/88116116

1.类加载器

对于类加载器,java虚拟机规范中有这么一句,“通过一个类的全限定名来获取描述此类的二进制字节流”,正因为这个看似宽泛的约束使得java虚拟机的实现有了很多的种类,各自有自己的特点并占有自己的领域。虽然类加载器是由Applet技术开发出来的,但是Applet技术基本上已经挂了,但是因类加载思想却在类的层次划分,OSGI,热部署,代码加密等领域大放异彩。

对于任意一个类,都需要由加载他的类加载器和这个类本身一同确立在java虚拟机上的唯一性,这也就是package包的一个侧面引证吧,每一个类加载器都有自己的独立命名空间,和前面的包的概念不谋而合,判断两个类是否是相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,即使两个来源于同一个Class 文件,被同一个虚拟机加载,只要两者的类加载器不同,这两个类就不等。(相等是指,Class对象的equals方法,isAssignableFrom方法,isInstance方法的返回结果相同)。

双亲委派模型:

对于java类加载器来说,总体上可分为两类,一类是由C++等语言实现的(如HotSpot)的启动类加载器,另一类是剩余的其它的加载器,都由java语言实现,独立于虚拟机外部,都继承于java.lang.ClassLoader。如下图:

这是双亲委派模型的一个示意图,在此模型中,除了顶层的启动类加载器外,其余的类加载器都应当由自己的父类加载器,这里的继承方式不是面向对象中的继承(Inheritance)而是使用组合的方式实现父类加载器的代码的复用。
工作过程:

如果一个类加载器受到了类加载的请求,他首先不会自己区尝试加载这个类,而是把这个请求为派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父类的加载器反馈自己无法完成这个请求时,子加载器才回去尝试加载。这种方式的好处就是能够有一种优先的层级关系,例如Object类,他放在rt.jar文件中,无论哪一个类加载器都要加载这个类,因为他是所有类的父类,最终都是为派给启动类加载器去加载Object。因此所有的Object类是唯一的。如果都自己去编写,那么可能会产生多个Object。实现的代码都在Java.lang.ClassLoader的loadClass方法中。

双亲委派模型的破坏:

上提到的模型并不是一个强制性的约束,而是给开发者的一个推荐的模型,这里说明3个双亲委派模型达不到,或者是不太适合的情形:

1. 时间节点,JDK1.2; 在1.2发布之前,模型还没有被引入到Java的类加载中,之前的类加载器和抽象类java.lang.ClassLoader都在JDK1.0时就已经存在了,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入此模型时不得不做出一定的妥协,为了能够很好的向前兼容,JDK1.2后加入了一个新的protected方法,findClass,在此之前用户继承java.lang.CalssLoader就是为了能够重写loadClass方法,因为虚拟机在执行的时候会调用loadClassInternal方法,而这个方法的唯一的逻辑就是调用自己的loadClass方法。在1,.2之后就不再提倡用户覆盖loadClass方法,而是把自己的逻辑写到findClass方法中,如果loadClass方法父类加载失败则可以调用自己的findClass方法完成加载,这样就可以保证符合模型的规则了。

2.由于自身的模型的缺陷导致的无法解决某些问题而造成的破坏。模型很好的解决了各个类加载器的基础类的统一问题,这是因为他们总是作为用户代码调用的API,但是如果我们需要调用用户代码该怎么办?一个最典型的例子就是JNDI服务,他的代码由启动类加载器去加载,但是JNDI的目的就是对资源进行集中管理和查找,她需要调用独立厂商实现并部署在应用程序上的ClassPath路径下面的JNDI 接口提供者 SPI,但是启动类不可能加载这些类。 此时引入了一个不太优雅的设计,线程上下文加载器。这个加载器可以通过java.lang.Thread类的setContextClassLoader方法进行设置,如果创建线程没有设置,将会从父类线程中继承一个,如果在应用程序的全局范围内都没有设置的话,那么这个类加载器默认就是应用程序类加载器,实际上这已经违背了双亲委派模型的一般性原则,其它如JDBC,JCE,JAXB等,都有类似的实现。

3.程序的动态性的支持以及一些热代码替换,热模块部署,若进行某个重要的硬件如鼠标等替换,如果个人计算机,重启一次没多大问题(当然现在都已经实现了热拔插技术),但是对于大型的软件企业就会造成很大的损失。对此OSGi实现了模块热部署,关键点在于他自己定义了类加载机制。每一个程序模块(OSGi中称为Bundle)都有一个自己的类加载器,当更换需要一个Baundle时候就把这个Bundle和类加载器一同换掉,实现代码的热替换。

2.虚拟机字节码执行引擎

执行引擎是java虚拟机核心组成部分之一,虚拟机中的执行引擎是相对于物理机的概念,区别就是物理机与硬件、指令集和操作系统层面上的相关,而java虚拟机的执行引擎是自由实现的,java虚拟机规范中并没有给出或者是要求用何种方式去实现一个执行引擎,所以java自己定义了自己的执行引擎和指令集(以HotSpot虚拟机为例),从而这些指令不被硬件直接执行。

java的class文件以格式紧凑而闻名,在java 的指令集设计上就已经有了体现,他的指令大多数是单个的操作码,或者是单个指令跟一个操作数,操作码尽量设计成一个字节。而普通的基于硬件的指令集,如常见的X86指令集,则会有多个操作码。java的class文件的特点和java面向网路的特性是分不开的,紧凑的文件更利于网络的传输,但是,最大的问题是相对于基于硬件的指令集性能上是有一定落后的。

运行时栈帧结构

栈帧结构是用于虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区域中虚拟机栈的元素,存储了局部变量表,操作数栈,动态连接和方法返回地址等信息,每一方法的调用都会经历出栈和入栈的过程。在编译代码时,栈的最大深度和局部变量数量已经被写入方法属性中的Code属性中,max_stacks和max_locals分别表示最大栈深度和最大局部变量数量,因此一个栈需要分配多少内存是不会受到运行时变量的数据的影响,而取决与虚拟机的实现。一般说来,只有位于栈顶的栈帧是有效的,称为当前栈帧,其关联的方法叫做当前方法,执行引擎的所有字节码指令都只针对当前的栈帧操作,概念如下图所示:

局部变量表:

局部变量表是以变量槽slot为最最小单位,虚拟机中并没有明确指出一个slot应该占用内存的大小,只是导向性的说明每个slot都应该能存放一个boolean,char,byte,short,int,float.reference或returnAddress数据,returnAddress数据现在使用的比较少了,在早期的java虚拟机中的jsw以及jsw_r等指令用于返回调用的地址。对于其他的7个,首先应先看最小的slot使用多大的物理内存来表示的,32位或64位是随着处理器,操作系统以及虚拟机的不同而变化的。例如32位,则需要保证使用32位的物理内存空间去实现一个slot,虚拟机仍然需要对其和补白,对于64位的来说,只不过是为了让64位的和32位的在外观上看起来一致。然而对于reference类型,并没有规范说明其长度,也没有明确的说明这种引用应该又怎样的结构,但是一般说来需要支持两点:首先,从此引用能够直接后者是间接的找到对象在java堆中的数据存放的起始索引,而是此引用中直接或者简介的找到对象所属数据类型在方法区中存储的类型信息,否则无法实现java语言规范中定义的语法约束。

不过对于long和double数据来说,由于局部变量表建立在线程的堆上,是线程私有的数据,关于读写long或者是double的数据类型的原子性操作需要要论一下,因为线程私有不会共享,所以此种情况心下是不会出现数据安全问题的,然而在局部变量表中,加入n位置为一个32位的数据,n+1位一个long类型的整数,那么n+1表示的数据需要两个slot来进行存放。不允许以任何方法访问两个之中的任何一个,否则会抛出异常。

slot默认0位置为传递方法所属对象的实例引用,可以通过this关键字来方法这个隐形的参数,其余的从1开始,参数表分配完毕后再根据方法体内部定义的变量的顺序和作用域分配其它的slot。为了尽可能的节省栈的空间,局部变量表slot是可以重用的,方法体中定义的变量,其作用于不一定会覆盖整个方法体,如果当前字节码pc计数器已经超过了某个变量的作用域,那么这个变量对应的slot就可以交个其他变量使用,也会带来一定的副作用,就是某些情况下slot的复用会引起系统垃圾收集行为的不同,如下:

public static void main(String[] args){
    byte[] placeholder = new byte[ 64 * 1024 * 1024 ];
    System.gc();
}

如果运行查看GC日志发现placeholder并没有别回收,因为在执行gc时,placeholder还处于作用域之内,虚拟机并不会回收。如修改为如下代码:

public static void main(String[] args){
    {
        byte[] placeholder = new byte[ 64 * 1024 * 1024 ];
    }
    //int a = 0;
    System.gc();
}

按道理说,placeholder已经被限制在内存的作用域之内了,但是查看gc日志,还是没有回收placeholder,然而加上注释掉的语句就又可以实现垃圾回收了,这又是为什么呢?这是因为局部变量表中的slot是否还存在有关于placeholder数组对象的引用,第一次修改,虽然代码已经离开了其作用域,但是没有 任何的局部变量表的读写操作,placeholder原本所占用的slot还没有被其它的变量所复用,所以作为GC Roots 一部分的变量表仍然保持着对于他的关联,这种关联还没有及时打断。如果将其设置为null同样也可以将起打断。局部变量表不同于类变量,类变量可以在准备阶段实现系统初始值的设置,在初始化阶段可以初始为自定义的初始值,但是,局部变量就不一样了,如果一个局部变量定义了但是没有设置初始值是不能够使用的。

操作数栈:

这是一个先入后出的数据结构,32位数据其数据类型所占的栈容量为1,64位为2,都不会超过Code属性值中记录的最大栈深度和最大局部变量个数。当一个方法开始i执行的时候操作数栈是空的,在执行时会进行出栈和入栈的行为。操作数栈中的字节码指令必须和数据类型完全匹配,这也是强类型的一个侧面证明,在类型校验阶段的数据流分析中还要再次验证。

动态连接:

每个栈帧都包含一个执行运行时常量中该栈帧所属方法的引用,吃用这个引用是为了支持方法调用过程中的动态连接,这些符号引用一部分会在类加载节点就转化为了直接引用,这种称为静态解析,另一部分会在每一次运行期间转化为直接引用,这部分称为动态连接,具体的动态连接及相关的内容会在组织一个博客来说明下。

方法返回地址

当一个方法开始i执行后,只有两种方式可以让这个方法退出。第一种方式就是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层方法的调用者,是否会有返回值和返回值的类型将根据遇到何种方法指令来决定,这种推出方法的方式称为正常完成出口。另一种退出方式是,方法执行过程中遇到了异常。代码如果在本方法的异常表中没有搜索到匹配的异常处理器就会导致方法的退出,这种退出方式称为异常完成退出,其不会给上层调用者返回任何值的。

方法的继续执行需要在方法退出时返回到被调用的位置,可能会在栈帧中保存一部分的信息,来帮助恢复它上层方法的执行状态。方法的退出等于当前方法的出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈,调整pc计数器中的值可移植性方法调用命令指令后面的一条指令。

附加信息:

虚拟机规范中允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,如调试相关信息,这部分完全取决于虚拟机的实现,一般会把动态连接,方法返回地址与其它的附加信息全部归为栈帧信息一类。

基于栈的字节码解释执行引擎

java虚拟机的执行引擎在执行java代码时候都有解释执行和编译执行的选择,解释执行,例如早期的java虚拟机通过字节码解释的方式运行,编译执行如当下的JIT编译,将本地某些热点代码便以为本机执行的二进制代码,提高代码的运行效率,在这里扯一下Java虚拟机两种模式,Client模式能够较快的启动,进行一些必要的优化,但是不会在后台进行深度的优化。Server模式则启动较慢,但是长时间运行性能要比Client模式高。这里其是最大的区别就在于后台编译执行的区别,Java虚拟机有三个编译的层次,第一次纯的字节码解释,在古老的java版本中使用较多,普通情况下默认c1级别的编译,解释字节码以及进行一些必要的一定程度的优化,而C2模式则会对代码进行深度的优化,比较适合于Server模式。

解释执行:

java出生的JDK1.0时代这种定义时比较准确的,当虚拟机中包含了即时编译器之后,Class文件中的代码到底会被解释执行还是编译执行这就成了虚拟机自己做选择的事情。再后来也有了直接生成本地代码的编译器,如GCJ,而C/C++也有了解释执行器的版本,如CINT,这时候笼统的说解释执行则对整个Java来说几乎成了没有意义的概念,只有确定了谈论对象是某种具体的Java实现版本和执行引擎的运行模式后,编译执行和解释执行的讨论才有意义。大部分的程序代码在物理机的目标代码或者是虚拟机能执行的指令集之前,都需要经过下图的过程:

大多数物理机或者是java虚拟机都会遵循现代经典编译原理的思路,在执行前都会对程序源码进行词法分析和语法分析,把源码抽象称为抽象的语法树。而对于c/c++而言,词法分析和语法分析以至于后面的优化器和目标代码生成器都可以选择独立与执行引擎。当然也可以把其中的一部分实现为一个半独立的编译器,这类的代表就是Java语言,又或者将这些都全部集中封装,如JavaScript执行器。对于Java而言,词法分析,语法分析然后抽象称为语法树这些工作都是在虚拟机之外进行的,二姐时期在虚拟机的内部,所以Java程序的编译就是半独立的实现。

基于栈的指令集和基于寄存器的指令集:

java基本上是一种基于栈的指令集架构,指令流中的指令大部分为零地址指令,他们依赖操作数栈进行工作。而另一种基于寄存器的指令集如上面提到的典型的X86的二地址指令集,举个1+1计算的例子看下不一样的地方:

#Java的单字节指令
iconst_1
iconst_1
iadd
istore_0

#x86的而地址指令集
mov eax, 1
add eax, 1

可以看到,java是进行出栈和入栈的操作,遇到iadd指令则计算然后将结果进行放到局部变量表的第0个slot中。而x86指令则使用mov指令将eax寄存器设置为1,然后利用add进行加法计算,然后将结果保存在eax寄存器中。基于栈的指令集优势在于有较好的可移植性,不太受到硬件的约束,编译器实现简单。而基于寄存器的指令则相比基于栈的指令中频繁的入栈出栈性能损失要小的多。相对于处理器来说,内存始终是执行速度的瓶颈。

猜你喜欢

转载自blog.csdn.net/qq_18870127/article/details/88116116