JVM字节码与类的加载——类的加载过程详解


我们知道class文件是存放在磁盘上的,如果想要在JVM中使用class文件,需要将其加载至内存当中。了解了class文件的结构,本帖将详细介绍class文件加载到内存中的过程。

1、概述

在Java中数据类型分为基本数据类型和引用数据类型。基本数据类型由JVM预先定义,可以直接被用户使用,引用数据类型则需要执行类的加载才可以被用户使用。Java虚拟机规范中规定,class文件加载到内存,再到类卸载出内存会经历7个阶段,分别是加载、验证、准备、解析、初始化、使用和卸载,其中,验证、准备和解析3个阶段统称为链接(Linking),整个过程称为类的生命周期,如下图所示:
在这里插入图片描述

2、加载(Loading)阶段

2.1、加载完成的操作

所谓加载,简而言之就是将Java类的class文件加载到机器内存中,并在内存中构建出Java类的原型,也就是类模板对象。所谓类模板对象,其实就是Java类在JVM内存中的一个快照,JVM将从class文件中解析出的常量池、类字段、类方法等信息存储到类模板对象中。JVM在运行期可以通过类模板对象获取Java类中的任意信息,能够访问Java类中的成员变量,也能调用Java方法,反射机制便是基于这一基础,如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法使用反射。在加载类时,JVM必须完成以下3件事情:

  • (1)通过类的全名,获取类的二进制数据流。
  • (2)解析类的二进制数据流为方法区内的数据结构(Java类模型)。
  • (3)创建java.lang.Class类的实例,作为方法区中访问类数据的入口。

2.2、二进制流的获取方式

JVM可以通过多种途径产生或获得类的二进制数据流,下面列举了常见的几种方式:

  • (1)通过文件系统读入一个后缀为.class的文件(最常见)。
  • (2)读入jar、zip等归档数据包,提取类文件。
  • (3)事先存放在数据库中的类的二进制数据。
  • (4)使用类似于HTTP之类的协议通过网络加载。
  • (5)在运行时生成一段Class的二进制信息。

在获取到类的二进制信息后,JVM就会处理这些数据,并最终转为一个java.lang.Class的实例。如果输入数据不是JVM规范的class文件的结构,则会抛出“ClassFormatError”异常。

2.3、类模型与Class实例的位置

1、类模型的位置
加载的类在JVM中创建相应的类结构,类结构会存储在方法区中。
2、Class实例的位置
类加载器将class文件加载至方法区后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构,该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。类模型和Class实例的位置对应关系如下图所示:
在这里插入图片描述
外部可以通过访问代表Order类的Class对象来获取Order类的数据结构。java.lang.Class类的构造方法是私有的,只有JVM能够创建。java.lang.Class实例是访问类型元数据的入口,也是实现反射的关键数据。通过Class类提供的接口,可以获得目标类所关联的class文件中具体的数据结构、方法、字段等信息。如代码清单如下所示,展示了如何通过java.lang.Class类获取方法信息。
在这里插入图片描述
通过上面的代码可以直接获取到String类的方法信息,运行结果如下,由于String类方法太多,只展示部分方法。
在这里插入图片描述

2.4、数组类的加载

创建数组类的情况稍微有些特殊,数组类由JVM在运行时根据需要直接创建,所以数组类没有对应的class文件,也就没有二进制形式,所以也就无法使用类加载器去创建数组类。但数组的元素类型仍然需要依靠类加载器去创建。创建数组类的过程如下:

  • (1)如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组的元素类型,JVM使用指定的元素类型和数组维度来创建新的数组类。
  • (2)如果数组的元素是基本数据类型,比如int类型的数组,由于基本数据类型是由JVM预先定义的,所以也不需要类加载,只需要关注数组维度即可。

如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为public。

3、链接(Linking)阶段

3.1、链接阶段之验证(Verification)

类加载到机器内存后,就开始链接操作,验证是链接操作的第一步。验证的目的是保证加载的字节码是合法、合理并符合规范的。验证的步骤比较复杂,实际要验证的项目也很繁多,如下图所示:
在这里插入图片描述
验证的内容涵盖了类数据信息的格式检查、语义检查、字节码验证、符号引用验证,其中格式检查会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中。格式检查之外的验证操作将会在方法区中进行。如果不在链接阶段进行验证,那么class文件运行时依旧需要进行各种检查,虽然链接阶段的验证拖慢了加载速度,但是却提高了程序执行的速度,正所谓“磨刀不误砍柴工”。

