Java虚拟机(二)-- 类的加载与执行

在我们初学Java的时候,我们的老师可能都对我们说过Java是一门平台无关的语言,Java在发布之初也提出了一个非常著名的口号:“Write once,Run anywhere!”,而这一切的关键便是Class字节码。它使得Java不再需要再关注平台的差异性,我只要生成相应的字节码,其他的交给虚拟机就行了。事实上,如今Java虚拟机不仅仅是平台无关了,还实现了语言无关;即是说,它不再是只为Java服务了,你还可在上面跑Scala,跑JRuby,跑Groovy…,可以想象以后会有越来越多的语言运行在Java虚拟机上,只要你给它提供一个字节码文件。好了,扯了这么多其实只想说明一个观点,了解虚拟机很重要。下面我们就来看一下Java虚拟机中类的加载和执行。

类文件结构

本篇博客既然是要研究类的加载和执行,那么我们肯定需要先了解一下这个Class文件中到底是些什么?但是这并不容易,笔者曾经反编译一个Class文件,结果里面一行行的字节码指令给笔者绕的头大,也曾买过《Java虚拟机规范》研读,但里面内容百分之八十都是对一些字节码指令的介绍,笔者看睡了很多次,直到现在还没有看完。因此这篇博客不会详细介绍字节码文件的结构,因为笔者也不会。。我们只需要知道它里面是一行行的字节码指令,具有很强的描述性,因为它不只可以表述所有的Java语言的语义(比如什么类啊,方法啊,锁啊,还有很多的关键字啊),还能表述其他语言的语义,在一定意义它可能比Java还要牛。本文我们重点看一下它是如何被加载进虚拟机并且解释执行的。

类加载机制

所谓类加载机制——就是说虚拟机把描述类的数据从Class文件中加载到虚拟机内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型的过程。类从被加载到虚拟机内存中到卸载出内存共包括了七个阶段,加载,验证,准备,解析,初始化,使用和卸载,其中验证,准备,解析三个步骤被统称为连接。整个过程如下图所示:

值得注意的是这个过程是在程序运行过程中动态发生的,这就使得Java这个静态语言可以具备一部分动态语言的特性,即是反射。下面具体看一下这几个阶段。

加载

在加载过程中虚拟机会做以下几件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流(这个二进制流可以来源压缩包,如jar包;可以来源网络,如RPC;可以是运行时)
  • 将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口(虚拟机规范并没有明确规定该对象在Java堆中,但对于常见的HotSpot虚拟机而言,它是在Java堆的方法区中)
验证

验证是连接阶段的第一步,主要是确保类的二进制字节流的合法性。因为这个二进制字节流可以有很多来源,甚至使用十六进制编辑器直接产生。因此我们需要验证它的合法性,使它可以被虚拟机识别,并且不会危害到虚拟机的安全。验证阶段大致会进行以下几个校验工作:文件格式验证,元数据验证,字节码验证,符号引用验证。

准备

准备阶段是正式为类变量分配内存并设置初始值的阶段,这些类变量所使用的内存都将在方法区中进行分配。这里需要注意的是分配在方法区中的是类变量而不是实例变量,实例变量会在对象实例化时随对象分配在Java堆中;还有一点是这里设置的是初始值,而不是代码中赋予的值。例如:

1
private static int value = 666;

在准备阶段后,value的值是0而不是666,因为int型的初始值为0 。但是需要注意的是当有final修饰时结果就不一样了,此时虚拟机会将value的值设置为666,并将它放入常量池中。

解析

解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。这里需要明白两个概念:什么是符号引用?什么是直接引用?

  • 符号引用:符号引用比较抽象,就是一组符号(就是字符串)来描述所要应用的目标,它可以是任何形式的字面量,主要能无歧义的定位到目标即可。如果查看一个Class文件会发现它大致是一个由全限定名,类型及引用层次组成的字符串。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。通过它我们就可以找到内存我们所引用的对象。
初始化

