Java虚拟机-类加载机制

501 阅读6分钟

总览

类从被加载到虚拟机,到被卸载。其整个生命周期包括以上几个阶段:加载 (Loading)、验证 (Verification)、准备 (Preparation)、解析 (Resolution)、初始化 (Initialization)、使用 (Using)、卸载 (Unloading)

其中类加载过程包括加载 (Loading)、验证 (Verification)、准备 (Preparation)、解析 (Resolution)、初始化 (Initialization)五个阶段。除了解析 (Resolution)阶段顺序不确定以外,其他四个阶段是按顺序开始的。

解析阶段可能在初始化阶段之后开始,其目的是为了支持Java的动态绑定,比如多态(调用父类方法实际执行的是子类覆盖的方法)。

加载阶段

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

  1. 通过一个类的全限定名来获取其定义的二进制字节流 (获取的途径可以从Class文件中、Jar包中、网络中(比如Applet)、或由其他文件生成(JSP应用))。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口。

类加载器 ClassLoader

在加载阶段我们可以实现自己的ClassLoader,从而可以动态的创建符合特定化需求的类,或者是可以从特定的数据源 (网络、文件系统、数据库等等) 获取class文件。

大致的类加载器层级结构如下,

  • 启动类加载器 (Bootstrap ClassLoader),负责加载 JDK\jre\lib 下,或被-Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有java.*开头的类均被Bootstrap ClassLoader加载)。启动类加载器无法被Java程序直接引用

  • 扩展类加载器 (Extension ClassLoader),负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器

  • 应用程序类加载器 (Application ClassLoader), 负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器。如果没有自定义的类加载器,一般这就是程序中默认的类加载器。

如上图所示的这种层级结构成为Java类加载器的双亲委派模型。当前加载器上层的叫做父加载器,但他们的关系不是靠继承来实现,而是使用组合的方式。

双亲委派

如果一个类加载器收到加载类的请求,首先将请求委托给父加载器,依次向上层请求,直到顶端的启动加载器,此时只有当该加载器找不到对应的类时,才会让子类去加载,直到找到该类。

Java设计者提出的这种约束模型,

  1. 首先具备了一种优先级的层次关系
  2. 其次保证Java程序运行的稳定和安全

例如要请求加载java.lang.Object类,最终所有的加载器都会委托启动加载器去加载,从而保证了无论是哪一个加载器想要加载Object类,最终都指向同一个类。

验证

验证是为了确保Class文件中的字节流符合虚拟机的要求,并且不会损害虚拟机安全。

验证大致分为四个阶段,

  1. 文件格式验证,保证文件的字节流能被正确的解析,并被存储到方法区中
  2. 元数据验证,确保元数据信息符合Java语法规范
  3. 字节码验证,对类的方法体校验,确保运行时不会危害到虚拟机
  4. 符号引用验证,对类自身外的匹配校验 (比如常量池中的符号引用)

准备

主要为类变量分配内存并设置变量初始值,都在方法区中进行分配。

注意要点:

  1. 此时的内存分配只包括类变量 (static)。实例变量在对象初始化时分配在Java堆中。
  2. 此时变量的初始值是对应数据类型的默认零值(如0、null、false等)。

举个例子,如下

    public static int number = 6;

变量number在准备阶段后的值为0,而不是6。因为此时还没有开始执行Java方法,而将变量number赋值为6是在程序编译后,执行putstatic指令,存放于类的构造器<clinit>()方法中,所以number赋值为6的操作将在初始化阶段进行。

但如果上述变量前加上final关键字,则会在编译期就将其结果放入常量池中,即准备阶段后此时number值为6.

解析

该阶段虚拟机完成将常量池中符号引用转化为直接引用的过程。其中,

  • 符号引用,是以一组符号来描述所引用的目标,与虚拟机实现的内存布局无关,即所引用的目标不一定加载到了内存中。
  • 直接引用,是一个指向目标的指针。与虚拟机实现的内存布局相关,如果目标有了直接引用,说明已经被加载到了内存中。

解析主要针对类或接口、字段、类方法、接口方法四类符号引用,他们和常量池中的类型对应关系如下表,

符号引用 常量类型(常量池)
类或接口 CONSTANT_Class_info
字段 CONSTANT_Fieldref_info
类方法 CONSTANT_Methodref_info
接口方法 CONSTANT_InterfaceMethodref_info

初始化

此阶段开始执行类中的Java代码,主要执行的是类构造器<clinit>()方法。

<clinit>()是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由 语句在源文件中出现的顺序决定。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类 变量只能赋值,不能访问。例如以下代码:

public class Test {
    static { 
        i = 0; // 给变量赋值可以正常编译通过
        System.out.print(i); // 这句编译器会提示“非法向前引用”
    } 
    static int i = 1;
}

由于父类的 <clinit>()方法先执行,也就意味着父类中定义的静态语句块的执行要优先于子类。例如以下代码:

static class Parent { 
    public static int A = 1; static { A = 2; } 
}

static class Sub extends Parent { 
    public static int B = A; 
} 

public static void main(String[] args) { 
    System.out.println(Sub.B); // 2 
}

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

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