类加载机制详解

软件发布|下载排行|最新软件

当前位置:首页IT学院IT技术

类加载机制详解

烟雨星空   2020-02-11 我要评论

之前在介绍JVM内存模型的时候(参看:JVM内存模型),提到了在运行时数据区之前,有个Class Loader,这个就是类加载器。用以把Class文件中的描述信息加载到内存中运行和使用。以下是《深入理解Java虚拟机第二版》对类加载器机制的定义原文:

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

一般我们把类从加载到内存到卸载出内存的整个过程分为七个阶段:加载,验证,准备,解析,初始化,使用和卸载。其中,验证、准备和解析统称为连接。

在这几个阶段中,加载、验证、准备、初始化和卸载这五个阶段的顺序是固定的,而解析阶段则不一定,它有时候可能会在初始化之后开始,这是为了支持Java的运行时绑定。需要特别注意的是,这里边的顺序指的是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常会互相交叉的混合进行。

了解类的加载机制非常有必要,下面将逐个解释说明类加载的全过程(即加载,验证,准备,解析,初始化五个阶段)。相信看完之后,你会对Java类某些问题有更深刻的理解(例如,为什么子类可以覆盖父类的字段和方法?饿汉式单例为什么天生是线程安全的?)

加载

加载过程分为三步:

1)通过一个类的全限定名来获取定义此类的二进制字节流。

2)将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构。

3)在内存中生成一个代表这个类的Class对象,作为方法区这个类的各种数据的访问入口。

上面的第一步获取二进制字节流,并没有限定只能从编译好的.class文件中获取,也可以是zip包,jar,war,网络流(Applet),运行时计算生成(如动态代理,通过反射在运行时动态生成代理类),其他文件(如jsp,因jsp最终会编译成class),数据库(用的场景较少)。

对于数组类的加载,和普通类的加载有所不同。数组类本身不通过类加载器加载,而是由虚拟机直接完成。但是数组类的元素类型(指数组类去除维度之后的类型,如String[] 数组的元素类型就是 String)是靠类加载器加载的。

加载阶段完成之后,虚拟机就会把外部的二进制字节流(不论从何处获取的)按照一定的数据格式存储在运行时数据区中的方法区。然后在内存中实例化一个java.lang.Class对象(Class这个对象比较特殊,它存放在方法区中而不是堆中),这个对象将作为程序访问方法区中的这些数据的外部接口。

验证

验证是连接阶段的第一步,这一阶段的主要目的就是确保Class文件流中的信息符合虚拟机的规范,并且不会危害虚拟机的安全。验证阶段一般分为四个阶段:文件格式验证,元数据验证,字节码验证和符号引用验证。

1)文件格式验证

第一阶段要验证二进制字节流是否符合Class文件格式的规范,确保能被虚拟机处理。主要包括以下验证点:

  • 是否以魔数 0xCAFEBABE 开头。(每个Class文件的头4个字节称为魔数,是一个16进制的固定值,它的作用就是确保这个Class文件能被虚拟机接受)
  • 主、次版本号是否在当前虚拟机的处理范围中(紧接着魔数后面的第5,6字节代表次版本号,第7,8字节代表主版本号)。
  • 常量池中的常量是否有不被支持的常量类型(依据常量的tag值)。

等等,还有其他很多验证,不再一一说明。这一阶段的验证主要是针对二进制字节流进行的,验证完成之后,字节流会进入内存中的方法区进行存储。所以后面的三个验证阶段不再直接操作二进制字节流。

2)元数据验证

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

  • 这个类是否有父类(除了Object类,所有类都应该有父类)。
  • 这个类是否继承了不允许被继承的类(被final修饰的类不可被继承)。
  • 是否实现了其父类或接口要求实现的所有方法。
  • 类中的字段、方法是否与父类产生矛盾(如覆盖了父类的final字段,或者重写、重载不符合规范)。

3)字节码验证

第三阶段主要是对类的方法体进行验证,确保程序语义是合法的、符合逻辑的。

  • 保证数据的定义和使用相匹配,如定义int类型数据,使用时不能以long型操作。
  • 保证跳转指令不会跳转到方法体以外的字节码指令上。
  • 保证方法体中的类型转换是有效的。如可以把子类对象赋值给父类引用,但是父类不可以直接赋值给子类(必须强转)或其他不相干的类型。

4)符号引用验证

最后一个阶段的验证发生在符号引用转换为直接引用的时候。实际的转换动作,发生在后面的解析阶段。主要对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。

