类文件结构
.class文件,类文件,字节码文件。
代码编译的结果从本期机器码转变为字节码,是存储格式发展的一步。
概述
计算机只能识别0和1,因此程序需要经编译器翻译成二进制格式。
而随着虚拟机的发展,越来越多程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式。即将程序编译成二进制本地机器码已经不是唯一的选择。
无关性的基石
平台无关系:
- 许多虚拟机都可以载入和执行同一种平台无关的字节码,实现“一次编写,到处运行。
- 各种不同平台的虚拟机与所有平台都统一使用的程序存储格式–字节码,是构成平台无关系的基石。
虚拟机的另一种中立特性,即语言无关性:
- 其他语言也可以允许在JVM上。Java虚拟机不和任何语言绑定,只与Class文件这种特定的二进制文件格式关联。
- Class文件包含JVM指令集和符号表以及若干其他辅助信息。基于安全,要求Class文件使用许多强制性的语法和结构化约束。
- Java语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成。字节码所能提供的语言描述能力比Java更强大。
Class类文件结构
任何一个Class文件都对应着唯一一个类或接口的定义信息,但是类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。
- Class文件是一组以8字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件中,中间没有添加任何分割符,使得Class文件中存储的内容几乎全部是程序允许的必要数据,没有空隙存在。
- 当遇到需要占用8字节以上空间的数据项时,则按照高位在前的方式分割成若干个8位字节进行存储。
- 高位在前:最高位字节在地址最低位,最低位字节在地址最高处。
- Class文件格式采用类C语言结构体的伪结构存储数据:
- 两种数据类型:无符号数,表。
- 无符号数:基本的数据类型,描述数字、索引引用、数量值或按UTF8编码的字符串值。u1、u2、u4、u8分别代表1、2、4、8个字节的无符号数。
- 表:由多个无符号数或其他表构成的符合数据结构,习惯性以_info结尾。用于描述具有层次关系的符合结构的数据。整个Class文件本质上是一张表。
魔数与Class文件版本
- 魔数magic:Class文件的头4个字节。用于确定文件是否为一个能够被虚拟机接受的Class文件。
- 即用于身份识别,例如jpeg等在文件头都有魔数。基于安全考虑,因为扩展名可以随意改动。
- 5-8字节:Class文件的版本号,5-6字节:minor_version次版本号,7-8字节:major_version主版本号。
- JDK能够向下兼容,但也必须拒绝执行超过其版本号的Class文件
常量池
跟在主次版本号之后的是常量池入口。
- 常量池可以理解为Class文件中的资源仓库,是Class文件结构中与其他项目关联最多的数据类型,也是占用空间最大的数据项目之一,同时是Class文件中第一个出现表数据项目。
- 常量池中的常量的数量是不固定的,因此在入口需要放置一项u2类型的数据,代表常量池容量计数值:constant_pool_count。其计数从1开始,0表达不引用任何一个常量池项目。
- 常量池主要存放两大类常量:
- 字面量。文本字符串、声明为Final的常量值。
- 符号引用。类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
虚拟机加载Class文件会进行动态连接,并不会像C一样有连接步骤,即Class文件不会保存各个方法、字段的内存最终布局信息。因此需要通过运行期转换才能得到真正的内存入口地址。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析,翻译到具体的内存地址之中。
常量池中的每个常量都是一个表,在JDK7中具有14中,其共同特点是表开始的第一位是一个u1类型标志位,判断该常量属于那种常量类型。
其中CONSTANT_Class_info类型代表一个类或接口的符号引用。
- name_index是一个索引值,指向一个CONSTANT_Utf8_info类型常量,代表了这个类或接口的全限定名。
- 若值为0x0002即指向常量池中的第二项常量
类型 | 名称 | 数量 |
---|---|---|
u1 | tag | 1 |
u2 | name_index | 1 |
CONSTANT_Utf8_info结构如下:
- length说明该UTF-8编码的字符串长度
- bytes字节的连续数据是一个UTF-8缩略编码表示的字符串。
类型 | 名称 | 数量 |
---|---|---|
u1 | tag | 1 |
u2 | length | 1 |
u1 | bytes | length |
其它常量
当利用javap进行分析常量池时,其中会出现一些其它的常量,例如I
、V
、<init>
、LineNumberTable
等,它们会被字段表、方法表、属性表引用到。用于描述一些不方便用固定字节表达的内容,例如方法的返回值是说明,有几个参数,每个参数的类型是说明等。
常量表常量项的结构总表
访问地址
位于常量池之后,紧接的两个字节代表访问标志access_flags
,标志用于标识一些类或者接口层次的访问信息。如该Class是类还是接口,是否为public等。如果是类,是否为final。
类索引、父类索引与接口索引集合
类索引this_class
、父类索引super_class
:一个u2类型的数据。接口索引的集合interfaces
:一组u2类型的数据。Class文件用于确定类的继承关系。
字段表集合
field_info
用于描述接口或者类中声明的变量。字段包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。
access_flags
描述字段的访问级别。name_index
是对常量池的引用,描述字段的简单名称。descriptor_index
描述字段和方法的描述符,描述符的作用是描述字段的数据类型、方法的参数列表(数量、类型、顺序)和返回值。
方法表集合
与字段表几乎一致,方法表的结构如下:
access_flags
描述作用域、静态or非静态、可变性、是否同步、是否本地方法、是否抽象。descriptor_index
是方法描述,例如参数、返回值。name_index
是方法名称。attributes
是方法内机器指令、异常信息、是否声明为deprecated。是属性表。
其access_flags的值:
方法内部的代码经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为Code
属性中。
TODO属性表集合
Class文件、字段表、方法表都可以携带自己的属性表集合,用于描述某些场景专有的信息。
属性表集合不再要求各个属性表具有严格的顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表写入自己定义的属性信息,JVM运行时会忽略其不认识的属性。
Code属性
Java程序方法体中的代码经过java编译后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合中,但并非所有的方法表都必须存在这个属性。例如接口与抽象类是不存在的。Code属性表的结构:
attribute_name_index
是一项指向CONSTANT_Utf8_info
型常量的索引,其值固定为”Code“代表了该属性的名称。max_stack
代表了操作数栈深度的最大值,方法执行的任意时刻都不会超过该深度,虚拟机运行时要根据这个值来分配栈帧中操作栈深度。max_locals
代表了局部变量表所需的存储空间。单位为Slot,对于char、int等32位数据类型,其占据1个Slot,而对于Double等64位占据两个。- 方法参数、显式异常处理器中的参数、方法体当中定义的局部变量。
- Slot可以重用,当代码执行超过一个局部变量的作用域时,这个局部变量表中的Slot可以被其他局部变量使用。
code_length
(要求长度不超过u2,否则拒绝编译)和code
存储Java程序编译后生成的字节码指令。每个字节码指令是一个u1类型的单字节。(u1取值0x00-0xFF,即一共256条指令)。- 当虚拟机读取到
code
当中的一个字节码,就可以对应找出这个字节码代表什么指令,并且可以知道这条指令后是否需要跟随参数,以及参数如何理解。
- 当虚拟机读取到
exception_table
是异常处理跳转信息。attributes
包含Java源码行号与机器码的对应关系,以及局部变量表描述信息。- LineNumberTable。因此抛出异常时可以知道是在代码中的哪一行等。
- LocalVaribale。会记录栈帧局部变量表和Java源码中定义的变量中的关系,不是运行时必须的属性。与IDE的代码提示有关。
实例
1 | public class TestClass{ |
考虑TestClass.class的init
方法的Code属性。
虚拟机顺序读取后面的5个字节,并根据字节码指令表翻译出对应的字节码指令:
- 2A。即aload_0,将第0个Slot中位reference类型的本地变量推送到操作数栈顶。
- B7。即invokespecial,以栈顶的reference类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private或父类方法。
- 之后跟随一个u2类型的参数说明具体调用哪一个方法,指向常量池中的一个CONSTANT_Mehodref_info类型的变量,即该方法的方法符号引用。
- 000A。是invokespecial的参数,查询常量池得对应一个实例构造器init方法的符号引用。
- B1。即return,返回此方法,并且返回值为void。方法结束。
考虑inc()的方法调用
1 | { |
虽然类中的两个方法都没有参数,但是Args_Size=1
是因为Java编译时将this
转换成一个普通的方法参数,在调用实例方法时将该参数传入。
TODO:方法的显式异常处理表
公有设计与私有实现
虚拟机类加载机制
如何将字节码文件加载到虚拟机当中。
java.lang.ClassNotFoundExcetpion类加载异常。
某个Java包的子包是由类加载器定义的。Java或者它的子包中的类和接口都是由启动类加载器加载的。
概述
虚拟机如何加载Class文件?Class文件中的信息进入虚拟机后会发生什么变化?
类加载机制:虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。
Java里,类型的加载、连接和初始化过程都是在程序运行期间完成的,虽然使得类加载时稍增加一些性能开销,但是为应用提供高度灵活性。这实现了Java可以动态扩展的语言特性,其天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。
- 编写一个面向接口的应用程序,可以等到运行时再指定实际的实现类。
- 用户可以通过Java预定义的和自定义的类加载器,让一个本地应用程序可以在运行时从网络或其他地方加载一个二进制流作为程序代码的一部分。
这里的Class文件:一串二进制字节流。
类加载的时机
类从被加载到虚拟机内存中开始,到卸载出内存为止,整个生命周期:
加载、验证、准备、解析、初始化、使用、卸载。
加载、验证、准备、初始化、卸载的顺序是确定的,依次顺序开始(只是开始,不是进行或者完成,他们中间混合交叉进行)。而解析阶段不一定,它在某些情况下可以在初始化之后再开始,以支持动态绑定。
立即对类进行初始化的情况
有且只有:这5种场景中的行为称为对一个类进行主动引用,除此外,所有引用类的方式都不会触发初始化,称为被动引用。
- 遇到new、getstatic、putstatic、invokestatic的字节码指令,如果类没有初始化,则需要先触发其初始化。
- 遇到java.lang.reflect包的方法对类进行反射调用时。
- 当初始化一个类,如果其父类还没有进行初始化,则先父类初始化(如果是接口,则不要求父类接口全部完成初始化,只有真正使用到父类接口时才会初始化)。
- 当虚拟机启动时,用户需要指定一个主类,先初始化主类。
- 当使用JDK7的动态语言支持时,例如一个Java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化则要先进行初始化。
类加载的过程
加载
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 读取途径:
- 从ZIP包读取,成为日后JAR格式的基础。
- 从网络中获取,如applet。
- 运行时计算生成,动态代理技术,java.lang.reflect.Proxy,为特定接口生成形式为
*$Proxy
的代理类的二进制字节流。 - 由其他文件生成,如JSP,由JSP文件生成Class类。
- 从数据库中读取等。
- 开发人员可控性最强(非数组类):
- 可以使用系统提供的引导类加载器完成,也可以由用户自定义的类加载器完成,重写一个loadClass()方法。
- 数组类:由JVM直接创建,不由类加载器创建。但是内部的元素由类加载器创建。一个数组类C的创建过程:
- 如果数组的组件(即数组去掉一个维度的类型)是引用类型,则递归加载这个组件类型,数组C将在加载该组件类型的类加载器的类名空间上被标识。
- 如果不是引用类型,Java虚拟机将在数组C标记为与引导类加载器关联。
- 数组类的可见性与它的组件类型可见性一致,如果组件类型不是引用类型,那么数组类的可见性默认为public。
- 读取途径:
- 加载结束后,虚拟机外部的二进制字节流就按照虚拟机要求的格式存储在方法区中。之后将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存(堆)中生成一个代表这个类的Java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证
连接阶段的第一步,为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身安全。验证阶段的工作量在虚拟机的类加载子系统中占了相当大的一部分。
文件格式验证
验证字节流是否符合Class文件格式的规范,并能够被当前JVM处理:
- 是否以魔数开头。
- 主次版本号是否在当前虚拟机处理范围内。
- 常量池的常量是否有不被支持的常量类型。
- 指向常量的各种索引值是否有指向不存在的常量或不符合类型的常量。
- 文件中各个部分已经文件本身是否有被删除或附加的其他信息。等等。
只有该阶段直接操作字节流,并且只有通过文件格式验证后,字节流才会进入内存的方法区。其他三个验证阶段都是基于方法区的存储结构进行的。
元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范:
- 这个类是否有父类。
- 这个类的父类是否继承了不允许被继承的类。
- 如果这个类不是抽象类,是否实现了父类或接口中要求的所有方法。
- 类中的字段、方法是否与父类矛盾。
字节码验证
目的是通过数据流和控制流分析,确定程序语义是合法的,符合逻辑的。
对类的方法体进行校验分析,保证方法在运行时不会做出危害虚拟机安全的事件:
- 保证方法体中的类型转换时有效,不会出现对象赋值给与它无关的数据类型。
- 保证跳转指令不会跳转到方法体以外的字节码指令上。
符号引用验证
发生在虚拟机将符号引用转化为直接引用的时候,这个转化在解析阶段发生。
对类自身以外的信息进行匹配性校验:
- 通过字符串描述的限定名是否能找到对应的类。
- 符号引用中的类、字段、方法的访问性是否可以被当前类访问。
确保解析动作能够正常执行。
准备
正式为类变量(static的)分配内存并设置类遍历初始值(0值)的阶段,这些变量使用的内存都将在方法区中进行分配。而实例变量将在对象实例化时随着对象一起分配在Java堆中。
如果类变量是final的,则初始化的时候即被赋值为final的那个值,如果不是的话,则会在初始化的时候进行静态赋值,即准备阶段后值依然为0。
解析
解析是根据运行时常量池里的符号引用来动态决定具体值的过程。
- 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。以字面量形式定义在Class文件中。
- 直接引用:直接指向目标的指针、相对偏移量或一个能够间接定位到目标的句柄。是与虚拟机实现的内存布局相关的。
- 符号引用可能不存在于内存当中,但直接引用一定存在内存中。
对于同一个符号引用进行多次解析是常见的,除invokedynamic指令外,虚拟机实现对第一次解析的结果进行缓存(缓存:在运行时常量池中记录直接引用,并将常量标识为已解析状态),从而避免解析重复进行。
虚拟机需要保证在同一个实体中,如果一个符号引用之前已经被成功解析过,那么后续的引用解析请求就应当一直成功。如果第一次失败了,那么其他指令对这个符号的解析请求也应该收到同样的异常。
invokedynamic指令用于动态语言支持,只有当实际运行到该节点,才开始解析。
解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号。
类或接口解析
假设当前代码所处的类为D,要将一个从未解析过的符号引用N解析为一个类或接口C的直接引用:
- 如果C不是一个数组类型, 虚拟机会把代表N的全限定名传递给D的类加载器去加载这个类C,加载过程中,会涉及验证等一系列相关类加载动作,如果加载过程中出现了任何异常则解析过程就宣告失败。
- 如果C是一个数组类型, 并且数组的元素类型为对象,则会按照之前的方式夹杂数组元素类型。如果是Integer则会由虚拟机生成一个代表此数组维度和元素的数组对象。
- 如果上面步骤没有任何异常,则C在虚拟机中已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认D是否具备对C的访问权限。若不具备则抛出
Java.lang.IllegalAccessError
。
字段解析
如果要解析从了类D指向类或接口C中某个字段的未解析符号引用,那么必须先解析指向该字段引用所提到的那个C符号引用。
因此在解析类后接口引用时发生的任何异常都可以当做解析字段引用的异常而抛出,如果指向C的引用能够成功解析,那么可以抛出解析字段引用本身时发生的异常。
当解析字段引用时,字段解析过程会先尝试在C和它的父类中查找这个字段:
- 如果C中声明的某个字段,与字段引用具有相同的名称以及描述符,则此次查找成功,字段查找的结果就是C中声明的那个字段。
- 否则字段查找就会递归地应用到类或接口C地直接父接口上。
- 否则如果C有一个父类S,那么字段查找就会递归到S上,否则就失败。即抛出
NoSuchFieldError
。 - 确认访问权限是否可见,否则抛出
IllgalAccessError
。
普通方法解析
为了解析D中对类或接口C中地某个方法的未解析符号引用,该方法引用所提到的对C的符号引用,就应当首先被解析。当解析一个方法引用时。
- 如果C是接口,那么方法解析抛出
IncompatibleClassChangeError
- 否则方法引用解析过程会检查C和它的父类中是否包含此方法。
- 如果查找失败会抛出NoSuchMethodError,如果权限不可见抛出
IllgalAccessError
。
接口方法解析
同普通方法解析
方法类型与方法句柄解析
调用点限定符解析
访问控制
一个类或接口C对另外一个类或接口D是可见的,当且仅当以下条件之一成立:
- C是public的
- C和D处于同一个运行时包下面,
一个字段或方法R对另外一个类或接口D是可见的,当且仅当以下条件之一成立:
- R是public的
- R在C中protected,D要么与C相同,要么就是C的子类。如果R不是static的,那么指向R的符号引用就必须包含一个指向T类的符号引用,T要么与D相同,要么就是D的子类或父类
- R要么是protected,要么具有默认访问权限,并且声明它的那个类与D处于同一运行时包下
- R是private的,并且声明在D类中
初始化
- 初始化真正开始执行类中定义的Java程序代码。执行类构造器<client>()方法的过程
- <client>方法由编译器收集类中所有类变量的赋值动作与静态语句块的语句合并而成,顺序由出现顺序而定。
- <client>方法与类构造函数不同,不需要显式调用父类构造器,虚拟机保证在此前,父类的<client>方法已经执行完毕
- 虚拟机会保证一个类的<client>方法会在多线程环境正确被加锁、同步。多个线程同时初始化一个类,只有一个线程去执行该方法,其他线程阻塞
类加载器
将类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”的动作放到虚拟机外部实现,以便让程序自己决定如何获取所需要的类。实现该动作的代码模块为类加载器。
类加载器(class loader)用来加载Java类到Java虚拟机中。一般来说,Java虚拟机使用Java类的方式如下:Java源程序(.java 文件)在经过Java编译器编译之后就被转换成Java字节代码(.class文件)。类加载器负责读取Java字节代码,并转换成java.lang.Class
类的一个实例。每个这样的实例用来表示一个Java类。通过此实例的newInstance()
方法就可以创建出该类的一个对象。实际的情况可能更加复杂,比如Java字节代码可能是通过工具动态生成的,也可能是通过网络下载的。
概述
类与类加载器
- 基本职责:根据一个指定的类的名称,找到或者生成其对应的字节代码,然后从这些字节代码中定义出一个 Java 类,即
java.lang.Class
类的一个实例。还负责加载Java应用所需的资源。 - 对于任意一个类,都需要由加载它的类加载器与这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器都有一个独立的类名称空间。
- 判断两个类是否相等,只有两个类是由同一个类加载器加载的前提下才有意义。
- 相等:equals等方法返回的结果,如果没有考虑类加载器的影响,某些情况下可能或产生具有迷惑性的结果。
- 对于同一个Class,我们使用系统应用程序类加载器与自定义类加载器加载,instanceof会返回false。它们是一个类文件,但是是两个独立的类。
双亲委派模型
从Java虚拟机角度讲,只存在两种不同的类加载器
- 启动类加载器,使用C++实现,是虚拟机自身一部分
- 所有其他的类加载器,Java实现,独立于虚拟机外部,并全部继承自抽象类java.lang.ClassLoader
从程序员角度讲
- 启动类加载器。负责将类库(
\lib下的jar包)加载到虚拟机内存当中,该加载器无法被Java程序直接引用。用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可 - 扩展类加载器。负责加载<JAVA_HOME>\lib\ext目录,或者java.ext.dirs系统变量指定的路径中所有类库,开发者可以直接使用扩展类加载器
- 应用程序(系统)类加载器。负责加载用户类路径上指定的路径,可以直接使用这个类加载器,并且是程序中的默认加载器
要求除了顶层的启动类加载器,其余的类加载器都应当有自己的父类加载器。父子关系一般不会以继承关系实现,而都是以组合关系来复用代码。
工作过程:
- 如果一个类加载器收到了类加载的请求,首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此既
- 所有的加载请求都会传送到顶层的启动类加载器,只有父类无法完成这个加载请求,子加载器才会尝试自己去加载
优势:
- Java类随着它的类加载器一起具备了一种带有优先级的层次关系。如Object类,任何一个类加载器加载这个类,都委派给了启动类加载器,因此他们都是同一个类。而如果让各个类去自行加载,那么系统会出现多个不同的object类。
- 其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
实现:
- 先检查是否已经被加载过
- 如果没有,则调用父类的loadClass
- 若父加载器为空,则默认使用启动类加载器作为父加载器
- 如果父加载失败,抛出异常,则调用子类的findClass方法加载
破坏双亲委派模型
模型缺陷
双亲委派模型是为了解决各个类加载器的基础类的统一问题,基础类之所以基础是因为它们总是作为被用户代码调用的API,但是如果基础类又要调回用户的代码该如何解决
例如JNDI服务,它的代码由启动类加载器加载,但JNDI的目的是对资源进行集中管理和查找,因此需要调用由独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI)的代码,但是启动类加载器不可能认识这些。
解决:引入了线程上下文类加载器,可以通过java.lang.Thread
类的setContextClassLoaser()
方法进行设置,如果创建线程时还未设置,则会从父线程中继承一个,如果应用程序全局没有设置,则该加载器默认就是应用程序类加载器
JNDI使用线程上下文类加载器加载需要的SPI代码,即类加载器请求子类加载器去完成类加载行为。例如JDBC等都是这样实现的。
用户对程序动态性的追求而导致
例如代码热替换、模块热部署即类似计算机一样可以更新外设。
OSGI模块化热部署的关键是在它自定义的类加载器机制的实现,每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个Bundle时就将Bundle连同类加载器一起换掉以实现代码的热替换。
在OSGI中类加载器不再是树状结构,而是发展为网状结构,当收到类加载请求时
- 将以java.*开头的类委派给父类加载器加载
- 否则将委派列表名单内的类委派给父类加载器加载
- 否则将Import列表内的类委派给Export这个类的Bundle的类加载器加载
- 否则查找当前Bundle的ClassPath,使用自己的类加载器加载
- 否则查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载
- 否则查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载
- 否则类查找失败
网络类加载器
下面将通过一个网络类加载器来说明如何通过类加载器来实现组件的动态更新。即基本的场景是:Java 字节代码(.class)文件存放在服务器上,客户端通过网络的方式获取字节代码并执行。当有版本更新的时候,只需要替换掉服务器上保存的文件即可。通过类加载器可以比较简单的实现这种需求。
类 NetworkClassLoader
负责通过网络下载 Java 类字节代码并定义出 Java 类。它的实现与 FileSystemClassLoader
类似。在通过 NetworkClassLoader
加载了某个版本的类之后,一般有两种做法来使用它。第一种做法是使用 Java 反射 API。另外一种做法是使用接口。需要注意的是,并不能直接在客户端代码中引用从服务器上下载的类,因为客户端代码的类加载器找不到这些类。使用 Java 反射 API 可以直接调用 Java 类的方法。而使用接口的做法则是把接口的类放在客户端中,从服务器上加载实现此接口的不同版本的类。在客户端通过相同的接口来使用这些实现类。网络类加载器的具体代码见 下载。
在介绍完如何开发自己的类加载器之后,下面说明类加载器和 Web 容器的关系。
源码
- Class loadClass(String name)。
- name参数指定类加载器需要装载类的名称,必须使用全限定名。
- Class loadClass(String name, boolean resolve)
- resolve参数决定类加载器是否需要解析该类,在初始化类前是否需要进行类解析的工作,如果JVM只需要知道是否存在或找出该类的超类,则不需要解析。
装载类。
- Class defineClass(String name, byte[] b, int off, int len)。
- name:是字节数组对应的全限定类名。
将类文件的字节数组转化成JVM的java.lang.Class对象,字节数组可以从本地文件系统、远程网络获取。
- Class findSystemClass(String name)。
- name:是类的全限定名。
从本地文件系统载入Class文件,如果本地文件系统不存在该Class文件,则抛出ClassNotFoundException
,是JVM默认的类装载机制。
- Class findLoadedClass(String name)。
- name:类的全限定名。
调用该方法来查看ClassLoader是否已装入某个类,如果已经装入则返回Class对象,否则返回null。如果强行装载已经存在的类,将抛出链接错误。
- ClassLoader getParent()。
获取类装载器的父装载器。如果为根装载器由于非Java语言,所以无法获得,则返回null。
自定义类加载器
双亲委派模型
首先看一下双亲委派模型的工作过程源码
1 | protected Class<?> loadClass(String name, boolean resolve) |
- 检查一下指定名称的类是否已经加载过,如果加载过了,就不需要再加载,直接返回。
- 如果此类没有加载过,那么,再判断一下是否有父加载器;如果有父加载器,则由父加载器加载(即调用parent.loadClass(name, false);).或者是调用bootstrap类加载器来加载。
- 如果父加载器及bootstrap类加载器都没有找到指定的类,那么调用当前类加载器的findClass方法来完成类加载。默认的findclass毛都不干,直接抛出ClassNotFound异常,所以我们自定义类加载器就要覆盖这个方法了。
- 可以猜测:ApplicationClassLoader的findClass是去classpath下去加载,ExtentionClassLoader是去java_home/lib/ext目录下去加载。实际上就是findClass方法不一样罢了。
自定义
即继承ClassLoader覆盖findClass()
方法
1 | import java.io.InputStream; |
效果示范
1 | MyClassLoader mcl = new MyClassLoader();//父类为Application ClassLoader |
实战自定义类加载器
一次实际的编写自定义类加载器的经验。背景如下(来自于:我又不是架构师):
我们在项目里使用了某开源通讯框架,但由于更改了源码,做了一些定制化更改,假设更改源码前为版本A,更改源码后为版本B,由于项目中部分代码需要使用版本A,部分代码需要使用版本B。版本A和版本B中所有包名和类名都是一样。那么问题来了,如果只依赖ApplicationClassLoader加载,它只会加载一个离ClassPath最近的一个版本。剩下一个加载时根据双亲委托模型,就直接返回已经加载那个版本了。所以在这里就需要自定义一个类加载器。大致思路如下图:
这里需要注意的是,在自定义类加载器时一定要把父类加载器设置为ExtentionClassLoader,如果不设置,根据双亲委托模型,默认父类加载器为ApplicationClassLoader,调用它的loadClass时,会判定为已经加载(版本A和版本B包名类名一样),会直接返回已经加载的版本A,而不是调用子类的findClass.就不会调用我们自定义类加载器的findClass去远程加载版本B了。
顺便提一下,作者这里的实现方案其实是为了遵循双亲委托模型,如果作者不遵循双亲委托模型的话,直接自定义一个类加载器,覆盖掉loadClass方法,不让它先去父类检验,而改为直接调用findClass方法去加载版本B,也是可以的.大家一定要灵活的写代码。
类加载器进阶
SPI机制
提出问题
我们系统里抽象的各个模块,往往有很多不同的实现方案,比如日志模块、xml解析模块、jdbc模块等方案。面向的对象的设计里,我们一般推荐模块之间基于接口编程,模块之间不对实现类进行硬编码。
一旦代码里涉及具体的实现类,就违反了可拔插的原则,如果需要替换一种实现,就需要修改代码。为了实现在模块装配的时候能不在程序里动态指明,这就需要一种服务发现机制。
Java SPI就是提供这样的一个机制:为某个接口寻找服务实现的机制。有点类似IOC的思想,就是将装配的控制权移到程序之外,在模块化设计中这个机制尤其重要。
是什么
SPI的全名为Service Provider Interface,主要是应用于厂商自定义组件或插件中。在java.util.ServiceLoader的文档里有比较详细的介绍。
概述
Java SPI的具体约定为:当服务的提供者提供了服务接口的一种实现之后,在jar包的META-INF/services/目录里同时创建一个以服务接口命名的文件。该文件里就是实现该服务接口的具体实现类。而当外部程序装配这个模块的时候,就能通过该jar包META-INF/services/里的配置文件找到具体的实现类名,并装载实例化,完成模块的注入。基于这样一个约定就能很好的找到服务接口的实现类,而不需要再代码里制定。jdk提供服务实现查找的一个工具类:java.util.ServiceLoader
。
直白一点说就是,我(JDK)提供了一种帮你(第三方实现者)加载服务(如数据库驱动、日志库)的便捷方式,只要你遵循约定(把类名写在/META-INF里),那当我启动时我会去扫描所有jar包里符合约定的类名,再调用forName加载,但我的ClassLoader是没法加载的,那就把它加载到当前执行线程的TCCL里,后续你想怎么操作(驱动实现类的static代码块)就是你的事了。
详细参见后面的实例。
类加载器实现Jar包隔离
提出问题
有时候我们需要在一个Project中运行多个不同版本的jar包,以应对不同集群的版本或其它的问题。如果这个时候选择在同一个项目中实现这样的功能,那么通常只能选择更低版本的jar包,因为它们通常是向下兼容的,但是这样也往往会失去新版本的一些特性或功能,所以我们需要以扩展的方式引入这些jar包,并通过隔离执行,来实现版本的强制对应。
是什么
默认的ClassLoader都规定了其指定的加载目录,一般也不会通过JVM参数来使其加载自定义的目录。
为了使运行扩展的jar包时,与启动项目实现绝对的隔离,我们需要保证他们所加载的类不会有相同的ClassLoader,根据双亲委托模型的原理可知,我们必须使自定义的ClassLoader的parent为null,这样不管是JRE自带的jar包或一些基础的Class都不会委托给App ClassLoader(当然仅仅是将Parent设置为null是不够的,后面会说明)。与此同时这些实现了不同版本的jar包,是经过二次开发后的可以独立运行的项目。
将服务框架自身用的类与应用用到的类都控制在User-Defined Class Loader级别,实现Jar包间的相互隔离。
实现
- 自定义ClassLoader,使其
Parent = null
,避免其使用系统自带的ClassLoader加载Class。 - 在调用相应版本的方法前,更改当前线程的ContextClassLoader,避免扩展包的依赖包通过
Thread.currentThread().getContextClassLoader()
获取到非自定义的ClassLoader进行类加载。 - 通过反射获取Method时,如果参数为自定义的类型,一定要使用自定义的ClassLoader加载参数获取Class,然后在获取Method,同时参数也必须转化为使用自定义的ClassLoade加载的类型(不同ClassLoader加载的同一个类不相等)。
线程上下文类加载器TCCL
ThreadContextClassLoader。
Java提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的SPI有JDBC、JCE、JNDI、JAXP和JBI等。
这些SPI的接口由Java核心库来提供,而这些SPI的实现代码则是作为Java应用所依赖的Jar包被包含进类路径(CLASSPATH)里。SPI接口中的代码经常需要加载具体的实现类。那么问题来了,SPI的接口是Java核心库的一部分,是由启动类加载器(Bootstrap Classloader)来加载的;SPI的实现类是由系统类加载器(System ClassLoader)来加载的。引导类加载器是无法找到SPI的实现类的,因为依照双亲委派模型,Bootstrap Classloader无法委派App ClassLoader来加载类。
而线程上下文类加载器破坏了“双亲委派模型”,可以在执行线程中抛弃双亲委派加载链模式,使程序可以逆向使用类加载器。
适用性
- 当高层提供了统一接口让低层去实现,同时又要是在高层加载(或实例化)低层的类时,必须通过线程上下文类加载器来帮助高层的ClassLoader找到并加载该类。
- 当使用本类托管类加载,然而加载本类的ClassLoader未知时,为了隔离不同的调用者,可以取调用者各自的线程上下文类加载器代为托管。
实战案例
以JDBC为例:
1 | // 加载Class到AppClassLoader(系统类加载器),然后注册驱动类 |
虽然写的Class.forName
被注释掉了,但程序依然可以正常运行。
Java1.6开始自带的jdbc4.0版本已支持SPI服务加载机制,只要mysql的jar包在类路径中,就可以注册mysql驱动。那到底是在哪一步自动注册了mysql driver的呢?重点就在DriverManager.getConnection()
中。我们都是知道调用类的静态方法会初始化该类,进而执行其静态代码块,DriverManager的静态代码块就是:
1 | static { |
初始化方法loadInitialDrivers()
的代码如下:
1 | private static void loadInitialDrivers() { |
从上面可以看出JDBC中的DriverManager的加载Driver的步骤顺序依次是:
- 通过SPI方式,读取META-INF/services下文件中的类名,使用TCCL加载。
- 通过
System.getProperty("jdbc.drivers")
获取设置,然后通过系统类加载器加载。
下面详细分析SPI加载的那段代码。
JDBC SPI
1 | ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class); |
现在我们了解了是如何加载进来了这个Class,但是这个loader是如何传进来的呢?
因为这句Class.forName(DriverName, false, loader)
代码所在的类在java.util.ServiceLoader类中,而ServiceLoader.class又加载在BootrapLoader中,因此传给forName的loader必然不能是BootrapLoader。
复习双亲委派加载机制请看:java类加载器不完整分析。这时候只能使用TCCL了,也就是说把自己加载不了的类加载到TCCL中(通过Thread.currentThread()
获取)。上面那篇文章末尾也讲到了TCCL默认使用当前执行的是代码所在应用的系统类加载器AppClassLoader。
再看下看ServiceLoader.load(Class)
的代码,的确如此:
1 | public static <S> ServiceLoader<S> load(Class<S> service) { |
ContextClassLoader默认存放了AppClassLoader的引用,由于它是在运行时被放在了线程中,所以不管当前程序处于何处(BootstrapClassLoader或是ExtClassLoader等),在任何需要的时候都可以用Thread.currentThread().getContextClassLoader()取出应用程序类加载器来完成需要的操作。
到这儿差不多把SPI机制解释清楚了。直白一点说就是,我(JDK)提供了一种帮你(第三方实现者)加载服务(如数据库驱动、日志库)的便捷方式,只要你遵循约定(把类名写在/META-INF里),那当我启动时我会去扫描所有jar包里符合约定的类名,再调用forName加载,但我的ClassLoader是没法加载的,那就把它加载到当前执行线程的TCCL里,后续你想怎么操作(驱动实现类的static代码块)就是你的事了。
好,刚才说的驱动实现类就是com.mysql.jdbc.Driver.Class,它的静态代码块里头又写了什么呢?是否又用到了TCCL呢?我们继续看下一个例子。
校验实例的归属
com.mysql.jdbc.Driver
加载后运行的静态代码块:
1 | static { |
registerDriver方法将driver实例注册到系统的java.sql.DriverManager类中,其实就是add到它的一个名为registeredDrivers的静态成员CopyOnWriteArrayList中 。
到此驱动注册基本完成,接下来我们回到最开始的那段样例代码:java.sql.DriverManager.getConnection()。它最终调用了以下方法:
1 | private static Connection getConnection( |
1 | private static boolean isDriverAllowed(Driver driver, ClassLoader classLoader) { |
总结
由于TCCL本质就是当前应用类加载器,所以之前的初始化就是加载在当前的类加载器中,这一步就是校验存放的driver是否属于调用者的Classloader。例如在下文中的tomcat里,多个webapp都有自己的Classloader,如果它们都自带 mysql-connect.jar包,那底层Classloader的DriverManager里将注册多个不同类加载器的Driver实例,想要区分只能靠TCCL了。
Spring加载
如果有10个Web应用程序都用到了Spring的话,可以把Spring的jar包放到common或shared目录下让这些程序共享。Spring的作用是管理每个web应用程序的Bean,getBean时自然要能访问到应用程序的类,而用户的程序显然是放在/WebApp/WEB-INF目录中的(由WebAppClassLoader加载),那么在CommonClassLoader或SharedClassLoader中的Spring容器如何去加载并不在其加载范围的用户程序(/WebApp/WEB-INF/)中的Class呢?
解答
答案呼之欲出:Spring根本不会去管自己被放在哪里,它统统使用TCCL来加载类,而TCCL默认设置为了WebAppClassLoader,也就是说哪个WebApp应用调用了Spring,Spring就去取该应用自己的WebAppClassLoader来加载Bean,简直完美~
源码分析
有兴趣的可以接着看看具体实现。在web.xml中定义的listener为org.springframework.web.context.ContextLoaderListener,它最终调用了org.springframework.web.context.ContextLoader类来装载Bean,具体方法如下(删去了部分不相关内容):
1 | public WebApplicationContext initWebApplicationContext(ServletContext servletContext) { |
具体说明都在注释中,spring考虑到了自己可能被放到其他位置,所以直接用TCCL来解决所有可能面临的情况。
参考
1 |