第七章——类加载机制

本文深入讲解Java类加载机制,包括加载、验证、准备、解析、初始化等阶段的详细流程,以及类加载器的工作原理和双亲委派模型。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

概述

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

类加载的时机

  一个类从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期包括:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)。其中,验证、准备、解析三个阶段统称为连接。

  加载、验证、准备、初始化、卸载这五个阶段的顺序是确定的,类的加载过程必须按照这个顺序开始(注意,是开始,不是“进行”或“完成”,这些阶段通常都是互相交叉的混合式进行的,会在一个阶段进行的过程中调用或激活另一个阶段)。解析阶段在某些情况下可以在初始化之后开始,这是为了支持Java语言的运行时绑定。

  没有规定什么时候开始进行类的“加载”阶段,但是严格规定了有且只有四种情况,必须立即开始初始化(相应的,加载、验证、准备自然要在这之前)。

  1. 遇到:new、getstatic、putstatic、invokestatic这4条字节码指令的时候,如果类没有初始化,就要先做类的初始化。生成这四条指令的Java代码最常见的是:使用“new”关键字实例化对象的时候;读取或者设置一个类的静态字段的时候(除非它被final修饰,在编译的时候已经把结果放在了常量池);调用一个类的静态方法的时候。
  2. 使用java.lang.reflect包中的方法对类进行反射调用的时候。
  3. 初始化一个类的时候发现父类还没有初始化,就要先初始化父类。
  4. 当虚拟机启动的时候,用户要指定一个要执行的主类,虚拟机会先初始化这个主类。

  举几个例子:通过子类使用父类的静态字段的时候,不会初始化子类,只会初始化父类,此时,要不要对子类进行加载和验证,虚拟机规范并没有规定,但是hotspot虚拟机是在此时加载了子类的;创建某个类的数组的时候,不会触发这个类的初始化,它会触发一个由编译器自动生成的类的初始化,这个自动生成的类里面包含一个数组应该有的属性和方法(但是用户只能访问public的length属性和clone方法),这个自动生成的类应该就是Java中代表数组的类;如果A类访问了B类被final修饰的静态字段,那么编译器会把A类中的访问结果直接存储到A类的常量池中,最后生成的字节码文件中,A类和B类是没有联系的。

  接口的初始化不能使用static语句块,但是编译器还是会为接口生成clinit()构造器用于初始化接口中定义的成员变量。接口初始化时不要求父接口必须全部初始化,只有真正使用到父接口的时候(如引用父接口中定义的常量)才会初始化父接口。

类加载的过程

1. 加载

  加载阶段虚拟机要做三件事:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。(没有规定字节流从哪里来,可以是压缩包,可以是网络,甚至可以用16进制编辑器写的二进制文件。)
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口。

  没有规定从何而来,所以可以使用系统提供的,或者用户自定义的类加载器来控制字节流的获取方式。加载阶段完成之后虚拟机外部的二进制字节流就按照虚拟机需要的格式(具体这个格式是什么,规范里面没有规定,看虚拟机自己的实现)存在方法区之中了。

2. 验证

  验证是连接阶段的第一步,目的是为了确保class文件中包含的信息符合虚拟机的要求,并不会威胁虚拟机自身的安全。

  不同虚拟机对验证的实现不一样,但是大致可以分为下面的四个阶段:文件格式验证、元数据验证、字节码验证、和符号引用验证。

  • 文件格式验证:检验是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。大概包含:是否以魔数0xCAFEBABE开头;主次版本号是否在当前虚拟机处理范围之内;检测常量tag标志,看是否有不支持的常量类型;等等……
  • 元数据验证:对字节码描述的信息进行语义分析,确保描述的信息符合Java语言的规范要求。例如:是否继承了不被允许继承的类,是否实现了接口中的方法;等等……
  • 字节码验证:整个验证过程中最复杂的阶段,主要进行数据流和控制流的分析。分析方法体,保证运行过程中不会出现危害虚拟机安全的行为。例如:保证方法体中的跳转指令不会跳转到方法体以外的字节码指令上;保证方法体中的类型转换是安全的;等等……
    通过了校验不一定就能保证没问题,因为通过一段代码来判断另一段代码的逻辑是无法做到准确的。而且会耗费很多时间做校验,所以从jdk1.7开始,编译器编译Java代码的时候会附加一些校验信息以供虚拟机进行校验,以节省一些时间。如果能保证字节码文件没有问题,还可以设置虚拟机跳过这个阶段。
  • 符号引用验证:连接阶段的解析阶段,符号引用转化为直接引用的时候发生这个校验。一般校验:符号引用中通过字符串描述的全限定名能否找到对应的类;在指定类中是否存在符合方法的字段描述符和简单名称所描述的方法和字段;等等……