类初始化阶段是类加载过程的最后一步,在这个过程会会执行类构造器()方法。在这个方法中会执行类变量和静态语句块的赋值操作,赋值的顺序由语句在源码中的顺序决定。值得注意的是,在调用子类的clinit方法前,会先调用父类的clinit方法(意味着父类的static变量会先于子类的被初始化),但是如果是接口,则不会调用父接口的clinit方法,并且如果一个类实现了一个接口,也不会调用该接口的clinit方法。对于一个类或者接口而言,这个方法不是必须的,如果源码中没有static变量,则编译器不会生成clinit方法。同时,这个操作是线程安全的,虚拟机会自动对其进行加锁,同步。如果有多个线程同时调用clinit方法,则只有一个线程可以执行这个方法,其他线程会被阻塞,直到初始化结束,并且在唤醒之后不会再调用这个clinit方法。这个特性也被程序员很好的利用到了,比如在线程安全的单例模式中,有一种便是通过这种方式实现的。

并非所用调用都会进行初始化操作,有且仅有在以下几种场景下,才会触发类的初始化操作。

  • 使用new实例化对象的时候,读取或设置一个类的静态字段(被final修饰、已在编译时就把结果放入常量池的静态字段除外,即static final修饰的字段)的时候,以及调用一个类的静态方法的时候。
  • 使用反射调用一个类的字段或方法的时候。
  • 初始化一个类时,如果它的父类还没有初始化,则初始化它的父类。(这点与接口不同,接口不会初始化它的父接口)
  • 当虚拟机启动时,虚拟机需要指定一个执行的主类(即包含main方法的类),虚拟机会首先初始化这个类
  • 读取或者设置代理类的静态字段,或者调用它的静态方法时

类加载器

类加载器顾名思义就是实现类的加载的东西。Java中的每一个类都会有一个它所对应的类加载器,它们共同构成了这个类的唯一性。就是说,如果要比较两个类是否相等,即equals,必须在它们都是由同一个类加载器的前提下,否则都是耍牛氓。类加载器可以分为以下几类:

  • 启动类加载器:该加载器由C/C++ 语言编写,是Java虚拟机的一部分,负责加载Java中的核心类库。
  • 拓展类加载器:负责加载JRE中的扩展类库
  • 应用程序类加载器:也叫系统类加载器,用于加载ClassPath上指定的第三方类库。如果没有自己自定义类加载器,一般情况下,这个就是默认的类加载器。
双亲委派模型

一个程序的运行需要上面三种类加载器彼此相互配合,特定情况下,还可以增加自己自定义的类加载器。它们的关系如下所示:

上图所示的这种层次关系被称为双亲委派关系,该模型要求除了顶层的启动类加载器,其余的类加载器都应该有自己的父加载器,需要注意的是这种父子关系不是通过继承来实现的,而是通过组合。

那么,双亲委派模型是如何工作的呢?它的工作流程大致是这样的:如果一个类加载器收到类加载请求,它首先不会自己去加载这个类,而是将它委派给父加载器去完成,每一个层次的类加载器都是如此,直到到达启动类加载器。只有当父类加载器无法加载这个类时(在它的搜索范围中没有找到这个类),子构造器才会自己尝试去加载。

双亲委派模型的好处显而易见,它避免了类型的混乱,使它们和它们对应的类加载器一样具备了层次关系。比如说java.lang.Object这个类,它属于Java的核心类库中,在双亲委派模型中,它最终会被启动类加载器加载,而不会被它的子类加载器加载,这样Object类在系统的类加载器环境中都是同一个类。试想一下,如果没有双亲委派模型,我们自己定义了一个java.lang.Object类,放在了ClassPath下,那么系统中就会出现两个Object类,那么在调用时就会产生混乱。JDK中以及Java中大多数的类库都是遵从双亲委派模型的,所以如果我们自己定义了一个和这些类库中同名的类,就会产生错误,它们可以被编译,但无法被加载。

破坏双亲委派模型

