类加载过程与类加载器

类加载过程

  类从被加载到虚拟机内存开始,到卸载出内存位置,它的整个生命周期包括以下 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)其中验证、准备、解析 3个部分统称为链接(Linking)

图1 类的生命周期

加载

  在加载阶段,虚拟机要完成以下 3 件事情:

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

  虚拟机的这三点要求并不算具体,因此虚拟机实现与具体应用的灵活度都相当大。例如第一条,它根本没有指明要从哪里获取、怎样获取,许多 Java 技术都是建立在这一基础之上的,例如:

  • 从 zip 包中读取,这很常见,最终成为日后 jar、ear、war 格式的基础。
  • 从网络中获取,典型的应用就是 Applet。
  • 运行时计算生成,这种场景使用的最多的就是动态代理技术
  • 由其他文件生成,典型场景就是 JSP, 由 JSP 文件生成对应的 Class 类。

验证

  加载阶段与连接阶段的部分内容(如验证)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这两个阶段的开始时间仍然保持着固定的先后顺序。

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

  整体上看,验证阶段大致分为 4 个阶段的校验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

文件格式验证

  第一阶段主要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,这一阶段可能包括下面这些严重点:

  • 是否以魔术 0xCAFEBABY 开头。
  • 主、次版本号是否在当前虚拟机处理范围之内。
  • 常量池中是否有不被支持的常量类型(检查常量tag标志)。
元数据验证

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

  • 这个类是否有父类(除了 Object 类外,所有类都应当有父类)
  • 这个类的父类书否继承了不被允许继承的类(被 final 修饰的类)
  • 如果这个类不是抽象类,是否实现类其父类或接口之中要求实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者不符合规则的方法重载,例如方法参数都一致,返回类型却不同)
字节码验证

  第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定语义是合法的、符合逻辑的。这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件,例如:

  • 保证任意时刻操作数栈的数据类型与指令码序列都能配合工作,例如不会出现类似这样的情况:在操作栈放置了一个 int 类型的数据,使用时却按 long 类型来加载到本地变量表中。
  • 保证跳转指令不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换是有效的,例如可以把一个子类对象赋值给父类数据类型,但不能反过来或者把对象赋值给与它毫无关联的一个数据类型。
符合引用验证

  最后一个阶段的发生在虚拟机将符合引用转化为直接引用的时候,这个转化动作将在解析阶段中发生。符号引用可以看做是对类自身以外(常量池中各种符号引用)的信息进行匹配性校验,通常需要校验以下内容:

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

准备

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

  这里所说的初始值通常情况下是数据类型的零值,假设一个类变量的定义为 public static int value = 123; ,那变量 value 在准备阶段过后的初始值为 0 而不是 123,因为这时候尚未开始执行任何 Java 代码, 而把 value 赋值为 123 的 putstatic 指令时程序被编译后,存在于 类构造器 <client>() 方法之中,所以把 value 赋值为 123 的动作将在初始化阶段才会执行。

  但假设类的字段属性表中存在 ConstabtValue 属性,那么在准备阶段变量 value 就会被初始化为 ConstabtValue 属性所指定的值,假设类变量的定义变为:public static final int value = 123;,那么在准备阶段 value 的值就会被设置为 123。

解析

  解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。符号引用在这一篇Class文件结构——常量池中有具体讲解过,那解析过程中的直接引用和符号引用又有什么关联呢?

  • 符号引用: 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在 Java虚拟机规范的 Class 文件格式中。
  • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。

  虚拟机规范之中并未规定解析阶段发送的具体时间,只要求了在执行 anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfield 和 putstatic 这 16 个用于操作符号引用的字节码指令之前,先对它们所使用的符号进行解析。所以虚拟机实现可以根据需要来判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用时才去解析它。

  对同一个符号引用进行多次解析请求也是很常见的事,出 invokedynamic 指令外,虚拟机可以实现对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标示为已解析状态)从而避免解析动作重复执行。无论是否真正执行了多次解析动作,虚拟机要求保证的是在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析就应当一致成功;同样的,如果第一次解析失败了,那么其他指令对这个符号的解析请求也应该收到相同的异常。

