概述

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

Java语言中,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让Java语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但却为了Java应用提供了极高的扩展性和灵活性。

两个约定:

  • 实际情况中,每个Class文件都有代表着Java语言中的一个类或接口的可能,后文中直接对“类型”的描述都同时蕴含着类和接口的可能性。
  • 本章提到的Class文件也并非特指某个具体存在于具体磁盘中的文件,而是一串二进制字节流,无论其以何种形式存在,包括但不限于磁盘文件、网络、数据库、内存和动态生成等。

类加载的时机

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

类的生命周期

image-20200515152606704.png

类型的加载过程必须按加载、验证、准备、初始化和卸载这五个阶段开始(这些阶段通常是互相交叉的混合运行,在一个阶段执行的过程中调用另一个阶段),但解析阶段不一定:它在某些情况下可以在初始化阶段后再开始,这是为了支持Java语言的运行时绑定特性。

《Java虚拟机规范》严格规定了六种情况必须立即对类进行初始化(加载等步骤在此前开始):

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,若类型没有进行初始化,则要先触发其初始化阶段。生成这四条指令的场景有:
    • 使用new关键字实例化对象。
    • 读取或设置一个类型的静态字段的时候(被final修饰、在编译期就把结果放入常量池的静态类型除外)。
    • 调用一个类型的静态方法的时候。
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果没有类型进行初始化,则需要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户指定一个要运行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口就要在其之前被初始化。

这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
class SuperClass {
static {
System.out.println("superclass");
}
public static int value = 123;
}
class SubClass extends SuperClass {
static {
System.out.println("subclass");
}
}

这段代码运行后,只会输出”superclass“。对于静态字段, 只有直接定义这个字段的类才会被初始化,子类引用父类的静态字段,只会触发父类的初始化而子类不会初始化。子类是否加载和验证,取决于虚拟机实现。

1
2
3
4
5
public class NotInitialization {
public static void main(String[] args) {
SuperClass[] sca = new SuperClass[10];
}
}

运行发现什么也没输出,说明没有初始化SuperClass,但这段代码触发了另一个名为[LSuperClass类的初始化,它是虚拟机自动生成的,直接继承Object的子类,创建动作由字节码指令newarray触发。

这个类代表一个元素类型为SuperClass的一维数组,数组应有的属性和方法(用户可直接使用的只有被public修饰的length属性和clone()方法)都在这个类里。Java对数组访问比较安全,很大程度上因为这个类包装了数组元素的访问。

1
2
3
4
5
6
7
8
9
10
11
class ConstClass {
static {
System.out.println("constclass");
}
public static final String HELLOWORLF = "hello world";
}
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLOWORLF);
}
}

输出“hello world”,因为虽然在Java源码中确实引用了ConstClass类的HELLOWORLD,但其实编译阶段通过常量传播优化,已经将此常量的值“hello world”存储在NotInitialization类的常量池中,以后NotInitialization对常量HELLOWORLD的引用,实际上都是对自身常量池的引用,这两个类在编译成class文件后就没有任何联系了。

反编译后的类

image-20200515193845010

接口的加载过程与类加载稍有不同,接口不能用static{}语句块,但编译器仍然会为接口生成<clinit>()类构造器,用于初始化接口定义的成员变量,但接口与类不同的地方是,接口在初始化时,不要求其父接口全部完成初始化,只有真正使用到父接口时,才会初始化。

类加载的过程

加载

加载阶段是整个类加载过程的一个阶段,在加载阶段,虚拟机需要完成三件事情:

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

相对于类加载过程的其他阶段,非数组类型的加载阶段是开发人员可控性最强的阶段。加载阶段既可以使用Java虚拟机里内置的引导类加载器完成,也可以使用用户自定义的类加载器去完成。

数组类则不同,数组类本身不通过类加载器创建,它是由Java虚拟机在内存中动态构造出来的。数组类与类加载器有密切关系,因为数组的元素最终还要类加载器来完成加载。一个数组类(简称C)的创建过程遵循以下规则:

  • 如果数组的组件类型是引用类型,那就递归采用本节定义的加载过程去加载这个组件类型,数组C将被标识在加载该组件类型的类加载器的类名称空间上。
  • 如果数组的组件类型不是引用类型,Java虚拟机将会把数组C标记为与引导类加载器关联。
  • 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问。

加载结束后,Java虚拟机外部的二进制字节流就按照虚拟机设定的格式存储在方法区中了。类型数据妥善安置在方法区后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中类型数据的外部接口。

加载阶段与连接阶段的部分动作(如一部分字节码文件格式的验证动作)是交叉进行的。

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流包含的信息符合《Java虚拟机规范》的约束,确保这些信息被当作代码运行后不会危害虚拟机自身的安全。