验证阶段是非常重要但是非必要的一个阶段。如果确保代码对程序运行期没有影响,则可以通过 -Xverify:node 参数关闭大部分的验证,以缩短类加载的总时间。

准备

准备阶段是类变量分配内存并设置初始值的阶段。这里的类变量指的是被static修饰的变量,而不包括实例变量。类变量被分配到方法区中,而实例变量存放在堆中。

这里的初始值指的是数据类型的默认值,而不是代码中所赋的值。例如

public static int value = 1 ;

在准备阶段之后,value值为0,而不是1。赋值为1的动作发生在初始化阶段。

但是,也要特殊情况,如果变量被static 和 final同时修饰,则准备阶段直接赋值为指定值。如

public static int value = 1 ;

在准备阶段之后,value的值即为1.

各数据类型的初始默认值如下:

数据类型 默认值
short (short)0
int 0
long 0L
float 0.0f
double 0.0d
char '\u0000'
byte byte(0)
boolean false
reference null

解析

解析阶段是将常量池中的符号引用转换为直接引用的过程。那什么是符号引用和直接引用呢?

符号引用是用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可(前面JVM的模型中,也提到了符号引用,它存在于常量池中,包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。看概念可能比较抽象,可以理解为它就是一个代号,就像你有一个大名,同时也有一个小名,但是不管怎么叫指代的都是你本人。

直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

解析动作主要针对类或接口、字段、类方法、接口方法、方法属性、方法句柄、调用点限定符7类符号引用。此处分别介绍一下前四种的解析过程。

1)类或接口的解析

如果类C不是数组类型,那么虚拟机会把类C直接传给类加载器。如果类C是数组类型并且元素类型是对象(如String[]),那么先用类加载器加载元素类型(String类型),再由虚拟机创建代表此数组维度和元素的数组对象。判断调用类是否有权限访问被加载类,如果不允许的话,就抛出IllegalAccessError异常。

2)字段的解析

首先解析字段所属的类或接口的符号引用。如果类中有字段的符号引用(字段的名称和描述符)和目标字段相匹配,则返回这个字段的直接引用。如果没有,则自下而上查找其实现的接口和父接口,若匹配到,则返回这个字段的直接引用。如果还没有,就自下而上查找其继承的父类,若匹配到,则返回这个字段的直接引用。否则,查找失败,抛出NoSuchFieldError异常。最后如果查找成功的话,会判断字段访问权限,如果该字段不允许访问,则抛出 IllegalAccessError异常。

这么一大段,如果乍看没明白,下面用代码解释一下就懂了。

public class ResolveTest {
    public static void main(String[] args) {
        System.out.println(Child.a);
    }

    interface Interface0 {
        int a = 0;
    }

    static class Parent {
        static int a = 1;
    }

    //①
    static class Child {
        static int a = 2;
    }
    //①
}

比如,我去查找类Child中的a字段,目前来看可以直接查到,就是a=2。如果我把①所包围的代码修改为

static class Child implements Interface0 {
        
}

则表示在本类中找不到a字段,因此去Child类实现的接口Interface0中查找,于是,成功找到 a=0。

再次把①代码修改为

static class Child extends Parent {

}

本类找不到a,则去它的父类查找,于是查找成功,a=1。

那么聪明的同学可能想到了,如果我修改代码为既继承父类又实现接口会怎么样呢?

static class Child extends Parent implements Interface0 {

}

这样是不行的,编译器会拒绝编译。其实,想一下,就能明白,这个时候Child应该取父类中字段的值还是接口中字段的值呢,编译器是不知道的,所以不能编译。其实,如果是在编译期,代码开发工具会给一条这样的报错信息:Reference to 'a' is ambiguous, both 'Parent.a' and 'Interface0.a' match.

如果强制执行这段代码,控制台则会报错如下信息:

思考一下,如果,我非要既继承父类又实现接口,应该怎样修改代码才能编译通过呢?

3)类方法解析

类方法解析第一步同字段解析一样,也需要先解析方法所属的类或接口的符号引用。类方法和接口方法符号引用的常量类型是分开的。如果,在类方法中解析出来的是一个接口,则会抛出 IncompatibleClassChangeError 异常。如果在类中有方法的符号引用(方法的名称和描述符)和目标方法相匹配,则返回这个方法的直接引用,查找结束。否则,在类的父类中递归查找,若找到则返回,查找结束。否则,查找它实现的接口和父接口,如果找到,说明此类是一个抽象类,抛出 AbstractMethodError异常。若都找不到,就抛出NoSuchMethodError 异常。最后,如果查找成功,会判断此方法是否有访问权限,若没有,则抛出 IllegalAccessError异常。