3.1.1、格式检查

主要检查是否以魔数OxCAFEBABE开头,主版本和副版本号是否在当前JVM的支持范围内,数据中每一个项是否都拥有正确的长度等。

3.1.2、字节码的语义检查

JVM会进行字节码的语义检查,但凡在语义上不符合规范的,JVM也不会验证通过,比如JVM会检查下面4项语义是否符合规范:

  • (1)是否所有的类都有父类的存在(Object除外)。
  • (2)是否一些被定义为final的方法或者类被重写或继承了。
  • (3)非抽象类是否实现了所有抽象方法或者接口方法。
  • (4)是否存在不兼容的方法,比如方法的签名除了返回值不同,其他都一样。

3.1.3、字节码验证

JVM还会进行字节码验证,字节码验证也是验证过程中最为复杂的一个过程。它试图通过对字节码流的分析,判断字节码是否可以被正确地执行,比如JVM会验证字节码中的以下内容。

  • (1)在字节码的执行过程中,是否会跳转到一条不存在的指令。
  • (2)函数的调用是否传递了正确类型的参数。
  • (3)变量的赋值是不是给了正确的数据类型等。
  • (4)检查栈映射帧的局部变量表和操作数栈是否有着正确的数据类型。

遗憾的是,百分之百准确地判断一段字节码是否可以被安全执行是无法实现的,因此,该过程只是尽可能地检查出可以预知的明显的问题。如果在这个阶段无法通过检查,JVM也不会正确装载这个类。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的。在前面3次检查中,已经排除了文件格式错误、语义错误以及字节码的不正确性。但是依然不能确保类是没有问题的。

3.1.4、符号引用验证

class文件中的常量池会通过字符串记录将要使用的其他类或者方法。因此,在验证阶段,JVM就会检查这些类或者方法是否存在,检查当前类是否有权限访问这些数据,如果一个需要使用的类无法在系统中找到,则会抛出“NoClassDefFoundError”错误,如果一个方法无法被找到,则会抛出“NoSuchMethodError”错误。注意,这个过程发生在链接阶段的解析环节。

3.2、链接阶段之准备(Preparation)

当一个类验证通过时,JVM就会进入准备阶段。准备阶段主要负责为类的静态变量分配内存,并将其初始化为默认值。JVM为各类型变量默认的初始值如下表所示:
在这里插入图片描述
Java并不直接支持boolean类型,对于boolean类型,内部实现是int,int的默认值是0,对应的boolean类型的默认值是false。

注意,这个阶段不会为使用static final修饰的基本数据类型初始化为0,因为final在编译的时候就会分配了,准备阶段会显式赋值。也不会为实例变量分配初始化,因为实例变量会随着对象一起分配到Java堆中。这个阶段并不会像初始化阶段那样会有初始化或者代码被执行。代码清单如下展示了static final修饰的基本数据类型不会被初始化为0。
在这里插入图片描述
查看该类的字节码字段属性,如下图所示:
在这里插入图片描述
如果类字段的字段属性表中存在ConstantValue属性,那么在准备阶段该类字段value就会被显式赋值,也就是说在准备阶段,num的值是1,而不是0。仅被static修饰的类变量,在准备阶段初始化为默认值。

3.3、链接阶段之解析(Resolution)

在准备阶段完成后,类加载进入解析阶段。解析阶段主要负责将类、接口、字段和方法的符号引用转为直接引用。

符号引用就是一些字面量的引用,和JVM的内部数据结构及内存布局无关。比如class文件中,常量池存储了大量的符号引用。在程序实际运行时,只有符号引用是不够的,比如当println()方法被调用时,系统需要明确知道该方法的位置。

以方法为例,JVM为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用。代码清单如下演示了方法在解析阶段的调用过程。
在这里插入图片描述
其对应的字节码如下:

     0 getstatic #2 <java/lang/System.out>
     3 ldc #3 <atguigu>
     5 invokevirtual #4 <java/io/PrintStream.println>
     8 return