Java语言相对安全,纯粹的Java代码无法做到将对象转型为未实现的类型或访问数组边界外的区域,但使用字节码是可以实现的,所以验证字节码是Java虚拟机保护自身的一项必要措施。

验证阶段很重要,这个阶段是否严谨,直接决定了Java虚拟机能否承受恶意代码的攻击。

验证阶段大致分为以下四个阶段的检验动作:文件格式验证、元数据验证、字节码验证和符号引用验证:

文件格式验证

验证字节流是否符合Class文件格式的规范,并能被当前版本虚拟机处理。比如:是否以0xCAFEBABE开头,主次版本号是否在当前虚拟机接受范围内。只有通过这个阶段验证后,字节流才允许进入Java虚拟机内存的方法区进行存储,后面三个验证阶段都是基于方法区的存储结构上进行的,不会再读取、操作字节流了。

元数据验证

第二阶段是对字节码描述的信息进行语义分析,保证其描述的信息符合《Java语言规范》的要求。比如验证是否有父类,父类是否继承了不允许被继承的类等等。

字节码验证

第三阶段是整个验证过程最复杂的一个阶段。主要目的是通过数据流的分析和控制流分析,确定程序语义是合法的、符合逻辑的。对元数据信息的数据类型校验完毕后,就要对类的方法体(Class文件的Code属性)进行校验分析。由于数据流分析和控制流分析的复杂性,为了避免执行时间消耗在字节码验证阶段 ,在jdk6后的javac编译器和Java虚拟机里进行了联合优化,把尽可能多的校验辅助措施挪到javac编译器里进行。

符号引用验证

最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化在解析阶段发生。符号引用验证可以看作是对类自身以外(常量池的各种符号引用)的各类信息进行匹配性校验,比如校验符号引用中通过字符串描述的全限定名能否找到指定的类等等。

验证阶段对于虚拟机的类加载机制来说,是非常重要,但不是必须执行的阶段。只要通过验证,其后就对程序运行期没有影响了。如果代码被反复使用和验证过,那么就可以考虑使用-Xverify:none参数来关闭大部分的验证措施,缩短虚拟机类加载时间。

准备

准备阶段是正式为定义的变量(即静态变量,被static修饰的变量)分配内存并设置类变量初始值的阶段。

在准备阶段,进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

另外,这里说的初始值“通常情况”下是数据结构的零值,比如一个类变量定义为public static int value = 123; ,那么变量value在准备阶段后的初始值为0而不是123,因为这时尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法中,所以把value赋值为123的动作要到类的初始化阶段才会被执行。

基本数据类型的零值
数据类型 零值 数据类型 零值
int 0 boolean false
long 0L float 0.0f
short (short)0 double 0.0d
char ‘\u0000’ reference null
byte (byte)0

上面提到”通常情况“是零值,那就有特殊情况,比如类字段的字段属性表存在ConstantValue属性,那在准备阶段变量值就会被初始化为ConstantValue属性所指定的初始值,比如public static final int value = 123;编译时javac将会为value生成ConstantValue属性,虚拟机就会根据ConstantValue的设置将value赋值为123。

解析

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

  • 符号引用:符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量。符号引用与虚拟机的内存布局无关,引用的目标不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但他们所能接收的符号引用必须是一致的。
  • 直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄,直接引用和虚拟机实现的内存布局直接相关,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。

《Java虚拟机规范》中没有定义解析阶段发生的时间,只要求在执行某些用于操作符号引用的字节码指令前,先对它们所使用的符号引用进行解析。对方法或者字段的访问,也会在解析阶段对它们的可访问性(public、private等)进行检查。

对同一个符号引用进行多次解析请求是很常见的,除invokedynamic外,虚拟机实现可以对第一次解析的结果进行缓存,比如运行时直接引用常量池的记录,并把常量标识为已解析状态,避免重复解析。不过对于invokedynamic指令,就不会这样了,invokedynamic指令的目的本来就是用于动态语言支持,它对应的引用称为“动态调用点限定符”,这里动态的含义是指必须等到程序实际运行这条指令时,解析动作才执行。其他的可能刚刚完成加载阶段,就提前解析。

初始化

类的初始化阶段是类加载过程最后一个步骤。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权交给应用程序。

进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,才开始初始化类变量和其他资源。初始化阶段就是执行类构造器<clinit>()方法的过程。<clinit>()并不是程序员在Java代码中直接编写的方法,它是javac编译器的自动生成物。

  • <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问。
  • <clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显式的调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个执行的<clinit>()方法的类型肯定是java.lang.Object。
  • 由于父类的<clinit>()先执行,也就意味着父类中定义的静态语句块要优于子类的变量赋值操作。
  • <clinit>()方法对于类或接口来说不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  • 接口不能有静态语句块,但有变量初始化的赋值操作,所以也有<clinit>()方法。但接口执行<clinit>()时不用先执行父接口的<clinit>()方法。父接口定义的变量被使用时,父接口才被初始化。
  • Java虚拟机保证一个类的<clinit>()在多线程环境被正确的加锁同步。

