《剑指JVM》读书笔记之类加载过程

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

1. 概述

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

image-20231109103546258

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实例的位置对应关系如图所示。

image-20231109104038031

外部可以通过访问代表Order类的Class对象来获取Order类的数据结构。java.lang.Class类的构造方法是私有的,只有JVM能够创建。java.lang.Class实例是访问类型元数据的入口,也是实现反射的关键数据。通过Class类提供的接口,可以获得目标类所关联的class文件中具体的数据结构、方法、字段等信息。

2.4 数组类的加载

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

(1)如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组的元素类型,JVM使用指定的元素类型和数组维度来创建新的数组类。

(2)如果数组的元素是基本数据类型,比如int类型的数组,由于基本数据类型是由JVM预先定义的,所以也不需要类加载,只需要关注数组维度即可。

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

3. 链接(Linking)阶段

3.1 链接阶段之验证(Verification)

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

image-20231109104538224

1 格式检查

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

2 字节码的语义检查

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

(1)是否所有的类都有父类的存在(Object除外)。

(2)是否一些被定义为final的方法或者类被重写或继承了。

(3)非抽象类是否实现了所有抽象方法或者接口方法。

(4)是否存在不兼容的方法,比如方法的签名除了返回值不同,其他都一样。

3 字节码验证

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

(1)在字节码的执行过程中,是否会跳转到一条不存在的指令。

(2)函数的调用是否传递了正确类型的参数。

(3)变量的赋值是不是给了正确的数据类型等。

(4)检查栈映射帧的局部变量表和操作数栈是否有着正确的数据类型。

4 符号引用验证

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

3.2 链接阶段之准备(Preparation)

当一个类验证通过时,JVM就会进入准备阶段。准备阶段主要负责为类的静态变量分配内存,并将其初始化为默认值

image-20231109110344017

Java并不直接支持boolean类型,对于boolean类型,内部实现是int,int的默认值是0,对应的boolean类型的默认值是false。

注意,这个阶段不会为使用static final修饰的基本数据类型初始化为0,因为final在编译的时候就会分配了,准备阶段会显式赋值。也不会为实例变量分配初始化,因为实例变量会随着对象一起分配到Java堆中。这个阶段并不会像初始化阶段那样会有初始化或者代码被执行。

3.3 链接阶段之解析(Resolution)

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

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

以方法为例,JVM为每个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法。通过解析操作,符号引用就可以转变为目标方法在类中方法表中的位置,从而使得方法被成功调用

不过Java虚拟机规范并没有明确要求解析阶段一定要按照顺序执行。在HotSpot虚拟机中,加载、验证、准备和初始化会按照顺序有条不紊地执行,但解析操作往往会在JVM在执行完初始化之后再执行。

4. 初始化(Initialization)阶段

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

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

(1)一个类中并没有声明任何的类变量,也没有静态代码块时。

(2)一个类中声明类变量,但是没有明确使用类变量的初始化语句以及静态代码块来执行初始化操作时。

(3)一个类中包含static final修饰的基本数据类型的字段,这些类字段初始化语句采用编译时常量表达式。

image-20231109133942331

4.1 static与final搭配

static与final定义的变量在准备阶段完成赋值,但是并不是所有的变量都在链接阶段的准备阶段完成赋值。

image-20231127151643810

只有定义类成员变量a、INTEGER_CONSTANT1、INTEGER_CONSTANT2和s1时是在初始化阶段的<clinit>()方法中完成。INT_CONSTANT和helloworld0两个常量是在链接阶段的准备阶段完成的。

基本数据类型和String类型使用static和final修饰,并且显式赋值中不涉及方法或构造器调用,其初始化是在链接阶段的准备环节进行,其他情况都是在初始化阶段进行赋值

4.2 <clinit>()方法的线程安全性

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

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

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

1 主动使用

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

(1)创建一个类的实例时,比如使用new关键字、反射、克隆或反序列化等方式创建实例。

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

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

当字段使用static修饰且没有使用final字段修饰时,如果使用该字段会触发类的初始化;当static和final同时修饰字段时,且该字段是一个固定值则不会触发类的初始化,因为该类型的字段在链接过程的准备阶段就已经被初始化赋值了,不需要类初始化以后才能使用,所以不会执行类的初始化。

(4)初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

(5)如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类在初始化之前需要实现接口的初始化。

(6)JVM启动时,用户需要指定一个要执行的主类[包含main()方法的那个类],JVM会先初始化这个主类。这个类在调用main()方法之前被链接和初始化,main()方法的执行将依次加载,链接和初始化后面需要使用到的类。

(7)初次创建MethodHandle实例时,初始化该MethodHandle实例时指向的方法所在的类,即涉及解析REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄对应的类的初始化。

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 类的卸载

当类对象没有引用时,可能会产生类的卸载,类的卸载需要满足如下三个条件。

(1)该类所有的实例已经被回收。

(2)加载该类的类加载器的实例已经被回收。

(3)该类对应的Class对象没有任何对方被引用。

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

6.4 回顾:方法区的垃圾回收

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

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

(1)该类所有的实例都已经被回收。也就是Java堆中不存在该类及其任何派生子类的实例。

(2)加载该类的类加载器已经被回收。这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。

(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

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

【本文完】

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值