下面通过代码解释:

public class ResolveTest2 {
    public static void main(String[] args) {
        Child child = new Child();
        child.method0();
    }

    interface Interface0 {
        void method0();
    }

    static class Parent {
        void method0(){
            System.out.println("parent method0");
        }
    }

    //②
    static class Child extends Parent {
        void method0(){
            System.out.println("child method0");
        }
    }
    //②
}

②中,如果当前类Child中有method0方法,则直接返回此方法,打印结果child method0。若把Child中的method0方法注释掉,则会去找父类Parent的method0,打印结果 parent method0 。最后一点,如果类是实现了接口Interface0,并在接口中找到了method0方法,则说明Child类一定是抽象类。因为,只有抽象类才可以选择不重写接口的抽象方法。如果不是抽象类,则需要实现接口的全部方法,此时就可以直接在当前Child类中找到method0方法,而不必去接口中查找方法了。

//必须是抽象类,否则,需要实现接口的全部方法
static abstract class Child implements Interface0 {

}

4)接口方法的解析

首先解析方法所属的类或接口的符号引用,和类方法解析同理,如果发现解析出来是一个类方法,则会抛出 IncompatibleClassChangeError 异常。如果所属接口中匹配到目标方法,则返回此方法的直接引用。否则,在父接口中查找,若找到,则返回。否则,查找失败,抛出 NoSuchMethodError 异常。由于接口的方法都是public的,所以不存在访问权限的问题。

初始化

这是类加载的最后一步,到这才真正开始执行Java代码。在准备阶段,已经为类变量分配内存,并赋值了默认值。在初始阶段,则可以根据需要来赋值了。可以说,初始化阶段是执行类构造器 < clinit > 方法的过程。

首先说下类构造器 < clinit > 方法和实例构造器 < init > 方法有什么区别。< clinit > 方法是在类加载的初始化阶段执行,是对静态变量、静态代码块进行的初始化。而< init > 方法是new一个对象,即调用类的 constructor方法时才会执行,是对非静态变量进行的初始化。

类构造器方法有如下特点:

  • 保证父类的 < clinit > 方法执行完毕,再执行子类的 < clinit > 方法。
  • 由于父类的 < clinit > 方法先执行,所以父类的静态代码块也优于子类执行。
  • 如果类中没有静态代码块,也没有为变量赋值,则可以不生成 < clinit > 方法。
  • 执行接口的 < clinit > 方法时,不需要先执行父接口的 < clinit > 方法。只有父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不执行接口的 < clinit > 方法。
  • 虚拟机会保证在多线程环境下 < clinit > 方法能被正确的加锁、同步。如果有多个线程同时请求加载一个类,那么只会有一个线程去执行这个类的 < clinit > 方法,其他线程都会阻塞,直到方法执行完毕。同时,其他线程也不会再去执行 < clinit > 方法了。这就保证了同一个类加载器下,一个类只会初始化一次。(这也是为什么说饿汉式单例模式是线程安全的,因为类只会加载一次。)

类的初始化时机:只有对类主动使用的时候才会触发初始化,主动使用的场景如下:

  • 使用new关键词创建对象时,访问某个类的静态变量或给静态变量赋值时,调用类的静态方法时。
  • 反射调用时,会触发类的初始化(如Class.forName())
  • 初始化一个类的时候,如其父类未初始化,则会先触发父类的初始化。
  • 虚拟机启动时,会先初始化主类(即包含main方法的类)。

另外,也有些场景并不会触发类的初始化:

  • 通过子类调用父类的静态变量,只会触发父类的初始化,而不会触发子类的初始化(因为,对于静态变量,只有直接定义这个变量的类才会初始化)。
  • 通过数组来创建对象不会触发此类的初始化。(如定义一个自定义的Person[] 数组,不会触发Person类的初始化)
  • 通过调用静态常量(即static final修饰的变量),并不会触发此类的初始化。因为,在编译阶段,就已经把final修饰的变量放到常量池中了,本质上并没有直接引用到定义常量的类,因此不会触发类的初始化。

原文首发地址: 类加载机制你真的了解吗?
文末可获取《深入理解Java虚拟机第二版》pdf电子书,及JVM视频

Copyright 2022 版权所有 软件发布 访问手机版

声明:所有软件和文章来自软件开发商或者作者 如有异议 请与本站联系 联系我们