0%

类加载机制

类加载机制

源代码经过编译器编译之后生成了一个字节码文件(class文件),字节码文件中存储的是jvm指令,而不是机器指令

Java虚拟机的启动是通过引导类加载器创建一个初始类来完成的,这个类由虚拟机的具体实现指定,Java虚拟机链接这个初始类,初始化并调用它的main方法,之后整个执行过程都是由对此方法的调用开始。执行main方法中的java虚拟机指令可能会导致Java虚拟机链接另外一些类或接口,也可能会调用另外的方法

类加载器子系统负责从文件系统或者网络中加载class文件,class文件在文件开头有特定的文件标识,class字节码文件在类装载器子系统(Class Loader SubSystem)中有三个阶段

  • 第一阶段:加载 找到class文件并把这个文件包含的字节码加载到内存
  • 第二阶段:链接 链接中包含 字节码验证、准备和解析
  • 第三阶段:初始化,类中的静态属性和初始化赋值以及静态块的执行等

加载、验证、准备、初始化这几个阶段是确定的,但是解析阶段则不一定,在某些情况下可以在初始化之后再开始,为了支持java语言的运行时绑定特性(动态绑定或晚期绑定)。

加载阶段

加载阶段是根据特定名称查找类或接口类型的二进制表示,将java字节码数据从不同的数据源加载到JVM中

ClassLoader只负责class文件的加载(使用到该类时才会加载),将java类的字节码文件加载到机器内存,是否可以运行是由Execution Engine决定,加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中还会存放运行时常量池信息(包括字符串字面量和数字常量,是class文件中常量池部分的内存映射)

类加载过程

  • 通过一个类的全类名获取定义该类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在堆内存中生成一个代表这个类的java.lang.Class类的实例对象,作为方法区这个类的各种数据的访问入口,java.lang.Class类的构造方法是私有的,只有JVM能够创建

类加载的方式

  • 从本地系统中加载
  • 通过网络获取
  • 从zip压缩包中加载(jar、war是在此基础上做的)
  • 运行时计算生成,如动态代理
  • 其他文件生成,如jsp
  • 从加密文件中获取,为了防止class文件被反编译的保护措施

链接阶段

链接阶段是为了让类或接口可以被java虚拟机执行,而将类或接口并入虚拟机运行时状态的过程

又分为了三个步骤,验证、准备、解析

验证(Verify)

有四种验证:文件格式类型,元数据验证,字节码验证,符号引用验证

验证的目的是为了确保class文件的字节流中包含的信息符合当前虚拟机的要求,保证被加载的类不会危害虚拟机自身的安全

  • 格式检查 进行魔数检查、版本检查、长度检查 是在加载过程检查的
  • 语义检查 检查是否继承final类、是否有父类、抽象方法是否有实现
  • 字节码验证 跳转指令是否指向正确位置、操作数类型是否合理、函数调用是否传入了正确类型的参数
  • 符号引用检查 检查符号引用的直接引用是否存在,Class文件在其常量池会通过字符串记录自己将要使用的其他类或方法,所以需要检查这些类或者方法是否存在(常量池中描述的类是否存在),并且当前类有权限访问这些数据(访问的方法或字段是否存在足够的权限),类无法找到会报NoClassDefFoundError 是在解析阶段执行的

准备(Prepare)

准备阶段主要是为静态变量分配内存并且设置该静态变量的默认初始值,类变量会分配在方法区(JDK8以后,类变量会随着Class对象一起存放在Java堆中)

基本数据类型和字符串字面量 (就是final修饰的static基本数据类型的变量)在编译的时候就会分配了默认值,在准备阶段会进行显式的赋值;实例变量也不会分配初始化,是会在对象实例化的时候和对象一起分配在堆中

如果是final static修饰的引用数据类型的话,会在<clinit>中进行赋值

1
2
3
4
// 在准备阶段值是0,而不是44
public static int COUNT = 44;
// 在准备阶段值是200,默认初始值是在编译时设置的
public static final int MAX = 200;

解析(Resolve)

解析实际上是在初始化之后才执行

解析其实是将常量池内的符号引用转换为直接引用的过程,解析动作主要针对于类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。对应常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等

符号引用是一组符号来描述所引用的目标,符号引用的字面量形式定义在虚拟机规范的class文件格式中

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

这里也要说一下符号引用和直接引用,符号引用虽然可以在常量池中找到,但是当方法或变量调用的时候,需要明确的知道该方法或变量的位置,为此,JVM为每个类都准备了一张方法表和字段表,将所有的方法和字段都列在表中,当调用一个类的方法时,只需要知道该方法在方法表中的偏移量就可以直接调用该方法了,解析操作就是将符号引用转变为目标方法在勒种方法表的位置

初始化阶段

初始化阶段是执行类初始化方法<clinit>()的过程,来为类的静态变量进行赋值,该方法是由类静态成员的赋值语句,以及静态代码块内的语句合并而来的,是根据源代码的书写顺序来执行的。

<clinit>()不是类的构造器(类的构造器在jvm中是<init>()方法),如果该类有父类,会保证子类的<clinit>()方法在父类的<clinit>()方法执行完毕后再进行执行