invokevirtual #4 <java/io/PrintStream.println>方法的符号引用指向常量池中第四个选项,如下图所示:
在这里插入图片描述
方法调用的常量是类中方法的符号引用,包含类名和方法以及方法参数,解析阶段就是获取这些属性在内存中的地址,具体过程如下图所示:
在这里插入图片描述

通过第4项常量找到第21项类名常量和第22项方法的名称描述符即可。

4、初始化(Initialization)阶段

类的初始化是类装载的最后一个阶段。如果前面的步骤都没有问题,那么表示类可以顺利装载到系统中,然后JVM才会开始执行Java字节码,也就是说到了初始化阶段,JVM才真正开始执行类中定义的Java程序代码。初始化阶段的重要工作是执行类的()方法(即类初始化方法),该方法仅能由Java编译器生成并被JVM调用,程序开发者无法自定义一个同名的方法,也无法直接在Java程序中调用该方法。()方法是由类静态成员的赋值语句以及static语句块合并产生的。通常在加载一个类之前,JVM总是会试图加载该类的父类,因此父类的()方法总是在子类()方法之前被调用,也就是说,父类的static语句块优先级高于子类,简要概括为由父及子,静态先行。

Java编译器并不会为所有的类都产生()方法。以下情况class文件中将不会包含()方法:

  • (1)一个类中并没有声明任何的类变量,也没有静态代码块时。
  • (2)一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作时。
  • (3)一个类中包含static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式。

代码清单如下展示了哪些情况不会产生()方法:
在这里插入图片描述
查看该类对应的方法信息,如下图所示,可以看到不存在()方法。
在这里插入图片描述

4.1、static与final搭配

static与final定义的变量在准备阶段完成赋值,但是并不是所有的变量都在链接阶段的准备阶段完成赋值,下面通过代码案例说明不同情况下的不同阶段赋值,如代码清单如下所示:
在这里插入图片描述
对应字节码指令在()方法中:
在这里插入图片描述
从字节码指令中看到只有定义类成员变量a、INTEGER_CONSTANT1、INTEGER_CONSTANT2和s1时是在初始化阶段的()方法中完成。那么另外两个类变量是怎么赋值的呢?通过jclasslib查看字段属性表,如下图所示,可以看到只有INT_CONSTANT和helloworld0两个常量拥有ConstantValue,说明INT_CONSTANT = 10和String s0="helloworld0"是在链接阶段的准备阶段完成的。
在这里插入图片描述
我们得出的结论就是,基本数据类型和String类型使用static和final修饰,并且显式赋值中不涉及方法或构造器调用,其初始化是在链接阶段的准备环节进行,其他情况都是在初始化阶段进行赋值。

4.2、()方法的线程安全性

对于()方法的调用,JVM会在内部确保其多线程环境中的安全性。JVM会保证一个类的()方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。正是因为方法()带锁线程安全的,如果在一个类的()方法中有耗时很长的操作,就可能造成多个线程阻塞,导致死锁,这种死锁是很难发现的,因为并没有可用的锁信息。如果之前的线程成功加载了类,则等在队列中的线程就没有机会再执行()方法了,当需要使用这个类时,JVM会直接返回给它已经准备好的信息。

4.3、类的初始化时机:主动使用和被动使用

初始化阶段是执行类构造器()方法的过程。虽然有些类已经存在()方法,但是并不确定什么时候会触发执行,可以触发()方法的情景称为主动使用,不能触发()方法执行的情景称为被动使用。主动使用可以触发类的初始化,被动使用不能触发类的初始化。

4.3.1、主动使用

JVM不会无条件地装载class文件,class文件只有在首次使用的时候才会被装载。JVM规定,一个类或接口在初次使用前,必须要进行初始化。这里的“使用”是指主动使用,主动使用包含下列几种情况。

(1)创建一个类的实例时,比如使用new关键字、反射、克隆或反序列化等方式创建实例。首先创建Order类,Order类中写了一段静态代码块,如下代码清单所示:
在这里插入图片描述
一个类被初始化的标志就是执行()方法,查看Order类的()方法,如下图所示,说明只要执行了静态代码块就表示执行了()方法,即Order类被初始化。
在这里插入图片描述
代码清单如下演示了new[test()方法]关键字创建实例、反序列化[test2()方法],以及反射[test3()方法]都会调用类的初始化,代码中如果输出了Order类中对应的输出语句即表示执行了类的初始化。注意,案例中序列化[test1()方法]的作用仅仅是将对象序列化为order.dat文件,为反序列化[test2()方法]做铺垫,虽然序列化[test1()方法]也输出了Order类中的语句,这是因为new关键字调用了类的初始化,而不是序列化调用了类的初始化。
在这里插入图片描述
test()、test2()、test3()方法的执行结果如下,表明使用new关键字、反序列化、反射等方式都会执行类的初始化。

     Order类的初始化过程

