JVM-中篇-字节码解读
第一章-虚拟机的基石:Class文件
1.1 字节码文件是什么?
源代码经过编译器编译生成一个字节码文件,字节码文件是一种二进制文件,内容是JVM指令,不像C、C++直接生成的机器码。
1.2 什么事字节码指令
Java虚拟机的指令是由一个字长度的、代表某种特定操作含义的**操作码(opcode)以及跟随其后的零个或多个代表此处操作数需要参数的操作数(Operand)**所构成。
虚拟机中许多指令不包含操作数,只有一个操作码。
字节码指令(byte code) = 操作码(opcode) + [操作数(operand)]
1.3 如何解读虚拟机提供的字节码指令?
-
方式一:一个个二进制对照着看
使用NotePad++按照HEX-Editor插件,或者使用BinaryViewer
-
方式二:JDK提供的javap 指令
-
方式三:适应IDEA插件:jclasslab或jclasslib viewer
1.4 Class类的本质
任何一个Class文件都对应唯一一个类或者接口的定义信息,但反过来说,Class文件不一定以磁盘文件的形式存在。
Class文件是一组以8位字节为基础单位ID二进制流。
1.5 Class文件格式
Class文件结果不像XML等描述语言,由于Class问没有任何的分隔符号,所以其中的数据项,无论是字节顺序还是数量,都是被严格限定的,哪个字节代表什么,长度多少,先后顺序,都不允许改变。
Class文件采用一种类似C语言结构体的方式对数据进行储存,这种结构有两种数据类型:无符号数和表。
- 无符号数:无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节、8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码过程字符串值。
- 表:表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有的表都习惯性的以“_info”结尾。表用于描述有层次关系的复合数据结构的数据,怎个Class文件本质上就是一张表。由于表没有固定长度,所以通常在其前面加个数进行说明。
代码:
public class Demo01ClassStruct {
private int num = 1;
public int add() {
num = num + 1;
return num;
}
}
字节码:
第二章-Class文件结构
当我们充分理解每一个字节码文件的细节,我们自己能够像IDEA一样可以反编译出Java的源文件。
2.1 Class文件概述
Class文件的结构并不是一概不变的,而是随着Java虚拟机的不断发展,总是不可避免地会对Class文件结构做出一些调整,但从基本的结构和框架是非常稳定的。
Class文件结构主要包含以下几个部分:
- 魔术
- Class文件版本
- 常量池
- 访问标志
- 类索引,父类索引,接口索引集合
- 字段表集合
- 方法表集合
- 属性表集合
2.2 魔数
Magic Number,魔数。
每一个Class文件开头的4个字节的无符号整数称为魔数。
它的唯一作用是确定这个文件是否是一个能被虚拟机有效接受的合法的Class文件。
即:魔数是文件的标识符。
魔数的固定值为0xCAFEABE,不会改变。
如果一个Class文件开通不是0XCAFEBABE,则虚拟机执行的时候就会抛出A JNI error has occurred ...
。
使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意改动。如可以将a.txt 直接改为a.class。
2.3 版本号
魔数之后紧接着的4个字节是Class文件的版本号。b
第6个代表的是编译的主版本号major version。
假如有个Class文件的主版本号是M,副版本号是n,那么该Class文件的版本号为:M.n。
Java中版本号和Java编译器的对于关系如下表:
主版本号(十进制) | 副版本号(十进制) | 编译器版本 |
---|---|---|
45 | 3 | 1.1 |
46 | 0 | 1.2 |
47 | 0 | 1.3 |
48 | 0 | 1.4 |
49 | 0 | 1.5 |
50 | 0 | 1.6 |
51 | 0 | 1.7 |
52 | 0 | 1.8 |
53 | 0 | 1.9 |
54 | 0 | 1.10 |
55 | 0 | 1.11 |
有该表结合图1中的数,可以知道图1的Class文件版本号为1.8(0x34 = 52 ==> 1.8)
Java版本号从45开始,JDK1.1之后的每个JDK大版本发布,主版本号向上加1。
不同版本的Java编译器编译的Class文件的版本是不一样的。
高版本的Java编译器可以执行低版本编译器生成的Class文件,反之不行,会抛出UnsupportedClassVersionError
异常。
2.4 常量池
在版本号之后紧接着是常量池的数量,以及若干个常量池表项。
常量池是Class文件中内容最为丰富的区域之一。
常量池对Class文件中字段和方法解析也有着至关重要的作用。
随着Java虚拟机的发展,常量池的内容也日渐丰富,可以说常量池是整个Class文件的基石。
常量池中的常量的数量不是固定的,因此在常量池入口需要放置一项u2类型的无符号数,代表常量池容量计数值(constan_pool_count)。
与Java语言习惯不一样,容量计数是从1开始,而不是0开始。
类型 | 名称 | 数量 |
---|---|---|
u2(无符号数) | constant_pool_size | 1 |
cp_info(表) | constant_pool | constant_pool_count-1 |
由上表可以知道,Class文件使用了一个前置的容量计数器(constant_pool_count)加若干个连续的数据项(constant_pool)的形式来描述常量池内容。我们把这一系列连续常量池数据称为常量池集合。
常量池表项中,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
常量计数器Constant Pool Count
由于常量池的数量不固定,时长时短,所以需要放置两个字节用来表示常量池容量计数值。
常量池容量计数值(u2类型):从1开始,表示常量池中有多少项常量。
constant_pool_count=1表示常量池中有0个常量项。
上图中,0x16 = 22,但是实际上只有(22-1)项常量。索引范围时1-21。
为什么常量池计数是从1开始的呢?
把第0项空出来,是为了满足后面某些执行常量池的索引的数据在特定情况下需要表达“不引用任何一个常量池条目”的含义,这种情况下使用索引0来表示。
常量池表Constant Pool
constant_pool[] 常量池
constant_pool是一种表结构,以1~constant_pool_count-1为索引。表明了后面有多少个常量项。
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)
它包含了class文件结构及其子结构中引用的所有字符串常量、类或接口名、字段名和其他常量。常量池中的每一项都具备相同的特征。
第一个字节作为类型标记,用于确定该项的格式,这个字节称为tag byte(标记字节、标签字节)
类型 | 标志或标识 | 描述 |
---|---|---|
CONSTANT_utf8_info | 1 | UTF-8编码的字符串 |
CONSTANT_Integer_info | 3 | 整形字面量 |
CONSTANT_Float_info | 4 | 浮点型字面量 |
CONSTANT_Long_info | 5 | 长整型字面量 |
CONSTANT_Double_info | 6 | 双精度浮点型字面量 |
CONSTANT_Class_info | 7 | 类或接口字面量 |
CONSTANT_String_info | 8 | 字符串类型字面量 |
CONSTANT_Fieldref_info | 9 | 字段的符号引用 |
CONSTANT_Methodref_info | 10 | 类中方法的符号引用 |
CONSTANT_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的符号引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法的句柄 |
CONSTANT_MethodType_info | 16 | 表示方法的类型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
CONSTANT_MethodHandle_info/CONSTANT_MethodType_info/CONSTANT_InvokeDynamic_info是JDK7引入。
字面量与符号引用
上面提到,常量池中主要存放两大常量:字面量(Literal)和符号引用(Symbolic References)。如下:
- 字面量
- 文本字符串
- 声明为final的常量值
- 符号引用
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
全限定名
top/tobing/mid/ch1/Demo01ClassStruct就是该列的全限定名。
全限定名仅仅是将全类名中的.
替换成了/
,为让连续的多个全限定名之间不产生混淆,在全限定名之后会加上;
表示结束。
简单名称
简单名称就是指没有类型很参数修饰的方法或者字段名称。
如add()方法和num字段的简单名称就是add和num。
描述符
描述符的作用是用来描述字段的数据类型、方法参数列表(参数数量、类型及顺序)和返回值。
根据描述符的规则,基本数据类型(byte、char、short、int、long、float、double、boolean)以及代表无返回值的void类型都用一个大写字符表示;
而对象类型则用字符L加对象的全限定名来表示。如下表:
标志符 | 含义 |
---|---|
B | 基本数据类型byte |
C | 基本数据类型char |
D | 基本数据类型double |
F | 基本数据类型float |
I | 基本数据类型int |
J | 基本数据类型long |
S | 基本数据类型short |
Z | 基本数据类型boolean |
V | void类型 |
L | 对象类型,如:Ljava/lang/Object; |
[ | 数组类型,代表一维数组。[[表示二维数组,三维以此类推。 |
用描述符描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“()”中。
如方法java.lang.String toString()的描述符为() Ljava/lang/String;
、方法int abc(int[] x, int y)的描述符为([II) I
。
补充说明:
虚拟机在加载Class文件的时候才会进行动态里链接。
即Class文件中不会保存各个方法和字段的最终内存布局信息,因此这些字段和方法的符号引用不经过转换是无法直接被虚拟机使用的。
当虚拟机运行的时候,需要从常量池中获取对应的符号引用,再在类中加载的过程中的解析阶段将其替换为直接引用,并翻译到具体的内存地址中。
符号引用于直接引用的区别
-
符号引用
符号引用以一组符号来描述所引用的目标,符号可以是任意形式的字面量,只要使用时无歧义地定位到目标即可。
符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到了内存中。
-
直接引用
直接引用可以是直接执行目标的指针、相对偏移或者说是能间接定位的句柄。
直接引用于虚拟机实现的内存布局有关,同一个符号引用在不同虚拟实例上翻译出来的直接引用一般不会相同。
如果有了直接引用,那就说明引用的目标一定在内存之中。
常量类型与结构
14种表结构的共同点是,表的开始的第一位都是一个u1类型tag,代表当前这个常量项使用的是哪种表接口,即哪种常量类型。
在常量池列表中,CONSTANT_utf8_info常量项是一种使用改进过的UTF-8编码格式来储存诸如文本字符串、类或者接口的全限定名、字段或方法的简单名称以及描述符等常量字符串信息。
14种表常量项结果还有一个特点是,其中13个常量项占固定大小,只用CONSTANT_utf8_info占用字节不固定,其大小有length决定。
为什么呢?因为从常量池存放的内容可以知道,其存放的是字面量和符号引用,最终这些内容都会是一个字符串,这些字符串的大小是在编写程序的时候才确定的。
比如定义一个类,类名的长度是可选的,在没有编译之前都是可变的,编译之后,通过utf-8编码,可以知道其长度。
总结
常量池可以理解为Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型(后面的很多数据类型都会指向此处),也是占用Class文件开年最多的数据项目之一。
为什么要在常量池中存放如此多的内容呢?
Java代码在进行编译的时候,不会像C和C++那样有“连接”的过程,Java采用的是,在JVM加载Class文件的时候进行动态链接,也就是说:在Class文件中不会保存各个方法、字段的最终内存布局,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也无法直接被虚拟机使用。
当虚拟机运行的时候,需要从常量池中获取对应的符号引用,再在类创建或运行时解析、翻译到具体的内存地址中。
2.5 访问标记
在常量池之后,紧接着的是访问标记。该标记使用你两个字节表示,用于识别一些类或者接口层次访问信息。
包括:这个类是接口还是接口;释放定义为public;是否为abstract;如果类是否为final等。详细如下表:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 标志为public类型 |
ACC_FINAL | 0x0010 | 标志声明为final,只有是类的时候才能设置 |
ACC_SUPER | 0x0020 | 标志允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类这个标志默认为 真。(使用增强的方法调用父类的方法) |
ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract,对于接口或抽象类,则标志值为真,其他为假 |
ACC_SYNTHETIC | 0x1000 | 标志此类并非由用户代码产生(即:有编译器产生的类,没有源码对应) |
ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
ACC_ENUM | 0x4000 | 标志这是一个枚举 |
类的访问权限通常为ACC_开头的常量;
每一种类型的表示都是通过设置访问标记32位中的特定位来实现的。比如是public final,则标记为 ACC_PUBLIC|ACC_FINAL ==> 0x0001 | 0x0010 = 0x0011
.
使用ACC_SUPER可以让类更准确地定位父类的方法super.method(),现代编译器都会设置并使用该标记。
注意
- 带有ACC_INTERFACE标志的class的文件表示的接口而不是类。
- 如果class文件设置了ACC_INTERFACE标志,同时也会被标志位ACC_ABSTRACT,同时不能设置为ACC_FINAL。
- 如果class文件没有设置ACC_INTERFACE标志,那么class文件可以具有上表处理ACC_ANNOTATION外的其他所有标志。
- ACC_FINAL和ACC_ABSTRACT是互斥的,不能同时使用。
- ACC_SUPER标志用于确定类或者接口中的invokespecial指令使用的是哪一种执行语义。针对Java虚拟机指令集的编译器应该都设置这个标志。对于JavaSE8以及后继版本,无论class文件中这个标志实际值是什么,也不管class文件的版本号是多少,Java虚拟机都认为每个class文件都设置了ACC_SUPER标志。
- ACC_SUPER标志是为向后兼容由于旧的Java编译器所编译的代码而设计的。目前的AC_SUPER标志有JDK1.0.2之前编译器所生成的access_flags中是没有确定含义的,如果设置了该标志,那么Oracle的Java虚拟机实现会将其忽略。
- ACC_SYTHETIC标志意味着在各类或接口是编译器生成的,而不是源代码生成的。
- ACC_ANNOTATION标志位注解类型,此时必须要设置ACC_INTERFACE标志。
- ACC_ENUM标志标明该类或者父类是枚举类型。
- 表中没有使用的access_flag标志是为未来扩充而预留的,这些预留的标志在编译器中应该设置为0,Java虚拟机也应该忽略他们。
2.6 类索引、父类索引、接口索引集合
访问标记之后,会指定类的类别、父类类别以及实现的接口,格式如下:
长度 | 含义 |
---|---|
u2 | this_class |
u2 | super_class |
u2 | interface_count |
u2 | interfaces[interfaces_count] |
- 类索引用于确定类的全限定名;
- 父类索引可以确定类的父类的全限定名;(Java是单继承,因此只需要固定位置标示父类)
- 接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按implements语句后的顺序从左到右排列在接口索引集合中。
this_class(类索引)
2字节无符号整数,指向常量池的索引。提供了类的全限定名,如top/tobing/Demo01。
this_class的值必须是对常量池表中某项的一个有效索引值。
常量池在这个索引出的成员必须为CONSTANT_Class_info类型的结构体,该结构体表示这个class文件所有定义的类或接口。
super_class(父类索引)
2字节无符号整数,指向常量池的索引。提供了当前类的父类的全限定名。
如果没有显示继承类,默认是java/lang/Object类。
由于Java不支持多继承,因此只能是有一个父类。
superclass指向的类不能是final修饰的。
interfaces
2字节无符号整数,指向常量池的索引。提供一个符号引用到所有已经实现的接口
由于一个类可以实现多个接口,一次需要以数组形式保存多个接口的索引,表示接口的每个索引都是指向常量池的CONSTANT_Class(这里提供接口,而不是类)。
-
interfaces_count(接口计数器)
该项表示当前类或接口的直接超接口数量。
-
interfaces[](接口索引集合)
interfaces[]每个成员的值必须是对常量池表中某项的有效索引,他的长度为interface_count。
每个成员interfaces[i]必须为CONSTANT_Class_info结构,其中0 <= i < interfaces_count。
在interfaces[]中,个成员所表示的接口顺序和源代码定义的顺序一致。
2.7 字段表集合
字段表集合fields
用来描述接口或类中声明的变量。
字段包含类级字段以及实例级字段,不包含方法内部、代码块内部声明的局部变量。
字段名称、定义的数据类型都是无法固定,需要引用常量池中的常量来描述。
字段表集合指向常量池索引集合,描述了每一个字段的完整信息。如:字段标识符、访问修饰符(public/private/pro)、类变量或实例变量(static)、是否常量(final)等.
注意:
字段表集合中不会列出父类,或者从父类、接口中继承而来的字段,但有可能列出原本Java代码中不存在的字段。譬如在内部类中为了保持对外部类的访问行,自动添加指向外部类实例的字段。
Java语言中,字段无法重载。不管两个字段的数据类型、修饰符是否相等,都必须使用不一样的名称;但是对于字节码来说,如果两字段的描述符不一样,那么字段重名就是合法的。
字段计数器fields_count
fields_count的值表示当前class文件fields表的成员个数,使用2个字节来表示。
fields表中每一个成员都是一个field_info结构,用于表示这个类或接口声明的所有字段,不包括方法内部的变量,也不包括从父类或者父接口继承下来的字段。
字段表fields[]
fields表中每一个成员都是一个field_info结构,用来表示类或接口的完整字段描述。
一个字段的信息包含如下信息,这些信息中,都是bool值,要么有,要么没有:
-
作用域:public/private/protected
-
实例变量或类变量:static
-
可变性:final
-
并发可见性:volatile
-
可序列化性:transient
-
字段数据类型:基本数据类型、对象、数组
-
字段名称:
字段表结构:字段作为一个表,也有它自身的结构:
类型 | 名称 | 含义 | 数量 |
---|---|---|---|
u2 | access_flag | 访问标志 | 1 |
u2 | name_index | 字段名索引 | 1 |
u2 | descriptor | 描述符索引 | 1 |
u2 | attributes_count | 属性计数器 | 1 |
attribute_info | attributes | 属性集合 | attributes_coutn |
字段表-访问标志
一个字段可以被各种关键字修饰,如作用域修饰符等。
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 字段是否为public |
ACC_PRIVATE | 0x0002 | 字段是否为private |
ACC_PROTECTED | 0x0004 | 字段是否为protected |
ACC_STATIC | 0x0008 | 字段是否为static |
ACC_FINAL | 0x0010 | 字段是否为final |
ACC_VOLATILE | 0x0040 | 字段是否为volatile |
ACC_TRANSTENT | 0x0080 | 字段是否为transient |
ACC_SYNCHETIC | 0x0100 | 字段是否有编译器产生 |
ACC_ENUM | 0x4000 | 字段是否为enum |
字段表-字段名索引
根据字段名索引的值,可以去查询常量池中的特定索引项。
字段表-描述符索引
描述符用来描述字段发数据类型、方法的参数列表(数量、类型、顺序)和返回值等。
根据描述符规则,基本数据类型以及void类型用一个大写字符表示,而对象则是用L+对象全限定名表示。
字段表-属性表集合
一个字段还可能拥有一些属性,用来储存额外的信息。
如:初始化值、注释信息等。
属性个数信息存放在attribute_count中,属性具体内容存放在attributes数组中。
以常量属性为例,结构为:
ConstantValue_attribute{
u2 attribute_name_index;
u4 attribute_length;
u2 constantvalue_index;
}
对于常量属性来说,attribute_length恒为2.
2.8 方法表集合
方法表集合:指向常量池索引集合,完整描述了每个方法的签名。
在字节码文件中,每个method_info项都对应一个类或接口的方法信息。如方法访问修饰符、方法的返回值类型以及方法的参数信息。
如果这个方法不是抽象的或者不是native,那么字节码中会体现出来。
一方面,methods只描述当前类或接口中声明的方法,不包括父类或者父接口继承的方法。
另一方面,methods可能会出现编译器自动添加的方法,最典型的就是编译器产生的方法信息。
如:类初始化方法<clinit>()和实例初始化方法<init>()。
【注意】
Java中要重载一个方法,处理要与原方法具有相同的简单名称之外,还必须要求拥有不同的特征签名。
特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此Java语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。
但是Class文件格式中,特征签名只要描述符不是完全一致的两个方法就可以共存。即如果两个方法有相同的名称和特征签名,但返回值不同,也可以合法共存于同一个class文件中。
也就是说,尽管Java语法规范并不允许在一个类或者接口中声明多个方法签名相同的方法,但和Java语法规范相反,字节码文件中却允许多个方法签名相同的方法,唯一的条件就是这些方法之间的返回值可能不同。
方法计数器method_count
该值表示当前class文件methods表的成员个数。使用2个字节来表示。
methods表中每一个成员都是一个method_info结构。
方法表methods[]
methods表中的每个成员都必须是一个method_info结构,用来表示当前类或接口中某个方法的完整描述。
如果某个method_info结构的access_flag项,既没有设置ACC_NATIVE标志,也没有设置ACC_ABSTRACT标志,那么该结构也应该包含实现这个方法所用的Java虚拟机指令。
method_info结构可以表示类和接口定义的所有方法,包含实例方法、类方法、实例初始化方法和类和接口初始化方法。
方发表的接口和字段表是一样的,结构如下:
类型 | 名称 | 含义 | 数量 |
---|---|---|---|
u2 | access_flag | 访问标志 | 1 |
u2 | name_index | 方法名索引 | 1 |
u2 | descriptor_index | 描述符索引 | 1 |
u2 | attributes_count | 属性计数器 | 1 |
attribute_info | attributes | 属性集合 | attribute_count |
2.9 属性表集合
属性表集合(attributes)指的是class文件携带的辅助信息。如该class文件的源文件名称。以及任何带有RetentionPolicy.CLASS或者RetentionPolicy.RUNTIME的注解。
这类信息通常用于Java程序调试。
属性表集合的限制不是很严格,甚至可以加入自定义ID属性信息,但Java虚拟机在运行时会忽略掉。
属性表attributes[]
属性表的每一项必须是attribute_info。属性表结构灵活,各种不同属性只要满足以下结构即可。
类型 | 名称 | 数量 | 含义 |
---|---|---|---|
u2 | attribute_name_index | 1 | 属性名索引 |
u4 | attribute_length | 1 | 属性长度 |
u1 | info | attribute_length | 属性表 |
2.10 javap
解析字节码的作用
通过反编译生成的字节码,可以深入了解Java代码的工作机制。
Oracle官方提供了javap命令可以使得开发人员方便地使用。
javap命令的作用是:将class文件反编译出当前类对应的code区、局部变量表、异常表和代码行偏移量映射表、常量池等信息。
通过局部变量表,可以看到局部变量的作用域、所在slot等信息,甚至slot复用等信息。
javac -g
在使用javap解析class文件的时候,一些信息需要使用javac编译class文件制定参数才能输出。
即直接javac命令有些信息不会输出到class文件中。
如:直接使用:javap xx.java
不会生成对应的局部变量表等信息。使用javap -g xx.java
可以输出相关信息。
IDEA或Eclipse默认使用的是javap -g
javap常用命令
javap中最常用的就是-v/-l/-c三个选项
javap -l
:输出行号和本地变量表信息
javap -c
:对当前class字节码文件进行反编译生成汇编代码
javap -v
:除了输出-c的内容,还会输出行号、局部变量表、常量池等信息。
第三章-字节码指令
3.1 概述
Java字节码对于虚拟机来说,就像汇编语言相对于计算机,属于基本执行指令。
Java虚拟机指令有一个字节长度的、代表特殊含义的数字**(操作码,Opcode)**以及紧随其后的零个或者多个代表此操作的参数(**操作数,Oprands)**而构成。
由于Java虚拟机采用面向操作数栈的而不是面向寄存器的结构,所以大多数指令都不包括操作数,只有一个操作码。
熟悉虚拟机的指令,对动态字节码生成、反编译Class文件、Class文件修补都有重要价值。
执行模型
如果不考虑异常处理,Java虚拟机解释器可以使用以下伪代码当做最基本的执行模型来理解。
do {
自动计算PC寄存器的值加1;
根据PC寄存器的指示位置,从字节码流中取出操作码;
if(字节码存在操作数) 从字节码流中取出操作数;
执行操作码所定义的操作;
}while(字节码长度>0);
字节码与数据类型
在Java虚拟机的指令集中,大多数指令包含了其操作的数据的类型信息。
如:iload 是指加载 int 类型数据到操作数栈中,fload 指令则是加载float类型到操作数栈中。
大部分数据类型相关的字节码指令,它的操作码助记符都有特殊的字符来表明专门为那种数据类型服务:
- i 代表 int 类型
- l 代表 long
- s 代表 short
- b 代表 byte
- c 代表 char
- f 代表 float
- d 代表 double
也存在一些指令的助记符中没有明确地指明操作类型的字面,如arraylength指令,它没有带代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。
还有另外一些指令,如无条件跳转指令goto则是与数据类型无关。
大部分的指令都没有支持整数类型byte、char和short,设置没有任何指令支持boolean。
编译器会在编译期将byte和short类型的数据带符号扩展(sign-extend)为相应的int类型,将boolean和char类型数据零位扩展(zero-extend)为相应的int类型数据。
类似地,在处理boolean、short、byte和char等类型的数组的时候,也会将其转换为对应的int类型字节码指令来处理。
因此,大多数对于boolean、short、byte和char类型的数据操作,实际上都是使用对应的int类型作为运算类型。
指令分类
JVM的指令按照用途可以大致分为9类:
- 加载与存储指令
- 算术指令
- 类型转换指令
- 对象的创建和访问指令
- 方法调用与返回指令
- 操作数栈管理指令
- 比较控制指令
- 异常处理指令
- 同步控制指令
在对值进行操作时:
一个指令可以从局部变量表、常量池、堆中对象、方法调用、系统调用中取出数据,这些数据被压入操作数栈;
一个指令也可从操作数栈中取出一个到多个值,完成赋值、加减乘除、方法传参、系统调用等操作。
3.2 加载与存储指令
作用
加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递。
常用指令
1、【局部变量压栈指令】:将一个局部变量加载到操作数栈中:xload、xload_<n>
其中x代表:i、l、f、d、a、n;n为0-3
2、【常量入栈指令】:将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、lds2_w、aconst_null、iconst_m1、iconst_<i>、iconst_<l>、iconst_<f>、iconst_<d>
3、【出栈装入局部变量表指令】:将一个数值从操作数栈储存到局部变量表中:xstore、xstore_<n>
其中x代表:i、l、f、d、a、n;n为0-3;xastore(其中x为i、l、f、d、a、b、c、s)
4、【局部变量表访问索引指令】:扩充部分,wide
以上列举的助记符中,一些用<>标记,实际上代表一组指令。
对于一些指令,虽然表面上没有操作数,但是实际上操作数蕴含在指令中。
如:
iload_0:将局部变量表index=0的位置数据压如操作数栈中;(一个指令,相比于下面更加节省空间)
iload 0:将局部变量表index=0的位置数据压如操作数栈中;(指令 + 操作数)
局部变量压栈指令
从局部变量表中将数据压入栈中操作数栈。
这类数据大体上分为:
- xload_<n>(x为i、l、f、d、a;n为0-3)
- xload(x为i、l、f、d、a)
xload_<n>表示将第n个局部变量压入操作数栈中,aload_n将第一个对象压入操作数栈。
xload通过制定参数的形式,把局部变量压入操作数栈中,通常是在局部变量的数量超过4个的情况下使用。
public void load(int num, Object obj, long count, boolean flag, short[] arr) {
System.out.println(num);
System.out.println(obj);
System.out.println(count);
System.out.println(flag);
System.out.println(arr);
/**
* 0 getstatic #2 <java/lang/System.out>
* 3 iload_1 // 将局部变量表序号为1【num】 压入 操作数栈
* 4 invokevirtual #3 <java/io/PrintStream.println>
* 7 getstatic #2 <java/lang/System.out>
* 10 aload_2 // 将局部变量表序号为2【obj】 压入 操作数栈
* 11 invokevirtual #4 <java/io/PrintStream.println>
* 14 getstatic #2 <java/lang/System.out>
* 17 lload_3 // 将局部变量表序号为3【count】 压入 操作数栈
* 18 invokevirtual #5 <java/io/PrintStream.println>
* 21 getstatic #2 <java/lang/System.out>
* 24 iload 5 // 将局部变量表序号为4【flag】 压入 操作数栈
* 26 invokevirtual #6 <java/io/PrintStream.println>
* 29 getstatic #2 <java/lang/System.out>
* 32 aload 6 // 将局部变量表序号为6【arr】 压入 操作数栈
* 34 invokevirtual #4 <java/io/PrintStream.println>
* 37 return
*/
}
常量入栈指令
常量入栈指令功能是将常数压入操作数栈中。根据数据类型和入栈内容的不同,可分为const系列、push系列和ldc指令。
1、const系列
用于对特定常量入栈,入栈的常量蕴含着操作码本身。
有iconst_<i>(i从-1到5)、lconst_<l>(l从0到1)、fconst_<f>(f从0到2)、dconst_<d>(l从0到1)、lconst_<l>(l从0到1)、aconst_null
2、push系列
主要包括bipush和sipush,主要区别在于接受的数据类型不同。bipush接受8位整数,sipush接受16位整数。都将参数入栈
3、ldc指令系列
如果以上都不满足需求,可以使用万能ldc,可以接受一个8位参数,参数指向常量池中int、float或者String的索引,将指定的内容压入堆栈中。
类型 | 常数指令 | 范围 |
---|---|---|
int(boolean,byte,char,short) | iconst | [-1,5] |
bipush | [-128,127] | |
sipush | [-32768,32767] | |
ldc | any int value | |
long | lconst | 0,1 |
ldc | any long value | |
float | fconst | 0,1,2 |
ldc | any float value | |
double | dconst | 0,1 |
ldc | any double value | |
reference | aconst | null |
ldc | String literal, Class literal |
// 常量入栈
public void loadPushConstLdc() {
int i = -1;
int a = 5;
int b = 6;
int c = 127;
int d = 128;
int e = 32767;
int f = 32768;
/**
* 0 iconst_m1 // -1 入栈
* 1 istore_1
* 2 iconst_5 // 5 入栈
* 3 istore_2
* 4 bipush 6 // 6 入栈
* 6 istore_3
* 7 bipush 127 // 127 入栈
* 9 istore 4
* 11 sipush 128 // 128 入栈
* 14 istore 5
* 16 sipush 32767 // 32767 入栈
* 19 istore 6
* 21 ldc #7 <32768> // 32768 入栈
* 24 istore 7
* 26 return
*/
}
public void loadConstLdc() {
long a1 = 1;
long a2 = 2;
float b1 = 2;
float b2 = 3;
double c1 = 1;
double c2 = 2;
Date d = null;
/**
* 0 lconst_1 // 1 入栈
* 1 lstore_1
* 2 ldc2_w #8 <2> // 2 入栈
* 5 lstore_3
* 6 fconst_2 // 2 入栈
* 7 fstore 5
* 9 ldc #10 <3.0> // 3.0 入栈
* 11 fstore 6
* 13 dconst_1 // 1 入栈
* 14 dstore 7
* 16 ldc2_w #11 <2.0>// 2.0 入栈
* 19 dstore 9
* 21 aconst_null // null 入栈
* 22 astore 11
* 24 return
*/
}
出栈装入局部变量表指令
出栈装入局部变量表指令用于将操作数栈中栈顶元素弹出后,装入局部变量表的指定位置,用于给局部变量赋值。
这类指令主要以store的方式存在,比如xstroe(x为i、l、f、d、a)、xstore_n(x为i、l、f、d、a;n为0-3)。
其中istore_n将从操作数栈中弹出一个整数,并把该值放到索引n的位置。
指令xstore由于没有隐含参数信息,因此需要提供一个byte类型的参数类指定目标局部变量表的位置。
显然istore_n方式是为了降低字节码文件的大小。
// 出栈装入局部变量表指令
public void store(int k, double d) {
int m = k + 2;
long l = 12;
String str = "tobing";
float f = 10.0F;
d = 10;
/**
* 0 iload_1 // 从局部变量表中加载索引为1【k=1】到操作数栈
* 1 iconst_2 // 将【常量2】加载到操作数栈
* 2 iadd // 将变量【k】与【常量2】出栈,执行add操作,并将结果保存在操作数栈中
* 3 istore 4 // 将结果出栈储存在局部变量表索引为4的位置【m】
* 5 ldc2_w #13 <12> // 将【常量12】加载到操作数栈中
* 8 lstore 5 // 将【常量12】出栈存储到局部变量表索引5的位置【l】
* 10 ldc #15 <tobing> // 将【字符串tobing】加载到操作数中
* 12 astore 7 // 将【字符串tobing】出栈存储到局部变量表索引为7的位置【str】
* 14 ldc #16 <10.0> // 将【常量10.0】加载到操作数栈中
* 16 fstore 8 // 将【常量10.0】出栈存储到局部变量表索引为8的位置【f】
* 18 ldc2_w #17 <10.0> // 将【常量10.0】加载到操作数栈中
* 21 dstore_2 // 将【常量10.0】出栈存储到局部变量表索引为2的位置【d】
* 22 return
*/
}
public void foo(long l, float f) {
{
int i = 0;
}
{
String s = "Hello world";
}
/**
* 0 iconst_0 // 将【常量0】压入操作数栈中
* 1 istore 4 // 将【常量0】出栈存储到局部变量表索引为4的位置【i】
* 3 ldc #19 <Hello world> // 将【常量Hello world】压入操作数栈中
* 5 astore 4 // 将【常量Hello world】出栈存储到索引为4的位置【f】(此处存在slot复用)
* 7 return
*/
}
3.3 算术运算
作用
算术指令用于对两个操作数栈上的值就行某种特定的运行,并把结果重新压入操作数栈中。
分类
算术指令大体上分为两种:对整数数据运算的指令与对浮点数据类型进行运算的指令。
3、ldc指令系列
byte、short、char和boolean类型说明
Java虚拟机没有直接支持byte、short、char和boolean类型的算术指令,对于这些数据的运算都使用int类型指令来处理。此外在处理boolean、byte、char和short类型时,也会转换为对应的int类型的字节码指令来处理。
实际类型 | 运算类型 | 分类 |
---|---|---|
boolean | int | 一 |
byte | int | 一 |
char | int | 一 |
short | int | 一 |
int | int | 一 |
float | float | 一 |
reference | reference | 一 |
returnAddress | returnAddress | 一 |
long | long | 二 |
double | double | 二 |
运算时溢出
数据运算的时候可能会溢出,例如两个很大的正整数相加,结果可能是一个负数。
其实Java虚拟机规范并无明确规范整数溢出的具体结果,仅仅规定了处理整形数据的时候,只有除法指令以及求余指令中当出现除数为0是会导致虚拟机抛出异常ArithmeticException
。
运算模式
- 向最近数舍入模式:JVM要求浮点运算时,所有的运算结构必须要舍入到适当的精度,非精确结构必须要舍入为可被表示的最接近的精确值,如果两种可表示的形式与该值一样接近,将优先选择最低有效位为零的;
- 向零舍入模式:将浮点数装换为整数的时候,采用该模式,该模式将目标数值类型中选择一个最接近但不但与原值的数字作为最精确的舍入结果。
NaN值的使用
当一个操作溢出时,将会使用有符号数的无穷大表示;
如果某个操作没有明确定义的数学定义的时候,将会使用NaN(Not a Number)表示。
与NaN操作的结果都是NaN。
public void testInf() {
int i = 10;
double j = i / 0.0;
System.out.println(j); // Infinity
}
public void testNaN() {
double i = 0.0;
double j = i / 0.0;
System.out.println(j); // NaN
}
所有算术运算符
加法指令:iadd、ladd、fadd、dadd
减法指令:isub、lsub、fsub、dsub
乘法指令:imul、lmul、fmul、dmul
除法指令:idiv、ldiv、fdiv、ddiv
求余指令:irem、lrem、frem、drem // remainder:余数
取反指令:ineg、lneg、fneg、dneg // negation:取反
自增指令:iinc
位运算指令,可分为:
- 位移运算:ishl、ishr、iushr、lshl、lshr、lushr
- 按位或指令:ior、lor
- 按位与指令:iand、land
- 按位异或指令:ixor、lxor
比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
public void testSub() {
float i = 10;
float j = -i;
i = -j;
/**
* 0 ldc #7 <10.0> // 从堆中加载【常量10.0】到操作数栈中
* 2 fstore_1 // 将【常量10.0】出栈存储到局部变量表为1的位置【i】
* 3 fload_1 // 加载局部变量表中中索引为1的值【i】到操作数栈中
* 4 fneg // 将【i=10】出栈进行fneg取反操作,并将结果压入栈中
* 5 fstore_2 // 将结果-10出栈并存储到局部变量表索引为2的位置【j】
* 6 fload_2 // 将【j】值压入栈
* 7 fneg // 将【j】值出栈并取反,存储会栈中
* 8 fstore_1 // 将结果存储到局部变量表索引为1 的位置【i】
* 9 return
*/
}
public void testAdd() {
int i = 100;
i = i + 5;
i += 5;
/**
* 0 bipush 100 // 将100压入操作数栈中
* 2 istore_1 // 将100存储到局部变量表索引为1的位置【i】
* 3 iload_1 // 将i=100入操作数栈
* 4 iconst_5 // 将常量5压入操作数栈
* 5 iadd // 将100和5出栈,执行add操作,并将结果压入栈中
* 6 istore_1 // 将结果105当做int保存到索引为1的位置
* 7 iinc 1 by 5 // 将索引为1的变量【i】自增5
* 10 return
*/
}
public int testAri() {
int a = 80;
int b = 7;
int c = 10;
return (a + b) * c;
/**
* 0 bipush 80 // 80入操作数栈
* 2 istore_1 // a = 80
* 3 bipush 7 // 7入操作数栈
* 5 istore_2 // b = 7
* 6 bipush 10 // 10入操作数栈
* 8 istore_3 // c =10;
* 9 iload_1 // a = 80 入栈
* 10 iload_2 // b = 7 入栈
* 11 iadd // 80、7出栈并执行add,将结果压入栈
* 12 iload_3 // c = 10 入栈
* 13 imul // 10 与 87执行imul,将结果压栈
* 14 ireturn // 返回栈顶结果
*/
}
public int testAriPlus(int i, int j) {
return ((i + j - 1) & ~(j - 1));
/**
* 0 iload_1 // i 入栈
* 1 iload_2 // j 入栈
* 2 iadd // add(i,j)结果入栈
* 3 iconst_1 // 1入栈
* 4 isub // isub(1,add(i,j) 结果入栈
* 5 iload_2 // j 入栈
* 6 iconst_1 // 1 入栈
* 7 isub // sub(1,j) 结果入栈
* 8 iconst_m1 // -1 入栈
* 9 ixor // 将 -1 与之前运算的结果执行异或操作 ==> 实现取反 , 最后将讲过入栈
* 10 iand // and 操作
* 11 ireturn // 返回and操作结果
*/
}
public void testAddAndGet() {
int i = 100;
++i;
// i++;
/** 无论是 ++i 还是 ++i 都是一下字节码指令
* 0 bipush 100 // 100 入栈
* 2 istore_1 // 100 出栈 保存到 i
* 3 iinc 1 by 1 // i 直接 +1
* 6 return // 返回
*/
}
public void testAddPlus() {
int i = 10;
int a = i++;
int j = 20;
int b = ++j;
/**
* 0 bipush 10 // 10 入栈
* 2 istore_1 // 10出栈保存到i中
* 3 iload_1 // 加载i的值入栈
* 4 iinc 1 by 1 // i值 +1【相加不需要经过操作数栈】
* 7 istore_2 // 将栈顶元素10【之前i的值】出栈并保存到a中
* =======================================================
* 8 bipush 20 // 20 入栈
* 10 istore_3 // 20出栈保存到j
* 11 iinc 3 by 1 // j值 +1
* 14 iload_3 // 加载j的值入栈
* 15 istore 4 // 将栈顶值出栈保存到b
* 17 return // 返回
*/
}
public void testAddPlusPlus() {
int i = 10;
i = i++;
System.out.println(i);
/**
* 0 bipush 10 // 10 入栈
* 2 istore_1 // 10 出栈保存到i
* 3 iload_1 // i 的值入栈
* 4 iinc 1 by 1 // i 的值加1
* 7 istore_1 // 将操作数栈的栈顶数组赋值给i
* 8 getstatic #5 <java/lang/System.out>
* 11 iload_1
* 12 invokevirtual #8 <java/io/PrintStream.println>
* 15 return
*/
}
比较运算声明
比较指令的作用是比较栈顶两个元素的大小,并将比较结果入栈。
比较指令有:dcmpg、dcmpl、fcmpg、fcmpl、lcmp (d 代表 double,f 代表 float,l 代表 long)。
对于double和float类型的数字,由于存在NaN,各有两个版本的比较指令。如 fcmpg、fcmpl,它们的区别在于遇到NaN值的时候,处理结果不同。
指令 lcmp 针对的是 long 类型的整数,不存在NaN,因此不需要两套。
fcmpg 和 fcmpl 都从栈中压出两个操作数,并比较。
设栈顶元素为v2,第二个元素为v1:
v1 == v2 ,压入 0 ;
v1 > v2 ,压入 1 ;
v1 < v2 ,压入 -1 ;
两个指令的不同之处在于,遇到NaN,fcmpg 压入1, fcmpl 压入-1
3.4 类型转换指令
类型转换指令说明
- 类型转换指令可以将两种不同的数值类型进行相互转换;
- 这些类型转换一般用于实现用户代码中的显式类型转换,或者用于处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
宽化类型转换(Widening Numeric Conversions)
1、类型转换规则
Java虚拟机直接支持以下的数值宽化类型转换,也就是说,不需要指令的执行,包括:
- int --> long/float/double :i2l/i2f/i2d
- long --> float/double :l2f/l2d
- float --> double :f2d
总结:int --> long --> float --> double
2、精度损失问题
- 宽化类型转换不会因为超过目标最大值而丢失信息,转换前后的值是精度相等的。 如:int --> long int --> double
- int、long --> float ,long --> double 时,可以发生精度丢失,丢失掉几个最低有效位的值。
- 尽管宽化类型转换实际上会导致精度发生丢失,但是这种情况不会导致Java虚拟机抛出运行时异常。
public void upperCast02() {
int i = 123123123;
float f = i;
System.out.println(f); // 1.2312312E8 精度丢失
long l = 123123123123123123L;
double d = l;
System.out.println(d); // 1.2312312312312312E17 精度丢失
}
3、补充说明
byte/char/short --> int类型的宽化类型转化是不存在的。对于 byte --> int 虚拟机没有做实际的处理,而 byte --> long 使用的是 i2l ,因此实际上内部是把 byte等当作 int 来处理。
以上的做法主要有两部分原因:
一方面,可以减少指令数量。我们知道指令数最多256个,为了节省指令数,把byte、short当作int处理也是情理之中。
另一方面,没有必要。局部变量表的基本单位是slot,固定32位,无论byte还是short至少要占用一个slot,一次没必要取特意区分这几个。
窄化类型转换(Narrowing Numeric Conversion)
1、转换规则
Java虚拟机支持以下窄化类型转换:
- int --> byte/short/char : i2b / i2s / i2c
- long --> int : l2i
- float --> int/long : f2i / f2l
- double --> int/long/float : d2i / d2l / d2f
2、精度损失问题
窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此转换很可能丢失精度。
尽管类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。
3、补充说明
-
当浮点值窄化转换位整数类型T时,将遵循以下转换规则:
- 如果时NaN,结果时0;
- 如果不是无穷大,浮点值使用IEEE 754的向零舍入模式取整,获得整数值v,如果v在T(long或int)范围之内,结果就是v,否则根据v符号,转换为最大或者最小正数。
-
当一个double类型窄化位float类型时,将遵循以下规则:
通过向最接近舍入模式一个可以使用float类型表示的数字。最后结果根据3个规则判定:
- 转换结果绝对值太小而无法用float表示,返回flaot 的正负零;
- 转换结果绝对值太大而无法用float表示,返回float的正负无穷大;
- 对于 double 类型的 NaN 值按规定转换为float的 NaN 值。
3.5 对象的创建于访问指令
Java是面向对象的程序设计语言,虚拟机平台从字节码层面就对面向对象做了深层次的支持。
有一系列指令专门用于对象操作:创建指令、字段访问指令、数组操作指令、累心检查指令。
创建指令
类实例和数组都是对象,但是使用的字节码指令并不相同。
1、创建类实例的指令
- new:接受一个操作数,指向常量的索引,表示要创建的类型,执行完成之后,将对象压入栈中。
2、创建数组的指令
- newarray:创建基本数据类型的数组
- anewarray:创建引用类型数组
- multianewarray:创建多维数组
字段访问指令
对象创建之后,可以通过对象访问指令获取对象实例或数组实例中的字段或数组元素。
1、访问类字段
- getstatic:含有一个操作数,执行常量池的Fieldref索引,作用是获取Fieldref指定的对象或值,将其压入操作数栈中。
- putstatic
2、访问类实例字段
- getfield
- putfield
数组操作指令
数组操作指令主要有:xastore和xaload
- 把一个数组元素加载到操作数栈:baload、caload、saload、iaload、faload、daload、aaload
- 把一个操作数栈的值保存到数组:bastore、castore、sastore、iastore、lastore、fastore、dastore、aastore
- 获取数组长度:arraylength
数组类型 | 加载指令 | 存储指令 |
---|---|---|
byte(boolean) | baload | bastore |
char | caload | castore |
short | saload | sastore |
int | iaload | iastore |
long | laload | lastore |
flaot | faload | fastore |
double | daload | dastore |
reference | aaload | aastore |
说明:
- xaload将数组元素压栈。执行时,要求栈顶元素为数组索引 i ,距离栈顶的第2个元素是数组的引用 a;执行时弹出2个元素,并将
a[i]
入栈。 - xastore用于数组复制。执行时,要求准备3个元素:值、索引、数组引用,xastore将会弹出3个值,并将复制给数组特定位置。
- arraylength用于获取数组长度。使用时将弹出栈顶数组元素,获取数组长度,将长度压栈。
类型检查指令
- checkcast:检查类型强制转换是否可以进行。如果可以进行,不会改变操作数栈;如果不行,会抛出ClassCastException异常。
- instanceof:判断给定对象是否是某个类实例。将结果压入操作数栈。
public String testCheckCast(Object obj) {
if (obj instanceof String) {
return (String) obj;
}
return null;
}
3.6 方法相关指令
方法调用指令
- invokevirtual:用于调用对象的实例方法,根据对象的实际类型进行分配【虚方法分派】,支持多态。是Java语言中最常见的方法分派方式。
- invokeinterface:用于调用接口方法,会在运行时搜索由特定对象所实现的这个接口方法,并找出合适的方法进行调用。
- invokespecial:用于调用一些需要特殊处理的实例方法,包括实例初始化方法(构造器)、私有方法和父类方法。这些方法是静态类型绑定的,不会在调用的时候进行动态派发。
- invokestatic:用于调用类方法(static方法)。静态绑定
- invokedynamic:用于调用动态绑定的方法,JDK1.7引入的新指令。在运行时动态解析出调用点限定符引用的方法,并执行该方法。前4条调用指令的分派垃圾固化在Java虚拟机内部,而invokedynamic指令的分派垃圾室友用户设定的引导方法决定。
1、invokespecial
public void testInvokeSpecial() {
// 情况1:类的实例构造器方法:<init>()
Date date = new Date();
Thread thread = new Thread();
// 情况2:父类的方法
super.toString();
// 情况3:私有方法
testPrivate();
}
2、invokestatic
public void testInvokeStatic() {
testStatic();
}
public static void testStatic() {
}
3、invokeinterface
public void testInvokeInterface() {
Runnable t = new Thread();
t.run();
Comparable<Integer> com = null;
com.compareTo(123);
}
方法返回指令
方法调用结束的时候,需要返回,方法返回的指令是根据返回值类型来区分的。
- ireturn、lreturn、freturn、dreturn、areturn
- return:声明为void、实例初始化方法计议类和接口初始化方法使用
返回类型 | 返回指令 |
---|---|
void | return |
int(boolean,byte,char,short) | ireturn |
long | lreturn |
float | freturn |
double | dreturn |
reference | areturn |
通过xreturn指令,将当前函数操作数栈的顶层元素弹出,并将返回值压入调用者的操作数栈,之后当前函数操作数栈的内容将会被丢弃。
如果当前返回的是synchronized方法,还会执行一个隐含指令monitorexit
指令,退出临界区。
最后,会丢弃该方法的整个帧,回复调用者的帧,并将控制权交给调用者。
3.7 操作数栈管理指令
JVM提供了操作数栈的管理指令,可以先直接操作操作数栈。
主要包含以下内容:
- 弹出元素:pop,pop2 ;
- 复制元素并重新压栈:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2;
- 将栈最顶层两个元素进行交换:swap;
- 特殊指令:nop。
以上指令都是通用型指令,无需对弹出元素进行数据类型指明。
nop:特殊指令,字节码为0x00。和汇编语言的nop一样,不做操作,往往用于调试、占位等。
dup:复制1个slot数据,如int、reference等。
dup2:复制2个slot数据,如long、double、int+int、int+float等
dup_x1:复制并插入栈顶以下位置:1+1=2,即栈顶2个slot下面;
dup_x2:复制并插入栈顶以下位置:1+2=3,即栈顶3个slot下面;
dup2_x1:复制并插入栈顶以下位置:2+1=3,即栈顶3个slot下面;
dup2_x2:复制并插入栈顶以下位置:2+2=4,即栈顶4个slot下面;
pop:将栈顶一个slot元素弹出,如int、reference。
pop2:将栈顶2个slot元素弹出,如int+int、long、double等
3.8 控制转移指令
程序流程控制离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令,大体上分为:
- 比较指令
- 条件跳转指令
- 比较条件指令
- 多条件分支跳转指令
- 无条件跳转指令
条件跳转指令
条件跳转指令通常与比较指令结合使用。在条件跳转指令执行前,一般可以用比较指令进行栈顶元素的准备,任何进行条件跳转。
条件跳转指令有:ifeq、iflt、file、ifne、ifgt、ifge、ifnull、ifnonnull。这些指令都接受1个2字节的操作数,用于计算跳转位置。
它们的统一含义是:弹出栈顶元素,测试它是否满足某一条件,如果满足,则挑战到指定位置。
命令 | 说明 |
---|---|
ifeq | 当栈顶int类型数值等于0时跳转 |
ifne | 当栈顶int类型数值不等于0时跳转 |
iflt | 当栈顶int类型数值小于0时跳转 |
ifle | 当栈顶int类型数值小于等于0时跳转 |
ifgt | 当栈顶int类型数值大于0时跳转 |
ifge | 当栈顶int类型数值大于等于0时跳转 |
ifnull | 为null时跳转 |
ifnonnull | 不为null时跳转 |
【 注意】
1、与前面的运算规则一致
- 对于boolean、short、byte、char类型的条件分支比较操作,都是使用int类型的比较指令完成的
- 对于long、float、double类型的条件分支比较操作,则会先执行相应的比较运算指令,比较运算指令会返回一个整数到操作数栈中,随后执行int类型的分支比较操作来完成整个分支跳转。
2、引用各类型最终都会转换为int类型进行比较,因此Java虚拟机提供的int类型的分支指令是最为丰富和强大的。
比较条件跳转指令
比较条件跳转指令类似于比较指令和条件跳转指令的结合体,它将比较和跳转合二为一。
指令 | 说明 |
---|---|
if_icmpeq | 比较栈顶两个int类型大小,当前者等于后者时跳转 |
if_icompne | 比较栈顶两个int类型大小,当前者不等于后者时跳转 |
if_icmplt | 比较栈顶两个int类型大小,当前者小于后者时跳转 |
if_icmple | 比较栈顶两个int类型大小,当前者小于等于后者时跳转 |
if_icmpgt | 比较栈顶两个int类型大小,当前者大于后者时跳转 |
if_icmpge | 比较栈顶两个int类型大小,当前者大于等于后者时跳转 |
if_acmpeq | 比较栈顶两个引用类型大小,当前者等于后者时跳转 |
if_acmpne | 比较栈顶两个引用类型大小,当前者不等于后者时跳转 |
以上指令都接受2个字节操作数作为参数,用于计算跳转位置。同时在执行指令的时候,栈顶需要准备两个元素比较。
指令执行完之后,栈顶的2个元素被清空,且没有任何数据入栈。
如果预设条件成立,则执行挑战,否则继续执行下一条语句。
多条件分支跳转指令
多条件分支跳转指令是专门为了switch-case
语句设计的,主要有tableswitch和lookupswitch。
- tableswitch:要求多条件分支值是连续的,内部只存放起始值和终止值,以及若干跳转便宜量,同给定的操作数index,可以立即定位,因此效率高。
- lookupswitch:存放着的是离散的case-offset,每次执行要全部搜索,找到匹配的case,然后根据offset计算跳转,因此效率较低。
lookupswitch是离散的case值,出于效率,编译会对case值排序。
无条件跳转指令
无条件跳转主要是goto。
goto接受2个字节的操作数,共同组成一个带符号整数,用来指定指令的偏移量,指令执行的目的是跳转到偏移量给定的位置。
如果指令偏移量太多,超过双字节带符号整数的范围,可以用goto_w,但实际开发极少出现。
除了goto,无条件跳转还有:jsr、jsr_w、ret,主要用于try-finally,但已经被及虚拟机组件废弃。
3.9 异常处理指令
抛出异常指令
-
athrow指令
在Java程序中显式抛出异常的操作(throw语句)都是有athrow指令实现。
除了使用throw语句显式抛出异常外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常情况自动抛出。
例如:在整数运算的时候,如果除数为零虚拟机会在idiv或者ldiv指令中那个抛出ArithmeticException异常。
-
注意
正常情况下,操作数栈的压入和弹出都是一条条指令完成的,唯一例外的就是在抛出异常的时候,Java虚拟机会清除操作数栈的所有内容,而后将异常实例压入调用者操作数栈中。
-
异常以及异常处理:
- 过程一:异常对象生成过程 ====> throw(手动、自动) ====> 指令 athrow
- 过程二:异常的处理:抓抛模型 try-cath-finally ====> 使用异常表
异常处理及异常表
1、处理异常
Java虚拟机中,异常处理(catch语句)不是有字节码指令实现的,而是采用异常表完成的。
2、异常表
如果一个方法定义了一个try-catch
或者try-finally
的异常处理,就创建一个异常表。
异常表中包含了每个异常处理或者finally块的信息。
异常表保存了每个异常的信息,如下:
- 起始位置
- 结束位置
- 程序计数器记录的代码处理的偏移地址
- 被捕获的异常类在常量池的索引
当一个异常被抛出,JVM会在当前方法里寻找一个匹配的处理,如果没有找到,这个方法就会强制结束并弹出当前栈帧,并且异常会重新抛给上层的调用的方法。
如果所有栈帧都弹出仍没有占到合适的异常处理,这个线程就会终止。
如果这个异常在最后一个非守护线程中抛出,将会导致JVM自己终止,比如这个线程是Main线程。
不管什么时候抛出异常,如果异常处理最终匹配了所有的异常类型,代码就会继续执行。
这种情况下,如果方法结束没有抛出异常,仍然执行finally块,在return前,它会直接跳到finally块来完成目标。
3.10 同步控制指令
Java虚拟机支持2种同步结构:方法级同步和方法内部一段指令序列的同步,这两种同步都是使用monitor来支持的。
方法级同步
方法级同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。
虚拟机可以从方法常量池的方法表结构中ACC_SYNCHRONIZED访问标志得知该方法是否声明为同步方法。
当方法调用时,调用指令首先检查方法的ACC_SYNCHRONIZED访问标志是否设置。
- 如果设置了,执行线程先持有同步锁,然后执行方法,最后在方法完成是释放同步锁。(无论正常完成还是非正常完成都会释放)
- 在方法执行期间,执行线程持有了同步锁,其他线程都无法再获得同一个锁
- 如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法锁持有的锁将在抛出异常到同步方法在外时自动释放。
对于同步方法而言,当虚拟机通过方法的访问标志符判断是一个同步方法时,会自动在方法调用前进行加锁,当方法执行完毕,会释放该锁,因此对于同步方法而言,monitorenter和monitorexit指令是隐式存在的,并未出现在字节码中。
方法内指定指令序列的同步
当一个线程加入同步代码块时,它使用monitorenter指令请求进入。
如果当前对象的监视器计数器为0,则允许进入;如果是1,判断当前监视器的线程是否为自己,如果是,则进入,不是则等待,直到监视器计数器为0。
当一个线程推出同步块时,需要使用monitorexit指令声明退出。
在Java虚拟机中,任何对象都有一个监视器与之关联,用来判断对象释放被锁定,的那个监视器被持有之后,对象处于锁定状态。
指令monitorenter和monitorexit在执行时,都需要在操作数栈顶压入对象,之后monitorenter和monitorexit的锁定和释放都是针对这个对象的监视器进行的。
第四章-类的加载过程
4.1 概述
Java中数据类型分为基本类型和引用数据类型。基本数据类型有虚拟机预先定义,引用数据类型则需要进行类的加载。
按照Java虚拟机规范,从class文件加载到内存中的类,到类卸载出内存为止,整个生命流程分为以下7个阶段:
4.2 加载阶段
4.2.1 加载完成的操作
加载的理解
加载,简而言之是Java类的字节码文件加载到内存中,并在内存中构建出Java的原型–类模板对象。
所谓类模板对象就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。
反射机制的实现正是基于这一基础。
加载完成的操作
加载阶段,简而言之,查找并加载类的二进制数据,生成Class实例。
在加载类的过程中,Java虚拟机需要完成以下3件事:
- 通过全类名,获取类的二进制流。如:
java.lang.String
- 解析类的二进制数据流为方法区内的数据结构。
- 创建java.lang.Class的类实例,表示该类型。作为方法区这个类的各种数据的访问入口。
4.2.2 二进制流获取的方式
二进制流的获取方式
对于类的二进制流,JVM可以通过多种方式产生或获取,只需要保证获取的二进制流符合JVM规范即可。
- 通过文件系统读入一个class或者的文件
- 读入jar/zip等归档数据包,提前类文件
- 事先存放在数据库的二进制流数据
- 基于HTTP等网络协议进行加载
- 运行时动态生成一段Class的二级制信息等
在获取类的二级制信息之后,Java虚拟机会处理这些信息,并最终转为一个java.lang.Class实例。
如果输入的二进制流数据不符合ClasFile结构,会抛出ClassFromatError。
4.2.3 类模型和Class实例的位置
类模型的位置
加载的类在JVM中创建相应的类结构,类结构会储存在方法区中。
Class实例的位置
类将二进制字节码文件(.class)文件加载到方法区之后,会在堆空间创建与之对应的java.lang.Class对象,用来封装类位于方法区的数据结构,该Class对象是在加载类的过程中创建的,每一个类都有一个对应的Class类型的对象。
补充
Class类的构造器是私有的,只有JVM能够创建。
java.lang.Class实例是访问类型元数据的接口,也是实现反射的关键数据、入口。
通过Class类提供的接口,可以获得目标类关联的.class文件中具体的数据结构,如方法、字段等信息。
>通过Class获取String数据结构 :Demo01Class.java
public class Demo01Class {
public static void main(String[] args) throws ClassNotFoundException {
// 获取String类的Class对象
// Class持有String.class在方法区中的引用
// 因此Class可以获取String.class在方法区中的数据结构
Class<?> clazz = Class.forName("java.lang.String");
// 获取String类中的方法信息
Method[] methods = clazz.getMethods();
StringBuilder sb = new StringBuilder();
for (Method method : methods) {
// 返回值类型 方法名 (参数);
sb.append(method.getReturnType().getSimpleName());
sb.append(" ");
sb.append(method.getName());
sb.append(" ");
sb.append("(");
Class<?>[] parameterTypes = method.getParameterTypes();
if (parameterTypes != null && parameterTypes.length > 0) {
for (Class<?> parameterType : parameterTypes) {
sb.append(parameterType.getSimpleName());
sb.append(" ");
}
}
sb.append(")");
sb.append(";");
sb.append("\n");
}
System.out.println(sb.toString());
}
}
4.2.4 数组类的加载
创建数组类的时候比较特殊,因为数组类本身并不是由类加载器负责的,而是有JVM在运行时根据需要直接创建的,但数组的元素类型仍然需要依靠类加载器创建。以下是创建数组类的过程:
- 数组的元素类型是引用类型,那么就遵循定义的加载过程,递归加载和创建数组的元素类型;
- JVM使用特定的元素类型和数组维度来创建新的数组类。
如数组元素类型时引用类型,数组类的访问性由元素类型的可访问性决定,否则数组类的可访问性将被缺省定义为public。
4.3 链接阶段
4.3.1 验证(Verification)
当类加载到系统之后,就开始链接操作,验证是连接操作的第一步。
验证的内容涵盖了类数据信息的格式验证、语义检查、字节码验证以及符号引用验证等。
其中格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制信息加载到方法区中。
格式验证之外的验证操作在方法区中进行。
验证阶段比较复制,虽然拖慢了加载速度,但是避免了字节码在运行时候还需要各种检查,磨刀不误砍柴工。
验证过程比较复杂,主要分以下阶段:
1、格式验证
验证开头的0xCAFEBABE,主版本号和副版本号是否在当前Java虚拟机支持防伪,数据中的每一项是否都拥有正确的长度。
2、语义检查
- 是否所有的父类都存在;(Java中,除Object无父类,其余都有父类)
- 是否一些定义为final的方法或类被重写或继承了;(Java中,修饰为final的方法不能被重写,修饰为final的类不能被继承)
- 是否非抽象类实现了抽象类或接口的所有抽象方法;
- 是否存在不兼容问题,如方法除了返回值不同,其他都一样;abstract和final同时修饰了方法。
3、字节码验证【最为复杂的过程】
试图通过对字节码流的分析,判断字节码能否被正确的执行。如:
- 字节码执行过程中,是否会跳转到一条不存在的指令;
- 函数的调用是否传递了正确类型的参数;
- 变量的赋值是否穿了正确的数据类型等。
栈映射帧(StackMapTable)就是在这个阶段,用于检测在特定字节码处,其局部变量表和操作数栈是否有正确的数据类型。
但是整个过程不会100%保证字节码是否能安全执行,因此该过程只能尽可能检测出明显的问题。
4、符号引用验证
在前3次检查中,排查了文件格式错误、语义错误以及字节码的不正确性,但依然不能保证类是没有问题的。
校验器还将进行符号引用的检验。
Class文件在其常量池中会通过字符串记录自己将要使用的类或方法。因此,在验证阶段,虚拟机会检查这些类或者方法是否存在,并且当前类是否有权限访问这些数据,如果一个需要使用类无法在系统中找到,则抛出NoClassDefFoundError
,如果方法无法被找到,则抛出NoSuchMethodError
。
此阶段在解析才会执行。
4.3.2 准备(Preparation)
准备阶段简单来说就是:为类的静态变量分配内存,并将其初始化为默认值
类验证通过会进入准备阶段,在这个阶段虚拟机会为类分配相应的内存空间,设默认初始值,如下表所示:
类型 | 默认初始化值 |
---|---|
byte | (byte)0 |
short | (short)0 |
int | 0 |
long | 0L |
float | 0.0 |
double | 0.0 |
char | \u0000 |
boolean | false |
refrence | null |
Java 并不支持boolean、对于boolean类型,字节码实现是int,由于int默认是0,对应地boolean是false
【注意】
- 准备阶段的默认初始化不包括static final修饰的情况,因为final在编译的时候就分配了,准备阶段是显式赋值;
- 该阶段不会为实例变量分配初始化,类变量分配在方法区中,而实例变量随着对象分配在堆中;
- 该阶段不会像初始化阶段那样会有初始化或者代码被执行。
4.3.3 解析(Resolution)
在准备阶段完成之后,就进入了解析阶段。
解析阶段简而言之就是:将类、接口、字段和方法的符号引用转换为直接引用。
1、具体描述
符号引用是一些字面量的引用,和虚拟机的内部数据结构和内存布局无关。
在Class文件中,通过常量池进行大量的符号引用。但在程序运行时,只有符号引用是不够的。
比如:当println方法在执行的时候,必须要知道该方法的地址。
以方法为例,Java虚拟机为每一个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个表的方法的时候,只要知道这个方法在发放表的偏移量就可以这调用该方法。通过解析操作,符号引用可以转换为目标方法所在类中的方法表的位置,从而使得方法被成功调用。
因此,所谓解析就是将符号引用转换为直接引用,也就是得到类、字段、方法在内存的指针或者偏移量。
2、字符串补充
当Java代码中直接使用字符串常量时,就会在类中出现CONSTANT_String,它表示字符串常量,并且会引用一个CONSTANT_UTF8的常量项。
在Java虚拟机内部运行中的常量池中,会维护一张字符串拘留表(intern),它会保存所有出现过的字符串常量,并且没有重复项。
只要以CONSTANT_String形式出现的字符串都会出现在字符串拘留表中。使用String.intern()方法可以得到一个字符串在拘留表中的引用,因为表中没有重复项,所以任何字面相同的字符串的String.intern()方法返回总是想等的。
4.4 初始化过程
为类静态变量赋予正确值的过程。
类初始化是装载的最后一个阶段。到了初始化阶段,才开始真正执行Java程序代码。
初始化阶段的重要工作是执行类的初始化方法 <clinit>()
方法。
4.4.1 clinit方法
class init,类初始化时被调用的方法。
该方法只能由Java编译器生成,并由JVM调用,程序开发者无法自定义同名的方法,也无法在程序中调用该方法。
clinit 方法是由类静态成员的赋值语句以及static语句合并而成的。
在类加载之前,先试图加载器父类,因此父类的clinit方法总是先于子类的clinit方法执行,即父类的 static 先于子类执行。
注意!Java编译器并不会为所有的类都产生<clini>()方法,如以下情况
- 类中没有生命任何类变量,也没静态代码块;
- 类中只声明了类变量,但是没有类变量初始化语句以及静态代码块执行初始化的操作;
- 类中包含static final修饰的基本数据类型,这些类型字段初始化采用在编译时常量表达式
4.4.2 static final 问题
对于static final 修饰的字段,在何时被赋值呢?
- 链接阶段的准备环节
- 初始化阶段的<clinit>()方法阶段
【链接-准备情况】
- 对于基本数据类型,使用显示复制,即直接赋值常量,而非调用方法或构造函数
- 对于 String 类型,使用字面量的方式,即
String s = "str";
【clinit 情况】
除了链接准备阶段,就是clinit阶段。
4.4.3 clinit 线程安全性
对于<clinit>()方法的调用,也就是类的初始化,虚拟机会在内部确保线程安全性。
虚拟机会确保<clinit>()在多线程环境下能被正常的加锁、同步。
如果多线程同时初始化一个类,只能有一个线程执行该类的<clinit>()方法。
因为<clinit>()函数是带锁的线程安全,因此如果一个类的<clinit>()方法执行时间很长,将可能造成多个线程阻塞,引发死锁。
由<clinit>()引发的死锁是很难发现的。
如果一个线程成果加载了类,那么等待队列中的线程就不会再执行<clinit>()方法,此时虚拟机会直接返回已经准备好的信息。
clinit引发的死锁程序
public class Demo06StaticDeadLock {
public static void main(String[] args) {
new Thread(() -> {
try {
Class.forName("top.tobing.mid.ch5.ClassA");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}).start();
new Thread(() -> {
try {
Class.forName("top.tobing.mid.ch5.ClassB");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}).start();
}
}
class ClassA {
static {
try {
Thread.sleep(1000);
Class.forName("top.tobing.mid.ch5.ClassB");
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("ClassA初始化成功!");
}
}
class ClassB {
static {
try {
Thread.sleep(1000);
Class.forName("top.tobing.mid.ch5.ClassA");
} catch (Exception e) {
e.printStackTrace();
}
System.out.println("ClassB初始化成功!");
}
}
4.4.4 主动使用与被动使用
Java程序对类的使用分为两种:主动使用 和 被动使用。
主动使用意味着调用 clinit 方法。
1、主动使用
Class 必须要在首次使用的时候才会被加载,Java虚拟机不会无条件的装载Class类型。
Java虚拟机规定,一个类接口在初次使用的时候,必须要进行初始化。此处的使用是指主动使用。
主动使用只有一下几种情况:
- 创建一个类的实例,包括new、反射、克隆、反序列化。
- 调用类的静态方法,即执行了invokestatic指令。
- 使用了类、接口静态字段,如使用了getstatic、putstatic指令,final修饰的静态字段要特殊处理。
- 使用了java.lang.reflect包中的方法反射类的方法时,如
Class.forName("top.tobing.Main")
,称主动使用Main。 - 一个子类初始化是,发现父类还没进行过初始化,先触发父类初始化。
- 一个接口定义了default方法,直接或间接实现该类的类的初始化,该接口在之前被初始化。
- 虚拟机启动时,用户需要指定执行的主类,虚拟机会先初始化该类(带main那个类)。
- 当初次调用MethodHandle实例时,处死话MethodHandle指向的方法所在的类,涉及解析REF_getStatic/REF_putStatic/REF_invokeStatic方法句柄对应的类。
针对5补充
Java虚拟机初始化一个类的时候,要求其父类都已经被初始化,但在不适用于接口
-
初始化一个类时,不会先初始化其实现的接口;
-
初始化一个接口时,不会先初始化其父接口。
因此父接口不会因为子接口初始化而初始化,只有当程序首次使用特定接口的静态字段时,才会导致该接口初始化。
针对7补充
JVM启动的时候通过类加载器加载一个初始类。这类在调用main方法之前被连接和初始化。这个方法的执行将依次导致需要的类的加载,链接和初始化。
处理以上的主动使用,其他都是被动使用。被动使用不会引起类的初始化。
也就是说:并不是在代码中出现的类就一定会被加载或者初始化。如果不符合主动使用的条件,就不会初始化。
- 当访问一个静态字段时,只有真正声明这字段的类才会被初始化。因此通过子类引用父类的静态i被拿来不会导致子类初始化。
- 通过数组定义类引用,不会触发此类的初始化。
- 引用常量不会触发类或接口的初始化,因为常量在链接的过程已经被显式赋值。
- 调用ClassLoader雷丹loadClass()方法加载一个类,不是对于类主动使用,不会导致类的初始化。
4.5 类的使用
经历了加载、链接和初始化3个阶段和初始化3个类加载步骤,就可以使用。可以在程序中访问和调用它的静态成员信息,或者使用new创建实例对象。
4.6 类的卸载
4.6.1 类、类的加载器、类实例之间的引用关系
在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。
另一方面,一个Class对象总是会引用他的类加载器,调用Class对象的getClassLoader()方法,就可以获得其类加载器对象。
因此,类和类加载器是双向关联的关系。
一个类的实例总是引用了代表该类的Class对象。在Object类中定义了getClass()方法,这个方法返回代表所属类的Class对象的引用。
此外,所有的Java类都有一个静态属性class,它引用代表了这个类的Class对象。
4.6.2 类的生命周期
当Sample类被加载、链接和初始化之后,生命周期便开始了。当代表Sample类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,Sample类在方法区内的数据也会被卸载,从而结束Sample类的生命周期。
如图1,loader1变量和obj变量间接引用了Sample的Class对象,而objClass直接引用了该对象。
程序运行中,如果最左边的三个引用都被设置为null,此时Sample对象结束生命周期,MyClassLoader对象结束生命周期,代表Sample类的Class对象也结束生命周期,Sample类在方法区内的二进制数据被卸载。
当再次有需要的时候,会检查Sample类的Class对象是否存在,如果存在会直接使用,不再重新加载;
如果不存在Sample类会被重新加载,在Java虚拟机的堆区会生成一个新的代表Sample类的Class实例。
4.6.3 类的卸载
- 启动类加载器加载的类型在整个运行期间是不可能被卸载的,JVM和JLS规范
- 被系统类加载器和扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器或扩展类加载器的实例基本上在整个运行期间总能直接或间接的访问的到,其达到unreachable的可能性极小。
- 开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境才会被卸载,且一般借助虚拟机垃圾收集功能才能卸载。
综上所述,一个类型被加载之后及难被卸载。
第五章-类加载器
5.1 概述
类加载器是JVM执行类加载机制的前提。
5.1.1 ClassLoader作用
.class -----二进制流-----> Class对象
ClassLoader是Java的核心组件,所有的Class都是ClassLoader进行加载的,ClassLoader负责通过各种方式将Class信息的二进制流数据读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响类加载,而无法通过ClassLoader区改变类的链接和初始化行为。至于是否可以运行,有Execution Engine决定。
类加载器最早出现在Java1.0中,那时候只是单纯为了满足Java Applet应用而被研发出来,但如今却在OSGi、字节码加解密中大放异彩。这主要归功于Java虚拟机的设计者当初在设计类加载的时候,并没有考虑将它绑定到JVM内部,这样做的好处是能够灵活和动态的执行类加载操作。
5.1.2 类加载的分类
JVM可以通过显式加载与隐式加载将class文件加载到内存中
- 显式加载,通过调用ClassLoader加载class对象,如直接使用Class.forName(name)或this.getClass().getClassLoader().loadClass()加载class对象。
- 隐式加载,通过Java虚拟机自动加载到内存中,如加载某个类时,该类中引用了另外一个类,此时则通过隐式加载。
5.1.3 类加载器的必要性
一般情况下不需要显式使用类加载器,但了解类加载器的加载机制却至关重要:
- 避免开发中出现java.lang.ClassNotFoundException异常或java.lang.NoClassDefFoundError异常时无从下手;
- 需要指出类的动态加载或需要对编译后的字节码问进行加解密的时候,需要与其打交道;
- 开发人员可以在程序中编写自定义的类加载器来重新定义类的加载规则,以便于实现一些自定义逻辑。
5.1.4 命名空间
1、类的唯一性
类的加载器和类本身可以确定Java虚拟机的唯一性。
每一个类加载器都有独立的类名称空间:比较两个类是否相等,只有在同一个类加载器下才有意义。两个不同类加载器加载的类必定不相等。
2、命名空间
每个类都有自己的命名空间,命名空间由类的加载器以及所有的父加载器加载的类构成;
在同一个命名空间中,不允许出现全类名完全相同的类;
不同命名空间中,可能会出现全类名完全相同的类。
在大型引用中,如Tomcat可以同这一特性,来运行同一个类的不同版本。
5.1.5 类机制的基本特征
类加载的时候有三个特征:
- 双亲委派模型。不是所有的类加载都遵循这个模型。有时候,启动类加载的类型可能要加载用户代码,如JDK内部的ServiceProvider、ServiceLoader机制,用户可用在标准的API框架上,提供自己的实现,如Java中的JNDI、JDBC、文件系统、Cipher等,都是利用该机制。这时不是采用双亲委派机制,而是上下文加载器。
- 可见性,子类加载器可用访问父加载器加载的类型,但反过来不行 。不然因为缺少不要的割裂就没办法利用类加载器去实现容器的逻辑。
- 单一性,由于父加载器的类型对于子加载器是可见的,所以父加载器中加载过的类型,就不会在子加载器中重复加载。但是注意,类加载器“邻居”间,同一类型仍然可以被加载多次,因为互不可见。
5.2 类加载器的分类
JVM把类加载器分为两类:引导类加载器(Bootstrap ClassLoader)和自定义加载器(User-Defined ClassLoader)。
不同的类加载器主机不是继承关系,而是包含关系,下层类加载器包含上层类加载器的引用。
5.2.1 引导类加载器
Bootstrap ClassLoader,使用C/C++实现,嵌套在JVM内部。
加载Java核心库:JAVA_HOME/jre/lib/rt.jar或sun.boot.class.path,用于提供JVM自身需要的类。
并不继承与java.lang.ClassLoader,没有父加载器。
出于安全考虑,Bootstrap启动类加载器只加载包名为java/javax/sun
等开头的类。
引导类加载器可以加载扩展类加载器和应用程序类加载器,并为他们指定父类加载器。
5.2.2 扩展类加载器
Extension ClassLoader,Java编写,sun.misc.Launcher$ExtClassLoader实现。
Extension ClassLoader继承于ClassLoader,它的父类加载器为启动类加载器。
从java.ext.dirs系统属性指定的目录加载类库,或从jre/lib/ext子目录下加载类库。
如果用户将jar放到以上目录,将会自动被加载。
5.2.3 应用程序加载器
AppClassLoader,Java编写,sun.misc.Launcher$AppClassLoader实现。
AppClassLoader继承于ClassLoader,它的父类加载器为扩展类加载器。
负责加载claspath或系统属性java.class.path指定路径下的类库。
应用程序的类加载器默认是系统类加载器。
它是用户自定义类加载器的默认父加载器。
通过ClassLoader的getSystemClassLoader()方法可以获取该类加载器。
5.2.4 用户自定义类加载器
日常开发中都是使用上述三种类加载器交叉使用,但必要时我们也可以自定义类加载器。
Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以是jar,也可以是网络的远程资源。
**通过类加载器可以实现精美绝伦的插件机制。**如著名的OSGI组件框架、Eclipse插件机制等。
类加载器为应用程序提供了一种动态添加新功能的机制,这种机制无需重新打包发布应用程序就能实现。
同时,自定义类加载器可以实现应用隔离,如 Tomcat、Spring等中间件和组件框架都在内部实现了自定义加载器,并且通过自定义加载器隔离不同的组件模块。
自定义类加载器通常继承与ClassLoader。
5.2.5 测试不同的类加载器
值得注意的是,数组类的Class对象不是有类加载器创建的,而是Java运行期JVM根据需要自动创建的。
对于数组类加载器来说,是通过Class.getClassLoader() 返回的,与数组当中的元素的类加载器一致。
对于基本数据类型的数组,基本数据类型是JVM创建的,因此无类加载器。
// 一些特殊的类加载器
public static void testSpecialClassLoader() {
// 字符串数组
// null:代表是引导类加载器
String[] str = new String[10];
System.out.println("String[]: " + str.getClass().getClassLoader());
// 自定义类数组
// sun.misc.Launcher$AppClassLoader:应用程序类加载器
Demo01ClassLoader[] classLoaders = new Demo01ClassLoader[10];
System.out.println("Demo01ClassLoader[]: " + classLoaders.getClass().getClassLoader());
// 基本数据类型数组
// null:代表该类不用加载器,有JVM自动加载
int[] ints = new int[10];
System.out.println("ini[]:" + ints.getClass().getClassLoader());
}
5.3 ClassLoader的源码剖析
5.3.1 ClassLoader主要方法
ClassLoader是一个抽象方法,但是内部没有抽象方法,主要方法有:
public final ClassLoader getParent()
:返回类加载器的父类加载器public Class<?> loadClass(String name) throws ClassNotFoundException
:加载名称为name的类,结果为java.lang.Class。找不到抛出异常,方法中具体实现了双亲委派机制。protected Class<?> findClass(String name)throws ClassNotFoundException
:查找二进制名称为name的类,结果为java.lang.Class。protected修饰,JVM鼓励重写此方法来自定义加载器,从而实现双亲委派机制。protected final Class<?> defineClass(String name, byte[] b, int off, int len) throws ClassFormatError
:将给定的字节数组b转换为Class实例,off和len参数代表实际Class信息在byte数组中的位置和长度,其中byte数组b是ClassLoader从外部获取的。protected修饰,只能由子类使用。
补充1
JDK1.2之前,在自定义类加载器时,总会继承ClassLoader类并重写loadClass方法,从而实现自定义的类加载器。
JDK1.2之后,不再建议覆盖loadClass方法,而是推荐把类的加载逻辑放在findClass方法中。
因为loadClass中实现了双亲委派机制,重写loadClass会可能会破坏双亲委派机制,而重写findClass则不会。
需要注意的是,ClassLoader抽象类中没有对loadClass具体实现,只是抛出了ClassNotFountException异常。
同时应该注意的是findlass方法应该和defineClass方法一起使用。
一般情况下,自定义类加载器时:
- 直接覆盖findClass方法并编写加载规则,取得加载类的字节码后转换为流,然后调用defineClass方法生成类的Class对象
补充2
defineClass方法是用来将byte字节码流转换为JVM识别的Class对象,通过这个方法将一个class文件实例化为一个Class对象,还可以从网络字节码中生成。
Class.forName与loadClass
- Class.forName:静态方法,将Class文件加载到内存,并初始化(clinit<>())。
- loadClass:实例方法,将Class文件加载到内存,不初始化,可以指定使用那个类加载器。
5.3.2 Secure ClassLoader与URLClassLoader
Secure ClassLoader:ClassLoader子类,主要添加了代码源验证和权限定义验证;
URLClassLoader:Secure ClassLoader子类,主要是协助获取Class字节码流。
5.4 双亲委派机制
JDK1.2开始,类的加载过程采用双亲委派机制,这机制能够更好的保证Java平台安全。
5.4.1 定义和本质
1、定义
一个类加载器在接收到类加载的请求的时候,首先不会自己尝试加载,而是先把这请求交给父类加载器去加载,依次递归。
如果父类加载器可以完成类的加载任务,则成功返回;否则自己其加载。
2、本质
规定了类的加载顺序是:引导类加载器 --> 扩展类加载器 --> 系统类加载器 --> 自定义类加载器
5.4.2 优势与劣势
1、优势
- 避免类的重复加载,确保类的全局唯一性。
- 保护程序安全,防止核心API被随意篡改。
2、代码支持
双亲委派机制具体实现是在java.lang.ClassLoader.loadClass(String, boolean)
中体现。逻辑如下:
(1)当前类加载器缓存中查找该类,有则直接返回;
(2)判断当前父类加载器是否为空,不为空,则调用parent.loadClass()进行加载;【双亲委派机制】
(3)如果父类加载器为空,则调用findBootstrapClassOrNull()接口,让引导类加载器加载;【双亲委派机制】
(4)如果以上三步都不成功,自己加载,调用findClass(name)。该接口最终调用ClassLoader的defineClass系列的native接口加载目标Java类。
3、思考
我们知道,loadClass实现了双亲委派机制,但如果我们重写了loadClass方法,把双亲委派机制破坏,是否就可以加载核心类库了呢?
这是不行,因为JDK中对核心类库进行了包含,不管是自定义类加载器还是系统类加载器、扩展类加载器,最终必须调用java.lang.ClassLoader.defineClass
方法,而该方法中会执行preDefineClass,其中提供了对JDK核心类库的保护。
4、双亲委派机制的弊端
检查类加载的委托过程是当向的,顶层Class Loader无法访问底层Class Loader加载的类。
通常情况下,启动类加载器为系统核心类,包含系统重要接口;应用类加载器为应用类。此时应用类访问系统类是没有问题的,但是系统类访问应用类则会出现问题。
如系统类提供了一个接口,该接口要在实现类中被实现,该接口还绑定一个工厂方法,用于创建该接口的实例,而接口和工厂方法都在系统类加载器中,此时该工厂方法无法创建应用类加载器加载的应用实例问题。
5、结论
Java虚拟机规范没有明确要求类加载器的加载机制一定要采用双亲委派机制,只是建议使用。
在Tomcat中,类加载器采用的加载机制和双亲委派机制有一定区别,当缺省的类加载器收到一个加载任务的时候,首先会自行加载,当加载失败,才会将类加载委派给父类加载器,同时也是Servlet规范推荐的做法。
5.4.3 破坏双亲委派机制
双亲委派机制不是一个具有强制性的模型,而是Java设计者推荐使用的类加载方式。
在JDK9之前,出现过三次破坏双亲委派机制的情况。
(1)JDK1.2前
ClassLoader诞生于JDK1.0,但是双亲委派机制是JDK1.2才引入。因此JDK1.2 之前的自定义实现的用户自定义类加载器是不具有双亲委派机制的。同时,为了对已有代码的兼容,JDK1.2在对具体类加载器实现时,将类字节码文件的加载以及字节码文件转换为Class文件分离,分别是loadClass与defineClass方法。推荐使用重写defineClass来避免破坏双亲委派机制。
(2)线程上下文
双亲委派模式自身存在缺陷,即上级加载器无法使用下级加载器的类。而实际引用中存在上级定义规范,下级实现的情况,如JDBC。JDBC是Java定义的规范接口,需要实际的数据库厂商根据自身特点来实现,而JDBC接口是Java核心类库定义的,应该属于引导类加载器管理;而数据库厂商实现的自然是应用程序类加载器,此时如果依旧采用纯双亲委派机制就无法实现JDBC的需求。
为了解决这个困境,Java设计团队引入了线程上下文类加载器。线程上下文类加载器运行父类加载器去请求子类加载器完成类的加载的行为。这便破坏了双亲委派机制。
(3)热代码替换
这一次破坏源于用户对程序动态性的追求,Hot Swap,Hot Deployment等。
IBM主导的JSR-291实现的模块热部署采用了自定义类加载器,该自定义加载器不再使用双亲委派模型推荐的树形结构,而是采用的复杂的网状结构。
小结
“被破坏”不意味着就是贬义,只要理由充分,突破旧的原则无疑就是一种创新。
5.4.4 热替换实现
热替换,程序在运行过程中,不停止服务,只是通过替换程序文件就可以实现程序的修改。
热替换关键需求在于不中断服务,修改必须立即表现在运行的系统中。
对于Java来说,当一个类已经加载到系统,通过修改类的文件,并无法让系统重新在加载并重定义该类,因此需要通过运用ClassLoader来实现。
5.5 沙箱安全机制
Java 采用沙箱安全机制来保证程序的安全以及保护Java原生的JDK代码,是Java安全模型的核心。
沙箱:一种限制程序运行的环境。
沙箱机制:将Java代码限定在JVM特定运行范围,并严格限制代码对本地资源的访问,通过这样措施来保证对代码的有限隔离,防止本地系统被破坏。
5.5.1 JDK1.0时期
JDK1.0时期将程序分成本地代码和远程代码,本地代码默认信任,可以访问一起本地资源,远程不信任。
5.5.2 JDK1.1时期
JDK1.0的沙箱安全机制给扩展带来了障碍,JDK1.1对其进行了改进,增加了安全策略,允许指定用户对本地资源的访问权限。
5.5.3 JDK1.2时期
JDK1.2再次改进,引入代码起那么,实现差异化的代码执行权限控制。
5.5.4 JDK1.6时期
JDK1.6,引入了域的概念。系统域部分专门负责关键资源的交互,应用域则通过系统域的部分代理实现资源访问。
5.6 自定义类加载器
5.6.1 为什么自定义类加载器
- 隔离加载类:确保应用中的jar包不会影响中间件中的jar包;Tomcat应用服务器内部定义多个类加载器以隔离不同的应用程序。
- 修改类的加载方式:处理Bootstrap,其他可以动态加载。
- 扩展加载源:从数据库、网络甚至电视机顶盒加载。
- 防止源码泄漏:加密解密。
5.6.2 常见场景
- 进程内隔离,例如两个模块依赖不同版本的.class,不同的加载器加载可以避免类库的仲裁覆盖,如:OSGI、JavaEE、JPMS。
5.6.3 实现自定义加载器
- 方式1:重写loadClass方法
- 方式2:重写findClass方法 --> 推荐
class MyClassLoader extends ClassLoader {
private String path;
public MyClassLoader(String path) {
this.path = path;
}
public MyClassLoader(ClassLoader parent, String path) {
super(parent);
this.path = path;
}
// findClass ----> 加载类的字节码流
// defineClass ----> 将类的字节码流转换为Class
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
BufferedInputStream bis = null;
ByteArrayOutputStream baos = null;
try {
// 拼接字节码文件路径
String classPath = this.path + name + ".class";
// 将.class文件通过输入流加载,在通过输出流输出到byteArray
bis = new BufferedInputStream(new FileInputStream(classPath));
baos = new ByteArrayOutputStream();
int len = 0;
byte[] buf = new byte[1024];
while ((len = bis.read(buf)) != -1) {
baos.write(buf, 0, len);
}
byte[] byteCodes = baos.toByteArray();
// 将byteArray转换为Class对象
return defineClass(null, byteCodes, 0, byteCodes.length);
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (bis != null)
bis.close();
} catch (IOException e) {
e.printStackTrace();
}
try {
if (baos != null)
baos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
return null;
}
}
// 使用自定义类加载器
public class Demo02DIYClassLoader {
public static void main(String[] args) {
MyClassLoader myClassLoader = new MyClassLoader("d:/");
try {
Class<?> clazz = myClassLoader.findClass("Demo01Code");
System.out.println(clazz.getClassLoader()); // top.tobing.mid.ch6.MyClassLoader@4554617c
System.out.println(clazz.getClassLoader().getParent()); // sun.misc.Launcher$AppClassLoader@18b4aac2
System.out.println(clazz.getClassLoader().getClass().getName());// top.tobing.mid.ch6.MyClassLoader
System.out.println(clazz.getClassLoader().getParent().getClass().getName());// sun.misc.Launcher$AppClassLoader
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
}
5.7 Java9新特性
JDK1.9为了实现模块化,对三层类加载模式以及双亲委派模型底层进行了局部改动。
- 扩展机制被移除
- 平台类加载器和应用类加载器不再继承java.net.URLClassLoader,而是依赖于BuiltinClassLoader
- Java9中,加载器有了名称,可以通过getName获取,该属性在调试时比较方便
- 启动类加载器是JVM内部和Java类库共同协作实现,不再是C++实现
- 委派模型发生了改动,按模块对应的加载器加载