类或接口的解析

  假设当前代码所处的类为 D ,如果要把一个从未解析过的符号引用 N 解析为一个类或接口 C 的直接引用,那虚拟机完成解析的过程需要以下 3 个步骤。

  1. 如果 C 不是一个数组类型,那虚拟机将会把代表 N 的全限定名传递给 D 的类加载器去加载这个类 C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
  2. 如果 C 是一个数组类型,并且数组里的元素类型为对象,也就是 N 的描述符会是类似“[Ljava/lang/Integer” 的形式,那将会按照第一点的规则加载数组元素类型,接着有虚拟机生成一个代表此数组和元素的数组对象。
  3. 如果上面的步骤没有出现任何异常,那么 C 在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认 D 是否具有对 C 的访问权限,如果不具备,将抛出 IllegalAccessError 异常。
字段解析

  要解析一个未被解析过的字段符号引用,首先会对字段所属的类或接口的符号引用进行解析,如果解析过程中出现异常,那么字段解析失败。如果解析成功完成,那将这个字段所属的类或接口用 C 表示,之后将按以下步骤进行:

  1. 如果 C 本身就包含了简单名称和字段描述符都与之目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  2. 否则,如果在 C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与之目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  3. 否则,如果 C 不是 java.lang.Object 的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与之目标相匹配的字段,则返回这个字段的直接引用,查找结束。
  4. 否则,查找失败,抛出 NoSuchFieldError 异常。
  5. 如果查找过程中成功返回了引用,也将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出 IllegalAccessError异常。
类方法解析

  不是所有类方法都会在此刻进行解析,只有这种方法才会被解析:方法在程序运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析。符合“编译期可知,运行期不可变”这个要求的方法有静态方法、私有方法、构造方法、父类方法、final 修饰的方法。解析调用一定是个静态的过程,在编译期间就完全确定。

  关于运行时解析,请看此文章的动态分派部分 动态分派,这里不再说明,以下是静态解析的过程:

  第一个步骤与字段解析一样,也需要先解析出其类或者接口的符号引用,如果解析成功,依然用 C 来表示这个类,剩余步骤如下:

  1. 类方法和接口方法符号引用的常量类型定义是分开的,如果发现 C 是个接口,直接抛出 IncompatibleClassChangeError 异常。
  2. 在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回和这个方法的直接引用,查找结束。
  3. 否则,在类 C 的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回和这个方法的直接引用,查找结束。
  4. 否则,在类 C 实现的接口列表及它们的父接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则说明 C 是一个抽象类(只有抽象类才能不实现接口中的方法),这时查找结束,抛出AbstractMethodError 异常。
  5. 否则,查找失败,抛出 NoSuchFieldError 异常。
  6. 如果查找过程中成功返回了引用,也将会对这个方法进行权限验证,如果发现不具备对字段的访问权限,将抛出 IllegalAccessError异常。
接口方法解析

  同样地,也需要先解析出其类或者接口的符号引用,如果解析成功,依然用 C 来表示这个接口,剩余步骤如下:

  1. 与类方法解析相反,如果发现 C 是个类,直接抛出 IncompatibleClassChangeError 异常。
  2. 否则,就在 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回和这个方法的直接引用,查找结束。
  3. 否则,在 C 的父接口中递归查找,直到 Obejct 类(查找范围会包括 Object 类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回和这个方法的直接引用,查找结束。
  4. 否则,查找失败,抛出 NoSuchFieldError 异常。
  5. 由于接口中的所有方法默认都是 public 的,所以不存在访问权限的问题,一次接口方法的符号解析应当不会抛出 IllegalAccessError异常。

初始化

  到了初始化阶段,才真正开始执行类中定义的 Java 程序代码。在准备阶段,变量已经赋过一次系统要求的初始值,而初始化阶段是执行**类构造器<clinit>()**方法的过程。

  <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的。

  <clinit>()方法与类的构造函数(或者说实例构造器<init>()方法)不同,它不需要显式地调用父类构造器,虚拟机会保证子类的<clinit>()方法执行之前,父类的<clinit>()方法一家执行完毕,因此,在虚拟机中第一个被执行的<clinit>()方法肯定是Object类

  <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

  接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化的时候一样不会执行接口的<clinit>()方法。

  虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其它线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有很耗时的操作,就可能造成多个线程阻塞。

  对于初始化阶段,虚拟机规范严格规定了有且只有五种情况必须立即对类进行初始化(而加载验证准备自然需要在此之前开始):

  1. 遇到newgetstaticputstaticinvokestatic这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这四条指令最常见的场景是:使用关键字 new 关键字实例化对象的时候、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候、以及调用一个类的静态方法的时候。
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
  3. 当初始化一个类的时候,发现其父类还没有金香果初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

  以上五个场景称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。例如,通过子类引用父类的静态字段,不会导致子类初始化;通过数组定义来引用类,不会触发此类的初始化

类加载器

  虚拟机设计团队把类加载阶段的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放在虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为类加载器

类与类加载器

  对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定其在 Java 虚拟机中的唯一性,每个类加载器都拥有一个独立的类名称空间。

  比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,如果类加载器不同,那这两个类必定不相等。这里所指的“相等”,包括代表类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法和 instanceof 关键字。

双亲委派模型

  从虚拟机的角度讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 实现,是虚拟机自身的一部分;另一种就是所有其他的类架子啊器,都由 Java 实现,独立于虚拟机外部,并且全部继承自抽象类 java.lang.ClassLoader

  从 Java 开发人员的角度看,类加载器可以划分的更细一些,大都会使用到以下三种:

  • 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存放在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,其余的就算放在这个目录下也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用。
  • 扩展类加载器(Extension ClassLoader):这个加载器负责加载<JAVA_HOME>\lib\ext 目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader):由于这个类加载器是 ClassLoader 中 getSystemClassLoader() 方法的返回值,所以一般也称它为系统类加载器,它负责加载用户类路径(ClassPath)所指定的类库,开发者可以直接使用这个类加载器。如果应用程序没有自定义过自己的类加载器,一般情况下,这个就是程序中默认的类加载器。

  我们的应用程序都是由这三种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器,其关系一般如下:

图2

  上图中展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载之间的父子关系一般不会以继承的方式实现,而都是使用组合关系来复用父加载器的代码。

  第一次看见这个名词可能会感到疑惑,双亲是指一个父亲一个母亲吗?并不是,这是一个翻译问题,原文是 Parents Delegation Model ,parents在英文中是双亲,父母的意思,但在这里,实际要表达是父母这一辈的 ClassLoader 而已,并不是真的一个父请一个母亲 ClassLoader。

  双亲委派模型并不是一个强制性的约束模型,而是 Java 设计者推荐给开发者的一种类加载器实现方式。它的工作过程是: 如果一个类加载器收到了类加载的请求,它首先不会自己尝试去加载这个类,而是委派给父类加载器去完成,因此所有加载请求最终都应该传送到顶层的骑到启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

  双亲委派模型对于保证 Java 程序的稳定运行很重要,它使 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如 java.lang.Object,它存放在 rt.java 中,无论哪一个类加载器要加载这个类,最终都会委托给处于模型最顶端的启动类加载器进行加载,因此,Object 类在程序的各种加载器环境中都是同一个类。如果没有使用双亲委派模型,由各个类加载器自行加载的话,用户可以编写一个称为 java.lang.Object 的类,并放在 classpath 下,那系统中将出现多个不同的 Obejct 类, Java 体系中最基础的行为也就无法得到保证,应用程序一片混乱。