(2)调用类的静态方法时,即当使用了字节码invokestatic指令时。

Order类中添加静态方法,如下所示:

     public static void method(){
         System.out.println("Order method()....");
     }

ActiveUse1类中添加test4()方法用于调用类的静态方法,如下所示:
在这里插入图片描述
test4()方法执行结果如下,可以发现调用类的静态方法的时候也执行了类的初始化:

     Order类的初始化过程
     Order method()....

(3)使用类、接口的静态字段时(final修饰特殊考虑),字节码指令中使用了getstatic或者putstatic指令。

在Order类中添加以下属性:

     public static int num1 = 1;
     public static final int num2 = 2;
     public static final int num3 = new Random().nextInt(10);

创建ActiveUse2类用于测试使用类的静态字段是否会触发类的初始化,如代码清单如下所示:
在这里插入图片描述
test1()方法的执行结果如下所示:

     Order类的初始化过程
     test1()方法执行结果:1

test2()方法的执行结果如下所示:

     test2()方法执行结果:2

test3()方法的执行结果如下所示:

     Order类的初始化过程
     test3()方法执行结果:8

从结果来看,当字段使用static修饰且没有使用final字段修饰时,如果使用该字段会触发类的初始化;当static和final同时修饰字段时,且该字段是一个固定值则不会触发类的初始化,因为该类型的字段在链接过程的准备阶段就已经被初始化赋值了,不需要类初始化以后才能使用,所以不会执行类的初始化;num3是因为在程序执行之前无法确定具体的数值,所以需要执行类的初始化以后才能继续执行。

上面讲述了类的静态字段是否触发类的初始化,接下来再测试接口的静态字段是否会触发接口的初始化,创建CompareA接口如代码清单所示:
在这里插入图片描述
查看CompareA中()方法,如下图所示,可以看到如果创建了线程对象t并且里面的代码块语句输出则表示执行了()方法。
在这里插入图片描述
在ActiveUse2类中添加test4()和test5()方法用于测试接口的静态属性是否会触发类的初始化:
在这里插入图片描述
test4()方法执行结果如下:

     test4()方法执行结果:1

test5()方法执行结果如下:

     CompareA的初始化
     test5()方法执行结果:3

可以看到接口的静态字段和类的静态字段对类的初始化效果是一样的,需要注意的是接口的字段默认是由static final修饰的,接口中没有字段是被static单独修饰的。

(4)初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。JVM虚拟机初始化一个类时,要求它的所有父类都已经被初始化,但是这条规则并不适用于接口。在初始化一个类时,并不会先初始化它所实现的接口;在初始化一个接口时,并不会先初始化它的父接口。因此,一个父接口并不会因为它的子接口或者实现类的初始化而初始化。只有当程序首次使用特定接口的静态字段时,才会导致该接口的初始化。

4.3.2、被动使用

除了以上的情况属于主动使用,其他的情况均属于被动使用。被动使用不会引起类的初始化。也就是说并不是在代码中出现的类,就一定会被加载或者初始化。如果不符合主动使用的条件,类就不会初始化。被动使用包含如以下几种情况:

  • (1)当访问一个静态字段时,只有真正声明这个字段的类才会被初始化。当通过子类引用父类的静态变量,不会导致子类初始化。
  • (2)通过数组定义类引用,不会触发此类的初始化。
  • (3)引用常量不会触发此类或接口的初始化,因为常量在链接阶段已经被显式赋值,主动使用第3条规则我们已经讲过了,不再赘述。
  • (4)调用ClassLoader类的loadClass()方法加载一个类,并不是对类的主动使用,不会导致类的初始化。

5、类的使用(Using)

