Java虚拟机进阶之路——类加载机制

1.概述

Java虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转化解析和初始化,直到转化成可以被虚拟机使用的数据类型,这个过程被称作类加载机制。

  与那些需要在编译期进行连接的语言不同,在Java中,类型的加载、连接、初始化都是在程序运行时期完成的,这会增加开销,但也让Java成为可以动态扩展的语言。

2.类加载的时机

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它会经过加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。

类型的加载过程必须按照这种顺序按部就班开始,但解析阶段则不一定:它在某些情况下也可以在初始化阶段后开始,这是为了支持动态绑定。

虚拟机规定有且只有六种情况必须对类进行初始化:

1)遇到newgetstaticputstaticinvokestatic这四条字节码指令时,如果类型没有进行过初始

化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

·使用new关键字实例化对象的时候。

·读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)

的时候。

·调用一个类型的静态方法的时候。

2)使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需

要先触发其初始化。3)当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4)当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先

初始化这个主类。

5)当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解

析结果为REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial四种类型的方法句

柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。

6)当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有

这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

这六种情况被称为对一个类型的主动引用。

下面举个例子,说明什么是被动引用,并且验证前面的六条:

package org.fenixsoft.classloading;

/**

* 被动使用类字段演示一:

* 通过子类引用父类的静态字段,不会导致子类初始化

**/

public class SuperClass {

static {

System.out.println("SuperClass init!");

}

public static int value = 123;

}

public class SubClass extends SuperClass {

static {

System.out.println("SubClass init!");

}

}

/**

* 非主动使用类字段演示

**/

public class NotInitialization {

public static void main(String[] args) {

System.out.println(SubClass.value);

}

}

上述代码运行之后,只会输出“SuperClass init,而不会输出“SubClass init。对于静态字段,

只有直接定义这个字段的类才会被初始化,因此通过其子类来引用父类中定义的静态字段,只会触发

父类的初始化而不会触发子类的初始化。

接口与类的区别:前面六条中的第三条,当一个类被初始化时,要求其父类全部已经被初始化过了,但一个接口在初始化时,并不要求其父接口全部被初始化过,只有在用到父接口的时候(如引用父接口里的常量),才会进行初始化。

  1. 类加载的过程(加载、准备、验证、解析、初始化)

加载

我来这里只干三件事:

1)通过一个类的全限定名来获取定义此类的二进制字节流。

2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

(在堆里生成)

以上是对于非数组类的加载过程;

对于数组类情况则有所不同,数组类本身不通过类加载器去生成,它是由Java虚拟机直接在堆中创建,

但数组类仍然与类加载器有很密切的关联,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终还是要靠类加载器来完成加载。

一个数组类型(c)遵循以下规则:

·如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型,注意和前面的元素类

型区分开来)是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组C将被标

识在加载该组件类型的类加载器的类名称空间上(这点很重要,在7.4节会介绍,一个类型必须与类加

载器一起确定唯一性)。

·如果数组的组件类型不是引用类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组C

标记为与引导类加载器关联。

·数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的

可访问性将默认为public,可被所有的类和接口访问到。

加载阶段结束后,虚拟机会在Java堆中实例化一个class对象,用于访问数据类型的外部接口入口。

验证

目的:确保class文件中的字节流包含的信息符合安全要求,能被正确使用且不会危害虚拟机。

验证包含以下四部分:

文件格式验证:

第一阶段要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶

段可能包括下面这些验证点:

·是否以魔数0xCAFEBABE开头。

·主、次版本号是否在当前Java虚拟机接受范围之内。

·常量池的常量中是否有不被支持的常量类型(检查常量tag标志)。

·指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。

·CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据。

·Class文件中各个部分及文件本身是否有被删除的或附加的其他信息。

这是是为了保证字节流能被正确的存储在方法区之内,验证基于二进制字节流进行。

元数据验证:

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

求,这个阶段可能包括的验证点如下:

·这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。

·这个类的父类是否继承了不允许被继承的类(被final修饰的类)。

·如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。

·类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方

法重载,例如方法参数都一致,但返回值类型却不同等)。

这个部分主要保证字节码语义正确。

字节码验证:

这部分目的是通过数据流分析和控制流分析,确定语义合法逻辑正确。

主要对类的方法体进行校验分析,确保不会对虚拟机做出伤害行为。

符号引用验证:

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在

