源代码经过编译器编译生成一个字节码文件,字节码文件是一种二进制文件,内容是JVM指令,不像C、C++直接生成的机器码。
Java虚拟机的指令是由一个字长度的、代表某种特定操作含义的**操作码(opcode)以及跟随其后的零个或多个代表此处操作数需要参数的操作数(Operand)**所构成。
虚拟机中许多指令不包含操作数,只有一个操作码。
字节码指令(byte code) = 操作码(opcode) + [操作数(operand)]
方式一:一个个二进制对照着看
使用NotePad++按照HEX-Editor插件,或者使用BinaryViewer
方式二:JDK提供的javap 指令
方式三:适应IDEA插件:jclasslab或jclasslib viewer
任何一个Class文件都对应唯一一个类或者接口的定义信息,但反过来说,Class文件不一定以磁盘文件的形式存在。
Class文件是一组以8位字节为基础单位ID二进制流。
Class文件结果不像XML等描述语言,由于Class问没有任何的分隔符号,所以其中的数据项,无论是字节顺序还是数量,都是被严格限定的,哪个字节代表什么,长度多少,先后顺序,都不允许改变。
Class文件采用一种类似C语言结构体的方式对数据进行储存,这种结构有两种数据类型:无符号数和表。
代码:
public class Demo01ClassStruct {
private int num = 1;
public int add() {
num = num + 1;
return num;
}
}
字节码:
当我们充分理解每一个字节码文件的细节,我们自己能够像IDEA一样可以反编译出Java的源文件。
Class文件的结构并不是一概不变的,而是随着Java虚拟机的不断发展,总是不可避免地会对Class文件结构做出一些调整,但从基本的结构和框架是非常稳定的。
Class文件结构主要包含以下几个部分:
Magic Number,魔数。
每一个Class文件开头的4个字节的无符号整数称为魔数。
它的唯一作用是确定这个文件是否是一个能被虚拟机有效接受的合法的Class文件。
即:魔数是文件的标识符。
魔数的固定值为0xCAFEABE,不会改变。
如果一个Class文件开通不是0XCAFEBABE,则虚拟机执行的时候就会抛出A JNI error has occurred ...
。
使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意改动。如可以将a.txt 直接改为a.class。
魔数之后紧接着的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
异常。
在版本号之后紧接着是常量池的数量,以及若干个常量池表项。
常量池是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)的形式来描述常量池内容。我们把这一系列连续常量池数据称为常量池集合。
常量池表项中,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
由于常量池的数量不固定,时长时短,所以需要放置两个字节用来表示常量池容量计数值。
常量池容量计数值(u2类型):从1开始,表示常量池中有多少项常量。
constant_pool_count=1表示常量池中有0个常量项。
上图中,0x16 = 22,但是实际上只有(22-1)项常量。索引范围时1-21。
为什么常量池计数是从1开始的呢?
把第0项空出来,是为了满足后面某些执行常量池的索引的数据在特定情况下需要表达“不引用任何一个常量池条目”的含义,这种情况下使用索引0来表示。
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)。如下:
全限定名
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文件中不会保存各个方法、字段的最终内存布局,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也无法直接被虚拟机使用。
当虚拟机运行的时候,需要从常量池中获取对应的符号引用,再在类创建或运行时解析、翻译到具体的内存地址中。
在常量池之后,紧接着的是访问标记。该标记使用你两个字节表示,用于识别一些类或者接口层次访问信息。
包括:这个类是接口还是接口;释放定义为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(),现代编译器都会设置并使用该标记。
访问标记之后,会指定类的类别、父类类别以及实现的接口,格式如下:
长度 | 含义 |
---|---|
u2 | this_class |
u2 | super_class |
u2 | interface_count |
u2 | interfaces[interfaces_count] |
2字节无符号整数,指向常量池的索引。提供了类的全限定名,如top/tobing/Demo01。
this_class的值必须是对常量池表中某项的一个有效索引值。
常量池在这个索引出的成员必须为CONSTANT_Class_info类型的结构体,该结构体表示这个class文件所有定义的类或接口。
2字节无符号整数,指向常量池的索引。提供了当前类的父类的全限定名。
如果没有显示继承类,默认是java/lang/Object类。
由于Java不支持多继承,因此只能是有一个父类。
superclass指向的类不能是final修饰的。
2字节无符号整数,指向常量池的索引。提供一个符号引用到所有已经实现的接口
由于一个类可以实现多个接口,一次需要以数组形式保存多个接口的索引,表示接口的每个索引都是指向常量池的CONSTANT_Class(这里提供接口,而不是类)。
interfaces_count(接口计数器)
该项表示当前类或接口的直接超接口数量。
interfaces[](接口索引集合)
interfaces[]每个成员的值必须是对常量池表中某项的有效索引,他的长度为interface_count。
每个成员interfaces[i]必须为CONSTANT_Class_info结构,其中0 <= i < interfaces_count。
在interfaces[]中,个成员所表示的接口顺序和源代码定义的顺序一致。
字段表集合fields
用来描述接口或类中声明的变量。
字段包含类级字段以及实例级字段,不包含方法内部、代码块内部声明的局部变量。
字段名称、定义的数据类型都是无法固定,需要引用常量池中的常量来描述。
字段表集合指向常量池索引集合,描述了每一个字段的完整信息。如:字段标识符、访问修饰符(public/private/pro)、类变量或实例变量(static)、是否常量(final)等.
注意:
字段表集合中不会列出父类,或者从父类、接口中继承而来的字段,但有可能列出原本Java代码中不存在的字段。譬如在内部类中为了保持对外部类的访问行,自动添加指向外部类实例的字段。
Java语言中,字段无法重载。不管两个字段的数据类型、修饰符是否相等,都必须使用不一样的名称;但是对于字节码来说,如果两字段的描述符不一样,那么字段重名就是合法的。
fields_count的值表示当前class文件fields表的成员个数,使用2个字节来表示。
fields表中每一个成员都是一个field_info结构,用于表示这个类或接口声明的所有字段,不包括方法内部的变量,也不包括从父类或者父接口继承下来的字段。
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.
方法表集合:指向常量池索引集合,完整描述了每个方法的签名。
在字节码文件中,每个method_info项都对应一个类或接口的方法信息。如方法访问修饰符、方法的返回值类型以及方法的参数信息。
如果这个方法不是抽象的或者不是native,那么字节码中会体现出来。
一方面,methods只描述当前类或接口中声明的方法,不包括父类或者父接口继承的方法。
另一方面,methods可能会出现编译器自动添加的方法,最典型的就是编译器产生的方法信息。
如:类初始化方法
【注意】
Java中要重载一个方法,处理要与原方法具有相同的简单名称之外,还必须要求拥有不同的特征签名。
特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此Java语言里无法仅仅依靠返回值的不同来对一个已有方法进行重载。
但是Class文件格式中,特征签名只要描述符不是完全一致的两个方法就可以共存。即如果两个方法有相同的名称和特征签名,但返回值不同,也可以合法共存于同一个class文件中。
也就是说,尽管Java语法规范并不允许在一个类或者接口中声明多个方法签名相同的方法,但和Java语法规范相反,字节码文件中却允许多个方法签名相同的方法,唯一的条件就是这些方法之间的返回值可能不同。
该值表示当前class文件methods表的成员个数。使用2个字节来表示。
methods表中每一个成员都是一个method_info结构。
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 |
属性表集合(attributes)指的是class文件携带的辅助信息。如该class文件的源文件名称。以及任何带有RetentionPolicy.CLASS或者RetentionPolicy.RUNTIME的注解。
这类信息通常用于Java程序调试。
属性表集合的限制不是很严格,甚至可以加入自定义ID属性信息,但Java虚拟机在运行时会忽略掉。
属性表的每一项必须是attribute_info。属性表结构灵活,各种不同属性只要满足以下结构即可。
类型 | 名称 | 数量 | 含义 |
---|---|---|---|
u2 | attribute_name_index | 1 | 属性名索引 |
u4 | attribute_length | 1 | 属性长度 |
u1 | info | attribute_length | 属性表 |
通过反编译生成的字节码,可以深入了解Java代码的工作机制。
Oracle官方提供了javap命令可以使得开发人员方便地使用。
javap命令的作用是:将class文件反编译出当前类对应的code区、局部变量表、异常表和代码行偏移量映射表、常量池等信息。
通过局部变量表,可以看到局部变量的作用域、所在slot等信息,甚至slot复用等信息。
在使用javap解析class文件的时候,一些信息需要使用javac编译class文件制定参数才能输出。
即直接javac命令有些信息不会输出到class文件中。
如:直接使用:javap xx.java
不会生成对应的局部变量表等信息。使用javap -g xx.java
可以输出相关信息。
IDEA或Eclipse默认使用的是javap -g
javap中最常用的就是-v/-l/-c三个选项
javap -l
:输出行号和本地变量表信息
javap -c
:对当前class字节码文件进行反编译生成汇编代码
javap -v
:除了输出-c的内容,还会输出行号、局部变量表、常量池等信息。
Java字节码对于虚拟机来说,就像汇编语言相对于计算机,属于基本执行指令。
Java虚拟机指令有一个字节长度的、代表特殊含义的数字**(操作码,Opcode)**以及紧随其后的零个或者多个代表此操作的参数(**操作数,Oprands)**而构成。
由于Java虚拟机采用面向操作数栈的而不是面向寄存器的结构,所以大多数指令都不包括操作数,只有一个操作码。
熟悉虚拟机的指令,对动态字节码生成、反编译Class文件、Class文件修补都有重要价值。
如果不考虑异常处理,Java虚拟机解释器可以使用以下伪代码当做最基本的执行模型来理解。
do {
自动计算PC寄存器的值加1;
根据PC寄存器的指示位置,从字节码流中取出操作码;
if(字节码存在操作数) 从字节码流中取出操作数;
执行操作码所定义的操作;
}while(字节码长度>0);
在Java虚拟机的指令集中,大多数指令包含了其操作的数据的类型信息。
如:iload 是指加载 int 类型数据到操作数栈中,fload 指令则是加载float类型到操作数栈中。
大部分数据类型相关的字节码指令,它的操作码助记符都有特殊的字符来表明专门为那种数据类型服务:
也存在一些指令的助记符中没有明确地指明操作类型的字面,如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类:
在对值进行操作时:
一个指令可以从局部变量表、常量池、堆中对象、方法调用、系统调用中取出数据,这些数据被压入操作数栈;
一个指令也可从操作数栈中取出一个到多个值,完成赋值、加减乘除、方法传参、系统调用等操作。
加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传递。
1、【局部变量压栈指令】:将一个局部变量加载到操作数栈中:xload、xload_
其中x代表:i、l、f、d、a、n;n为0-3
2、【常量入栈指令】:将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、lds2_w、aconst_null、iconst_m1、iconst_、iconst_
3、【出栈装入局部变量表指令】:将一个数值从操作数栈储存到局部变量表中:xstore、xstore_
其中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_
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
* 3 iload_1 // 将局部变量表序号为1【num】 压入 操作数栈
* 4 invokevirtual #3
* 7 getstatic #2
* 10 aload_2 // 将局部变量表序号为2【obj】 压入 操作数栈
* 11 invokevirtual #4
* 14 getstatic #2
* 17 lload_3 // 将局部变量表序号为3【count】 压入 操作数栈
* 18 invokevirtual #5
* 21 getstatic #2
* 24 iload 5 // 将局部变量表序号为4【flag】 压入 操作数栈
* 26 invokevirtual #6
* 29 getstatic #2
* 32 aload 6 // 将局部变量表序号为6【arr】 压入 操作数栈
* 34 invokevirtual #4
* 37 return
*/
}
常量入栈指令功能是将常数压入操作数栈中。根据数据类型和入栈内容的不同,可分为const系列、push系列和ldc指令。
1、const系列
用于对特定常量入栈,入栈的常量蕴含着操作码本身。
有iconst_(i从-1到5)、lconst_
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】加载到操作数中
* 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】压入操作数栈中
* 5 astore 4 // 将【常量Hello world】出栈存储到索引为4的位置【f】(此处存在slot复用)
* 7 return
*/
}
算术指令用于对两个操作数栈上的值就行某种特定的运行,并把结果重新压入操作数栈中。
算术指令大体上分为两种:对整数数据运算的指令与对浮点数据类型进行运算的指令。
3、ldc指令系列
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
。
当一个操作溢出时,将会使用有符号数的无穷大表示;
如果某个操作没有明确定义的数学定义的时候,将会使用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
位运算指令,可分为:
比较指令: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
* 11 iload_1
* 12 invokevirtual #8
* 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
1、类型转换规则
Java虚拟机直接支持以下的数值宽化类型转换,也就是说,不需要指令的执行,包括:
总结:int --> long --> float --> double
2、精度损失问题
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,一次没必要取特意区分这几个。
1、转换规则
Java虚拟机支持以下窄化类型转换:
2、精度损失问题
窄化类型转换可能会导致转换结果具备不同的正负号、不同的数量级,因此转换很可能丢失精度。
尽管类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。
3、补充说明
当浮点值窄化转换位整数类型T时,将遵循以下转换规则:
当一个double类型窄化位float类型时,将遵循以下规则:
通过向最接近舍入模式一个可以使用float类型表示的数字。最后结果根据3个规则判定:
Java是面向对象的程序设计语言,虚拟机平台从字节码层面就对面向对象做了深层次的支持。
有一系列指令专门用于对象操作:创建指令、字段访问指令、数组操作指令、累心检查指令。
类实例和数组都是对象,但是使用的字节码指令并不相同。
1、创建类实例的指令
2、创建数组的指令
对象创建之后,可以通过对象访问指令获取对象实例或数组实例中的字段或数组元素。
1、访问类字段
2、访问类实例字段
数组操作指令主要有:xastore和xaload
数组类型 | 加载指令 | 存储指令 |
---|---|---|
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 |
说明:
a[i]
入栈。public String testCheckCast(Object obj) {
if (obj instanceof String) {
return (String) obj;
}
return null;
}
1、invokespecial
public void testInvokeSpecial() {
// 情况1:类的实例构造器方法:()
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);
}
方法调用结束的时候,需要返回,方法返回的指令是根据返回值类型来区分的。
返回类型 | 返回指令 |
---|---|
void | return |
int(boolean,byte,char,short) | ireturn |
long | lreturn |
float | freturn |
double | dreturn |
reference | areturn |
通过xreturn指令,将当前函数操作数栈的顶层元素弹出,并将返回值压入调用者的操作数栈,之后当前函数操作数栈的内容将会被丢弃。
如果当前返回的是synchronized方法,还会执行一个隐含指令monitorexit
指令,退出临界区。
最后,会丢弃该方法的整个帧,回复调用者的帧,并将控制权交给调用者。
JVM提供了操作数栈的管理指令,可以先直接操作操作数栈。
主要包含以下内容:
以上指令都是通用型指令,无需对弹出元素进行数据类型指明。
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等
程序流程控制离不开条件控制,为了支持条件跳转,虚拟机提供了大量字节码指令,大体上分为:
条件跳转指令通常与比较指令结合使用。在条件跳转指令执行前,一般可以用比较指令进行栈顶元素的准备,任何进行条件跳转。
条件跳转指令有: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、与前面的运算规则一致
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。
lookupswitch是离散的case值,出于效率,编译会对case值排序。
无条件跳转主要是goto。
goto接受2个字节的操作数,共同组成一个带符号整数,用来指定指令的偏移量,指令执行的目的是跳转到偏移量给定的位置。
如果指令偏移量太多,超过双字节带符号整数的范围,可以用goto_w,但实际开发极少出现。
除了goto,无条件跳转还有:jsr、jsr_w、ret,主要用于try-finally,但已经被及虚拟机组件废弃。
athrow指令
在Java程序中显式抛出异常的操作(throw语句)都是有athrow指令实现。
除了使用throw语句显式抛出异常外,JVM规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常情况自动抛出。
例如:在整数运算的时候,如果除数为零虚拟机会在idiv或者ldiv指令中那个抛出ArithmeticException异常。
注意
正常情况下,操作数栈的压入和弹出都是一条条指令完成的,唯一例外的就是在抛出异常的时候,Java虚拟机会清除操作数栈的所有内容,而后将异常实例压入调用者操作数栈中。
异常以及异常处理:
1、处理异常
Java虚拟机中,异常处理(catch语句)不是有字节码指令实现的,而是采用异常表完成的。
2、异常表
如果一个方法定义了一个try-catch
或者try-finally
的异常处理,就创建一个异常表。
异常表中包含了每个异常处理或者finally块的信息。
异常表保存了每个异常的信息,如下:
当一个异常被抛出,JVM会在当前方法里寻找一个匹配的处理,如果没有找到,这个方法就会强制结束并弹出当前栈帧,并且异常会重新抛给上层的调用的方法。
如果所有栈帧都弹出仍没有占到合适的异常处理,这个线程就会终止。
如果这个异常在最后一个非守护线程中抛出,将会导致JVM自己终止,比如这个线程是Main线程。
不管什么时候抛出异常,如果异常处理最终匹配了所有的异常类型,代码就会继续执行。
这种情况下,如果方法结束没有抛出异常,仍然执行finally块,在return前,它会直接跳到finally块来完成目标。
Java虚拟机支持2种同步结构:方法级同步和方法内部一段指令序列的同步,这两种同步都是使用monitor来支持的。
方法级同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。
虚拟机可以从方法常量池的方法表结构中ACC_SYNCHRONIZED访问标志得知该方法是否声明为同步方法。
当方法调用时,调用指令首先检查方法的ACC_SYNCHRONIZED访问标志是否设置。
对于同步方法而言,当虚拟机通过方法的访问标志符判断是一个同步方法时,会自动在方法调用前进行加锁,当方法执行完毕,会释放该锁,因此对于同步方法而言,monitorenter和monitorexit指令是隐式存在的,并未出现在字节码中。
当一个线程加入同步代码块时,它使用monitorenter指令请求进入。
如果当前对象的监视器计数器为0,则允许进入;如果是1,判断当前监视器的线程是否为自己,如果是,则进入,不是则等待,直到监视器计数器为0。
当一个线程推出同步块时,需要使用monitorexit指令声明退出。
在Java虚拟机中,任何对象都有一个监视器与之关联,用来判断对象释放被锁定,的那个监视器被持有之后,对象处于锁定状态。
指令monitorenter和monitorexit在执行时,都需要在操作数栈顶压入对象,之后monitorenter和monitorexit的锁定和释放都是针对这个对象的监视器进行的。
Java中数据类型分为基本类型和引用数据类型。基本数据类型有虚拟机预先定义,引用数据类型则需要进行类的加载。
按照Java虚拟机规范,从class文件加载到内存中的类,到类卸载出内存为止,整个生命流程分为以下7个阶段:
加载,简而言之是Java类的字节码文件加载到内存中,并在内存中构建出Java的原型–类模板对象。
所谓类模板对象就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。
反射机制的实现正是基于这一基础。
加载阶段,简而言之,查找并加载类的二进制数据,生成Class实例。
在加载类的过程中,Java虚拟机需要完成以下3件事:
java.lang.String
对于类的二进制流,JVM可以通过多种方式产生或获取,只需要保证获取的二进制流符合JVM规范即可。
在获取类的二级制信息之后,Java虚拟机会处理这些信息,并最终转为一个java.lang.Class实例。
如果输入的二进制流数据不符合ClasFile结构,会抛出ClassFromatError。
加载的类在JVM中创建相应的类结构,类结构会储存在方法区中。
类将二进制字节码文件(.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());
}
}
创建数组类的时候比较特殊,因为数组类本身并不是由类加载器负责的,而是有JVM在运行时根据需要直接创建的,但数组的元素类型仍然需要依靠类加载器创建。以下是创建数组类的过程:
如数组元素类型时引用类型,数组类的访问性由元素类型的可访问性决定,否则数组类的可访问性将被缺省定义为public。
当类加载到系统之后,就开始链接操作,验证是连接操作的第一步。
验证的内容涵盖了类数据信息的格式验证、语义检查、字节码验证以及符号引用验证等。
其中格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制信息加载到方法区中。
格式验证之外的验证操作在方法区中进行。
验证阶段比较复制,虽然拖慢了加载速度,但是避免了字节码在运行时候还需要各种检查,磨刀不误砍柴工。
验证过程比较复杂,主要分以下阶段:
1、格式验证
验证开头的0xCAFEBABE,主版本号和副版本号是否在当前Java虚拟机支持防伪,数据中的每一项是否都拥有正确的长度。
2、语义检查
3、字节码验证【最为复杂的过程】
试图通过对字节码流的分析,判断字节码能否被正确的执行。如:
栈映射帧(StackMapTable)就是在这个阶段,用于检测在特定字节码处,其局部变量表和操作数栈是否有正确的数据类型。
但是整个过程不会100%保证字节码是否能安全执行,因此该过程只能尽可能检测出明显的问题。
4、符号引用验证
在前3次检查中,排查了文件格式错误、语义错误以及字节码的不正确性,但依然不能保证类是没有问题的。
校验器还将进行符号引用的检验。
Class文件在其常量池中会通过字符串记录自己将要使用的类或方法。因此,在验证阶段,虚拟机会检查这些类或者方法是否存在,并且当前类是否有权限访问这些数据,如果一个需要使用类无法在系统中找到,则抛出NoClassDefFoundError
,如果方法无法被找到,则抛出NoSuchMethodError
。
此阶段在解析才会执行。
准备阶段简单来说就是:为类的静态变量分配内存,并将其初始化为默认值
类验证通过会进入准备阶段,在这个阶段虚拟机会为类分配相应的内存空间,设默认初始值,如下表所示:
类型 | 默认初始化值 |
---|---|
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
【注意】
在准备阶段完成之后,就进入了解析阶段。
解析阶段简而言之就是:将类、接口、字段和方法的符号引用转换为直接引用。
1、具体描述
符号引用是一些字面量的引用,和虚拟机的内部数据结构和内存布局无关。
在Class文件中,通过常量池进行大量的符号引用。但在程序运行时,只有符号引用是不够的。
比如:当println方法在执行的时候,必须要知道该方法的地址。
以方法为例,Java虚拟机为每一个类都准备了一张方法表,将其所有的方法都列在表中,当需要调用一个表的方法的时候,只要知道这个方法在发放表的偏移量就可以这调用该方法。通过解析操作,符号引用可以转换为目标方法所在类中的方法表的位置,从而使得方法被成功调用。
因此,所谓解析就是将符号引用转换为直接引用,也就是得到类、字段、方法在内存的指针或者偏移量。
2、字符串补充
当Java代码中直接使用字符串常量时,就会在类中出现CONSTANT_String,它表示字符串常量,并且会引用一个CONSTANT_UTF8的常量项。
在Java虚拟机内部运行中的常量池中,会维护一张字符串拘留表(intern),它会保存所有出现过的字符串常量,并且没有重复项。
只要以CONSTANT_String形式出现的字符串都会出现在字符串拘留表中。使用String.intern()方法可以得到一个字符串在拘留表中的引用,因为表中没有重复项,所以任何字面相同的字符串的String.intern()方法返回总是想等的。
为类静态变量赋予正确值的过程。
类初始化是装载的最后一个阶段。到了初始化阶段,才开始真正执行Java程序代码。
初始化阶段的重要工作是执行类的初始化方法
方法。
class init,类初始化时被调用的方法。
该方法只能由Java编译器生成,并由JVM调用,程序开发者无法自定义同名的方法,也无法在程序中调用该方法。
clinit 方法是由类静态成员的赋值语句以及static语句合并而成的。
在类加载之前,先试图加载器父类,因此父类的clinit方法总是先于子类的clinit方法执行,即父类的 static 先于子类执行。
注意!Java编译器并不会为所有的类都产生
()方法,如以下情况
- 类中没有生命任何类变量,也没静态代码块;
- 类中只声明了类变量,但是没有类变量初始化语句以及静态代码块执行初始化的操作;
- 类中包含static final修饰的基本数据类型,这些类型字段初始化采用在编译时常量表达式
对于static final 修饰的字段,在何时被赋值呢?
【链接-准备情况】
String s = "str";
【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初始化成功!");
}
}
Java程序对类的使用分为两种:主动使用 和 被动使用。
主动使用意味着调用 clinit 方法。
1、主动使用
Class 必须要在首次使用的时候才会被加载,Java虚拟机不会无条件的装载Class类型。
Java虚拟机规定,一个类接口在初次使用的时候,必须要进行初始化。此处的使用是指主动使用。
主动使用只有一下几种情况:
Class.forName("top.tobing.Main")
,称主动使用Main。针对5补充
Java虚拟机初始化一个类的时候,要求其父类都已经被初始化,但在不适用于接口
初始化一个类时,不会先初始化其实现的接口;
初始化一个接口时,不会先初始化其父接口。
因此父接口不会因为子接口初始化而初始化,只有当程序首次使用特定接口的静态字段时,才会导致该接口初始化。
针对7补充
JVM启动的时候通过类加载器加载一个初始类。这类在调用main方法之前被连接和初始化。这个方法的执行将依次导致需要的类的加载,链接和初始化。
处理以上的主动使用,其他都是被动使用。被动使用不会引起类的初始化。
也就是说:并不是在代码中出现的类就一定会被加载或者初始化。如果不符合主动使用的条件,就不会初始化。
经历了加载、链接和初始化3个阶段和初始化3个类加载步骤,就可以使用。可以在程序中访问和调用它的静态成员信息,或者使用new创建实例对象。
在类加载器的内部实现中,用一个Java集合来存放所加载类的引用。
另一方面,一个Class对象总是会引用他的类加载器,调用Class对象的getClassLoader()方法,就可以获得其类加载器对象。
因此,类和类加载器是双向关联的关系。
一个类的实例总是引用了代表该类的Class对象。在Object类中定义了getClass()方法,这个方法返回代表所属类的Class对象的引用。
此外,所有的Java类都有一个静态属性class,它引用代表了这个类的Class对象。
当Sample类被加载、链接和初始化之后,生命周期便开始了。当代表Sample类的Class对象不再被引用,即不可触及时,Class对象就会结束生命周期,Sample类在方法区内的数据也会被卸载,从而结束Sample类的生命周期。
如图1,loader1变量和obj变量间接引用了Sample的Class对象,而objClass直接引用了该对象。
程序运行中,如果最左边的三个引用都被设置为null,此时Sample对象结束生命周期,MyClassLoader对象结束生命周期,代表Sample类的Class对象也结束生命周期,Sample类在方法区内的二进制数据被卸载。
当再次有需要的时候,会检查Sample类的Class对象是否存在,如果存在会直接使用,不再重新加载;
如果不存在Sample类会被重新加载,在Java虚拟机的堆区会生成一个新的代表Sample类的Class实例。
综上所述,一个类型被加载之后及难被卸载。
类加载器是JVM执行类加载机制的前提。
.class -----二进制流-----> Class对象
ClassLoader是Java的核心组件,所有的Class都是ClassLoader进行加载的,ClassLoader负责通过各种方式将Class信息的二进制流数据读入JVM内部,转换为一个与目标类对应的java.lang.Class对象实例。然后交给Java虚拟机进行链接、初始化等操作。因此,ClassLoader在整个装载阶段,只能影响类加载,而无法通过ClassLoader区改变类的链接和初始化行为。至于是否可以运行,有Execution Engine决定。
类加载器最早出现在Java1.0中,那时候只是单纯为了满足Java Applet应用而被研发出来,但如今却在OSGi、字节码加解密中大放异彩。这主要归功于Java虚拟机的设计者当初在设计类加载的时候,并没有考虑将它绑定到JVM内部,这样做的好处是能够灵活和动态的执行类加载操作。
JVM可以通过显式加载与隐式加载将class文件加载到内存中
一般情况下不需要显式使用类加载器,但了解类加载器的加载机制却至关重要:
1、类的唯一性
类的加载器和类本身可以确定Java虚拟机的唯一性。
每一个类加载器都有独立的类名称空间:比较两个类是否相等,只有在同一个类加载器下才有意义。两个不同类加载器加载的类必定不相等。
2、命名空间
每个类都有自己的命名空间,命名空间由类的加载器以及所有的父加载器加载的类构成;
在同一个命名空间中,不允许出现全类名完全相同的类;
不同命名空间中,可能会出现全类名完全相同的类。
在大型引用中,如Tomcat可以同这一特性,来运行同一个类的不同版本。
类加载的时候有三个特征:
JVM把类加载器分为两类:引导类加载器(Bootstrap ClassLoader)和自定义加载器(User-Defined ClassLoader)。
不同的类加载器主机不是继承关系,而是包含关系,下层类加载器包含上层类加载器的引用。
Bootstrap ClassLoader,使用C/C++实现,嵌套在JVM内部。
加载Java核心库:JAVA_HOME/jre/lib/rt.jar或sun.boot.class.path,用于提供JVM自身需要的类。
并不继承与java.lang.ClassLoader,没有父加载器。
出于安全考虑,Bootstrap启动类加载器只加载包名为java/javax/sun
等开头的类。
引导类加载器可以加载扩展类加载器和应用程序类加载器,并为他们指定父类加载器。
Extension ClassLoader,Java编写,sun.misc.Launcher$ExtClassLoader实现。
Extension ClassLoader继承于ClassLoader,它的父类加载器为启动类加载器。
从java.ext.dirs系统属性指定的目录加载类库,或从jre/lib/ext子目录下加载类库。
如果用户将jar放到以上目录,将会自动被加载。
AppClassLoader,Java编写,sun.misc.Launcher$AppClassLoader实现。
AppClassLoader继承于ClassLoader,它的父类加载器为扩展类加载器。
负责加载claspath或系统属性java.class.path指定路径下的类库。
应用程序的类加载器默认是系统类加载器。
它是用户自定义类加载器的默认父加载器。
通过ClassLoader的getSystemClassLoader()方法可以获取该类加载器。
日常开发中都是使用上述三种类加载器交叉使用,但必要时我们也可以自定义类加载器。
Java开发者可以自定义类加载器来实现类库的动态加载,加载源可以是jar,也可以是网络的远程资源。
**通过类加载器可以实现精美绝伦的插件机制。**如著名的OSGI组件框架、Eclipse插件机制等。
类加载器为应用程序提供了一种动态添加新功能的机制,这种机制无需重新打包发布应用程序就能实现。
同时,自定义类加载器可以实现应用隔离,如 Tomcat、Spring等中间件和组件框架都在内部实现了自定义加载器,并且通过自定义加载器隔离不同的组件模块。
自定义类加载器通常继承与ClassLoader。
值得注意的是,数组类的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());
}
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方法一起使用。
一般情况下,自定义类加载器时:
补充2
defineClass方法是用来将byte字节码流转换为JVM识别的Class对象,通过这个方法将一个class文件实例化为一个Class对象,还可以从网络字节码中生成。
Class.forName与loadClass
Secure ClassLoader:ClassLoader子类,主要添加了代码源验证和权限定义验证;
URLClassLoader:Secure ClassLoader子类,主要是协助获取Class字节码流。
JDK1.2开始,类的加载过程采用双亲委派机制,这机制能够更好的保证Java平台安全。
1、定义
一个类加载器在接收到类加载的请求的时候,首先不会自己尝试加载,而是先把这请求交给父类加载器去加载,依次递归。
如果父类加载器可以完成类的加载任务,则成功返回;否则自己其加载。
2、本质
规定了类的加载顺序是:引导类加载器 --> 扩展类加载器 --> 系统类加载器 --> 自定义类加载器
1、优势
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规范推荐的做法。
双亲委派机制不是一个具有强制性的模型,而是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实现的模块热部署采用了自定义类加载器,该自定义加载器不再使用双亲委派模型推荐的树形结构,而是采用的复杂的网状结构。
小结
“被破坏”不意味着就是贬义,只要理由充分,突破旧的原则无疑就是一种创新。
热替换,程序在运行过程中,不停止服务,只是通过替换程序文件就可以实现程序的修改。
热替换关键需求在于不中断服务,修改必须立即表现在运行的系统中。
对于Java来说,当一个类已经加载到系统,通过修改类的文件,并无法让系统重新在加载并重定义该类,因此需要通过运用ClassLoader来实现。
Java 采用沙箱安全机制来保证程序的安全以及保护Java原生的JDK代码,是Java安全模型的核心。
沙箱:一种限制程序运行的环境。
沙箱机制:将Java代码限定在JVM特定运行范围,并严格限制代码对本地资源的访问,通过这样措施来保证对代码的有限隔离,防止本地系统被破坏。
JDK1.0时期将程序分成本地代码和远程代码,本地代码默认信任,可以访问一起本地资源,远程不信任。
JDK1.0的沙箱安全机制给扩展带来了障碍,JDK1.1对其进行了改进,增加了安全策略,允许指定用户对本地资源的访问权限。
JDK1.2再次改进,引入代码起那么,实现差异化的代码执行权限控制。
JDK1.6,引入了域的概念。系统域部分专门负责关键资源的交互,应用域则通过系统域的部分代理实现资源访问。
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();
}
}
}
JDK1.9为了实现模块化,对三层类加载模式以及双亲委派模型底层进行了局部改动。