Java 虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这个过程被称作虚拟机的类加载机制。与那些在编译时需要进行连接的语言不同,在 Java 里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让 Java 语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为 Java 应用提供了极高的扩展性和灵活性,Java 天生可以动态扩展的语言特性就是依赖运行期动态扩展和动态连接这个特点实现的。
类加载的时机
一个类型从被加载到虚拟机内存中开始,到卸出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备和解析三个部分统称为连接(Linking)。这七个阶段的发生顺序如下图所示:
加载、验证、准备、初始化和卸载这五个阶段的顺序时确定的,类型的记载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这时为了支持 Java 的运行时绑定特性(也称为动态绑定或晚期绑定)。注意,这些阶段通常都是互相交叉的混合进行的,会在一个阶段执行的过程中调用、激活另一个阶段。
类加载的过程
加载
在加载阶段,Java 虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取这个类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。
验证
验证阶段的目的是确保 Class 文件的字节流中包含的信息符符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段是非常重要的,这个阶段是否严谨,直接决定了 Java 虚拟机是否能承受恶意代码的攻击,从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。
从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:
- 文件格式验证
- 元数据验证
- 字节码验证
- 符号引用验证
准备
准备阶段是正式为类型定义的变量(即静态变量,被 static 修饰的变量)分配内存并设置类变量初始值的阶段,从概念上讲,这些变量所使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域,在 JDK7 之前,HotSpot 使用永久代来实现方法区时,实现是完全符合这种概念的;而在 JDK8 之后,类变量会随着 Class 对象一起存放在 Java 堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了。
关于准备阶段,还有两个容易混淆的概念需要着重强调,首先这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象的初始化时随着对象一起分配在 Java 堆中。其次是这里所说的初始值“通常情况下”是数据类型的零值。
解析
解析阶段是 Java 虚拟机将常量池内的符号引用替换为直接引用的过程。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用进行。
初始化
前面介绍的几个加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由 Java 虚拟机来主导控制。直到初始化阶段,Java 虚拟机才真正开始执行类中编写的 Java 程序代码,将主导权移交给应用程序。
初始化阶段就是执行类构造器
() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{} 块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中智能访问定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态教育局快可以赋值,但是不能访问。 () 方法与类的构造函数不同,它不需要显示地调用父类构造器,Java 虚拟机会保证在子类地 () 方法执行前,父类的 () 方法已经执行完毕。因此在 Java 虚拟机中第一个被执行的 () 方法的类的类型肯定是 java.lang.Object。 由于父类的
() 方法先执行,也就意味着父类中定义的静态语句块要由于子类的变量赋值操作。 () 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 () 方法。 接口中不能使用静态语句块,但仍然由变量初始化的赋值操作,因此接口与类一样都会生成
() 方法。但接口与类不同的是,执行接口的 () 方法不需要先执行父接口的 () 方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的 () 方法。 Java 虚拟机必须保证一个类的
() 方法在多线程环境中被正确的加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的 () 方法,其他线程都需要阻塞等待,直到活动线程执行完毕 () 方法。
类加载器
Java 虚拟机设计团队有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”这个动作放到 Java 虚拟机外部去实现,以便让应用程序自己决定如何去获取所需类。实现这个动作的代码被称为“类加载器”(Class Loader)。
类与类加载器
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。
双亲委派模型
在 JDK9 之前,Java 应用都是由启动类加载器、扩展类加载器、应用程序类加载器这三类加载器互相配合来完成加载的,如果用户认为有必要,还可以加入自定义的类加载器来进行拓展,典型的如增加除了磁盘位置之外的 Class 文件来源,或者通过类加载器实现类的隔离、重载等功能。
双亲委派模型的工作过程是:如果一个类加载器收到了类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去完成加载。
类加载器之间的父子关系一般不是以集成(Inheritance)的关系实现的,而是通常使用组合(Composition)关系来复用父加载器的代码。
使用双亲委派模型来组织类加载器之间的关系,一个显而易见的好处就是 Java 中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载环境中都能够保证是同一个类。
Java 模块化系统
在 JDK9 中引入了 Java 模块系统(Java Platform Module Systemm, JPMS),它是为了能够实现模块化的关键目标——可配置的封装隔离机制。JDK9 的模块不仅仅像之前的 JAR 包那样只是简单地充当代码地容器,除了代码外,Java 的模块定义还包括以下内容:
依赖其他模块的列表。
导出的包列表,即其他模块可以使用的列表。
开放的包列表,即其他模块可反射访问模块的列表。
使用的服务了列表。
提供服务的实现列表。
为了保证兼容性,JDK9 并没有从根本上动摇从 JDK 1.2 以来运行了二十年之久的三层类加载结构以及双亲委派模型。但是为了模块化系统的顺利实施,模块下的类加载器仍然发生了一些应该被注意到的变动,主要包括以下几个方面。
首先,是扩展类加载器(Extension Class Loader)被平台类加载器(Platform Class Loader)取代。
其次,平台类加载器和应用程序类加载器都不再派生自 java.net.URLClassLoader。现在启动类加载器、平台类加载器、应用程序类加载器全部继承于 jdk.internal.loader.BuiltinClassLoader,在 BuiltinClassLoader 中实现了新的模块化架构下类如何从模块中加载的逻辑,以及模块中资源可访问行的处理。
另外,启动类加载器现在是 Java 虚拟机内部和 Java 类库共同协作实现的类加载器,尽管有了 BootClassLoader 这样的 Java 类,但为了与之前的代码保持兼容,所有在获取启动类加载器的场景(譬如 Object.class.getClassLoader())中仍然会返回 null 代替,而不会得到 BootClassLoader 的实例。
最后,JDK9 中虽然仍然维持着三层类加载器和双亲委派的架构,但类加载的委派关系也发生了变动。当平台及应用程序类加载器收到类加载请求,在委派给父加载器加载前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责那个模块的加载器完成加载。