有些类是不会产生<clinit>()初始化方法的

  • 一个类中并没有声明任何的类变量,也没有静态代码块
  • 一个类中声明类变量,但是没有明确的使用类变量的初始化语句以及静态代码块来执行初始化操作(即没有进行显式的赋值)
  • 一个类中包含static final修饰的基本数据类型的字段或者字符串字面量,不管是否进行显式赋值,都不会生成<clinit>,是在准备阶段进行赋值的

需要注意的是:由于虚拟机会保证一个类的<clinit>方法在多线程内可以正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>方法,其他线程阻塞等待,所以static可以用来做单例模式,但是也注意不要写出来死锁的代码

类初始化方法的触发

类的使用有两种,一种是主动使用,一种是被动使用

主动使用

类在第一次主动使用前会触发类初始化方法

  • 遇到new、getstatic、putstatic、invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发初始化阶段,其对应的场景为
    • 使用new关键字实例化对象
    • 读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候
    • 调用一个类的静态方法的时候
  • 使用反射对类进行反射调用时,如果类没有进行过初始化,则需要先触发其初始化
  • 当初始化子类时,如果发现父类还没有进行初始化,则需要先触发父类的初始化,但是并不会初始化它所实现的接口,只有在程序首次使用特定接口的静态字段时,才会导致接口的初始化
  • 当虚拟机启动时,用户需要指定一个主类,虚拟机会先初始化该主类
  • 当使用JDK7新加入的动态语言支持时,如果一个MethodHandle实例最后的解析为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要触发初始化
  • 如果一个接口定义了JDK8中的default方法,那么直接实现或者间接实现该接口的类的初始化,该接口要在其之前被初始化
被动使用

类的被动使用不会触发类初始化方法,也就是说不会执行静态块的内容

  • 当访问一个静态字段时,只有真正声明这个字段的类才会被初始化,当通过子类去引用父类的静态变量时,只会触发父类的初始化而不会触发子类的初始化,但是此时子类是被加载了的,只是没有进入到初始化阶段

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    public class TestInit {
    public static void main(String[] args) {
    // super init
    // com.zhanghe.study.jvm.init.AA@5ca881b5
    System.out.println(Sub.a);
    }
    }

    class Super{
    static {
    System.out.println("super init");
    }

    public static AA a = new AA();
    }

    class Sub extends Super{
    static {
    System.out.println("sub init");
    }
    }

    class AA{

    }
  • 通过数组定义类引用,不会触发此类的初始化,因为数组定义时,其内的元素默认都是null

  • 基本数据类型和字符串字面量常量不会触发此类或接口的初始化,因为常量在链接过程的准备阶段就已经被显式赋值了

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    public class TestClinit {
    public static void main(String[] args) {
    // 此时不会触发 AAA类初始化
    // System.out.println(AAA.i);
    // 此时会触发 AAA类初始化
    System.out.println(AAA.bb);
    }
    }

    class AAA {
    public static final int i = 10;
    public static final BBB bb = new BBB();
    static{
    System.out.println("AAA类初始化");
    }
    }

    class BBB {

    }
  • 调用ClassLoader类的loadClass方法加载一个类,不会导致类的初始化

加载类错误分析

在执行java程序时经常会遇到ClassNotFoundException和NoClassDefFoundError异常,这两种异常是不一样的

ClassNotFoundException

该异常是由于在JVM要加载指定文件的字节码到内存时,并没有找到这个文件对应的字节码,也就是这个文件并不存在,可以检查一下classpath目录下有没有指定文件存在

NoClassDefFoundError

该异常通常是由于使用new关键字、属性引用某个类、继承了某个接口或类,以及方法的某个参数中引用了某个类,触发JVM隐式加载这些类时发现这些类不存在的异常

示例讲解

来看一个例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class TestInit2 {
private static TestInit2 testLoad = new TestInit2();
private static int value1;
private static int value2 = 0;

public TestInit2(){
value1 = 10;
value2 = value1;
System.out.println("before value1="+value1);
System.out.println("before value2="+value2);

}

public static void main(String[] args) {
System.out.println("after value1="+value1);
System.out.println("after value2="+value2);
}
}

猜一下这个例子的输出结果是什么

1
2
3
4
before value1=10
before value2=10
after value1=10
after value2=0

是不是觉得很奇怪,来看一下这个过程吧,在类加载器将TestInit2类加载到JVM后,会生成一个对应的Class对象实例;当进入准备阶段时,JVM会对三个静态变量分配内存空间,并设置初始值,此时testLoad为null,value1、value2为0;接着开始初始化阶段,按照先后顺序进行执行,先对testLoad进行初始化,执行到构造方法中,并覆盖掉静态变量value1和value2之前的初始值,此时value1和value2的值都变成了10,接着JVM对value1进行初始化,由于value1没有显式的进行赋值,所以值还是10,然后对value2进行初始化,value2显式赋值为0,会覆盖掉之前构造方法中的赋值操作,所以value2就变成了0

欢迎关注我的其它发布渠道