双亲委派模型只是Java推荐的规范,而不是一个强制性的约束。因此有些类库为了实现特殊的功能,便破坏了这个模型,比如著名的OSGI。到目前为止,总共出现过三次双亲委派模型遭到破坏的情况。

  • 第一次被破坏是在双亲委派模型出现之前,即在JDK1.2版本出现之前。这次破坏其实是一种妥协,因为在JDK1.2之前,其实已经存在了ClassLoader类,即已经存在了用户自定义类加载器。为了向前兼容,JDK不得不添加了一个新的保护方法findClass。
  • 第二次破坏是由于调用外部独立厂商的SPI,如JNDI,JDBC等,因为它们需要父加载器去调用它们的子加载器,这种行为其实就打通了双亲委派模型的层次结构去逆向使用类加载器。
  • 第三次破坏是由于对于动态性的追求而导致,比如为了实现热部署、热替换,比较有名的就是OSGI。OSGI是Java的一个模块化热部署框架,它实现的管家就是自定义的类加载器机制。在OSGI中每一个模块(被称为Bundle)都会有一个自己的类加载器,当需要替换一个模块时,连同它的类加载器一起替换,以实现代码的热替换。

在OSGI中,类加载器不再是双亲委派模型中的树状结构,而是一种更加复杂的网状结构。当收到类加载请求时,OSGI按照一下的顺序进行类搜索:

  • 将以java.*开头的类委派给父类加载器加载
  • 否则,将委派列表名单中的类委派给父类加载器加载
  • 否则,将Import列表中的类委派给Export这个类的Bundle的类加载器加载
  • 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载
  • 否则,查找类是否在自己的Frament Bundle中,如果在,则委派给Frament Bundle的类加载器加载
  • 否则,查找Dynamic Improt列表的Bundle,委派给对应Bundle的类加载器加载
  • 否则,类加载失败

上面的查找顺序只要头两条符合双亲委派模型,其余的类加载都在平行的类加载器中进行。这里读者需要注意一点,破坏在这里并不是贬义的,上面得三种破坏中,无论是妥协还是创新,都有它存在的意义。

字节码执行引擎

执行引擎即是虚拟机中用于解析字节码的东西,是虚拟机最核心的部件之一(因为虚拟机的主要功能就是执行自定义的指令集,这是它跨平台的关键)。虽然不同的虚拟机有不同的执行引擎实现,可能会有解析执行,也可能还有编译执行,但外观上是大致相同的:输入的是字节码,输出的是执行结果。

如果读者了解Java虚拟机的内存区域情况,应该会了解Java虚拟机的内存被划分为了堆和栈,这个栈主要指的就是虚拟机栈(这里暂不讨论本地方法栈),虚拟机栈中存储的单位是一种叫做栈帧的东西,这个栈帧便是支持虚拟机进行方法调用和方法执行的结构。

下面看看这个栈帧,栈帧中分为局部变量表,操作数栈,动态连接和方法返回地址等信息,其中动态连接,方法返回地址这些被统称为栈帧信息。Java中每一个方法从被调用到执行完成都对应着一个栈帧的从入栈到出栈,这里看一下局部变量表和操作数栈到底是什么。

  • 局部变量表:局部变量表存储这方法的参数,局部变量等。如果是非static方法,表的第0个slot存储的是方法所在对象的this指针。之后是按顺序存储参数,和方法中的变量;如果是static方法,第0个slot就会从参数开始存储。
  • 操作数栈:也叫做操作栈,就是一个后入先出的栈,栈的深度同局部变量表 一样在编译时就已经确定。操作数栈可以看做是一个用于变量运算的东西,通过字节码指令来操作它,Java中的运算被转换成了一个入栈、出栈的过程。

总结

关于类的加载和执行就介绍到这里吧,本文中很多信息都没有详细阐述,因为确实比较复杂,而且不同的虚拟机的实现可能还不一样,在加上笔者本人水平有限,很多问题自己也没有整的太清楚。感兴趣或者有困惑的同学可以看一下《Java虚拟机规范》或者《深入理解Java虚拟机》这两本书,相信对你们会有帮助。