Walt You - 行是知之始

《深入理解Java虚拟机:JVM高级特性与最佳实践--第二版》学习日志(三)Part 2:类加载机制

2018-06-10
 

Class文件的里一串的字节流,虚拟机是如何使用呢?


学习资料主要参考: 《深入理解Java虚拟机:JVM高级特性与最佳实践(第二版)》,作者:周志明



概述

虚拟机是如何加载这些Class文件呢?Class文件信息在进入虚拟机后发生了什么变化?

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

Java语言里,类型的加载、连接和初始化过程都是在程序运行期间完成的。


类加载的时机

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括: 加载、验证、准备、解析、初始化、使用、 卸载。其中验证、准备、解析3个步骤统称为连接。

虚拟机规范对加载没有进行强制约束。但是对于初始化阶段,进行了严格规定。有且只有以下5种情况,必须立即对类进行初始化。

  1. 遇到new、getstatic、putstatic、invokestatic这4条字节码指令时,如果类没有初始化,则需要先触发初始化。生成这4种指令的代码场景是:使用new实例化对象;读取或者设置一个类的静态字段;调用一个类的静态方法
  2. 使用java.lang.refelect包的方法对类进行反射调用的时候,如果类没有初始化,则需要先触发初始化
  3. 当初始化一个类的时候,发现其父类还没有初始化,则需要先出发其父类初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先初始化这个主类。
  5. 当使用JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putstatic、REF_invkeStatic的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则先需要触发其初始化。

类加载的过程

1. 加载

在这个过程,虚拟机需要完成以下3件事情:

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

对于第一点,虚拟机规范并没有规定从哪里来获取、怎么获取字节流,所以引出了各种各样的方式,例如:

  • 从Zip包获取,称为了jar、ear、war格式的基础
  • 从网络获取,最典型的就是Applet
  • 运行时计算生成,这种场景使用最多的就是动态代理技术
  • 有其他文件生成,如jsp
  • 从数据库中读取

对于非数组类,开发人员可以通过重写一个类加载器的loadClass方法,来控制字节流的获取方式。

但是对于数组类而言,它本身不通过类加载器创建,它是由虚拟机直接创建的。

2. 验证

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

验证阶段大致上会完成下面4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

1)文件格式验证

这个阶段要验证字节流是否符合Class文件格式,而且能被当前版本的虚拟机处理,这个阶段可能包含的验证点如下:

  • 是否以魔数0xCAFEBABA开头
  • 主次版本号是否在当前虚拟机处理范围之内
  • 常量池的常量中是否有不被支持的常量类型
  • ……

2)元数据验证

这个阶段对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求,这个阶段可能包含的验证点如下:

  • 这个类是否有父类
  • 这个类的父类是否继承了不允许被继承的类
  • 如果这个类不是抽象类,是否实现了其父类或者接口之中要求实现的所有方法
  • 类是否与父类产生矛盾
  • ……

3)字节码验证

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

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作
  • 保证跳转指令不会跳转到方法体之外的字节码指令上
  • ……

4)符号引用验证

这个阶段发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在链接的第三阶段(解析阶段)发生。

它主要是为了对类自身之外的信息进行匹配性校验,通过需要检验如下内容:

  • 符合引用中通过字符串描述的全限定名是否能找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  • 符号引用的类、字段、方法的访问性是否可被当前类访问
  • ……

如果无法通过符号引用验证,会抛出一个java.lang.IncompatibleClassChangeError 异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

3. 准备

准备阶段是正式为类变量(被static修饰的变量)分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。

这里所说的初始值,“通常情况”下都是数据类型的零值。

那什么时候是特殊情况呢?那就是被final修饰了的变量,它们在准备阶段就会被赋值。

4. 解析

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

这两个引用分别是什么呢?

  • 符号引用:用一组符号来描述所引用的目标,符号可以是任何形式的字面量。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
  • 直接引用:可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用与内存布局有关,而且如果有了直接引用,那么目标必定已经在内存中。

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

5. 初始化

类初始化阶段是类加载过程的最后一步。到了初始化阶段,才真正开始执行类中定义的Java程序代码。

从另一个角度来看,初始化阶段是执行类构造器()方法的过程。