3. 准备

  准备阶段会为类级(也就是静态变量)变量赋初始值,注意,这个初始值不是代码中写的初始值,而是这种数据类型的零值,各种类型的零值如下所示:

数据类型零值
int0
long0L
short(short)0
char‘\u0000’
byte(byte)0
booleanfalse
float0.0f
double0.0d
referencenull

  除非代码中写了final,这个值会存在class文件的常量池中,在准备阶段会被赋值成常量池中的ConstantValue。
  如果不是final,又在代码中写了自己的初始值的话,赋值的putstatic指令会放在类构造器clinit中执行,也就是在初始化阶段才会执行。

4. 解析

  这个阶段要把符号引用转换成直接引用。规范中并没有规定解析要在什么时候进行,但是要保证在某些指令之前必须解析完成,所以虚拟机可以选择在类加载的时候就进行解析,或者用到符号引用的时候才解析。虚拟机要保证在同一个实体中,如果一个符号引用已经成功解析过,那么后续的解析请求就应该都成功,同样的如果第一次解析失败,那么其它指令对这个符号的解析请求也应该收到相同的异常。
  解析主要针对类和接口、字段、类方法、接口方法四种符号引用进行,对应常量池中的各类符号引用。

  • 类或接口的解析:在一个类D中,把符号N,解析成类或接口C。
  1. 如果C不是数组,那么虚拟机会把符号N传给D的类加载器加载C,期间可能触发别的加载动作,比如需要加载D的父类或者接口,一旦这个加载过程出了任何异常,解析过程就宣告失败。
  2. 如果C是数组,数组中的元素是对象的话,虚拟机会先按照步骤1加载这个类,然后再生成一个代表此数组维度和元素的数组对象。
  3. 最后,如果上面的步骤没有异常,那么还要验证访问权限,如果D不能访问C的话,会抛出异常。
  • 字段解析

  看的不是很明白,只理解到:字段肯定是属于某个类的,从字段的符号引用把它所属的类加载出来。剩余的内容要换本书看看。

  理解错了,大错特错,字段所属的类的意思是:如果有个类boy,里面有个静态的String属性sex,那么“sex字段所属的类”指的是boy这个类,不是String。

  我他妈的把“sex字段所属的类”,理解成String了,sex属于String嘛。看书的时候就很费解,淦。

  1. 首先要解析这个字段所属的类,对sex来说就是boy,记作C。
  2. 在C自己中寻找这个字段,如果找到了就返回这个字段的直接引用。
  3. 如果C实现了接口,就按照继承关系从下往上寻找字段。
  4. 如果还没找到,就从下往上搜索自己的父类寻找字段。
  5. 如果还没找到那就算查找失败了。

  实际上要是同时在接口和父类都有相同的字段的话,会编译失败。

  • 类方法解析

  同样,也要先解析出这个方法在哪个类里面,用C表示这个类。

  1. 如果在类方法表中发现class_index中索引的C是个接口,就会抛异常。
  2. 在类中查找这个方法,有的话就返回方法的直接引用。
  3. 如果上一步没找到,那么就在父类中查找。
  4. 否则,就在接口和父接口中查找,如果找到了说明类C是一个抽象类。查找结束,抛出AbstractMethodError异常。
  5. 上面都没找到,说明没有,抛出NoSuchMethodError。
  6. 最后,找到之后还要验证是否具有访问权限。
  • 接口方法解析

  和类方法解析很像,先要按接口方法表的class_index找到方法所属的接口,用C表示。

  1. 和类方法相反,如果class_index的索引指向了一个类,就抛异常。
  2. 否则,在接口C中查找。
  3. 如果没找到,就在接口C的父接口,包含Object类,中查找。
  4. 否则,查找失败。
  5. 接口方法默认public,所以应该不会抛出权限错误的异常。

