虚拟机类加载机制

概述

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

Java 里天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。(OSGi技术)

Class文件就是一串二进制的字节流,无论以何种形式存在都可以。

类加载的时机

加载( Loading)、 验证( Verification)、 准备( Preparation)、 解析( Resolution)、 初始化( Initialization)、 使用( Using) 和卸载( Unloading) 7 个阶段。

其中验证、准备、解析 3 个部分统称为连接( Linking)。

加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 语言的运行时绑定(也称为动态绑定或晚期绑定)。

虚拟机规范则是严格规定了有且只有 5 种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前开始):

  • 1) 遇到 new、 getstatic、 putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。
  • 2) 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  • 3) 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  • 4) 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
  • 5) 当使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、 REF_putStatic、 REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

通过 子类引用父类的静态字段,不会导致子类初始化。是否要触发子类的加载和验证,在虚拟机规范中并未明确规定,这点取决于虚拟机的具体实现。

通过数组定义来引用类,不会触发此类的初始化。比如SupperClass[] sca = new SupperClass[10];这里不会初始化SupperClass,但是触发了另外一个名为“[com.gavin.SuperClass”的类的初始化阶段,它是一个由虚拟机自动生成的,直接继承于Object的子类,创建动作由字节码指令newarray触发。

常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量类的初始化。比如“private static final CONST="123"”,不会引发此类的初始化。

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

类加载过程

加载

在加载阶段,虚拟机需要完成以下 3 件事情:

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

Class文件的获取方式:

  • 从 ZIP 包中读取,这很常见,最终成为日后 JAR、 EAR、 WAR 格式的基础。
  • 从网络中获取,这种场景最典型的应用就是 Applet。
  • 运行时计算生成,这种场景使用得最多的就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 来为特定接口生成形式为*$Proxy 的代理类的二进制字节流。
  • 由其他文件生成,典型场景是 JSP 应用,即由 JSP 文件生成对应的 Class 类。
  • 从数据库中读取。

一个非数组类的加载阶段(准确地说,是加载阶段中获取类的二进制字节流的动作)是开发人员可控性最强的。开发人员可以通过自定义的类加载器去控制字节流的获取方式(即重写一个类加载器的loadClass方法),这里以后可以找一些比较重要的类,分析里面所有的函数,从而知道这些东西怎么用。

对于数组类而言,情况就有所不同,数组类本身不通过类加载器创建,它是由 Java 虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型( Element Type, 指的是数组去掉所有维度的类型)最终是要靠类加载器去创建,一个数组类(下面简称为 C) 创建过程就遵循以下规则:

  1. 如果数组的组件类型( Component Type, 指的是数组去掉一个维度的类型)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组 C 将在加载该组件类型的类加载器的类名称空间上被标识(这点很重要,在 7. 4 节会介绍到,一个类必须与类加载器一起确定唯一性)。
  2. 如果数组的组件类型不是引用类型(例如 int[] 数组), Java 虚拟机将会把数组 C 标记为与引导类加载器关联。
  3. 数组类的可见性与它的组件可见性一致,如果组件类型不是引用类型,那数组类的可见性将默认设置为public

加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,方法区怎么存储由JVM自己定义。然后在内存中实例化一个java.lang.Class类的对象(并没有明确规定是在Java堆中,对于HotSpot虚拟机而言,Class对象比较特殊,它虽然是对象,但是存放在方法区里面)。

加载阶段和连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始。

验证

验证是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

在字节码语言层面上,上述 Java 代码无法做到的事情都是可以实现的,至少语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。

验证阶段大致上会完成下面 4 个阶段的检验动作:

文件格式验证

第一阶段要验证字节流是否符合 Class 文件格式的规范。

  1. 是否以魔数0xCAFEBABE开头
  2. 主、次版本号是否在当前虚拟机处理范围之内。
  3. 常量池中的常量中是否有不被支持的常量类型(检查常量tag标志)。

······

元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。

字节码验证

主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。

由于数据流验证的高复杂性,虚拟机设计团队为了避免过多的时间消耗在字节码验证阶段,在 JDK 1.6 之后的 Javac 编译器和 Java 虚拟机中进行了一项优化,给方法体的 Code 属性的属性表中增加了一项名为" StackMapTable" 的属性, 只需要检查StackMapTable属性中的记录是否合法皆可以了。

理论上StackMapTable属性也存在错误或者被篡改的可能,所以是否有可能在恶意篡改了Code属性的同时,也生成相应的StackMapTable属性来骗过虚拟机的类型校验则是虚拟机设计者值得思考的问题。

虚拟机中提供了-XX:-UseSplitVerifier选项来关闭这项优化,或者使用参数-XX:+FailOverToOldVerifier要求在类型校验失败的时候退回到旧的类型推导方式进行校验。

符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段—---—解析阶段中发生。

如果所运行的全部代码(包括自己编写的及第三方包中的代码)都已经被反复使用和验证过,那么在实施阶段就可以考虑使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

准备

这个阶段进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。其次,这里所说的初始值“通常情况”下是数据类型的零值。

public static int value=123;

value加载类的时候初始化为0,把 value 赋值为 123 的动作将在初始化阶段(方法中)才会执行。

特殊情况:如果类字段的字段属性表中存在 ConstantValue 属性,那在准备阶段变量 value 就会被初始化为 ConstantValue 属性所指定的值,假设上面类变量 value 的定义变为:

public static final int value= 123;

编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 ConstantValue 的设置将 value 赋值为 123。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。

除 invokedynamic(虚拟机指令) 指令以外,虚拟机实现可以对第一次解析的结果进行缓存。

解析动作主要针对类或接口、字段解析、类方法解析、接口方法解析、方法类型解析、方法句柄解析和调用点限定符 7 类符号引用进行。

初始化

初始化阶段是执行类构造器<clinit>方法的过程。稍后介绍它是怎么生成的,这里我们先看一下<clinit>方法执行过程中一些可能会影响程序运行行为的特点和细节,这部分相对更贴近于普通的程序开发人员。

  1. <clinit>方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问。
  2. 虚拟机保证子类的<clinit>方法执行之前,父类的<clinit>方法已经执行完毕。
  3. 对于接口,不能使用static块,但是可以有静态变量的赋值操作。子类接口的<clinit>方法调用并不保证父接口的<clinit>方法被先调用,只有用到父接口的静态变量的时候,父接口<clinit>方法才会被调用。接口的实现类在初始化时也一样不会执行接口的<clinit>方法。
  4. 虚拟机会保证一个类的<clinit>方法在多线程环境中被正确地加锁、同步。如果一个线程的<clinit>方法调用时间过长,就可能造成多个线程阻塞。

类加载器

让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。

类加载器却在类层次划分、 OSGi、 热部署、代码加密等领域大放异彩。

类与类加载器

即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。每一个类加载器,都拥有一个独立的类空间。

不同的类加载器对 instanceof 关键字运算的结果的影响。除此之外,Class对象的equals方法、isAssignableFrom方法、isInstance方法的返回结果也会受影响。

虚拟机中存在了两个 ClassLoaderTest 类,一个是由系统应用程序类加载器加载的,另外一个是由我们自定义的类加载器加载的

双亲委派模型

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

绝大部分 Java 程序都会使用到以下 3 种系统提供的类加载器:

  • 启动类加载器(Bootstrap ClassLoader):这个类将器负责将存放在JAVA_HOME\lib 目录中的,或者被-Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar, 名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用 null 代替即可。
  • 扩展类加载器(Extension ClassLoader):它负责加载JAVA_ HOME\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库。
  • 应用程序类加载器(Application ClassLoader):由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称它为系统类加载器。它负责加载用户类路径( ClassPath) 上所指定的类库。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

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

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成。

类 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。

可以尝试去编写一个与 rt.jar 类库中已有类重名的 Java 类,将会发现可以正常编译,但永远无法被加载运行。

破坏双亲委派模型

双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前————即 JDK 1.2 发布之前。

JDK 1.2 之后已不提倡用户再去覆盖 loadClass() 方法,而应当把自己的类加载逻辑写到 findClass() 方法中,在 loadClass() 方法的逻辑里如果父类加载失败,则会调用自己的 findClass() 方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的。如果基础类又要调用回用户的代码,那该怎么办?

为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:线程上下文类加载器( Thread Context ClassLoader)。这个类加载器可以通过java.lang.Thread类的setContextClassLoader方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认是应用类加载器。

双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的。

OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块( OSGi 中称为 Bundle) 都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。在 OSGi 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。

在 Java 程序员中基本有一个共识: OSGi 中对类加载器的使用是很值得学习的,弄懂了 OSGi 的实现,就可以算是掌握了类加载器的精髓。

results matching ""

    No results matching ""