以下是()方法的几个特点:

  • ()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(statis{}块)中的语句合并产生的。编译器收集的顺序是由语句在源文件中出现的顺序所决定的。也就是说静态语句块只能访问定义在静态语句块之前的变量。
  • ()方法与类的构造函数不同,它不需要显式的调用父类构造器,虚拟机会保证子类的()方法执行之前,父类的()方法已经执行完毕。
  • 由于父类的()方法先执行,那么父类的静态代码块也先于子类的变量赋值操作。
  • ()方法对于类和接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,编译器可以不为这个类生成()方法。
  • 接口中不能使用静态语句块,但仍然有变量参数的赋值操作,所以接口和类都会生成()方法。但是接口与类不同的是,执行接口的()方法不需要先执行父接口的()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时,也一样不会执行接口的()方法。
  • 虚拟机会保证一个类的()方法在多线程环境中被正确地加锁、同步。如果多个线程同时初始化一个类,那么只会有一个线程去执行()方法,其他线程都需要阻塞等待。

类加载器

我们把用来实现“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作的代码模块称为“类加载器”。

1. 类与类加载器

如果两个类“相等”,那么它们必然是由同一个类加载器加载的。

这里所谓的“相等”,包括代表类的Class对象的equals方法、isAssignableFrom方法、isInstance方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况。

2. 双亲委派模型

1)加载器分类

从虚拟机角度来讲,只存在两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader),这个类加载器有 C++ 实现
  • 所有其他的类加载器。这些类都由Java实现,独立于虚拟机外部,并且全都继承抽象类 java.lang.ClassLoader。

从Java开发人员角度看,类加载器可分为3种:

  • 启动类加载器:这个类加载器负责将存放在 < JAVA_HOME>/lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径总的,并且是虚拟机识别的类库加载到虚拟机内存。用户无法直接引用启动类加载器,但是如果想要使用,只需再自定义的加载器中,返回null即可。
  • 扩展类加载器(Extension ClassLodaer):这个加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载< JAVA_HOME>/lib/ext目录中的、或者被java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):这个类加载器由sun.misc.Launcher$AppClassLoader 实现。由于这个类加载器是ClassLoader中的getSystemClassLoader方法的返回值,所以一般成它为系统类加载器。它负责加载用户路径(ClassPath)上所指定的类库。开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下就会使用这个类加载器。

我们的应用程序都是由这3种类加载器相互配合进行加载的。它们的层级关系如下:

2)定义

参考上图,类加载器之间的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。(注: 谷歌翻译为家长代表模式,个人感觉这个名字更加准确。)

这些类之间的父子关系一般不会用继承(Inheritance)的关系来实现,而是使用组合(Composition)关系来复用父加载器的代码。

3)工作过程

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

4)优点

保证了Java类型体系中最基础的行为,比如类 java.lang.Object,无论哪个加载器加载它,都会最终委派给顶端的启动类加载器,这样子Object类在程序的各个类加载环境中都是同一个类。

3. 破坏双亲委派模型

双亲委派模型主要出现过3次较大规模的破坏。

1)JDK1.2 之前

JDK 1.2后,才引入了双亲委派模型,所以在此之后,推荐使用findClass方法,而不是loadClass。在loadClass方法的逻辑里,如果父类加载失败,则会调用自己的findClass方法来完成加载。

2)基础类无法回调用户代码

双亲委派模型很好的解决了各个类加载器的基础类统一问题,然而有些情况下,基础类需要回调用户代码(如JNDI服务),那该怎么办?

因此Java引入了线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader方法进行设置,如果创建线程时还未设置,它会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

通过使用线程上下文类加载器,父类加载器就可以请求子类加载器去完成类加载器的动作。

3)热部署、热替换

为了不重启程序,来完成代码热替换、模块热部署,出现了标准OSGi R4.2。

它规定了每个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。

在OSGi环境下,类加载器不再是双亲委派模型的树状结构,而是进一步发展为更加复杂的网状结构。当收到类加载请求时,OSGi将按照下面的顺序进行类搜索:

  1. 将以java.*开头的类委派给父类加载器加载
  2. 否则,将委派列表名单内的类委派给父类加载器
  3. 否则,将 Import 列表中的类委派给Export这个类的Bundle的类加载器加载
  4. 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载
  5. 否则,查找类是否在自己的Fragment Bundle 中,如果在,则委派给 Fragment Bundle的类加载器加载
  6. 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载
  7. 否则,类查找失败

Content