类加载器

类和类加载器

对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。比较两个类是否相等(比如equals()、isInstance()、instanceof等),只有这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这两个类必不相等。

双亲委派模型

在虚拟机角度看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个加载器使用C++实现,是虚拟机自身一部分;另一个是其他所有类的加载器,这些类加载器由Java实现,继承自Object。

在开发人员角度看,类加载器就应当划分的更细致一些。自JDK1.2来,Java一直保持这三层类加载器、双亲委派的类加载架构。

  • 启动类加载器:这个类加载器负责加载存放在\lib目录,或被-Xbootclasspath指定的路径,而且是Java虚拟机能够识别的(按照文件名识别,如rt.jar、tools.jar等)类库加载到虚拟机内存。
  • 扩展类加载器(Extension Class Loader):这个类加载器是在类sun.misc.Launcher$ExtClassLoader中实现的,负责加载jre/lib/ext的类库。允许用户将具有通用性的类库放置在ext目录里以扩展JavaSE的功能。
  • 应用程序类加载器(Application Class Loader):也叫系统类加载器,负责加载用户类路径下的所有类库,开发者统一也可以直接在代码中使用这个类加载器。

JDK9之前的Java应用都是这三类加载器互相配合完成加载的,用户还可以加入自定义的类加载器,如增加除了磁盘位置外的Class文件来源,或通过类加载器实现类的隔离、重载等功能。

image-20200516220011082.png

双亲委派模型除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。这里的父子关系是用组合关系来复用。

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

好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,无论哪个加载器要加载这个类,都要先让最顶端的加载器去尝试加载这个类,因此Object类在各种类加载器环境中能保证是同一个类。

破坏双亲委派模型

第一次破坏:

由于双亲委派模型是在JDK1.2之后才被引入的,而类加载器和抽象类java.lang.ClassLoader则在JDK1.0时代就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java设计者引入双亲委派模型时不得不做出一些妥协。JDK1.2之后的ClassLoader添加了一个新的findClass方法,建议用户自定义类加载器时使用findClass方法,按loadClass的逻辑,如果父类加载失败,就会自动调用自己的findClass方法来完成加载,这样既不影响用户按自己的意愿加载类,又可以保证新的类加载器符合双亲委派规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
Class<?> c = findLoadedClass(name);//首先检查类是否被加载过。
if (c == null) {
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {//父类加载器无法加载
}

if (c == null) {//若父类加载器无法加载时,再调用本身的findClass来进行类加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}

第二次破坏:

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷所导致的,双亲委派很好地解决了各个类加载器的基础类的同一问题(越基础的类由越上层的加载器进行加载),基础类之所以称为“基础”,是因为它们总是作为被用户代码调用的API,但世事往往没有绝对的完美。

原生的JDBC中Driver驱动本身只是一个接口,并没有具体的实现,具体的实现是由不同数据库类型去实现的。例如,MySQL的mysql-connector-*.jar中的Driver类具体实现的。 原生的JDBC中的类是放在rt.jar包的,是由启动类加载器进行类加载的,在JDBC中的Driver类中需要动态去加载不同数据库类型的Driver类,而mysql-connector-*.jar中的Driver类是用户自己写的代码,那启动类加载器肯定是不能进行加载的,既然是自己编写的代码,那就需要由应用程序启动类去进行类加载。于是乎,这个时候就引入线程上下文件类加载器(Thread Context ClassLoader)。有了这个东西之后,程序就可以把原本需要由启动类加载器进行加载的类,由应用程序类加载器去进行加载了。

1
2
3
4
5
6
7
8
9
10
private static Connection getConnection(
String url, java.util.Properties info, Class<?> caller) throws SQLException {
//callerCL为空的时候,其实说明这个ClassLoader是启动类加载器,但是这个启动类加载并不能识别rt.jar之外的类,这个时候就把callerCL赋值为Thread.currentThread().getContextClassLoader();也就是应用程序启动类
ClassLoader callerCL = caller != null ? caller.getClassLoader() : null;
synchronized(DriverManager.class) {
if (callerCL == null) {
callerCL = Thread.currentThread().getContextClassLoader();
}
}...
}

第三次破坏:

双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求导致的,这里所说的“动态性”指的是当前一些非常“热门”的名词:代码热替换、模块热部署等,简答的说就是机器不用重启,只要部署上就能用。
OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(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)否则,类加载器失败。