类加载过程和类加载模式

JVM 和字节码文件

讲类的加载之前,先了解一下 JVM 和字节码文件,有助于理解后面的内容。

从学习 Java 语言开始,就了解到我们先编写 .java 文件,然后 Java 编译器会将 .java 文件编译成 .class 文件,然后交由 JVM 去解释执行。

我们经常说的一次编写,多处执行是指不同平台下的 JVM 可以对同一个字节码文件作出同样的解释,执行得到同样的结果。这个字节码文件就是一个关键所在,《Java 虚拟机规范》为字节码文件的格式做了详尽的规范,Java 语言中的各种变量、关键字和运算符的语义最终都是由多条字节码命令组合而成的。

在 Java 语言中,最基本的单位是类,一个 class 文件通常意义上就是一个类或者接口,将 class 文件加载到虚拟机内存中,并解释成 Java 语言中相应的对象的过程,被称之为 类加载过程.

类加载

什么时候类会被加载到内存中?

类什么时候被加载/类加载时机:

  1. 生成该类对象的时候,会加载该类及该类的所有父类;
  2. 访问该类的静态成员的时候;
  3. class.forName("类名")

类加载的步骤

类加载过程可以大致分为三步:

  1. 加载(Loading)
  2. 链接(Linking)
  3. 初始化(Initialization)

其中链接部分又可以细分为三部分:

  1. 验证(Verification)
  2. 准备(Preparation)
  3. 解析(Resolution)

初始化之后 JVM 就开始对我们的字节码文件做解释执行了,进入到 使用(Using)阶段;当该类的对象没有任何引用的时候,该类就会被卸载(Unloading)

从加载到卸载,贯穿了类的整个生命周期,今天重点讲加载部分,也就是从加载到初始化。

加载

类的加载简单意义上来说就是将 class 文件从各个来源通过类加载器载入到 JVM 内存中。

为什么说是各个来源呢?因为 JVM 并没有限定我们从什么地方读取字节码内容,所以也就有了多种多样的读取方式:

  1. 从本地 .class 文件读取
  2. 从压缩包读取:jar 包
  3. 由其他文件生成:JSP 技术
  4. 动态代理实时编译

这里,字节码文件被载入到 JVM 内存当中,这仅仅是第一步。

验证

之前说过了,JVM 虚拟规范对字节码文件的格式做了详尽的规范,这一步就是验证载入到内存中的字节码是否符合规范:

  • 文件格式的验证:比如常量中是否包含不被支持的常量?文件中是否有不规范或者附加的其他信息?
  • 元数据的验证:比如该类是否集成了被 final 修饰的类?类中的字段、方法是否与父类冲突?是否出现了不合理的重载?
  • 字节码的验证:保证程序语义的合理性,比如要保证类型转换的合理性。
  • 符号引用的验证:比如校验符号引用中通过全限定类名是否能找到相应的类?校验符号引用中的访问性(private、public 等)是否可以被当前类访问?

载入内存的字节码通过验证,则开始下一步:

准备

这一步开始为 类变量(static 修饰) 分配内存,并且为它们 赋初值;注意,这里的赋初值并不是赋予代码中的值,而是根据变量类型赋予该类型的默认值。譬如下面的代码:

public class Demo {

    private static Demo demo = new Demo();
    public static int a;
    public static int b = 0;

    private Demo() {
        a++;
        b++;
    }

    public static Demo getInstance() {
        return demo;
    }

    public static void main(String[] args) {
        Demo demo = Demo.getInstance();
        System.out.println("a=" + a);
        System.out.println("b=" + b);
    }
}

在上面的例子中,类变量就是 demo、a 和 b,在准备阶段,这三者的值分别就是 null、0 和 0。

需要注意的是,在该阶段,常量值就是代码中设置的值,而非类型默认值。

解析

将常量池中的符号引用替换为直接引用的过程。
两个需要注意的地方:

  1. 符号引用:也就是一个字符串,但是这个字符串给出了一些能够唯一性识别的一个方法、一个变量、一个类的相关信息。
  2. 直接引用:可以理解为一个内存地址,或者一个偏移量。比如类方法,类变量的直接引用是指向方法区的指针;而实例方法、实例变量的直接引用则是从实例的头指针开始算起到这个实例变量的位置偏移量。

具体有关 符号引用直接引用 的内容,请看这里 [JVM里的符号引用如何存储?
](https://www.zhihu.com/question/30300585)

初始化

类初始化是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段程序可以通过自定义类加载参数参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的 Java 代码(或者说字节码)。
初始化类主要有以下几个步骤:

  1. 若该类还没有被加载和连接,则先加载并连接该类
  2. 若该类的直接父类没有被初始化,则先初始化其父类(接口没有此规则)
  3. 若该类有初始化语句(赋值语句和静态代码块),则按照代码中申明的顺序依次执行初始化语句

至此,类加载完成。

需要注意的是,在加载过程中也可以验证,也就是说,类的加载过程并不是完全一致的,字节码文件的数据结构从前到后包括 魔数/版本/常量池/访问标志/类索引/父类索引/接口索引/字段表/方法表/属性表,在加载了魔数之后,系统会直接判断魔数对不对,如果不对,后面的内容就不加载了,如果对,就继续加载。

类加载器

在加载阶段,将外部内容转为加载到 JVM 中,实现这个动作的模块就是 类加载器,JVM 规定,对于任意一个类,加载它的加载器和它本身一同确定其在 JVM 中的唯一性,也就是说,同一个类,由不同的加载器加载到虚拟机中,虚拟机会认为它们不是同一个类。

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

从 Java 开发者角度来看,类加载器还可以划分的更细致一些,绝大部分 Java 程序都会使用到以下 3 种系统提供的类加载器:

  • 启动类加载器(Bootstrap ClassLoader)
  • 扩展类加载器(Extension ClassLoader)
  • 应用程序类加载器(Application ClassLoader)

在日常开发中,类的加载几乎都是由上述三种类加载器相互配合执行的,当然,我们也可以自定义自己的类加载器。

双亲委派模式

Java 虚拟机对字节码文件采用的是按需加载的方式,也就是说,当需要使用这个类的是,才会将它加载到内存当中生成类的对象,并且在加载类的时候,使用的是双亲委派模式,即把请求交由父类处理。

这里类加载器之间的父子关闭一般不会一继承的关系来实现,而是都使用组合关系来复用父类加载器的代码。
它的工作模式是:如果一个类加载器收到了类加载的请求,它首先不会自己去加载这个二垒,而是把这个请求委派给父类加载这个类,父类同样也会执行这个操作,继续向上传递,直到最顶层的类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。

上面说了,JVM 使用加载器和类一同确定类的唯一性,所以双亲委派模式就保证了同一个类是由同一个加载器加载的,确保了唯一性。

双亲委派模式并不是一个强制性的约束,而是 Java 设计者推荐给开发者的一种类加载实现方式。