5. 初始化

  类加载过程中,只有加载阶段才可以通过自定义的类加载器参与,到初始化阶段才开始执行用户代码。初始化阶段会执行程序员写的初始化代码,也就是执行类构造器clinit方法的过程。

  1. clinit方法是由编译器自动收集类中的所有类变量的复制动作和静态语句块中的语句合并产生的,编译器收集的顺序是语句在源文件中的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在静态语句块之后的变量在前面的静态语句块中可以赋值,但是不能访问。
  2. clinit方法与类的构造函数(也就是init)方法不同,它不需要显式的调用父类构造器,虚拟机会保证在自雷的clinit方法执行之前,父类已经执行完毕,所以虚拟机中第一个被执行的clinit方法的类肯定是Object类。
  3. 由于父类的clinit方法先执行,所以父类中定义的静态语句块要优先于子类的变量赋值操作。
  4. clinit方法对类或者接口来说不是必须的,如果一个类中没有静态语句块也没有对变量的赋值操作,那么编译器可以不为这个类生成clinit方法。
  5. 接口的clinit方法不要求父接口的先执行,而是用到的时候才执行父接口的clinit方法。接口的实现类也不要求先执行接口的clinit方法。
  6. 虚拟机会保证一个类的clinit方法在多线程环境中被正确的加锁和同步,多线程同时加载类的时候只会有一个线程执行初始化方法,其它的阻塞等待。

类加载器

  类加载器和类自身共同决定了类的唯一性(class文件一样,但是类加载器不一样,他们也不算同一个类),Java开发人员看来,加载器有三种:

  • 启动类加载器(Bootstarp ClassLoader):负责加载<JAVA_HOME>\lib目录中的,或被-Xbootclasspath参数指定的路径中的,虚拟机识别(按文件名识别,例如rt.jar)的类库。
  • 扩展类加载器(Extension ClassLoader):由sun.misc.Launcher$ExtClassLoader实现,负责加载<JAVA_HOME>\lib\ext目录中的,或被java.ext.dirs系统变量所指定的路劲中的所有类库。开发者可直接使用。
  • 应用程序类加载器(Application ClassLoader):由sun.misc.Launcher$AppClassLoader实现,负责加载用户类路径(ClassPath)上所指定的类库。开发者可直接使用。
  1. 双亲委派模型:除了顶层的启动类加载器之外,其他的类加载器都要有自己的父类加载器(父子关系一般不用继承,而是用组合关系)。

  双亲委派模型的工作过程是:如果一个类加载器收到了一个加载请求,它首先不会自己尝试去加载,而是交给父类去加载,只有当父类反馈无法加载的时候自己才会尝试加载。
  有一个好处是Java类随着它的类加载器有了一种层级结构,例如Object类,无论哪一个类都要加载Object,双亲委派机制可以保证Object类永远都是启动类加载器加载的,在各种类加载环境下都是同一个Object类。

  1. 破坏双亲委派模型

  第一次:在双亲委派模型出现之前,程序员可以重写ClassLoader里面的loadClass()方法去加载一个类。
  第二次:出现了类库中的代码要加载用户代码的时候(启动类加载器要加载一个应用程序加载器才能加载得到的类,这显然加载不到啊),于是有了线程上下文加载器,这个加载器可以在创建线程时设置,如果不设置就从父线程继承一个,如果整个应用程序都没有,那么就默认是应用程序加载器。
  第三次:热更新,热部署OSGi,有自己不同于双亲委派模型的类加载顺序。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值