理解 JVM 中的类加载机制(读书笔记)
上篇文章中,我们介绍了 .class 文件的结构,.class 文件只是一个静态的文件,那 JVM 是加载 .class 文件是什么样的一个过程呢?这就涉及到 JVM 的类加载机制了,是本篇文章将要讲解的内容
所谓的 JVM 的类加载机制是指 JVM 把描述类的数据从 .class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是 JVM 的类加载机制
Java 语言中的加载、连接、初始化都是在运行期完成的,这样虽然对性能会有影响,但是却十分灵活。Java 语言的动态扩展性很强,其原因就是依赖于 Java 运行期动态加载和动态连接的特性,动态加载是指我们可以通过预定义的类加载器和自定义的类加载器在运行期从本地、网络或其他地方加载 .class 文件;动态连接是指在面向接口编程中,在运行时才会指定其实际的实现类
一. 类加载的时机
首先讲一下类的生命周期。
类的生命周期总共分为7个阶段:加载、验证、准备、解析、初始化、使用和卸载,如下图所示,其中验证、准备、解析三个步骤又可统称为连接。
life.png
加载、验证、准备、初始化和卸载五个步骤的顺序都是确定的,解析阶段在某些情况下有可能发生在初始化之后,这是为了支持 Java 语言的运行期绑定的特性。
在 JVM 虚拟机规范中并没有规定加载的时机,但是却规定了初始化的时机,而加载、验证、准备三个步骤是在初始化之前。有以下五种情况需要必须立即对类进行初始化
- 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令最常见的 Java 代码场景是:使用 new 关键字实例化对象、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入到常量池的静态字段除外)以及调用一个类的静态方法的时候
- 使用 java.lang.reflect 包的方法对类进行反射调用的时候
- 当初始化一个类的时候,如果发现其父类还没有被初始化过,则需要先触发其父类的初始化
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的类),虚拟机会先初始化这个主类
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invodeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化(说实话,这句话我也没理解。。)
主动引用:上面这五种行为称为对一个类的的主动引用,会触发类的初始化
被动引用:除上面五种主动引用之外,其他引用类的方式都不会触发类的初始化,称为类的被动引用
下面举几个被动引用的例子:
1.1 被动引用示例一
首先看段代码,如下所示:
package com.lijiankun24.classpractice;
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 24;
}
public class SubClass extends SuperClass {
static {
System.out.println("SubClass init!");
}
}
public class Demo {
public static void main(String[] args){
System.out.println("The value is " + Subclass.value);
}
}
上面代码运行之后输出结果如下所示
SuperClass init!
The value is 24
对于静态字段,只有直接定义这个字段的类会被初始化,如果是通过子类引用父类的字段,父类会被初始化,子类不一定会被初始化,子类会不会被初始化 JVM 虚拟机规范并没有明确规定,取决于虚拟机的具体实现
1.2 被动引用示例二
有如下代码
public class SubClass {
static {
System.out.println("SubClass init!");
}
}
public class Demo {
public static void main(String[] args){
SubClass[] subClassArray = new SubClass[10];
}
}
上面代码运行之后,并不会输出 "SubClass init!",因为在上面 Demo#main() 方法中,并没有初始化 SubClass 类,而是初始化了一个 SubClass[] 数组类,SubClass[] 数组类代表了一个元素类型为 SubClass 的一维数组,继承自 Object 类,由 newarray 字节码创建。
1.3 被动引用示例三
public class Constant {
static {
System.out.println("Constant init!");
}
public static final String VALUE = "Hello World!";
}
public class Demo {
public static void main(String[] args){
System.out.println(Constant.VALUE);
}
}
上面代码运行之后也并不会输出 "Constant init!",因为这涉及到一个概念 ---- “常量传播优化”。虽然在代码中,Demo 类引用了 Constant 类中的常量 VALUE,但是在编译阶段,会将 VALUE 的实际值 "Hello World!" 放到 Demo 类中的常量池中,Demo 类每次使用 "Hello World!" 常量的时候都会从自己的常量池中去找。Demo 类不会持有 Constant 类的符号引用,所以 Constant 类也并不会被初始化。
二. 类加载的过程
2.1. 加载
在加载阶段有三个步骤:
- 通过一个类的全限定名获取定义此类的二进制字节流
- 将二进制字节流所代表的静态存储结构转换为方法区中的运行时数据结构
- 在内存中生成一个代表此类的 java.lang.Class 的对象,作为方法区中这个类的访问入口
在这个阶段,有两点需要注意:
- 并没有规定从哪里获取二进制字节流。我们可以从 .class 静态存储文件中获取,也可以从 zip、jar 等包中读取,可以从数据库中读取,也可以从网络中获取,甚至我们自己可以在运行时自动生成。
- 在内存中实例化一个代表此类的 java.lang.Class 对象之后,并没有规定此 Class 对象是方法 Java 堆中的,有些虚拟机就会将 Class 对象放到方法区中,比如 HotSpot。
2.2 验证
验证是连接阶段的第一个步骤,验证的目的是为了确保 .class 文件中的字节流所包含的信息是符合当前虚拟机的要求,并且不会危害到虚拟机自身的安全的。
验证主要包括四个方面的验证:文件格式验证、元数据验证、字节码验证和符号引用验证。
- 文件格式验证:主要验证二进制字节流数据是否符合 .class 文件的规范,并且该 .class 文件是否在本虚拟机的处理范围之内(版本号验证)。只有通过了文件格式的验证之后,二进制的字节流才会进入到内存中的方法区进行存储。而且只有通过了文件格式验证之后,才会进行后面三个验证,后面三个验证都是基于方法区中的存储结构进行的
- 元数据验证:主要是对类的元数据信息进行语义检查,保证不存在不符合 Java 语义规范的元数据信息
- 字节码验证:字节码验证是整个验证中最复杂的一个过程,在元数据验证中,验证了元数据信息中的数据类型做完校验后,字节码验证主要对类的方法体进行校验分析,保证被校验的类的方法不会做出危害虚拟机的行为
- 符号引用验证:符号引用验证发生在连接的第三个阶段解析阶段中,主要是保证解析过程可以正确地执行。符号引用验证是类本身引用的其他类的验证,包括:通过一个类的全限定名是否可以找到对应的类,访问的其他类中的字段和方法是否存在,并且访问性是否合适等
2.3 准备
在准备阶段所做的工作就是,在方法区中为类 Class 对象的类变量分配内存并初始化类变量,有三点需要注意:
- 在方法区中分配内存的只有类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会跟随着对象在 Java 堆中为其分配内存
- 初始化类变量的时候,是将类变量初始化为其类型对应的 0 值,比如有如下类变量,在准备阶段完成之后,val 的值是 0 而不是 123,为 val 复制为 123,是在后面要讲的初始化阶段之后
public static int val = 123;
- 对于常量,其对应的值会在编译阶段就存储在字段表的 ConstantValue 属性当中,所以在准备阶段结束之后,常量的值就是 ConstantValue 所指定的值了,比如如下,在准备阶段结束之后,val 的值就是 123 了。
public static final int val = 123;
2.4 解析
解析是将符号引用解析为直接引用的过程,符号引用是指在 .class 常量池中存储的 CONSTANT_Class_info、CONSTANT_Fieldref_info 等常量,直接引用则是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄,如果有了直接引用,那引用的目标必定已经在内存中了。对于解析有以下 3 点需要注意:
- 虚拟机规范中并未规定解析阶段发生的具体时间,只规定了在执行newarray、new、putfidle、putstatic、getfield、getstatic 等 16 个指令之前,对它们所使用的符号引用进行解析。所以虚拟机可以在类被加载器加载之后就进行解析,也可以在执行这几个指令之前才进行解析
- 对同一个符号引用进行多次解析是很常见的事,除 invokedynamic 指令以外,虚拟机实现可以对第一次解析的结果进行缓存,以后解析相同的符号引用时,只要取缓存的结果就可以了
- 解析动作主要对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行解析
2.5 初始化
类的初始化阶段才是真正开始执行类中定义的 Java 程序代码。初始化说白了就是调用类构造器 <clinit>() 的过程,在类的构造器中会为类变量初始化定义的值,会执行静态代码块中的内容。下面将介绍几点和开发者关系较为紧密的注意点
- 类构造器 <clinit>() 是由编译器自动收集类中出现的类变量、静态代码块中的语句合并产生的,收集的顺序是在源文件中出现的顺序决定的,静态代码块可以访问出现在静态代码块之前的类变量,出现的静态代码块之后的类变量,只可以赋值,但是不能访问,比如如下代码
public class Demo { private static String before = "before"; static { after = "after"; // 赋值合法 System.out.println(before); // 访问合法,因为出现在 static{} 之前 System.out.println(after); // 访问不合法,因为出现在 static{} 之后 } private static String after; }
- <clinit>() 类构造器和<init>()实例构造器不同,类构造器不需要显示的父类的类构造,在子类的类构造器调用之前,会自动的调用父类的类构造器。因此虚拟机中第一个被调用的 <clinit>() 方法是 java.lang.Object 的类构造器
- 由于父类的类构造器优先于子类的类构造器执行,所以父类中的 static{} 代码块也优先于子类的 static{} 执行
- 类构造器<clinit>() 对于类来说并不是必需的,如果一个类中没有类变量,也没有 static{},那这个类不会有类构造器 <clinit>()
- 接口中不能有 static{},但是接口中也可以有类变量,所以接口中也可以有类构造器 <clinit>{},但是接口的类构造器和类的类构造器有所不同,接口在调用类构造器的时候,如果不需要,不用调用父接口的类构造器,除非用到了父接口中的类变量,接口的实现类在初始化的时候也不会调用接口的类构造器
- 虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的类构造器 <clinit>(),其他线程会被阻塞,直到活动线程执行完类构造器 <clinit>() 方法
三. 类加载器
关于类加载器,我们带着几个问题去学习,什么是类加载器,类加载器分为哪几类,提到 JVM 类加载器就会提到双亲委派模型,双亲委派模型有什么好处呢?我们就一一来解答这几个问题
3.1 类加载器
类加载器是完成"通过一个类的全限定名获取这个类的二进制字节流"的工作,类加载器是独立于虚拟机之外存在的。
对于每一个类,都需要加载这个类的类加载器和类本身来确认这个类在 JVM 虚拟机中的唯一性,每个类加载器都有独立的类名称空间。换句话说,比较两个类是否“相等”,必须是在这两个类是由同一个类加载器加载的前提下比较,如果两个类是由不同的类加载器加载的,即使它们两个来自于同一个 .class 文件,那这两个类也是不同的。
3.2 类加载器的分类
从不同的角度看,加载器可以有不同的分类方式。
- 从 Java 虚拟机角度来看呢,存在两种不同的类加载器
- 一种是启动类加载器(Bootstrap ClassLoader),这个类是由 C++ 语言实现的, 是虚拟机本身的一部分
- 另一种是除了启动类加载器之外,所有的其他类加载器,是由 Java 语言实现的,是独立于 Java 虚拟机之外的,并且全部继承自抽象类 java.lang.ClassLoader
- 从 Java 开发人员的角度来看的,可以分为以下 3 种类加载器
- 启动类加载器(Bootstrap ClassLoader):加载 <JAVA_HOME>\lib 目录下和 -Xbootclasspath 参数所指定的可以被虚拟机识别的类库到内存中
- 扩展类加载器(Extension ClassLoader):加载 <JAVA_HOME>\lib\ext 目录中的和 java.ext.dirs 系统变量所指定的路径中的类库加载到内存中
- 应用程序类加载器(Application ClassLoader):加载用户类路径上所指定的类库,开发者可以直接使用这个类加载器,是默认的类加载器
我们的应用程序就是由上面三种类加载器相互配合被加载进来的,如果有必要可以自定义类加载器
3.3 双亲委派模型
对于上面提到的 3 中类加载器,他们有如下图所示的关系
classloader.png
对于上图所示的这种关系呢,我们就称之类类加载器的双亲委派模型。在双亲委派模型中,除了顶层的 Bootstrap ClassLoader 之外,其他的类加载器都有自己的父加载器。
双亲委派模型的工作流程是这样的,如果一个类加载器收到了一个加载类的请求,会首先把这个请求委派给自己的父加载器去加载,这样的所有的类加载请求都会向上传递到 Bootstrap ClassLoader 中去,只有当父类加载器无法完成这个类加载请求时,才会让子类加载器去处理这个请求。
使用双亲委派模型的好处就是,被加载到虚拟机中的类会随着加载他们的类加载器有一种优先级的层次关系。比如,开发者自定义了一个 java.lang.Object 的类,但是你会发现,自定义的 java.lang.Object 永远无法被调用,因为在使用自定义的类加载器去加载这个类的时候,自定义的类加载器会将加载请求传递到 Bootstrap ClassLoader 中去,在 Bootstrap ClassLoader 中会从 rt.jar 中加载 Java 本身自带的 Java.lang.Object,这个时候加载请求已经完成,找到了这个类,就不需要自定义的 ClassLoader 去加载用户路径下的 java.lang.Object 这个类了。
双亲委派模型对于 Java 程序的稳定运行十分重要,实现却非常简单
- 首先会判断是否已经加载过此类了,如果已经加载过就不用再加载了
- 如果没有加载过,则调用父类加载器去加载
- 若父类加载器为空,则默认使用启动类加载器作为父类加载器加载
- 若父类加载器加载未能成功会抛出 ClassNotFoundException 的异常
- 再调用自己的 findClass() 方法进行加载
如下代码所示:
protected synchronized Class<?> loadClass(String name, boolean resolve) throw ClassNotFoundException {
Class c = findLoadedClass(name);
if(c == null){
try{
if(parent != null){
c = parent.loadClass(name, resolve);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e){
}
if(c == null){
c = findClass(name);
}
}
if(resolve){
resolveClass(c);
}
return c;
}
写在最后,在这篇文章中,我们对类的生命周期中的各个阶段进行了详细的介绍,并且对类的加载器及双亲委派模型进行了详细的介绍。上篇文章介绍了 .class 文件中的内容,这篇文章介绍了 Java 虚拟机怎么加载 .class 文件,并且介绍了类的生命周期,下篇文章将会介绍在 Java 虚拟机中字节码是怎么执行的。