类加载机制
前言
Java为了做到“一次编写,到处运行”,发布了运行在不同平台上的JVM,所有平台上的JVM都支持统一的程序存储格式————字节码(ByteCode),而存储字节码的二进制文件被称为Class文件。
对于JVM来说,不管是什么语言,只要能编译成Class文件,就可以在JVM中运行。换句话说,JVM不与任何语言绑定,而是与Class文件绑定。
任何一个Class文件都对应一个唯一的类或者接口,但是类或接口不一定定义在文件中(例如可以动态生成)。
Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程被称作虚拟机的类加载机制。
类文件结构
在学习类加载之前,需要先了解Class文件的结构。
首先给出一个简单的代码样例:
1 | public class TestClass{ |
使用 javac TestClass.java
对其编译后,生成了一个名为 TestClass.class
的文件。使用16进制编辑器打开后,内容如下:
后续的讲解都基于这个例子。
概述
Class文件是一组以8个字节为基础的二进制流,各个数据项目严格按照顺序紧凑地排列在文件之中,中间没有添加任何分隔符。
在Class文件中,只有两种数据类型————“无符号数”和“表”
- 无符号数:分为u1、u2、u4、u8,表示占用1个字节、2个字节、4个字节、8个字节。无符号数可以用来描述数字、索引引用、数量值或者UTF8字符串。
- 表:由多个无符号数或者其他表构成,用于描述由层次关系的复合结构的数据。为了便于区分,所有表的命名都习惯性地用“_info”结尾。
本质上,整个Class文件也是一个表,其结构如下:
无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时候称这一系列连续的某一类型的数据为某一类型的“集合”。
魔数
Class文件开头的第一个u4无符号数(magic)被称为魔数,用于标识这是一个Class类型的文件。
Class文件的魔数内容为 0xCAFEBABE
。
版本号
紧接着魔数,后面的一个u2无符号数(minor_version)是次版本号;第二个u2无符号数(major_version)是主版本号。
JDK1的主版本号为45,每个大版本发布后都会+1;例如JDK17的主版本号为 0x3D
,即61。
高版本的JDK可以向下兼容以前版本的Class文件。
次版本号在JDK1时曾经被使用过,后来直到JDK12之前均为使用,全部固定为0。到了JDK12时期,由于JDK新特性越来越多,次版本号又被启用。
常量池
版本号之后的是常量池(constant_pool)。常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一。由于常量池的数量不固定,因此常量池的入口前需要加一个u2无符号数(constant_pool_count,计数从1开始)表示常量池的容量。
常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念。
常量池中的每个常量都是一个表,并且结构各不相同。每个常量有个共同特点,表结构起始的第一位是一个u1的标志位,表示当前常量属于哪种常量类型。
常量池中的元素的类型如下:
访问标志
常量池之后的一个u2无符号数(access_flags)表示访问标志,用于标识类或接口层次的访问信息。它的值是各个标志值的或运算结果。
例如 access_flags = 0x0011 = 0x0001 | 0x0010
,则表示 public
和 final
。
访问标志类型:
类索引、父类索引与接口索引集合
访问标志后的是类索引(this_class)、父类索引(super_class)和接口索引(interfaces)。类索引和父类索引是u2类型的数据,而接口索引集合是一组u2类型的索引。
它们各自指向一个类型为CONSTANT_class_info类型的常量;类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,接口索引用于确定这个类实现或继承的接口的全限定名。
字段表集合
在此之后的是u2类型的字段表集合(fields_count和fields)。字段表集合中的每一个元素被称为字段表,用于描述一个字段(即声明在接口或类中的变量,Java语言中的字段包括类级变量和实例级变量)。
字段表的结构:
字段表中也有access_flags,它与类中的access_flags非常类似,具体如下:
在access_flags后的是两项索引值:name_index和descriptor_index。它们都是对常量池项的引用,分别代表着字段的简单名称以及字段和方法的描述符。
全限定名:把类全名中的
'.'
替换为'/'
,例如com.example.demo.TestClass
的全限定名为com/example/demo/TestClass
简单名称:没有类型和参数修饰的方法或者字段名称
描述符:描述字段的数据类型、方法的参数列表和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示
对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义为“java.lang.String[][]”类型的二维数组将被记录成“[[Ljava/lang/String;”,一个整型数组“int[]”将被记录成“[I”。
用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”之内。如方法void inc()的描述符为“()V”,方法java.lang.String toString()的描述符为“()Ljava/lang/String;”,方法
int indexOf(char[]source,int sourceOffset,int sourceCount,char[]target,int targetOffset,int targetCount,int fromIndex)
的描述符为“([CII[CIII)I”。
字段表的最后是一个属性表集合,用于存放字段的具体数值(具体内容将在后面提及)。
方法表集合
方法表集合(methods_count和methods)和字段表集合基本一致,仅在访问标志和属性表中有所差异。例如volatile不能用于修饰方法等等。
字段表的最后也是一个属性表集合,用于存放方法中的的具体实现(具体内容将在后面提及)。
属性表集合
在Class文件的最后,是一个属性表组成的集合(attributes_counts和attributes)。
属性表集合在之前的字段表和方法表中也有出现。
与Class文件中的其他数据项目不同,属性表并没有严格的顺序、长度、内容要求,并且只要不与已存在的属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。为了能正确解析Class文件,JVM规范中预定义了一些属性,只需了解一些其中常用的即可。
每一个属性的结构如下:
- attribute_name_index: 引用常量池中的一个 CONSTANT_Utf8_info 类型的常量作为名称
- attribute_length: 属性值占用的位数
- info: 自定义的属性值
Code属性
Code属性出现在方法表的属性集合中(并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在Code属性)。Code属性主要用于存放经过编译器处理后的方法体
- attribute_name_index: 指向CONSTANT_Utf8_info型常量,固定为”Code”,表示这个属性的名称是Code。
- attribute_length: 属性值的长度(属性表长度-6byte)。
- max_stack: 操作数栈最大深度。
- max_locals: 局部变量所需的存储空间。
- code_length: 方法体编译后的字节码的长度。
- code: 存储方法体编译后的字节码指令。
- exception_table_length: 异常表长度。
- exception_table: 异常表。
异常表描述的是try-catch的过程:如果当字节码从第start_pc行到第end_pc行之间(不含第end_pc行)出现了类型为catch_type或者其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理。当catch_type的值为0时,代表任意异常情况都需要转到handler_pc处进行处理。
Exception属性
Code属性出现在方法表的属性集合中,用于列举方法中可能抛出的异常(即throws关键字后的异常)
- execption_index_table: 指向常量池中的CONSTANT_Class_info型常量的索引。
ConstantValue属性
ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可以使用这项属性。
在Java中,用不同的方式定义的变量的赋值方式和时机都会不同。对于非static变量的赋值是在实例构造器<init>()
方法中进行的;对于static变量,可以在类构造器<clinit>()
中赋值或用ConstantValue属性赋值。
目前Oracle的Javac编译期的逻辑是:如果某个变量被static和final修饰,并且它的类型是基本数据类型或者String的话,就会将其认定为常量,生成ConstantValue属性来对其进行初始化。
- attribute_length: 固定为2
- constantvalue_index: 指向常量池中的一个常量
自行了解属性表参考:Class文件结构介绍属性表集合-CSDN博客
类加载机制
与在编译时期进行连接的语言不同,在Java中,类型的加载,连接和初始化都是在程序运行期间完成的。这种策略让Java很难提前编译,并且会增加
类的生命周期
一个类型(类和接口)从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)七个阶段,其中验证、准备、解析三个部分统称为连接(Linking)。
类加载的时机
《JVM规范》中没有对类加载的第一个阶段“加载”进行强制约束,可以由虚拟机自由把握。但是《JVM规范》对与“初始化”有着严格规定,规定以下六种情况下必须立即对类进行“初始化”(“加载”和“连接”会在此之前执行):
- 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化。能够生成这四条指令的Java代码场景有:
- 使用 new 关键字实例化对象
- 读取或设置类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)
- 调用一个类型的静态方法
- 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
- 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先 初始化这个主类。
- 当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
- 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
在《JVM规范》中,规定了“有且只有”这六种场景会触发初始化,这六种场景被称为主动引用。除此以外,其他引用的方式被称为被动引用,不会触发其初始化。
类加载的过程
加载
加载是整个类加载过程的第一个阶段。在这个阶段,JVM需要完成三件事:
- 通过一个类的全限定名获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
加载阶段结束后,JVM外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了。类型数据安置在方法区之后,会在Java堆内存中实例化一个java.lang.Class类的对象,这个对象将作为程序访问方法区中的类型数据的外部接口。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分,这两个阶段的开始时间仍然保持着固定的先后顺序。
验证
验证是连接阶段的第一个步骤,目的是为了确保Class文件的字节流中包含的信息符合《JVM规范》的约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段大致上分为下面四个阶段的检验动作:
文件格式验证
验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。
包括:
- 是否以魔数0xCAFEBABE开头。
- 主、次版本号是否在当前Java虚拟机接受范围之内。
- 是否有其他不符合Class文件规范要求的数据。
前面提到过,加载阶段和连接阶段的部分动作是交叉进行的。这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入Java虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。
元数据验证
对字节码描述的元数据信息进行语义分析,保证其描述的信息符合Java语言规范的要求(验证元数据的语法)。
包括:
- 这个类是否有父类(除了java.lang.Object之外,所有的类都应当有父类)。
- 这个类的父类是否继承了不允许被继承的类(被final修饰的类)。
- 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。
- 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载,例如方法参数都一致,但返回值类型却不同等)。
- …
字节码验证
对类的方法体(Class文件中的Code属性)进行校验分析。
符号引用验证
符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。
准备
准备阶段是正式为类变量(static变量)分配内存并设置初始值(零值)的阶段。这些变量使用的内存在方法区中分配。
需要强调两点:
- 准备阶段只会给类变量分配内存,而不包括实例变量。
- 通常情况下设置的初始值指的是零值。
赋零值是通常情况下的做法。
例如
public static int value = 123;
其中的 value 在准备阶段之后的值为 0 。但是如果是
public static int value = 123;
javac在编译这段代码后会为 value 生成常量属性,在准备阶段之后会直接赋值为 123 。
解析
解析阶段是JVM将常量池内的符号引用替换为直接引用的过程。
符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用的字面量形式在《JVM规范》中有明确定义,因此各个虚拟机能接受的符号引用必须一致。
直接引用:直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用和虚拟机的内存布局直接相关,同一个符号引用在不同虚拟机上翻译出来的直接引用不一定相同。
《JVM规范》没有严格规定解析阶段发生的具体时间,JVM可以根据需要自行判断到底是在类被加载器加载时就对常量池中的符号引用进行解析,还是等到一个符号引用将要被使用前才去解析它。
初始化
类的初始化是类加载过程的最后一步。
在准备阶段时,变量已经赋过一次零值了,而在初始化阶段,则会根据程序中编写的值进行初始化。换句话说,初始化阶段就是执行类构造器<clinit>()
方法的过程。
<clinit>()
方法是Javac编译器自动产生的。编译器会自动收集类中的所有类变量的赋值语句和静态语句,并将其合并成<clinit>()
方法。
类加载器
在前面的类加载过程中,第一步的加载阶段就是由类加载器实现的。
类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远超类加载阶段。对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在Java虚拟机中的唯一性。
类加载器分类
对于JVM来说,只存在两种不同的类加载器:
- 启动类加载器:用C++实现,是JVM的一部分。
- 其他类加载器:用Java实现,独立在JVM外部,并且全部继承自
Java.lang.ClassLoader
。
对于Java开发者来说,类加载器可以分为三层架构和双亲委派模型:
- 启动类加载器(Bootstrap Class Loader):负责加载
<JAVA_HOME>\lib
目录或者被-Xbootclasspath
参数所指定的路径中存放的类库。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器处理,那直接使用null代替即可。 - 扩展类加载器(Extension Class Loader):在类 sun.misc.Launcher$ExtClassLoader 中以Java代码的形式实现。负责加载
<JAVA_HOME>\lib\ext
目录或者被java.ext.dirs
系统变量所指定的路径中存放的类库。通过这个加载器,用户可以把一些具有通用性的类库放在ext目录中以拓展JDK的功能。在JDK9之后,这种扩展机制被JDK的模块化功能的扩展机制所取代。由于扩展类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。 - 应用程序类加载器(Application Class Loader):在类 sun.misc.Launcher$AppClassLoader 中实现。由于可以用
ClassLoader.getSystemClassLoader()
来获取,因此也被称为系统类加载器。它负责加载用户类路径(ClassPath)里的所有类库。开发者也可以直接在代码中使用应用程序类加载器,并且如果没有显示指定其他类加载器,一般情况下这个类加载器就是程序中默认的类加载器。
双亲委派模型
JDK9之前的Java应用程序都是由这三种类加载器互相配合完成加载的,用户也可以加入自定义的类加载器进行拓展。这些类加载器的协作关系如图:
这种类加载器之间的层次关系就被称为类加载器的“双亲委派模型”。“双亲委派模型”要求除了顶层的启动类加载器外,其余的类加载器都应该有自己的父类加载器。
“双亲委派模型”加载一个类的过程:如果一个类加载器收到了类加载的请求,它并不会首先自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,并且每一个层次的类加载器都是如此。因此所有的加载请求最终都会被传送到最顶层的启动类加载器中。只有父加载器无法完成这个加载请求,子加载器才会开始尝试自己去完成加载。
好处:前面提到过每个类的唯一性都需要这个类本身和它的加载器共同确立。在类加载器加载类时,会根据这个唯一性来确保让每个类只被加载一次,不会被重复加载。使用了双亲委派模型后,就可以让核心类(例如String)都用最顶层的启动类加载器加载,从而使得核心类不会被恶意篡改。
全文参考:《深入理解Java虚拟机-第三版》