任何一个类在使用之前都必须经历过完整的加载、链接和初始化3个步骤。一旦一个类成功经历这3个步骤之后,便“万事俱备,只欠东风”,就等着开发者使用了。开发人员可以在程序中访问和调用它的静态类成员信息(比如静态字段、静态方法等),或者使用new关键字创建对象实例。

6、类的卸载(Unloading)

和前面讲过对象的生命周期类似,对象在使用完以后会被垃圾收集器回收,那么对应的类在使用完成以后,也有可能被卸载掉。在了解类的卸载之前,需要先厘清类、类的加载器、类的Class对象和类的实例之间的引用关系。

6.1、类、类的加载器、类的Class对象、类的实例之间的引用关系

(1)类加载器和类的Class对象之间的关系:在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。另外,一个Class对象总是会引用它的类加载器,调用Class对象的getClassLoader()方法,就能获得它的类加载器。由此可见,代表某个类的Class对象与该类的类加载器之间为双向关联关系。

(2)类、类的Class对象、类的实例对象之间的关系:一个类的实例总是引用代表这个类的Class对象。Object类中定义了getClass()方法,这个方法返回代表实例所属类的Class对象的引用。此外,所有的Java类都有一个静态属性class,它引用代表这个类的Class对象。

6.2、类的生命周期

当类被加载、链接和初始化后,它的生命周期就开始了。当代表类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,类在方法区内的数据也会被卸载,从而结束类的生命周期。一个类何时结束生命周期,取决于代表它的Class对象何时结束生命周期。

6.3、案例

自定义一个类加载器MyClassLoader加载自定义类Order,那么就可以通过Order的Class对象获取到对应的类加载器,再通过Order类的实例对象获取到类Class对象,如下代码清单所示:
在这里插入图片描述
类、类的加载器、类的Class对象、类的实例之间的引用关系如下图所示:
在这里插入图片描述
myLoader变量和order变量间接引用代表Order类的Class对象,而orderClass变量则直接引用代表Order类的Class对象。如果程序运行过程中,将上图左侧三个引用变量都置为null,此时Order对象结束生命周期,myLoader对象结束生命周期,代表Order类的Class对象也结束生命周期,Order类在方法区内的二进制数据被卸载。当再次有需要时,会检查Order类的Class对象是否存在,如果存在会直接使用,不再重新加载;如果不存在Order类会被重新加载,在JVM的堆区会生成一个新的代表Order类的Class实例。

6.4、类的卸载

通过上面的案例可以知道当类对象没有引用时,可能会产生类的卸载,类的卸载需要满足如下三个条件:

  • (1)该类所有的实例已经被回收。
  • (2)加载该类的类加载器的实例已经被回收。
  • (3)该类对应的Class对象没有任何对方被引用。

但是需要注意,并不是所有类加载器下面的类都可以被卸载,Java自带的三种类加载器的实例是不可以被卸载的,所以它们加载的类在整个运行期间是不可以被卸载的,只有被开发者自定义的类加载器实例加载的类才有可能被卸载。一个已经加载的类被卸载的概率很小,至少被卸载的时间是不确定的。开发者在开发代码的时候,不应该对虚拟机的类卸载做任何假设,在此前提下,再来实现系统中的特定功能。

6.5、回顾:方法区的垃圾回收

方法区的垃圾收集主要回收两部分内容,分别是常量池中废弃的常量和不再使用的类。HotSpot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

JVM判定一个常量是否“废弃”还相对简单,而要判定一个类是否属于“不再被使用的类”的条件就比较苛刻了,需要同时满足下面三个条件:

  • (1)该类所有的实例都已经被回收。也就是Java堆中不存在该类及其任何派生子类的实例。
  • (2)加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
  • (3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

上述三个条件并不是JVM卸载无用类的必要条件,JVM可以卸载类也可以不卸载类,不会像对象那样没有引用就肯定回收。

7、小结

主要介绍了JVM将class文件加载到内存所经历的过程,这个过程可分为加载、链接和初始化三大步骤。加载阶段主要负责根据类的二进制数据创建类模板对象。链接阶段主要负责获取类或接口并将其组合到JVM的运行时状态,链接又分为验证、准备和解析三个阶段。初始化主要负责为静态字段赋值,以及执行()方法,注意类的初始化仅会被执行一次。学习类的加载过程可以帮助我们更加透彻地理解class文件的执行过程。

  • 18
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值