字节码与类的加载篇
第一章-Class 文件结构
概述
字节码文件的跨平台性
- Java语言:跨平台的语言(write once,run anywhere)
- Java虚拟机:跨语言的平台
Java的前端编译器
透过字节码指令看代码细节
BAT面试题
- 类文件结构有几个部分?
- 知道字节码吗?字节码都有哪些?Integer x = 5; int y = 5; 比较 x == y 都经过哪些步骤?
虚拟机的基石:Class文件
字节码文件里面是什么?
源代码经过编译器编译之后便会生成一个字节码文件,字节码是一种二进制的类文件,它的内容是JVM指令,而不像C、C++经由编译器直接生成机器码。
什么是字节码指令(byte code)?
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的操作码(opcode)以及跟踪其后的零至多个代表此操作所需参数的操作数(operand)所构成。虚拟机中许多指令并不包含操作数,只有一个操作码。
Class 文件结构
Class 文件本质
任何同一个Class文件都对应着唯一一个类或者接口的定义信息,但反过来说,Class 文件实际上并不一定是以磁盘文件的形式存在。Class 文件是一组以8位字节为基础单位的二进制流。
Class 文件格式
Class 的结构不像XML等描述语言,由于它没有任何分隔符号。所以在其中的数据项,无论是字节顺序还是数量,都是被严格限定的,哪个字节代表什么含义、长度多少、顺序如何等,都不允许改变。
Class 文件格式采用一种类似于 C语言结构体的方式进行存储,这种结构中只有两种数据类型:无符号数和表
- 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
- 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合数据结构的数据,整个Class文件本质上是一张表。由于表没有固定长度,所以通常会在其前面加上个数说明。
Class 文件结构概述
总体如下
- 魔数
- Class 文件版本
- 常量池
- 访问标志
- 类索引、父类索引、接口索引集合
- 字段表集合
- 方法表集合
- 属性表集合
使用javap指令解析Class 文件
-public仅显示公共类和成员-protected显示受保护的 / 公共类和成员-p -private显示所有类和成员-package显示程序包 / 受保护的 / 公共类和成员(默认)-sysinfo显示正在处理的类的系统信息(路径、大小、日期、MD5、散列、源文件)-constants显示静态最终常量-s输出内部类型签名-l输出行号和本地变量表-c对代码进行反编译-v -verbose输出附加细信息(包括行号、本地变量表、反汇编等详细信息)
第二章-字节码指令集的解析举例
概述
- Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(操作数,Operands)构成。由于Java 虚拟机采用面向操作数栈而不是寄存器的结构,所以大多数的指令都不包含操作数,只有一个操作码。
- 由于限制了Java虚拟机操作码的长度为一个字节(即0~255),这意味着指令集的操作码总条数不能超过256条。
字节码与数据类型
对于大部分与数据类型相关的字节码指令,他们的操作码助记符中都有特殊的字符来表名专门为哪种数据类型服务:
- i :int
- l :long
- s : short
- b : byte
- c : char
- f : float
- d : double
也有一些指令的住助记符中 没有明确地指明操作类型的字母,如arraylength指令,它没有代表数据类型的特殊字符,单操作数永远只能是一个数组类型的对象。
还有另外一些指令,如无条件跳转指令 goto 则是与数据类型无关的。
大部分的指令都没有支持整数类型 byte、char和short,甚至没有任何指令支持boolean类型。编译器会在编译期或运行期将byte和short类型的数据带符号扩展为相应的int类型数据,将boolean和char类型数据零位扩展为相应的int类型数据。与之类似,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理。因此,大多数对于boolean、byte、short、char类型的操作
指令分类
- 这里将JVM中的所有字节码指令集按照用途大致分成 9 类:
- 加载与存储指令
- 算数指令
- 类型转换指令
- 对象的创建于访问指令
- 方法调用与返回指令
- 操作数栈管理指令
- 比较控制指令
- 异常处理指令
- 同步控制指令
- 在做值相关操作时:
- 一个指令,可以从局部变量表、常量池、堆中对象、方法调用、系统调用中取得数据,这些数据被压入操作数栈。
- 一个指令,也可以从操作数栈中取出一个到多个值(pop多次),完成赋值、加减乘除、方法传参、系统调用等操作。
1-加载与存储指令
用于将数据从栈帧的局部变量表和操作数栈之间来回传递。
常用指令
再谈操作数栈与局部变量表
Operand Stack
Java字节码是Java虚拟机所使用的指令集。因此,它与Java虚拟机基于栈的计算模型是密不可分的。再解释执行过程中,每当Java方法分配栈帧时,Java虚拟机往往需要开辟一块额外的空间作为操作数栈,来存放计算的操作数以及返回结果。
具体来说便是:执行每一条指令之前,Java虚拟机要求该指令的操作数已被压入操作数栈中。在执行指令时,Java虚拟机会将该指令所需的操作数弹出,并且指令的结果重新压入栈中。
Local Variables
字节码程序可以将计算结果缓存在局部变量表中。
实际上,Java虚拟机将局部变量区当成一个数组,依次存放 this 指针(仅非静态方法)、所传入的参数、以及字节码中的局部变量。
和操作数栈一样,long 类型以及 double 类型将占两个单元,其余占用一个单元。
局部变量压栈指令
局部变量压栈指令将给定的局部变量表 中的数据压入操作数栈。
常量入栈指令
常量入栈指令的功能室将常数压入操作数栈,根据数据类型和入栈内容的不同,又可以分为const系列、push系列和ldc指令。
const 系列
push 系列
主要包括 bipush 和 sipush。他们的区别在于接收的数据类型不同,前者接收 8 位整数作为参数,后者接收 16 位整数,他们都将参数压入栈。
ldc 系列
如果以上指令都不能满足要求,那么可以使用万能的 ldc 指令,它可以接受一个 8 位的参数,该参数指向常量池中的 int、float或者String的索引,将指定的内容压入堆栈。
出栈装入局部变量表指令
出栈装入局部变量表指令用于将操作数栈中栈顶元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值。
这类指令主要以 store 形式存在,比如 xstore(x为 i、l、f、d、a)、xstore_n(x 为 i、l、f、d、a,r 为0-3)
- 其中,指令 istore_n 将从操作数栈中弹出一个整数,并把它赋值给局部变量索引 n 位置。
- 指令 xstore 由于没有隐含的参数信息,故需要提供一个 byte 类型的参数指定目标局部变量表的位置。
说明:
一般来说,类似像 store这样的指令需要带一个参数,用来指明将弹出的元素放在局部变量表的第几个位置。但是,为了尽可能压缩指令大小,使用专门的 istore_1 指令表示将弹出的元素放在局部变量表的第一个位置。类似的还有 istore_0 istore_2 istore_3 。
由于局部变量表前几个位置总是非常有用,因此这种做法虽然增加了指令数量,但是可以大大压缩生成的字节码的体积。如果局部变量表很大,需要存储的槽位大于3,那么可以使用 istore 指令,外加一个参数,用来表示需要存放的槽位位置。
2-算术指令
作用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈。大体上分为两种:对 整形数据 进行运算的指令与对 浮点类型数据 进行运算的指令。
所有运算指令包括
++i 与 i++
注:inc 1 by 1 指 直接递增局部变量表的相应位置变量,不涉及操作数栈
比较指令的说明
- 比较指令的作用是比较栈顶两个元素的大小,并将比较结果入栈
- 比较指令有:dcmpg 、 dcmpl、fcmpg、fcmpl、lcmp
- 与前面讲解的指令类似,首字符d表示double类型,f表示float,l表示long
- 对于double和float类型的数字,由于NaN的存在,各个版本的比较指令。以float为例,有fcmpg和fcmpl两个指令,他们的去区别在于数字比较时,若遇到NaN值,处理结果不同。
- 指令 lcmp 针对 long ,由于long没有NaN,故只有一个指令。
举例:
指令fcmpg和fcmpl都从栈中弹出两个操作数,并将他们作比较,设栈顶的元素为 v2,栈顶顺位第二位的元素为 v1,若 v1 = v2,则压入0;若v1>v2,则压入1;若v1<v2,则压入 -1.
两个指令的不同之处在于,如果遇到NaN,fcmpg会压入1,而fcmpl会压入 -1
3-类型转换指令
- 类型转换指令可以将两种不同的数值类型相互转换。
- 这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
宽化类型转换
转换规则
小范围向大范围的安全转换,并不需要指令执行,包括:
- 从 int 到 long、float、double。对应指令为 i2l i2f i2d
- 从 long 到 float double 。对应指令为 l2f l2d
- 从float 到 double 对应指令为 f2d
简化为:int --> long --> float --> double
精度损失问题
- 宽化类型转换是不会因为超过目标类型的最大值而丢失信息的,例如,从 int 到 long,或者从 int 到 double,都不会丢失任何信息,转换前后的值是精确相等的。
- 从 int 、 long类型数值转换到 float,或者long类型转换到double时,可能发生精度丢失:可能丢失掉几个最低有效位上的值,转换后的浮点数值是根据IEEE754最接近舍入模式得到的正确整数值,注意,这种精度丢失不会抛出异常。
补充说明
从 byte 、 char和short类型到 int 类型的宽化类型转换实际上是不存在的。对于byte类型转为int,虚拟机并没有做实质性的转化处理,只是简单地通过操作数栈交换了两个数据。而将byte转换为long时,使用的是 i2l,可以看到在内部 byte 在这里已经等同于 int 处理,类似的还有 short类型,这种处理方式有两个特点:
一方面可以减少实际的数据类型,如果 short 和 byte 都准备一套指令,那么指令的数量就会大增,而虚拟机目前的设计上,只愿意使用一个字节表示指令,因此指令总数不能超过 256 个,为了节省指令资源,将 short 和 byte 当做 int 处理也在情理之中。
另一方面,由于局部变量表中的槽位固定位32位,无论是 byte 或者 short 存入局部变量表,都会占用32位空间。从这个角度来说,也没有必要特意区分这几种数据类型。
窄化类型转换
转换规则
- 从 int 到 byte、short、char 对应指令 i2b i2s i2c
- 从 long 到 int 对应指令 l2i
- 从float 到 int 或者long 对应指令 f2i f2l
- 从double 到 int 、long或者float 对应指令 d2i d2l d2f
精度损失
窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此,转换过程可能会导致数值丢失精度。
尽管数据类型窄化转换可能发生上限溢出、下限溢出和精度丢失等情况,但是Java虚拟机规范中明确规定数值类型的窄化转换指令不能抛出运行时异常。
补充说明
当一个浮点数值窄化转换为整数类型T(int 或 long)的时候,将遵循以下规则:
- 如果浮点值是 NaN,那转换结果就是int或long的类型 0
- 如果浮点值不是无穷打的话,浮点值使用IEEE 754的向零舍入模式的取整,获得整数值v,如果v在目标类型T(int或long)的表示范围之内,那么转换结果就是v。否则将根据v的符号,转换为T所能表示的最大或者最小正数
当将一个 double 类型窄化转换为 float 类型时,将遵循以下转换规则:
- 如果转换结果的绝对值太小而无法使用 float 来表示,将返回 float 类型的 正负零。
- 如果转换结果的绝对值太大而无法使用 float 来表示,将返回 float 类型的正负无穷大。
- 对于 double 类型的 NaN值将按照规定转换为 float类型的NaN值
4-对象的创建与访问指令
创建指令
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令:
- 创建类实例的指令:new
- 它接收一个操作数,为指向常量池的索引,表示要创建的类型,执行完毕后,将对象的引用压入栈。
- 创建数组的指令:newarray、anewarray、multianewarray
- newarray:创建基本类型数组
- anewarray:创建引用类型数组
- multianewarray:创建多维数组
字段访问指令
对象创建后,就可以通过对象访问指令获取对象实例或数组实例中的字段或者数组元素。
- 访问类字段(static字段,或者称为类变量)的指令:getstatic、putstatic
- 访问类实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield
数组操作指令
主要是:xastore 和 xaload 指令。具体为:
- 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
- 将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore
类型检查指令
检查类实例或数组类型的指令:instanceof、checkcast
- 指令 checkcast 用于检查类型强制转换是否可行,如果可以,那么该指令不会改变操作数栈,否则会抛出ClassCastException异常
- 指令 instanceof 用来判断给定对象是否是某一个类的实例,它会将判断结果压入操作数栈
5-方法调用与返回指令
方法调用指令
以下五条指令用于方法调用:
- invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),支持多态。这也是Java语言中最常见的方法分派方式。
- invokeinterface 指令用于调用接口方法,它会在运行时搜索由特定对象所实现的这个接口方法,并找出合适的方法进行调用
- invokespecial 指令用于调用一些需要特殊处理的实例方法,包括构造方法、私有方法和父类方法。这些方法都是静态类型绑定的,不会在调用时进行动态派发的。
- invokestatic 指令用于调用命名类中的类方法(static方法)。这是静态绑定的。
- invokedynamic:调用动态绑定的方法,这个是JDK1.7后新加入的指令。用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法。前面四条调用指令的分派逻辑都固化在 java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法所决定的。
方法返回指令
方法调用结束前,需要进行返回。方法返回指令是根据返回值的类型区分的。
- 包括 ireturn(当前返回值是 boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn 和 areturn
- 另外还有一条 return 指令声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。
6-操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,JVM提供的操作数栈管理指令,可以用于直接操作操作数栈的指令。
这类指令包括:
- 将一个或两个元素从栈顶弹出,废弃操作:pop,pop2
- 复制栈顶一个或两个数值并将复制值或双份的复制值压入栈顶:dup,dup2,dup_x1,dup2_x1 ,dup2_x2
- 将栈顶两个Slot数值位置交换:swap。Java虚拟机没有提供交换两个64位数据类型(long,double)数值的指令。
- 指令 nop,这是一个非常特殊的指令,它的字节码为0x00。和汇编语言中的nop一样,它表示什么都不做,这条指令一般可用于调试、占位等。
7-控制转移指令
条件跳转指令
条件跳转指令通常和比较指令结合使用。在条件跳转指令执行前,一般可以先用比较指令进行栈顶元素的准备,然后进行条件跳转。
比较条件跳转指令
多条件分支跳转
专门为 switch-case 设计
| 指令名称 | 描述 |
|---|---|
| tableswitch | 用于switch条件跳转,case值连续 |
| lookupswitch | 用于switch条件跳转,case值不连续 |
无条件跳转
主要为 goto 指令,用于指定指令的偏移量,指令执行的目的就是跳转偏移量到给定的位置处。
| 指令名称 | 描述 |
|---|---|
| goto | 无条件跳转 |
| goto_w | 无条件跳转(宽索引) |
| jsr | 跳转至指定16位offset位置,并将jsr下一条指令地址压入栈顶 |
| jsr_w | 跳转至指定32位offset位置,并将jsr_w下一条指令地址压入栈顶 |
| ret | 返回至指定的局部变量所给出的指令位置(一般与jsr、jsr_w联合使用) |
8-异常处理指令
抛出异常指令
- athrow 指令
在Java程序中显式抛出异常(throw)都是由 athrow 指令实现。
除了使用 throw 语句显式抛出异常情况之外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。
注意:正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一例外的情况是在抛出异常时,Java虚拟机会清除操作数栈上的所有内容,而后将异常实例压入调用者的操作数栈上。
异常处理与异常表
异常处理
在 Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(早起使用 jsr、ret指令),而是采用异常表来完成。
异常表
如果一个方法定义了一个 try-catch 或者 try-finally 的异常处理,就会创建一个异常表。它包含了每个异常处理或者 finally 块的信息。异常表保存了每个异常处理信息。比如:
- 起始位置
- 结束位置
- 程序计数器记录的代码处理的偏移地址
- 被捕获的异常类在常量池中的索引
10-同步控制指令
方法级的同步
方法级的同步:是隐式的,即无需通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否声明为同步方法;
当调用方法时,调用指令将会检查方法的ACC_SYNCHRONIZED 访问标志是否设置。
- 如果设置,执行线程将先持有同步锁,然后执行方法。最后在方法完成(包括异常退出)时释放同步锁。
- 在方法执行期间,执行线程持有了同步锁,其他任何线程都无法再获得同一个锁。
方法内指定指令序列的同步
同步一段指令集序列:通常是由Java中的 synchronized 语句块来表示的。JVM指令集有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义。
第三章-类的加载过程详解
概述
在Java中数据类型分为基本数据类型和引用数据类型。前者由虚拟机预先定义,后者需要进行类的加载。
如下是类的整个生命周期:
过程一:Loading(加载)阶段
所谓加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型--类模板对象。所谓类模板对象,其实就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。反射机制即基于此。
加载完成的操作
查找并加载类的二进制数据,生成Class的实例。
- 通过类的全名,获取类的二进制数据流。
- 解析类的二进制数据流为方法区内的数据结构(Java类模型)
- 创建 java.lang.Class 类实例,表示该类型,作为方法区这个类的各种数据的访问入口。
类模型与Class实例的位置
类模型的位置
加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDK1.8之前:永久代;之后:元空间)
Class 实例的位置
类将 .class 文件加载至元空间后,会在堆中创建一个 java.lang.Class 对象,用来封装类位于方法区内的数据结构,该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。
数组类的加载
创建数组类的情况稍有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要直接创建的,但数组的元素类型仍然需要依靠类加载器区创建。创建数组类(下列简称A)的过程:
- 如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组A的元素类型;
- JVM使用指定的元素类型和数组维度来创建新的数组类。
如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为 public
过程二:Linking(链接)阶段
Verification(验证)
当类加载到系统后,就开始链接操作,验证是链接操作的第一步。
它的目的是保证加载的字节码是合法、合理并符合规范的。
步骤如下:
Preparation(准备)
为类的静态变量分配内存,并将其初始化为默认值。
注意:这里不包含 static final 修饰的情况,因为 final 在编译时就会分配了,准备阶段会显式赋值。如果使用字面量的方式定义一个字符串常量,也是在准备环节进行显式赋值(非 new 方式)。
Resolution(解析)
将类、接口、字段和方法的符号引用转为直接引用。
过程三:Initialization(初始化)阶段
为类的静态变量赋予指定的初始值。
初始化阶段重要工作是执行类的初始化方法:<clinit>( )方法
线程安全性
对于该方法的调用,也就是类的初始化,虚拟机会在内部确保其多线程环境中的安全性。
虚拟机会保证一个类的此方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程区执行这个类的该方法,其他线程都需要阻塞等带,知道活动线程执行此方法完毕。
正是因为此方法带锁线程安全的,因此,如果在一个类的该方法中有耗时很长的操作,就可能造成多个线程阻塞,引发死锁。并且这种死锁是很难发现的,因为看起来它们并没有可用的锁信息。
如果之前的线程成功返加载了类,则等在队列中的线程就没有机会再执行此方法了。那么,当需要使用这个类时,虚拟机会直接返回给它已准备好的信息。
类的初始化情况:主动使用与被动使用
主动使用:
Class 只有在必须要首次使用的时候才会被装载,Java虚拟机不会无条件的装载Class 类型。Java虚拟机规定,一个类或接口在初次使用前,必须要进行初始化。这里的“使用”,是指主动使用,主动使用只有下列几种情况:(即:如果出现如下情况,则会对类进行初始化操作。而初始化操作之前的加载、验证、准备已完成)
- 当创建一个类的实例时,比如使用 new 或者通过反射、克隆、反序列化。
- 当调用类的静态方法时,即当使用了字节码 invokestatic 指令
- 当使用类、接口的静态字段时(final修饰特殊考虑),比如,使用 getstatic 或者 putstatic 指令。(对应访问变量、赋值变量操作)
- 当使用 java.lang.reflect 包中的方法反射类的方法时。比如:Class.forName("com.example.test")
- 当初始化子类时,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化(不包含接口)。
- 如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,该接口在其之前被初始化。
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main( )方法的那个类),虚拟机会先初始化这个类(自己就是这个静态方法)。
- 当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。(涉及解析 REF_getStatic REF_putStatic REF_invokeStatic 方法句柄对应的类)
被动使用:
其余情况均属于类的被动使用。**被动使用不会引起类的初始化。**也就是说:并不是在代码中出现的类,就一定会被加载或者初始化。如果不符合主动使用的条件,类就不会初始化。
- 当访问一个静态字段时,只有真正声明这个字段类才会被初始化。当通过子类引用父类的静态变量,不会导致子类初始化。
- 通过数组定义类引用,不会触发此类的初始化。
- 引用常量不会触发此类或接口的初始化。因为常量在链接阶段已经被显式赋值了。
- 调用ClassLoader类的 loadClass( )方法加载一个类,并不是对类的主动使用,不会导致类的初始化。
第四章-再谈类加载器
概述
ClassLoader是Java的核心组件,所有的Class都是由ClassLoader进行加载的,ClassLoader负责通过各种方式将Class信息的二进制数据流读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响到类的加载,而无法通过ClassLoader去改变类的链接和初始化行为。至于它是否可以运行,则由执行引擎决定。
类加载器的分类
显式加载 vs 隐式加载
class 文件的显式加载与隐式加载的方式是指JVM加载class文件到内存的方式。
- 显式加载指的是在代码中通过调用ClassLoader加载class对象,如直接使用Class.forName( name ) 或 this.getClass( ).getClassLoader.oadCLass( )加载对象
- 隐式加载则是不使用代码中调用CLassLoader的方法加载class对象,而是通过虚拟机自动加载到内存中,如在加载某个类的class文件时,该类的class文件中引用了另外一个类的对象,此时额外引用的类将通过JVM自动加载到内存中,
日常开发中以上两种方式一般会混合使用。
命名空间
对于任意一个类,都需要由加载它的类加载器本身一同确认其在Java虚拟机中的唯一性。每一个类加载器,都拥有一个独立的类名称空间:比较两个类是否相等,只有在这两个类是由同一个加载器加载的前提下才有意义。
- 每个类加载器都有自己的命名空间,命名空间由该类加载器及所有的父类加载器所加载的类组成
- 在同一命名空间中,不会出现完整类的完整名字(包括类的包名)相同的两个类。
- 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类
几种类加载器
启动类加载器(引导类加载器、Bootstrap ClassLoader)
- 这个加载器使用C/C++语言实现,嵌套在JVM内部。
- 它用来加载Java的核心库(JAVA_HOME/jre/lib/rt.jar 或 sun.boot.class.path 路径下的内容),JVM自身需要的类。
- 并不继承自 java.lang.ClassLoader,没有父加载器
- 出于安全考虑,此加载器只加载包名为 java javax sun 等开头的类
- 加载扩展类和应用程序类加载器,并指定为他们的父加载器。
扩展类加载器(Extension ClassLoader)
- Java语言编写,由 sun.misc.Launcher$ExtClassLoader 实现
- 继承于 ClassLoader
- 父加载器为启动类加载器
- 从 java.ext.dirs 系统属性所指定的目录中加载类库,或从 JDK 的安装目录的 jre/lib/ext 子目录下加载类库。如果用户创建的JAR放在此目录下,也会自动由扩展类加载器加载。
应用程序类加载器(系统类加载器、AppClassLoader)
- Java 语言编写, 由sun.misc.Launcher$AppClassLoader实现
- 继承于ClassLoader类
- 父类加载器为扩展类加载器
- 它负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的类库
- 应用程序中的类加载器默认是系统类加载器
- 它是用户自定义加载器的默认父加载器
- 通过 ClassLoader.getSystemClassLoader( ) 可以获取此加载器
用户自定义类加载器
- 在Java的日常应用程序开发中,类的加载几乎是由上述三种类加载器完成的。在必要时,我们还可以定义自定义类加载器,来制定类的加载方式。
- 体现Java语言强大生命力和巨大魅力的关键因素之一便是,Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以使本地的JAR包,也可以是网络上的远程资源。
- 通过类加载器可以实现非常绝妙的插件机制,这方面的实际应用案例数不胜数。例如,著名的OSGI组件框架,再如Eclipse的插件机制。类加载器为应用程序提供了一种动态增加新功能的机制,这种机制无需重新打包发布应用程序就能实现。
- 同时,自定义加载器能够实现应用隔离,如 Tomcat,Spring 等中间件和组件框架都在内部定义了加载器,并通过自定义加载隔离不同的组件模块。这种机制比 C/C++要好太多,想不修改C/C++程序就能为其新增功能,几乎是不可能的,仅仅一个兼容性便能阻挡住所有美好的设想。
- 自定义类加载器通常需要继承于 ClassLoader
双亲委派模型
概述
如果一个类加载器接到加载类的请求时,它首先不会尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就成功返回。只有父类加载器无法完成此加载任务时,才自己去加载。
规定了类加载的顺序是:引导类加载器 -> 扩展类加载器 -> 系统类加载器 -> 自定义类加载器
代码支持
双亲委派机制在 java.lang.ClassLoader.loadClass( String , boolean )接口中体现。逻辑如下:
- 先在当前加载器的缓存中查找有无目标类,如果有则返回。
- 判断当前加载器的父加载器是否为空:如果不为空,则调用 parent.loadClass( name , false )接口进行加载。如果当前加载器的父类加载器为空,则调用 findBootstrapClassOrNull( name )接口,让引导加载器进行加载。
- 如果通过以上三条路径都没成功,则调用 findClass( name )接口进行加载。该接口最终会调用 java.lang.ClassLoader 接口的 defineClass 系列的 native 接口加载目标 Java类。
举例
假设当前加载的是 java.lang.Object 这个类,很明显,该类属于核心API,因此一定只能由引导类加载器进行加载。当JVM准备加载 java.lang.Object 时,JVM默认使用系统类加载器加载,按照上面四步的逻辑,在第一步从系统类加载器的缓存中找不到该类,于是进入第二步,由于从系统类加载器的父加载器是扩展类加载器,于是扩展类加载器继续从第一步开始重复。由于扩展类加载器的缓存中也一定查不到该类,因此进入第二步。扩展类的父加载器是null,因此系统调用 findClass( String ),最终通过引导类加载器进行加载。
优势
- 避免类的重复加载,确保一个类的全局唯一性
- 保护程序安全,防止核心API被篡改
弊端
检查类是否加载是单向的,这个方式虽然从结构上说比较清晰,使各个ClassLoader的职责非常明确,但是同时会带来一个问题,即顶层的ClassLoader无法访问底层的ClassLoader所加载的类。
通常情况下,启动类加载器中的类为系统核心类,包括一些重要的系统接口,而在应用类加载器中,为应用类。按照这种模式,应用类访问系统类自然是没有问题的,但是系统类访问应用类就会出问题。比如在系统中提供了一个接口,该接口需要在应用类中得以实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在启动类加载器中。这时,就会出现该工厂方法无法创建由应用类加载器加载的应用实例的问题。
历史上双亲委派机制的破坏
第一次破坏双亲委派机制
双亲委派模型的第一次破坏发生在双亲委派模型出现之前——即JDK 1.2之前的时代。由于双亲委派模型在JDK 1.2之后才被引入,但是类加载器的概念和抽象类 java.lang.ClassLoader 则在Java的第一个版本中就已经存在,面对已经存在的用户自定义类加载器的代码,Java设计者们引入双亲委派模型时不得不做出一些妥协,为了兼容这些已有代码,无法再以技术手段避免 loadClass( )被子类覆盖的可能性,只能JDK 1.2之后的 java.lang.ClassLoader 中添加一个新的 protected 方法 findClass( ),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass( )中编写代码。双亲委派机制就在这个方法里面,按照 loadClass( )方法的逻辑,如果父类加载失败,会自动调用自己的 findClass( )方法来完成加载,这样既不影响用户按照自己的意愿去加载类,又可以保证新写出来的类加载器是符合双亲委派规则的。
第二次破坏双亲委派机制
第三次破坏双亲委派机制
自定义类加载器
为什么要自定义类加载器
- 隔离加载类
在某些框架内进行中间件与应用模块隔离,把类加载到不同的环境。比如:阿里内某容器框架通过自定义类加载器确保应用中依赖的 jar 包不会影响到中间件运行时使用的 jar 包。再比如:Tomcat这类Web服务器,内部自定义了好几种类加载器,用于隔离同一个Web应用服务器上的不同应用程序。
- 修改类加载方式
类的加载模型并非强制,除Bootstrap外,其他的加载并非一定要引入,或者根据实际情况在某个时间点进行按需动态加载
- 扩展加载源
比如从数据库、网络、甚至是电视机顶盒进行加载
- 防止源码泄露
Java代码容易被编译和篡改,可以进行编译加密,那么类加载也需要自定义,还原加密的字节码。
常见的应用场景
- 实现类似进程内隔离,类加载器实际上用作不同的命名空间,已提供类似容器、模块化的效果。例如,两个模块依赖于某个类库的不同版本,如果分别被不同的容器所加载,就可以互不干扰。这个方面的集大成者是Java EE和OSGI、JPMS等框架。
- 应用需要从不同的数据源获取类定义信息,例如网络数据源,而不是本地文件系统。或是需要自己操纵字节码,动态修改或生成类型。
实现方式
- 重写 findClass( ) 仍然是双亲委派模型
- 重写 loadClass( ) (不推荐,可以去掉 ↑)
@Override
protected Class<?> findClass(String className) throws ClassNotFoundException {
String fileName = bytePath + className + ".class";
BufferedInputStream bis = null;
ByteArrayOutputStream baos = null;
try {
bis = new BufferedInputStream(new FileInputStream(fileName));
baos = new ByteArrayOutputStream();
int len;
byte[] data = new byte[1024];
while ((len = bis.read(data)) != -1) {
baos.write(data, 0, len);
}
byte[] byteCodes = baos.toByteArray();
Class<?> aClass = defineClass(null, byteCodes, 0, byteCodes.length);
return aClass;
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
baos.close();
bis.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}