类加载机制

类加载也就是JVM"读取"class文件,"class文件"不一定是以文件方式存储在磁盘的,也可能是从网络中接收到的二进制流,class由.java文件经过JDK编译而来,将源码编译为class文件的实现取决于各个JVM实现或各个编译器实现。class文件按照固定的格式来存储数据并且通常由类加载器来完成加载;class的执行在Sun JDK中有解释执行和编译为机器码执行两种方式;JVM的运行模式又分为server模式和client模式。

class类文件结构

JVM实现平台无关性的关键就是使用了统一的存储结构--字节码,实现语言无关性的关键是虚拟机和字节码存储格式,Java虚拟机不和任何语言绑定,而只于class文件绑定,class文件包含了Java虚拟机指令集和符号表及若干其他信息。JVM无关性包括平台无关性和语言无关性。



任何一个Class都对应着唯一一个类或接口的信息(这里的类包括抽象类哈),但反过来,类或接口信息并不一定都定义在文件里(比如类或接口可通过类加载器动态生成,Spring中AOP的实现中就有可能由类加载器动态生成代理类)。Class文件是一组以8字节为基础单位的二进制文件,各个数据项严格按照顺序紧凑着排列,中间没有任何分隔符,也就是说整个Class文件存储的几乎都是程序运行所需的必要数据。
那么class文件中都包含那些信息呢?class文件按照顺序排列都包括:

魔数与Class文件版本:CAFEBABE

  • 常量池:Class文件的资源仓库
  • 访问标志:标识类或接口的访问信息
  • 类索引、父类索引、接口索引集合:类的继承关系信息
  • 字段表集合:描述接口或类中声明的变量字段信息
  • 方法表集合:描述接口或类中定义的方法信息
  • 属性表集合:描述某些场景专有的信息

类加载机制

目前已经知道了class文件结构,但是class文件是什么时候才加载的呢,被什么加载的呢?虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、 转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

与那些在编译时需要进行连接工作的语言不同,比如C/C++语言,它们的.c或者.cpp文件编译成可执行文件需要经过预处理、编译、汇编、连接等流程,这些流程缺一不可。而在Java语言里,类型的加载、 连接和初始化过程都是在程序运行期间完成的,这种策略虽然会令类加载时稍微增加一些性能开销,但是会为Java应用程序提供高度的灵活性,Java里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。 例如,如果编写一个面向接口的应用程序,可以等到运行时再指定其实际的实现类;用户可以通过Java预定义的和自定义类加载器,让一个本地的应用程序可以在运行时从网络或其他地方加载一个二进制流作为程序代码的一部分,这种组装应用程序的方式目前已广泛应用于Java程序之中。 从最基础的Applet、 JSP到相对复杂的OSGi技术,都使用了Java语言运行期类加载的特性。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading)、 验证(Verification)、 准备(Preparation)、 解析(Resolution)、 初始化(Initialization)、 使用(Using)和卸载(Unloading)7个阶段。 其中验证、 准备、 解析3个部分统称为连接(Linking),这7个阶段的发生顺序如图所示。



对于什么时候进行类加载操作,JVM规范中并没有严格规定,这点由虚拟机自行实现。但对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行“初始化”(而加载、 验证、 准备自然需要在此之前开始):

  • 遇到new、 getstatic、 putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。 生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象的时候、 读取或设置一个类的静态字段(被final修饰、 已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
  • 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  • 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、 REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

对于以上5种情况会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的限定语:“有且只有”,这5种场景中的行为称为对一个类进行主动引用。 除此之外,所有引用类的方式都不会触发初始化(也就是不会进行类加载),称为被动引用

被动引用是什么,以下情况都属于被动引用的情况:

  • 通过子类引用父类的静态字段,不会导致子类的初始化。
  • 通过数组定义来引用类,不会触发该类的初始化。
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用定义常量的类,因此不会触发定义常量类的初始化。

注意,在一个类初始化的时候,要求其父类都已经初始化,但是一个接口在初始化的时候,并不要求其父接口全部都初始化,只有在真正使用到父接口的时候(比如引用父接口中常量)才会初始化。

类加载流程

类加载流程主要有5个阶段:

  • 加载:通过一个类的全限定名来获取定义此类的二进制字节流,然后将该字节流所代表的的静态存储结构转换为方法区运行时数据结构,在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
  • 验证:验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。这也是相对于C/C++来说,Java语言本身相对安全的原因。
  • 准备:准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。 注意,这里的类变量指的是类中被static修饰的变量,它们在该阶段都会被初始化对应的“零值”。
  • 解析:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用是在Class文件结构的常量池中的东西。
  • 初始化:类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。 到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。在准备阶段,变量已经赋过一次系统要求的初始值(零值),而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器<clinit>()方法的过程。

什么是<clinit>()方法
<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。

<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。 因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。

<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。 但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。 只有当父接口中定义的变量使用时,父接口才会初始化。 另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。

虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、 同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。 如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。 (比如类A中static块有while死循环,两个线程都进行new A()操作,则第二个进入类A的<clinit>()方法的线程会被阻塞)

类加载器与双亲委任模型

类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。 这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。
这里所指的“相等”,包括代表类的Class对象的equals()方法、 isAssignableFrom()方法、 isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

双亲委任模型
JVM的类加载的实现方式称为双亲委任模型,其流程是当收到一个类加载请求时,首先会交给父加载器完成,如果父加载器反馈自己无法加载时,子加载器才尝试自己完成加载,每一层次的类加载器都是如此。

从Java虚拟机的角度来讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现(Sun JVM就是由C++实现的),是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader。

从Java程序员角度来看,类加载器还可以划分得更细致一些,绝大部分Java程序都会使用到以下3种系统提供的类加载器。

  • 启动类加载器(Bootstrap ClassLoader):前面已经介绍过,这个类将器负责将存放在<JAVA_HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如rt.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机内存中。
  • 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher $AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载器。 它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

注意,Java程序都是由以上3种类加载相互配合进行加载的,如果有必要,还可以加入自定义的类加载器。



双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。 这里类加载器之间的父子关系一般不会以继承(Inheritance)的关系来实现,而是都使用组合(Composition)关系来复用父加载器的代码。

双亲委任模型好处
解决了类加载过程中的安全性问题,如果定义了一个Java基础类库中的一个类,使用双亲委任模型保证了Java基础类的加载由上层类加载器优先加载。

参考资料

1、《深入理解JVM虚拟机 第二版》

推荐阅读更多精彩内容