连接的第三阶段——解析阶段中发生。符号引用验证可以看作是对类自身以外(常量池中的各种符号

引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部

类、方法、字段等资源。本阶段通常需要校验下列内容:

·符号引用中通过字符串描述的全限定名是否能找到对应的类。

·在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。

·符号引用中的类、字段、方法的可访问性(privateprotectedpublic<package>)是否可被当

前类访问。

目的是确保之后的解析行为能正常进行。

准备

准备阶段是为类中的静态变量(static)分配内存并设初始值的阶段,这里变量仅包括类变量,不包括实例变量,初始值也是零值,并不是程序员写的构造里的初始值。只是系统默认的零值。

解析

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

·符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何

形式的字面量,只要使用时能无歧义地定位到目标即可。(内存无关)

·直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能

间接定位到目标的句柄。(内存相关)

对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令以外,虚拟机实现可以对第一次解析的结果进行缓存。相应的,无论是否真正执行了多次解析动作,Java虚拟机都需要保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直能够成功;同样地,如果第一次解析失败了,其他指令对这个符号的解析请求也应该收到相同的异常,哪怕这个请求的符号在后来已成功加载进Java虚拟机内存之中。

对于invokedynamic指令,上面的结论不成立,它必须等到程序实际运行这条指令时,才可以进行解析动作。

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

类符号引用进行。

初始化

类初始化是类加载的最后一个步骤,在这个阶段Java虚拟机才开始真正执行类中的Java源代码,将主导权交给程序。

  进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通

过程序编码制定的主观计划去初始化类变量和其他资源。我们也可以从另外一种更直接的形式来表

达:初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。

·<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的

语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问

到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如下所示:

public class Test {

static {

i = 0; // 给变量复制可以正常编译通过

System.out.print(i); // 这句编译器会提示非法向前引用

}

static int i = 1;

}

  ·<clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显

式地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行

完毕。

  ·由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值

操作,如下,字段B的值将会是2而不是1

static class Parent {

public static int A = 1;

static {

A = 2;

}

}

static class Sub extends Parent {

public static int B = A;

}

  ·<clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的

赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

  ·接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成

<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,

因为只有当父接口中定义的变量被使用时,父接口才会被初始化。

  Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同

时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法。

类加载器

类与类加载器

对于任意一个类,它的唯一性必须由这个类本身和它的类加载器共同确定,比较两个类是否相等,只有在这两个类是由同一个加载器加载进来的情况下才有意义,否则,只要加载器不一样,那类必定不相等。

双亲委派模型

  站在虚拟机角度来看,加载器只分为启动类加载器其他所有加载器,但在程序员角度来看,应该分的更细一些:

·启动类加载器(Bootstrap Class Loader:前面已经介绍过,这个类加载器负责加载存放在

<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所指定的路径中存放的,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jartools.jar,名字不符合的类库即使放在lib目录中也不会被加载)类库加载到虚拟机的内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器去处理,那直接使用null代替即可,代码清单7-9展示的就是

java.lang.ClassLoader.getClassLoader()方法的代码片段,其中的注释和代码实现都明确地说明了以null值来代表引导类加载器的约定规则。

/**

Returns the class loader for the class. Some implementations may use null to represent the bootstrap class loader. This method will return null in such implementations if this class was loaded by the bootstrap class loader.

*/

public ClassLoader getClassLoader() {

ClassLoader cl = getClassLoader0();

if (cl == null)

return null;

SecurityManager sm = System.getSecurityManager();

if (sm != null) {

ClassLoader ccl = ClassLoader.getCallerClassLoader();

if (ccl != null && ccl != cl && !cl.isAncestor(ccl)) {

sm.checkPermission(SecurityConstants.GET_CLASSLOADER_PERMISSION);

}

}

return cl;

}

·扩展类加载器(Extension Class Loader:这个类加载器是在类sun.misc.Launcher$ExtClassLoader中以Java代码的形式实现的。它负责加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所

指定的路径中所有的类库。

·应用程序类加载器(Application Class Loader

它负责加载用户类路径(ClassPath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

此图是类加载器之间的双亲委派模型。

双亲委派模型要求除了顶层的类加载器之外,其余的类加载器都应该有自己的父类加载器。但这里的类加载器之间的父子关系并不是以继承为实现,而是用组合的形式。

  双亲委派模型工作过程

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。

  优点:Java类随着加载器而具备了一种带优先级的层级关系,因此Object类在程序的各类加载器环境中都可以保证是同一个类(与前面类的相同性判断关联),如果没有双亲委派模型,那可能无法保证某些重要的类是唯一的,可能会出现恶意代码。

双亲委派模型的破坏

第一次:

双亲委派模型的第一次被破坏其实发生在双亲委派模型出现之前——JDK 1.2面世以前的

时代。由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类

java.lang.ClassLoader则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免loadClass()被子类覆盖的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一个新的

protected方法findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在

loadClass()中编写代码。上节我们已经分析过loadClass()方法,双亲委派的具体逻辑就实现在这里面,

按照loadClass()方法的逻辑,如果父类加载失败,会自动调用自己的findClass()方法来完成加载,这样

既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。

第二次:

线程上下文类加载器,详见书第285页。

第三次:

双亲委派模型的第三次被破坏是由于用户对程序动态性的追求而导致的,这里所说的动态性指的是一些非常门的名词:代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。说

白了就是希望Java应用程序能像我们的电脑外设那样,接上鼠标、U盘,不用重启机器就能立即使用,

鼠标有问题或要升级就换个鼠标,不用关机也不用重启。对于个人电脑来说,重启一次其实没有什么大不了的,但对于一些生产系统来说,关机重启一次可能就要被列为生产事故,这种情况下热部署就对软件开发者,尤其是大型系统或企业级软件开发者具有很大的吸引力。

代表有OSGi模块化热部署等。

我个人认为还有第四次破坏:

JDK 9中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了

变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载,也许这可以算是对双亲委派的第四次破坏。

不再严格向上一级请求,也可以向下请求。

  • 19
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值