类文件
Table of Contents
无关性的基石
实现语言无关性的基础仍然是虚拟机和字节码存储格式。Java虚拟机不和包括Java在内的任何语言绑定,它只与 Class文件 这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。基于安全方面的考虑,Java虚拟机规范要求在Class文件中使用许多强制性的语法和结构化约束,但任一门功能性语言都可以表示为一个能被Java虚拟机所接受的有效的Class文件。作为一个通用的、机器无关的执行平台, 任何其他语言的实现者都可以将Java虚拟机作为语言的产品交付媒介。例如 ,使用Java编译器可以把Java代码编译为存储字节码的Class文件,使用JRuby等其他语言的编译器同样可以把程序代码编译成Class文件,虚拟机并不关心Class的来源是何种语言,如图所示:
Java语言中的各种变量、关键字和运算符号的语义最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比Java语言本身更加强大。因此有一些Java语言本身无法有效支持的语言特性不代表字节码本身无法有效支持,这也为其他语言实现一些有别于Java的语言特性提供了基础
Class类文件
关于Class文件结构的讲解中,将以《Java虚拟机规范(第2版)》(1999年发布,对应于JDK1.4时代的Java虚拟机)中的定义为主线,这部分内容虽然古老,但它所包含的指令、属性是Class文件中最重要和最基础的。同时,也会以后续JDK1.5〜JDK1.7中添加的内容为支线进行较为简略的、介绍性的讲解
注意: 任何一个Class文件都对应着唯一一个类或接口的定义信息 ,但反过来说, 类或接口并不一定都得定义在文件里 (譬如类或接口也可以通过类加载器直接生成)。只是通俗地将任意一个有效的类或接口所应当满足的格式称为 Class文件格式 ,实际上它并不一定以磁盘文件的形式存在
Class文件是 一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符 ,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。 当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储
根据Java虚拟机规范的规定,Class文件格式采用一种 类似于C语言结构体的伪结构 来存储数据,这种伪结构中只有两种数据类型: 无符号数 和 表 ,后面的解析都要以这两种数据类型为基础:
- 无符号数:基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述
- 数字
- 索引引用
- 数量值
- UTF-8编码构成字符串值
- 表: 由多个无符号数或者其他表作为数据项构成的复合数据类型 ,所有表都习惯性地以 info 结尾。表用于描述 有层次关系的复合结构的数据
整个Class文件本质上就是一张表,它由下表所示的数据项构成:
类型 | 名称 | 数量 |
u4 | magic | 1 |
u2 | minor_version | 1 |
u2 | major_version | 1 |
u2 | constant_pool_count | 1 |
cp_info | constant_pool | constant_pool_count - 1 |
u2 | access_flags | 1 |
u2 | this_class | 1 |
u2 | super_class | 1 |
u2 | interfaces_count | 1 |
u2 | interfaces | interfaces_count |
u2 | fields_count | 1 |
field_info | fields | fields_count |
u2 | methods_count | 1 |
method_info | methods | methods_count |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称 这一系列连续的某一类型的数据为某一类型的集合
需要再重复强调, Class的结构不像XML等描述语言,它没有任何分隔符号 ,所以在表6-1中的数据项,无论是顺序还是数量,甚至于数据存储的字节序这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变
魔数与Class文件的版本
每个Class文件的头4个字节称为 魔数 (Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如gif者jpeg等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。Class文件的魔数的值为: OxCAFEBABE :-)
紧接着魔数的4个字节存储的是 Class文件的版本号 :
- 第5和第6个字节是 次版本号 (Minor Version)
- 第7和第8个字节是 主版本号 (Major Version)
Java的版本号是从45开始的,JDK1.1之后的每个JDK大版本发布主版本号向上加1(JDK1.0〜1.1使用了45.0〜45.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件, 即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件
例如,JDK1.1能支持版本号为45.0〜45.65535的Class文件,无法执行版本号为46.0以上的Class文件,而JDK1.2则能支持45.0〜46.65535的Class文件。现在最新的JDK版本为1.7,可生成的Class文件主版本号最大值为51.0
package org.fenixsoft.clazz; public class TestClass { private int m; public int inc() { return m + 1; } }
图6-2显示的是使用十六进制编辑器WinHex打开这个Class文件的结果,可以清楚地看见开头4个字节的十六进制表示是 OxCAFEBABE ,代表次版本号的第5个和第6个字节值为 0x0000 ,而主版本号的值为 0x0032 ,也即是十进制的50,该版本号说明这个文件是可以被JDK1.6或以上版本虚拟机执行的Class文件
下表列出了从JDK1.1到JDK1.7,主流JDK版本编译器输出的默认和可支持的Class文件版本号:
这种顺序称为 Big-Endian ,具体是指最高位字节在地址最低位、最低位字节在地址最高位的顺序来存储数据,它是SPARC、PowerPC等处理器的默认多字节存储顺序,而x86等处理器则是使用了相反的 Little-Endian 顺序来存储数据
常量池
紧接着主版本号的就是 常量池 ,常量池可以理解为class文件的资源仓库,它是class文件结构中与其它项目关联最多的数据类型,也是占用class文件空间最大的数据项目之一,也是class文件中第一个出现的表类型数据项目
由于常量池中常量的数量不是固定的,所以常量池入口需要放置一项u2类型的数据,代表常量池中的容量计数。不过,这里需要注意的是, 这个容器计数是从1开始的而不是从0开始 ,也就是说, 常量池中常量的个数是这个容器计数-1 。将0空出来的目的是 满足后面某些指向常量池的索引值的数据在特定情况下需要表达 不引用任何一个常量池项目 的含义 。class文件中只有常量池的容量计数是从1开始的,对于其它集合类型,比如接口索引集合、字段表集合、方法表集合等的容量计数都是从0开始的
常量池中主要存放两大类常量:
- 字面量 :比较接近Java语言的常量概念
- 文本字符串
- 声明为final的常量 等
- 符号引用 则属于编译原理方面的概念,它包括三方面的内容:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
Java代码在进行javac编译的时候并不像C和C++那样有 链接 这一步,而是在虚拟机 加载class文件 的时候进行 动态链接 。也就是说, 在class文件中不会保存各个方法、字段的最终内存布局信息 ,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,虚拟机也就无法使用。当虚拟机运行时, 需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址中
常量池中的每一项都是一个表,在JDK1.7之前有11中结构不同的表结构,在JDK1.7中为了更好的支持动态语言调用,又增加了3种:
- CONSTANT_MethodHandle_info
- CONSTANT_MethodType_info
- CONSTANT_InvokeDynamic_info
这14个表的开始第一个字节是一个 u1类型的tag ,用来 标识是哪一种常量类型 。这14种常量类型所代表的含义如下:
类型 | 标志 | 含义 |
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_InterfaceMethod_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的部分符号引用 |
CONSTANT_MethodHandle_info | 15 | 表示方法句柄 |
CONSTANT_MethodType_info | 16 | 标识方法类型 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
例子中的常量池结构:
之所以说常量池是最烦琐的数据,是因为这14种常量类型各自均有自己的结构。回头看看常量池的第一项常量,它的标志位(偏移地址:0x0000000A)是0x07,查表6-3的标志列发现这个常量属于CONSTANT_Class_info类型,此类型的常量代表一个类或者接口的符号引用
CONSTANT_Class_info
类型 | 名称 | 数量 |
u1 | tag | 1 |
u2 | name_index | 1 |
- tag是标志位,它用于区分常量类型
- name_index是一个索引值,它指向常量池中一个CONSTANT_Utf8_info类型常量,此常量代表了这个类(或者接口)的全限定名,这里name_index值(偏移地址:0x0000000B)为 0x0002 ,也即是指向了常量池中的第二项常量。继续从图6-3中查找第二项常量,它的标志位(地址:0x0000000D)是 0x01 ,查表6-3可知确实是一个CONSTANT_Utf8_info类型的常量
CONSTANT_Utf8_info
类型 | 名称 | 数量 |
u1 | tag | 1 |
u2 | length | 1 |
u1 | bytes | length |
- length值: 这个UTF-8编码的字符串长度是多少字节
- byte: 长度为length字节的连续数据是一个使用 UTF-8缩略编码 表示的字符串。UTF-8缩略编码与普通UTF-8编码的区别是:
- 从 \u0001 到 \u007f 之间的字符(相当于1〜127的ASCII码)的缩略编码使用 一个字节 表示
- 从 \u0080 到 \u07ff 之间的所有字符的缩略编码用 两个字节 表示
- 从 \u0800 到 \uffff 之间的所有字符的缩略编码就按照普通UTF-8编码规则使用 三个字节 表示
由于Class文件中方法、字段等都需要引用 CONSTANT_Utf8_info 型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。而这里的最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中 如果定义了超过64KB英文字符的变量或方法名,将会无法编译
这个字符串的length值(偏移地址:0x0000000E)为 0x001D ,也就是长29字节,往后29字节正好命在1〜127的ASCII码范围以内,内容为 org/fenixsofl/clazz/TestClass ,换算结果如下图选中的部分所示:
到此为止,分析了TestClass.class常量池中21个常量中的两个,其余的19个常量都可以通过类似的方法计算出来。为了避免计算过程占用过多的版面,后续的19个常量的计算过程可以借助计算机来帮我们完成。在JDK的bin目录中,Oracle公司已经为我们准备好一个专门用于分析Class文件字节码的工具: javap ,下面中列出了使用javap工具的 -verbose 参数输出的TestClass.class文件字节码内容(此清单中省略了常量池以外的信息)
klose@gentoo ~/tmp/org/fenixsoft/clazz $ javap -verbose TestClass.class Classfile /home/klose/tmp/org/fenixsoft/clazz/TestClass.class Last modified 2018-7-7; size 295 bytes MD5 checksum 81f2ab948a7a3068839b61a8f91f634b Compiled from "TestClass.java" public class org.fenixsoft.clazz.TestClass minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #4.#15 // java/lang/Object."<init>":()V #2 = Fieldref #3.#16 // org/fenixsoft/clazz/TestClass.m:I #3 = Class #17 // org/fenixsoft/clazz/TestClass #4 = Class #18 // java/lang/Object #5 = Utf8 m #6 = Utf8 I #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 inc #12 = Utf8 ()I #13 = Utf8 SourceFile #14 = Utf8 TestClass.java #15 = NameAndType #7:#8 // "<init>":()V #16 = NameAndType #5:#6 // m:I #17 = Utf8 org/fenixsoft/clazz/TestClass #18 = Utf8 java/lang/Object
注意:因为测试使用的是jdk1.8,实际结果和作者描述有出入
某些自动生成的常量没有在Java代码里面直接出现过,但它们会被后面即将讲到的字段表(field_info)、方法表(method_info)、属性表(attribute_info)引用到,它们会用来描述一些不方便使用 固定字节 进行表达的内容。譬如描述方法的返回值是什么?有几个参数?每个参数的类型是什么? 因为Java中的 类 是无穷无尽的, 无法通过简单的无符号字节来描述一个方法用到了什么类,因此在描述方法的这些信息时,需要引用常量表中的符号引用进行表达
常量池总结
常量 | 项目 | 类型 | 含义 |
CONSTANT_Utf8_info | tag | u1 | 1 |
length | u2 | UTF-8编码的字符串的长度 | |
bytes | u1 | 长度为length的UTF-8编码的字符串 | |
CONSTANT_Integer_info | tag | u1 | 3 |
bytes | u4 | 按照高位在前的int值 | |
CONSTANT_Float_info | tag | u1 | 4 |
bytes | u4 | 按照高位在前的float值 | |
CONSTANT_Long_info | tag | u1 | 5 |
bytes | u8 | 按照高位在前的long值 | |
CONSTANT_Double_info | tag | u1 | 6 |
bytes | u8 | 按照高位在前的double值 | |
CONSTANT_Class_info | tag | u1 | 7 |
index | u2 | 指向全限定名常量项的索引 | |
CONSTANT_String_info | Tag | u1 | 8 |
index | u2 | 指向字符串字面量的索引 | |
CONSTANT_Fieldref_info | tag | u1 | 9 |
index | u2 | 指向声明字段的类或接口描述符CONSTANT_Class_info的索引项 | |
index | u2 | 指向字段描述符CONSTANT_NameAndType_info的索引项 | |
CONSTANT_Methodref_info | tag | u1 | 10 |
index | u2 | 指向声明方法的类描述符CONSTANT_Class_info的索引项 | |
index | u2 | 指向名称及类描述符CONSTANT_NameAndType_info的索引项 | |
CONSTANT_InterfaceMethod_info | tag | u1 | 11 |
index | u2 | 指向声明方法的接口描述符COSNTANT_Class_info的索引项 | |
index | u2 | 指向名称及类描述符CONSTANT_NameAndType_info的索引项 | |
CONSTANT_NameAndType_info | tag | u1 | 12 |
index | u2 | 指向该字段或方法名称常量池的索引 | |
index | u2 | 指向该字段或方法描述符常量池的索引 | |
CONSTANT_MethodHandle_info | tag | u1 | 15 |
reference_kind | u2 | 值必须在1-9之间,决定了方法句柄的类型,方法句柄累心的值表示方法句柄的字节码行为 | |
reference_index | u2 | 值必须是对常量池的有效索引 | |
CONSTANT_MethodType_info | tag | u1 | 16 |
descriptor_index | u2 | 值必须是对常量池的有效索引,常量池在改索引处的项必须是CONSTANT_Utf8_info结构,表示方法的描述符 | |
CONSTANT_InvokeDynamic_info | tag | u1 | 18 |
bootstrap_method_attrindex | u2 | 值必须是对当前Class文件中引导方法表的bootstrap_methods[]数组的有效索引 | |
name_and_type_index | u2 | 值必须是对当前常量池的有效索引,常量池在该索引处的项必须是COSTANT_NameAndType_info结构,表示方法名和方法描述符 |
访问标志
常量池结束后紧接着的两个字节代表访问标志,用来标识一些类或接口的访问信息,包括:
- 这个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_SYNITHETIC | 0x1000 | 标识这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标识这是一个注解 |
ACC_ENUM | 0x4000 | 标识这是一个枚举类 |
access_flags中一共有16个标志位可以使用,当前只定义了其中8个,没有使用到的标志位要求为0
例子中的TestClass是一个普通Java类,不是接口、枚举或者注解,被public关键字修饰但没有被声明为final和abstract,并且它使用了JDK1.2之后的编译器进行编译 因此它的ACC_PUBLIC、ACC_SUPER标志应当为真 而ACC_FINAL、ACC_INTERFACE、ACC_ABSTRACT、ACC_SYNTHETIC、ACC_ANNOTATION、ACC_ENUM这6个标志应S为假 因此它的access_flags的值为: 0x0001 | 0x0020 = 0x0021
下图可以看出:access_flags标志(偏移地址:0x000000EF)的确为 0x0021
类索引、父类索引与接口索引集合
在访问标志 access_flags 后接下来就是类索引( this_class )和父类索引( super_class ),这两个数据都是 u2 类型的,而接下来的接口索引集合是一个 u2类型的集合 ,class文件由这三个数据项来 确定类的继承关系 。由于Java中是单继承,所以 父类索引只有一个 ;但Java类可以实现多个接口,所以 接口索引是一个集合
- 类索引:确定这个类的全限定名,这个全限定名就是说一个类的 类名包含所有的包名 ,然后使用 / 代替 . 。比如Object的全限定名是java.lang.Object
- 父类索引:确定这个类的父类的全限定名,除了Object之外,所有的类都有父类,所以 除了Object之外所有类的父类索引都不为0
- 接口索引:集合存储了implements语句后面按照从 左到右 的顺序的接口
类索引和父类索引都是一个索引,这个索引指向常量池中的 CONSTANT_Class_info 类型的常量。然后再CONSTANT_Class_info常量中的索引就可以找到常量池中类型为 CONSTANT_Utf8_info 的常量,而这个常量保存着类的全限定名:
从偏移地址 0x000000F1 开始的3个U2类型的值分别为 0x0001 、 0x0003 、 0x0000 ,也就是类索引为1,父类索引为3,接口索引集合大小为0,查询前面中javap命令计算出来的常量池,找出对应的类和父类的常量:
字段表集合
字段表用来 描述接口或类中声明的变量 。字段包括类级变量和实例级变量,但不包括方法内变量:
- 类级变量就是 静态变量 ,这个变量不属于这个类的任何实例,可以不用定义类实例就可以使用
- 实例级变量不是静态变量,是和类实例相关联的,需要定义类实例才能使用
声明一个变量需要信息:
- 字段的作用域: public 、 private 和 protected 修饰符
- 实例变量还是类变量: static 修饰符
- 可变性: final 修饰符
- 并发可见性: volatile 修饰符
- 是否可被序列化: transient 修饰符
- 字段的数据类型:
- 基本类型
- 对象
- 数组
- 字段名称
包含的信息有点多,不过不需要的可以不写。这些信息中,各个修饰符可以用布尔值表示。而字段叫什么名字、字段被定义为什么类型数据都是无法固定的,只能用常量池中的常量来表示。下面是字段表的格式:
类型 | 名称 | 数量 |
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
access_flags
和类中的access_flags类似,对于字段来说可以设置的标志位及含义如下:
标志名称 | 标志值 | 含义 |
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_TRANSIENT | 0x0080 | 字段是否是transient |
ACC_SYNTHETIC | 0x1000 | 字段是否是由编译器自动产生的 |
ACC_ENUM | 0x4000 | 字段是否是enum |
Java语言本身的规则决定:
- ACC_PUBLIC_、 _ACC_PRIVATE 和 ACC_PROTECTED 只能选择一个
- ACC_FINAL 和 ACC_VOLATILE 不能同时选择
- 接口中的字段必须有 ACC_PUBLIC 、 ACC_STATIC 和 ACC_FINAL 标志
name_index
字段名的常量池索引,注意:这是简单名而不是全限定名
descriptor_index
字段描述符的常量池索引
描述符是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值:
- 基本数据类型以及代表无返回值的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[][]: [[Ljava/lang/String
- double[]: [D
- 方法的描述符相对来说要复杂一些,因为一个方法除了返回值类型,还有参数类型,而且参数的个数还不确定。按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号 () 内:
- void inc(): ()V
- java.lang.String toString(): ()Ljava/lang/String
- int indexOf(char[], int, int, char[], int, int, int): ([CII[CIII)I
attributes
属性信息,下面会介绍
实例
- 字段表集合中 不会列出从超类或者父接口中继承而来的字段 ,但有可能列出原本Java代码之中不存在的字段,譬如在 内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段
- 字段是 无法重载 的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于 字节码来讲,如果两个字段的描述符不一致,那字段重名就是合法的
方法表集合
class文件存储格式中对方法的描述和对字段的描述几乎相同,方法表的结构也和字段表相同,这里就不再列出。不过,方法表的访问标志和字段的不同,列出如下:
标识名称 | 标志值 | 含义 |
ACC_PUBLIC | 0x0001 | 方法是否是public |
ACC_PRIVATE | 0x0002 | 方法是否是private |
ACC_PROTECTED | 0x0004 | 方法是否是protected |
ACC_STATIC | 0x0008 | 方法是否是static |
ACC_FINAL | 0x0010 | 方法是否是final |
ACC_SYNCHRONIZED | 0x0020 | 方法是否是synchronized |
ACC_BRIDGE | 0x0040 | 方法是否是由编译器产生的桥接方法 |
ACC_VARARGS | 0x0080 | 方法是否接受不定参数 |
ACC_NATIVE | 0x0100 | 方法是否是native |
ACC_ABSTRACT | 0x0400 | 方法是否是abstract |
ACC_STRICTFP | 0x0800 | 方法是否是strictfp |
ACC_SYNTHETIC | 0x1000 | 方法是否是由编译器自动产生的 |
方法里的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为 Code 的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目
实例
方法表集合入口地址为: 0x00000101 :
- 第一个u2类型的数据(即是计数器容量)的值为 0x0002 代表集合中有两个方法:
- 编译器添加的实例构造器<init>
- 访问标志值为 0x001 ,也就是只有 ACC_PUBLIC 标志为真
- 名称索引值为 0x0007 ,常量池得方法名为 <init>
- 描述符索引值为0x0008,对应常量为 ()V
- 属性表计数器attributes_count的值为 0x0001 就表示此方法的属性表集合有一项属性
- 属性名称索引为 0x0009 ,对应常量为 Code ,说明此属性是方法的字节码描述
- 源码中的方法inc()
- 编译器添加的实例构造器<init>
与字段表集合相对应的,如果父类方法在子类中没有被 重写 ,方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器 <clinit> 方法和实例构造器 <init> 方法
要 重载 一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个 与原方法不同的特征签名 ,特征签名就是一个方法中 各个参数在常量池中的字段符号引用的集合 ,也就是因为 返回值不会包含在特征签名中 ,因此Java无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法也可以共存。也就是说, 如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件中的
Java代码的方法特征签名只包括了方法名称、参数顺序及参数类型,而字节码的特征签名还包括 方法返回值以及受查异常表
属性表集合
属性表(attribute_info)在前面的讲解之中已经出现过数次,在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息
与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松了一些, 不再要求各个属性表具有严格顺序 ,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息, Java虚拟机运行时会忽略掉它不认识的属性 。为了能正确解析Class文件,《Java虚拟机规范(第2版)》中预定义了9项虚拟机实现应当能识别的属性,而在最新的《Java虚拟机规范(Java SE7)》版中,预定义属性已经增加到21项,具体内容见表6-13。后面将对其中一些属性中的关键常用的部分进行讲解
属性名称 | 使用位置 | 含义 |
code | 方法表 | Java代码编译成的字节码指令 |
constantvalue | 字段表 | final关键字定义的常量池 |
deprecated | 类,方法,字段表 | 被声明为deprecated的方法和字段 |
exceptions | 方法表 | 方法抛出的异常 |
enclosingmethod | 类文件 | 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法 |
innerclass | 类文件 | 内部类列表 |
linenumbertable | code属性 | Java源码的行号与字节码指令的对应关系 |
localvariabletable | code属性 | 方法的局部变量描述 |
stackmaptable | code属性 | JDK1.6中新增的属性,供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配 |
signature | 类,方法表,字段表 | 用于支持泛型情况下的方法签名 |
sourcefile | 类文件 | 记录源文件名称 |
sourcedebugextension | 类文件 | 用于存储额外的调试信息 |
synthetic | 类,方法表,字段表 | 标志方法或字段为编译器自动生成的 |
localvariabletypetable | 类 | 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 |
runtimevisibleannotations | 类,方法表,字段表 | 为动态注解提供支持 |
runtimeinvisibleannotations | 表,方法表,字段表 | 用于指明哪些注解是运行时不可见的 |
runtimevisibleparameterannotation | 方法表 | 作用与RuntimeVisibleAnnotations属性类似,只不过作用对象为方法 |
runtimeinvisibleparameterannotation | 方法表 | 作用与RuntimeInvisibleAnnotations属性类似,作用对象哪个为方法参数 |
annotationdefault | 方法表 | 用于记录注解类元素的默认值 |
bootstrapmethods | 类文件 | 用于保存invokeddynamic指令引用的引导方式限定符 |
对于每个属性,它的名称需要从常量池中引用一个 CONSTANT_utf8_info 类型的常量类表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性区说明属性值所占用的位数即可
类型 | 名称 | 数量 |
u2 | attribute_name_index | 1 |
u2 | attribute_length | 1 |
u1 | info | attribute_length |
Code 属性
Java程序方法体中的代码经过javac编译器处理后,最终变为字节码指令存储在 Code属性 内。Code属性出现在 方法表的属性集合 之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在Code属性,如果方法表有Code属性存在,那么它的结构将如下表所示:
类型 | 名称 | 数量 |
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | max_stack | 1 |
u2 | max_locals | 1 |
u4 | code_length | 1 |
u1 | code | code_length |
u2 | exception_table_length | 1 |
exception_info | exception_table | exception_length |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
- attribute_name_index : 指向CONSTANT_Utf8_info型常量的索引,常量值固定为 Code ,它代表了该属性的属性名称
- attribute_length : 指示了属性值的长度,由于属性名称索引与属性长度一共为6字节,所以属性值的长度固定为 整个属性表长度减去6个字节
- max_stack : 操作数栈( Operand Stacks )深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧中的操作栈深度
- max_locals : 局部变量表所需的存储空间 。这里的单位是 Slot ,Slot是虚拟机为局部变量分配内存所使用的最小单位
- 对于byte、char、float、int、short、boolean和return address等长度不超过32位的数据类型,每个局部变量占用1个Slot
- double和long这两种64位的数据类型则需要两个Slot来存放
- 需要使用局部变量表来存放:
- 方法参数:包含实例方法中的隐藏参数this
- 显式异常处理器的参数:try-catch语句中catch块所定义的异常
- 方法体中定义的局部变量
并不是在方法中用到了參少个局部变量,就把这些局部变量所占Slot之和作为max_locals的值 原因是局部变量表中的Slot可以重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的Slot可以被其他局部变量所使用 Javac编译器会根据变量的作用域来分配Slot给各个变量使用,然后计算出max_locals的大小
code_length : 字节码长度
虽然它是一个u4类型的长度值,理论上最大值可以达到2^32-1,但是虚拟机规范中明确限制了一个方法不允许超过65535条字节码指令,即它实际只使用了u2的长度 如果超过这个限制,Javac编译器也会拒绝编译 一般来讲,编写Java代码时只要不是刻意去编写一个超长的方法来为难编译器,是不太可能超过这个最大值的限制 但是某些特殊情况,例如在编译一个很复杂的JSP文件时,某些JSP编译器会把JSP内容和页面输出的信息归并于一个方法之中,就可能因为方法生成字节码超长的原因而导致编译失败
- code : 存储字节码指令的一系列字节流。也叫 字节码指令 ,那么每个指令就是一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及参数应当如何理解
一个u1数据类型的取值范围为0x00〜OxFF,对应十进制的0〜255,也就是一共可以表达256条指令 目前,Java虚拟机规范已经定义了其中约200条编码值对应的指令
Code属性实例
Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为:
- 代码 :方法体里面的Java代码
- 元数据 :包括类、字段、方法定义及其他信息
在整个Class文件中,Code属性用于描述 代码 ,所有的其他数据项目都用于描述 元数据 。上一节分析过的实例构造器 <init> 方法的Code属性如下图所示:
它的操作数栈的最大深度和本地变量表的容量都为 0x0001 ,字节码区域所占空间的长度为 0x0005 。虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随的5个字节,并根据字节码指令表翻译出所对应的字节码指令。翻译 2AB7000AB1 的过程为:
- 读入 2A ,查表得0x2A对应的指令为 aload_0 ,这个指令的含义是 将第0个Slot中为reference类型的本地变量推送到操作数栈顶
- 读入 B7 ,查表得0xB7对应的指令为 invokespecial ,这条指令的作用是 以栈顶的reference类型的数据所指向的对象作为方法接收者 ,调用此对象的实例构造器方法、private方法或者它的父类的方法
- 这个方法有一个u2类型的参数说明具体调用哪一个方法,它指向常量池中的一个CONSTANT_Methodref_info类型常量,即此方法的方法符号引用
- 读入 000A ,这是 invokespecial 的参数,查常量池得0x000A对应的常量为实例构造器 <init> 方法的符号引用
- 读入 B1 ,查表得0xB1对应的指令为 return ,含义是返回此方法,并且返回值为 void 。这条指令执行后,当前方法结束
再次使用javap命令把此Class文件中的另外一个方法的字节码指令也计算出来,结果如下图所示:
没有任何参数,没有定义任何局部变量,但是 Locals 和 Args_size 值为1: 在任何实例方法里面,都可以通过 this 关键字访问到此方法所属的对象 实现却非常简单,仅仅是通过Javac编译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已 因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个Slot位来存放对象实例的引用,方法参数值从1开始计算 这个处理只对实例方法有效,如果把inc()方法声明为static,那Args_size就不会等于1而是等于0了
在字节码指令之后的是这个方法的 显式异常处理表集合 ,异常表对于Code属性来说并不是 必须存在的
异常处理表集合
异常表的格式如下表所示:
类型 | 名称 | 数量 |
u2 | start_pc | 1 |
u2 | end_pc | 1 |
u2 | handler_pc | 1 |
u2 | catch_type | 1 |
它包含4个字段:如果当字节码在第 start_pc 行到第 end_pc 行之间(不含第end_pc行)出现了类型为 catch_type 或者其子类的异常(catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第 handler_pc 行继续处理。当catch_type的值为0时,代表 任意异常情况都需要转向到handler_pc处进行处理
异常表实际上是Java代码的一部分,编译器使用 异常表而不是简单的跳转命令 来实现Java异常及finally处理机制
下面代码主要演示了在字节码层面中try-catch-finally是如何实现的:
编译器为这段Java源码生成了3条异常表记录,对应3条可能出现的代码执行路径。从Java代码的语义上讲,这3条执行路径分别为:
- 如果try语句块中出现属于Exception或其子类的异常,则转到catch语句块处理
- 如果try语句块中出现不属于Exception或其子类的异常,则转到finally语句块处理
- 如果catch语句块中出现任何异常,则转到finally语句块处理
如果没有出现异常,返回值是1 如果出现了Exception异常,返回值是2 如果出现了Exception以外的异常,方法非正常退出,没有返回值
- 字节码中第0〜4行所做的操作就是将整数1赋值给变量x,并且将此时x的值复制一份副本到最后一个本地变量表的Slot中
这个Slot里面的值在ireturn指令执行前将会被重新读到操作栈顶,作为方法返回值使用 为了讲解方便,给这个Slot起了个名字: returnValue
- 如果这时没有出现异常
- 则会继续走到第5〜9行,将变量x赋值为3,然后将之前保存在 returnValue 中的整数1读入到操作栈顶
- 最后ireturn指令会以int形式返回操作栈顶中的值,方法结束
- 如果出现了异常
- PC寄存器指针转到第10行,第10〜20行所做的事情是将2赋值给变量x,然后将变量x此时的值赋给 returnValue ,最后再将变量x的值改为3
- 方法返回前同样将 returnValue 中保留的整数2读到了操作栈顶
- 从第21行开始的代码,作用是变量x的值賦为3,并将栈顶的异常拋出,方法结束
Exception 属性
Exceptions属性 是在方法表中与Code属性平级的一项属性,不要与前面刚刚讲解完的异常表产生混淆。Exceptions属性的作用是 列举出方法中可能拋出的受查异常 (Checked Excepitons),也就是方法描述时在throws关键字后面列举的异常:
类型 | 名称 | 数量 |
u2 | attribute_name_index | 1 |
u2 | attribute_length | 1 |
u2 | attribute_of_exception | 1 |
u2 | exception_index_table | number_of_exceptions |
- number_of_exceptions :方法可能拋出的受查异常数量
- exception_index_table : 是一个指向常量池中CONSTANT_Class_info型常量的索引,代表了该受查异常的类型
LineNumberTable属性
LineNumberTable 属性用于描述 Java源码行号与字节码行号(字节码的偏移量)之间的对应关系 。它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用 -g:none 或 -g:lines 选项来取消或要求生成这项信息
如果选择不生成LineNumberTable属性,对程序运行产生的最主要的影响就是当拋出异常时,堆栈中将不会显示出错的行号 并且在调试程序的时候,也无法按照源码行来设置断点
类型 | 名称 | 数量 |
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | line_number_table_length | 1 |
line_number_info | line_number_table | line_number_table_length |
- line_number_table是一个数量为line_number_table_length,类型为line_number_info的集合
- line_number_info表:
- start_pc: 字节码行号
- line_number: Java源代码行号
- line_number_info表:
LocalVariableTable属性
LocalVariableTable 属性用于描述 栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系 ,它也不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用 -g:none 或 -g:vars 选项来取消或要求生成这项信息
如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失 IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值
LocalVariableTable属性的结构:
其中local_variable_info项目代表了一个栈帧中与源码中局部变量的关联:
- start_pc: 局部变量的生命周期开始的 字节码偏移量
- length: 局部变量 作用范围覆盖的长度 ,与start_pc结合起来就是这个局部变量在字节码之中的作用域范围
- name_index: 指向常量池中CONSTANT_Utf8_info型常量的索引,代表局部变量的 名称
- descriptor_index: 指向常量池中CONSTANT_Utf8_info型常量的索引,代表局部变量的 描述符
- index: 局部变量在 栈帧局部变量表中Slot的位置
- 当这个变量数据类型是64位类型时(double和long),它占用的Slot为index和index+1两个
在JDK1.5引入泛型之后,LocalVariableTable属性增加了一个“姐妹属性”:LocalVariableTypeTable 这个新增的属性结构与LocalVariableTable非常相似,仅仅是把记录的字段描述符的descriptor_index替换成了字段的特征签名(Signature) 对于非泛型类型来说,描述符和特征签名能描述的信息是基本一致的,但是泛型引入之后,由于描述符中泛型的参数化类型被擦除掉,描述符就不能准确地描述泛型类型了,因此出现了LocalVariableTypeTable
SourceFile属性
SourceFile 属性用于 记录生成这个Class文件的源码文件名称 。这个属性也是可选的,可以分别使用Javac的 -g:none 或 -g:source 选项来关闭或要求生成这项信息。在Java中,对于大多数的类来说,类名和文件名是一致的,但是有一些特殊情况(如 内部类 )例外
如果不生成这项属性,当拋出异常时,堆栈中将不会显示出错代码所属的文件名
sourcefile_index数据项是指向常量池中CONSTANT_Utf8_info型常量的索引,常量值是源码文件的文件名
ConstantValue属性
ConstantValue 属性的作用是 通知虚拟机自动为静态变量赋值 。只有被 static 关键字修饰的变量(类变量)才可以使用这项属性。类似 int x = 123 和 static int x =123 这样的变量定义在Java程序中是非常常见的事情,但虚拟机对这两种变量賦值的方式和时刻都有所不同:
- 对于非static类型的变量(也就是实例变量)的賦值是在实例构造器 <init> 方法中进行的
- 而对于类变量,则有两种方式可以选择:
- 在类构造器 <clinit> 方法中
- 使用ConstantValue属性
目前Sun Javac编译器的选择是:
- 如果同时使用 final 和 static 来修饰一个变量(这里称 常量 更贴切),并且这个变量的数据类型是 基本类型 或者 java.lang.String 的话,就生成 ConstantValue 属性来进行初始化
- 如果这个变量没有被final修饰,或者并非基本类型及字符串,则将会选择在 <clinit> 方法中进行初始化
虽然有final关键字才更符合ConstantValue的语义,但虚拟机规范中并没有强制要求字段必须设置了ACC_FINAL标志 只要求了有ConstantValue属性的字段必须设置ACC_STATIC标志而已,对final关键字的要求是Javac编译器自己加入的限制 对ConstantValue的属性值只能限于基本类型和String,不过此属性的属性值只是一个常量池的索引号 由于Class文件格式的常量类型中只有与基本属性和字符串相对应的字面量,所以就算ConstantValue属性想支持别的类型也无能为力
ConstantValue属性的结构见下图:
ConstantValue属性是一个定长属性:
- attribute_length: 值必须固定为2
- constantvalue_index: 代表了常量池中一个字面量常量的引用,根据字段类型的不同,字面量可以是:
- CONSTANT_Long_info
- CONSTANT_Float_info
- CONSTANT_Double_info
- CONSTANT_Integer_info
- CONSTANT_Sring_info
InnerClasses属性
InnerClasses 属性用于记录 内部类与宿主类之间的关联 。如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成InnerClasses属性。该属性的结构见下图:
- number_of_classes: 记录多少个内部类信息
- inner_classes_info: 描述一个内部类
- inner_class_info_index: 指向常量池中CONSTANT_Class_info型常量的素引,代表了内部类的符号引用
- outer_class_info_index: 指向常量池中CONSTANT_Class_info型常量的素引,代表了宿主类的符号引用
- inner_name_index: 指向常量池中CONSTANT_Utf8_info型常量的索引,代表这个内部类的名称
- 如果是匿名内部类,那么这项值为0
- inner_class_access_flags: 内部类的访问标志,类似于类的access_flags,它的取值范围见下表:
Deprecated及Synthetic属性
Deprecated 和 Synthetic 两个属性都属于 标志类型的布尔属性 ,只存在有和没有的区别,没有属性值的概念:
- Deprecated: 表示某个类、字段或者方法,已经被程序作者定为不再推荐使用,它可以通过在代码中使用 @deprecated 注释进行设置
- Synthetic: 代表此字段或者方法并不是由Java源码直接产生的,而是由编译器自行添加的
在JDK1.5之后,标识一个类、字段或者方法是编译器自动产生的,也可以设置它们访问标志中的ACC_SYNTHETIC标志位,其中最典型的例子就是Bridge Method 所有由非用户代码产生的类、方法及字段都应当至少设置Synthetic属性和ACC_SYNTHETIC标志位中的一项 唯一的例外是实例构造器 <init> 方法和类构造器 <clinit> 方法
Deprecated和Synthetic属性的结构非常简单:
其中attribute_length数据项的值必须为 0x00000000 ,因为没有任何属性值需要设置
StackMapTable属性
StackMapTable 属性在JDK1.6发布后增加到了Class文件规范中,它是一个复杂的变长属性,位于 Code属性的属性表中 。这个属性会在 虚拟机类加载的字节码验证阶段被新类型检查验证器 使用,目的在于 代替以前比较消耗性能的基于数据流分析的类型推导验证器
新的验证器在同样能保证Class文件合法性的前提下,省略了在运行期通过数据流分析去确认字节码的行为逻辑合法性的步骤,而是在编译阶段将一系列的验证类型直接记录在Class文件之中,通过检查这些验证类型代替了类型推导过程,从而大幅提升了字节码验证的性能 这个验证器在JDK1.6中首次提供,并在JDK1.7中强制代替原本基于类型推断的字节码验证器 关于这个验证器的工作原理,《Java虚拟机规范(Java SE7版)》花费了整整120页的篇幅来讲解描述,并且分析证明新验证方法的严谨性
StackMapTable属性中包含零至多个 栈映射帧 (Stack Map Frames),每个栈映射帧都显式或隐式地代表了一个字节码偏移量,用于表示 该执行到该字节码时局部变量表和操作数栈的验证类型 。类型检查验证器会通过检查目标方法的局部变量和操作数栈所需要的类型来确定一段字节码指令是否符合逻辑约束。StackMapTable属性的结构见下表:
《Java虚拟机规范(Java SE7版)》明确规定: 在版本号大于或等于50.0的Class文件中,如果方法的Code属性没有包含StackMapTable属性,那就意味着他带有一个隐含的StackMapTable属性 这个StackMapTable属性的作用等同于number_of_entries为0的StackMapTable属性 一个方法最多只能有一个StackMapTable属性,否则会抛出ClassFormatError异常
Signature属性
Signature 属性在JDK1.5发布后增加到了Class文件规范之中,它是一个可选的定长属性,可以出现于类、属性表和方法表结构的属性表中。在JDK1.5中大幅增强了Java语言的语法,在此之后,任何类、接口、初始化方法或成员的泛型签名如果包含了 类型变量 (Type Variables)或 参数化类型 (Parameterized Types),则Signature属性会为它 记录泛型签名信息 。之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是 擦除法实现的伪泛型 ,在字节码(Code属性)中,泛型信息编译(类型变量、参数化类型)之后都通通被擦除掉
使用擦除法的好处是实现简单: 1. 主要修改Javac编译器,虚拟机内部只做了很少的改动 2. 非常容易实现Backport 3. 运行期也能够节省些类型所占的内存空间 坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待,例如运行期做反射时无法获得到泛型信息
Signature属性就是为了弥补 运行期做反射时无法获得到泛型信息 这个缺陷而增设的,Signature属性的结构见下表:
- signature_index : 值必须是一个对常量池的有效索引。常量池在该索引处的项必须是CONSTANT_Utf8_info结构,表示 类签名、方法类型签名或字段类型签名
- 如果当前的Signature属性是 类文件 的属性,则这个结构表示 类签名
- 如果当前的Signature属性是 方法表 的属性,则这个结构表示 方法类型签名
- 如果当前Signature属性是 字段表 的属性,则这个结构表示 字段类型签名
BootstrapMethods属性
BootstrapMethods 属性在JDK1.7发布后增加到了Class文件规范之中,它是一个复杂的变长属性,位于 类文件的属性表 中。这个属性用于保存 invokedynamic 指令引用的引导方法限定符
《Java虚拟机规范(JavaSE7版)》规定: 如果某个类文件结构的常量池中曾经出现过CONSTANT_InvokeDymmic_info类型的常量,那么这个类文件的属性表中必须存在一个明确的BootstrapMethods属性 即使CONSTANT_InvokeDymmic_info类型的常量在常量池中出现过多次,类文件的属表中最多也只能有一个BootstrapMethods属性
BootstrapMethods属性与JSR-292中InvokeDymmic指令和java.lang.Invoke包关系非常密切。BootstrapMethods属性的结构见下表:
其中引用到的bootstrap_method结构:
- num_bootstrap_methods: bootstrap_methods[]数组中的引导方法限定符的数量
- bootstrap_methods[] : 数组的每个成员包含了一指向常量池CONSTANT_MethodHandle结构的索引值,它代表了一个引导方法,还包含了这个引导方法静态参数的序列(可能为空)
- bootstrap_method_ref: 是一个对常量池的有效索引。常量池在该索引处的值必须是一个CONSTANT_MethodHandle_info结构
- num_bootstrap_arguments: bootstrap_arguments[]数组成员数量
- bootstrap_arguments[]: 数组的每个成员必须是一个对常量池的有效索引。在常量池的索引处必须是下列结构之一:
- CONSTANT_String_info
- CONSTANT_Integer_info
- CONSTANT_Long_info
- CONSTANT_Float_info
- CONSTANT_Double_info
- CONSTANT_Class_info
- CONSTANT_MethodHandle_info
- CONSTANT_MethodType_info
字节码指令
基础
Java虚拟机的指令 :
- opcode : 由一个字节长度的、代表着某种特定操作含义的操作码
- operands : 零至多个代表此操作所需参数的操作数
虚拟机中许多指令并不包含操作数,只有一个操作码。这种设计的优缺点都很鲜明:
- 缺点:
- 限制Java虚拟机操作码的长度为一个字节,最多只能支持255个命令
- Class文件格式放弃了编译后代码中操作数长度对齐,这就意味者虚拟机处理那些超过一个字节数据的时候,不得不在运行的时候从字节码中重建出具体数据的结构,这会带来性能损失
// 16位无符号整数需使用两个字节储存(假设为byte1和byte2),读取这个整数就变成了 (byte1 <<8 ) | byte2
- 优点:
- 可以省略很多填充和间隔符号
- 尽可能获得短小精干的编译代码
如果忽略异常处理,那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
- a : 代表 reference
- 没有明确的指明操作类型的字母,例如 arraylength 指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象
- 无条件跳转指令 goto 则是与数据类型无关的。
由于Java虚拟机的操作码长度只有一个字节,所以包含了数据类型的操作码对指令集的设计带来了很大的压力 如果每一种与数据类型相关的指令都支持Java虚拟机所有运行时数据类型的话,那恐怕就会超出一个字节所能表示的数量范围了 因此Java虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它 换句话说,指令集将会故意被设计成非完全独立的,即并非每种数据类型和每一种操作都有对应的指令 有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型
下表列举了Java虚拟机所支持的字节码指令集:
- 通过使用数据类型列所代表的特殊字符替换 opcode 列的指令模板中的 T ,就可以得到一个具体的字节码指令
- 指令模板与数据类型两列共同确定的格为空,则说明虚拟机不支持对这种数据类型执行这项操作
- load 指令有操作 int 类型的 iload ,但是没有操作 byte 类型的同类指令
在Java虚拟机中,实际类型与运算类型之间的映射关系,如下表所示:
指令
指令助记符中以 尖括号 结尾的:
- <n> 这些指令助记符实际上是代表了一组指令
- iload_<n> : 它代表了 iload_0、iload_1、iload_2和iload_3这几条指令
- 它们表面上没有操作数,不需要进行取操作数的动作,但操作数都是在 指令中隐含 的
- 在 尖括号之间的字母 制定了 指令隐含操作数的数据类型 :
- <i> 代表是 int 形数据
- <l> 代表 long 型
- <f> 代表 float 型
- <d> 代表 double 型
- 在操作 byte 、 char 和 short 类型数据时,也用 int 类型表示
加载和存储
将数据从栈帧的局部变量表和操作数栈之间来回传输 :
- 将一个 局部变量 加载到 操作栈 的指令包括有:
- iload
- iload_<n>
- lload
- lload_<n>
- fload
- fload_<n>
- dload
- dload_<n>
- aload
- aload_<n>
- 将一个数值从 操作数栈 存储到 局部变量表 的指令包括有:
- istore
- istore_<n>
- lstore
- lstore_<n>
- fstore、fstore_<n>
- dstore、dstore_<n>
- astore、astore_<n>
- 将一个 常量 加载到 操作数栈 的指令包括有:
- bipush
- sipush
- ldc
- ldc_w
- ldc2_w
- aconst_null
- iconst_m1
- iconst_<i>
- lconst_<l>
- fconst_<f>
- dconst_<d>
- 扩充局部变量表的访问索引 的指令:
- wide
访问对象的字段或数组元素的指令也同样会与操作数栈传输数据
运算
对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶 。大体上运算指令可以分为两种:
- 对 整型 数据进行运算的指令
- 对 浮点型 数据进行运算的指令
无论是那种算术指令,都是使用Java虚拟机的数字类型的。数据没有直接支持 byte 、 short 、 char 和 boolean 类型的算术指令,对于这些数据的运算,都是使用操作 int 类型的指令。,所有的算术指令包括:
- 加法指令:
- iadd
- ladd
- fadd
- dadd
- 减法指令:
- isub
- lsub
- fsub
- dsub
- 乘法指令:
- imul
- lmul
- fmul
- dmul
- 除法指令:
- idiv
- ldiv
- fdiv
- ddiv
- 求余指令:
- irem
- lrem
- frem
- drem
- 取反指令:
- ineg
- lneg
- fneg
- dneg
- 位移指令:
- ishl
- ishr
- iushr
- lshl
- lshr
- lushr
- 按位或指令:
- ior
- lor
- 按位与指令:
- iand
- land
- 按位异或指令:
- ixor
- lxor
- 局部变量自增指令:
- iinc
- 比较指令:
- dcmpg
- dcmpl
- fcmpg
- fcmpl
- lcmp
Java虚拟机没有明确规定整型数据溢出的情况,但是规定了 在处理整型数据时,只有除法指令(idiv 和 ldiv)以及求余指令(irem 和 lrem)出现除数为零时会导致虚拟机抛出异常 ,如果发生了这种情况,虚拟机将会抛出 ArithmeitcException 异常
Java虚拟机在处理浮点数时,必须遵循 IEEE 754 规范中所规定行为限制。也就是说 Java虚拟机要求完全支持IEEE 754中定义的 非正规浮点数值 和 逐级下溢 。这些特征将会使得某些数值算法处理起来变得更容易一些
Java虚拟机要求在进行浮点数运算时, 所有的运算结果都必须舍入到适当的进度,非精确的结果必须舍入为可被表示的最接近的精确值 ,如果有两种可表示的形式与该值一样接近,那将 优先选择最低有效位为零的 。这种舍入模式也是IEEE 754规范中的默认舍入模式,称为 向最接近数舍入模式
在把浮点数转换为整数时,Java虚拟机使用IEEE 754标准中的 向零舍入模式 ,这种模式的舍入结果会导致 数字被截断,所有小数部分的有效字节都会被丢弃掉 。向零舍入模式将在目标数值类型中选择一个最接近,但是不大于原值的数字来作为最精确的舍入结果
Java虚拟机在 处理浮点数运算时,不会抛出任何运行时异常 ,当一个操作产生溢出时,将会使用 有符号的无穷大 来表示,如果某个操作结果没有明确的数学定义的话,将会时候 NaN 值来表示。 所有使用NaN值作为操作数的算术操作,结果都会返回NaN
在对long类型数值进行比较时,虚拟机采用 带符号的比较方式 ,而对浮点数值进行比较时(dcmpg、dcmpl、fcmpg、fcmpl),虚拟机采用 IEEE 754 规范说定义的 无信号比较 方式
类型转换指令
将两种Java虚拟机数值类型进行相互转换 ,这些转换操作一般用于:
- 实现用户代码的显式类型转换操作
- 处理Java虚拟机字节码指令集中指令非完全独立独立的问题
Java虚拟机直接支持以下数值的 宽化类型转换 ( 小范围类型向大范围类型的安全转换 ):
- int 类型到 long 、 float 或者 double 类型
- long 类型到 float 、 double 类型
- float 类型到 double 类型
窄化类型转换 指令包括有:
- i2b
- i2c
- i2s
- l2i
- f2i
- f2l
- d2i
- d2l
- d2f
窄化类型转换可能会导致 转换结果产生不同的正负号、不同的数量级,转换过程很可能会导致数值丢失精度 :
- 在将 int 或 long 类型窄化转换为整数类型 T 的时候,转换过程仅仅是 简单的丢弃除最低位N个字节以外的内容 ,N是类型T的数据类型长度,这将可能导致 转换结果与输入值有不同的正负号 (在高位字节符号位被丢弃了)
- 在将一个 浮点值 转窄化转换为整数类型T (T限于 int 或 long 类型之一)的时候,将遵循以下转换规则:
- 如果浮点值是 NaN ,那转换结果就是 int 或 long 类型的 0
- 浮点值使用IEEE 754的 向零舍入模式 取整,获得整数值 v ,这时候可能有两种情况:
- 范围之内
- T是long类型,并且转换结果在long类型的表示范围之内,那就转换为long类型数值v
- T是int类型,并且转换结果在int类型的表示范围之内,那就转换为int类型数值v
- 范围之外
- 转换结果v的值太小(包括足够小的负数以及负无穷大的情况),无法使用T类型表示的话,那转换结果取int或long类型所能表示的最小数字
- 转换结果v的值太大(包括足够大的正数以及正无穷大的情况),无法使用T类型表示的话,那转换结果取int或long类型所能表示的最大数字
- 从 double 类型到 float 类型做窄化转换的过程与IEEE 754中定义的一致, 向最接近数舍入 模式舍入得到一个可以使用float类型表示的数字
- 如果转换结果的绝对值太小无法使用float来表示的话,将返回float类型的 正负零
- 如果转换结果的绝对值太大无法使用float来表示的话,将返回float类型的 正负无穷大
- 对于double类型的 NaN值 将就规定转换为float类型的 NaN 值
- 范围之内
尽管可能发生上限溢出、下限溢出和精度丢失等情况,但是Java虚拟机中数值类型的 窄化转换永远不可能导致虚拟机抛出运行时异常
对象
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令:
- 创建 类实例 的指令:
- new
- 创建 数组 的指令:
- newarray
- anewarray
- multianewarray
- 访问类 static 字段和实例字段的指令:
- getfield
- putfield
- getstatic
- putstatic
- 把一个 数组元素 加载到 操作数栈 的指令:
- baload
- caload
- saload
- iaload
- laload
- faload
- daload
- aaload
- 将一个 操作数栈的值 储存到 数组元素 中的指令:
- bastore
- castore
- sastore
- iastore
- fastore
- dastore
- aastore
- 取 数组长度 的指令:
- arraylength
- 检查类 实例类型 的指令:
- instanceof
- checkcas
操作数栈管理
Java虚拟机提供了一些用于 直接操作操作数栈 的指令,包括:
- pop
- pop2
- dup
- dup2
- dup_x1
- dup2_x1
- dup_x2
- dup2_x2
- swap
控制转移
让Java虚拟机 有条件或无条件地从 指定指令 而不是控制转移指令的下一条指令继续执行 程序。控制转移指令包括有:
- 条件 分支:
- ifeq
- iflt
- ifle
- ifne
- ifgt
- ifge
- ifnull
- ifnonnull
- if_icmpeq
- if_icmpne
- if_icmplt
- if_icmpgt
- if_icmple
- if_icmpge
- if_acmpeq
- if_acmpne
- 复合条件 分支:
- tableswitch
- lookupswitch
- 无条件 分支:
- goto
- goto_w
- jsr
- jsr_w
- ret
在Java虚拟机中有专门的指令集用来处理 int 和 reference 类型的条件分支比较操作,为了可以无需明显标识一个实体值是否null,也有专门的指令用来检测 null 值
- boolean 类型、 byte 类型、 char 类型和 short 类型的条件分支比较操作,都使用 int 类型的比较指令来完成
- 对于 long 类型、 float 类型和 double 类型的条件分支比较操作:
- 先执行相应类型的比较运算指令,运算指令会返回一个整形值到操作数栈中
- 再执行 int 类型的条件分支比较操作来完成整个分支跳转
由于各种类型的比较最终都会转化为int类型的比较操作,基于int类型比较的这种重要性,Java 虚拟机提供了非常丰富的int类型的条件分支指令
所有 int 类型的条件分支转移指令进行的都是 有符号的比较 操作
方法调用和返回
方法调用指令:
- invokevirtual :用于调用 对象的实例 方法,根据对象的 实际类型进行分派 (虚方法分派),这也是Java语言中最常见的方法分派方式
- invokeinterface :用于调用 接口方法 ,它会在运行时 搜索一个实现了这个接口方法的对象 ,找出适合的方法进行调用
- invokespecial : 用于调用一些需要 特殊处理的实例方法
- 实例初始化方法
- 私有方法
- 父类方法
- invokestatic : 用于调用类 static方法
方法返回指令则是根据返回值的类型区分的:
- ireturn :当返回值是
- boolean
- byte
- char
- short
- int
- lreturn
- freturn
- dreturn
- areturn
- return :
- void 的方法
- 实例初始化 方法
- 类和接口的类初始化 方法
异常
- 在Java程序中显式拋出异常的操作(throw语句)都由 athrow 指令来实现
- 除了用throw语句显式拋出异常情况之外,Java虛拟机规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动拋出
- 例如:整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中拋出 ArithmeticException 异常
在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的 很久之前曾经使用jsr和ret指令来实现,现在已经不用了 而是使用异常表来完成的
同步
Java虚拟机可以支持 方法级 的同步和方法内部 一段指令序列 的同步,这两种同步结构都是使用 管程 ( Monitor )来支持的
方法级 的同步是 隐式的,即无须通过字节码指令来控制 ,它实现在 方法调用 和 返回操作 之中:
- 虛拟机可以从方法常量池的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否声明为同步方法
- 当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置
- 如果设置了, 执行线程就要求先成功持有管程
- 执行方法:在方法执行期间,执行线程持有了管程, 其他任何线程都无法再获取到同一个管程
- 如果一个同步方法执行期间拋出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的 管程将在异常拋到同步方法之外时自动释放
- 当方法完成(无论是正常完成还是非正常完成)时 释放管程
同步 一段指令集序列 通常是由Java语言中的 synchronized 语句块来表示的,Java虚拟机的指令集中有 monitorenter 和 monitorexit 两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要 Javac编译器 与 Java虚拟机 两者共同协作支持。编译器必须确保无论方法通过何种方式完成, 方法中调用过的每条 monitorenter 指令都必须执行其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束
结构化锁定是指在方法调用期间每一个管程退出都与前面的管程进入相匹配的情形 无法保证所有提交给Java虚拟机执行的代码都满足结构化锁定,所以Java虚拟机允许(但不强制要求)通过以下两条规则来保证结构化锁定成立 假设 T 代表一条线程, M 代表一个管程的话: 1. T 在方法执行时持有管程M的次数必须与 T 在方法完成(包括正常和非正常完成)时释放管程 M 的次数相等 2. 在方法调用过程中,任何时刻都不会出现线程 T 释放管程M的次数比 T 持有管程 M 次数多的情况
同步指令实例
void onlyMe(Foo f) { synchronized(f) { dosomething(f); } }
这段代码生成的字节码序列如下:
从字节码序列中可以看到,为了保证在方法异常完成时 momtorenter 和 monitorexit 指令依然可以正确配对执行, 编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常 ,它的目的就是 用来执行momtorexit指令
公有设计和私有实现
Java虚拟机规范描绘了Java虚拟机应有的 共同程序存储格式 :
- Class文件格式
- 字节码指令集
这些内容与硬件、操作系统及具体的Java虚拟机实现之间是完全独立的,虚拟机实现者可能更愿意把它们看做是程序在各种Java平台实现之间互相安全地交互的手段
理解 公有设计与私有实现 之间的分界线是非常有必要的,Java虚拟机实现必须能够读取Class文件并精确实现包含在其中的Java虚拟机代码的语义。拿着Java虚拟机规范一成不变地逐字实现其中要求的内容当然是一种可行的途径,但一个优秀的虚拟机实现,在满足虚拟机规范的约束下对具体实现做出修改和优化也是完全可行的,并且虚拟机规范中明确鼓励实现者这样做。 只要优化后Class文件依然可以被正确读取,并且包含在其中的语义能得到完整的保持 ,那实现者就可以选择任何方式去实现这些语义,虚拟机后台如何处理Class文件完全是实现者自己的事情,只要它在外部接口上看起来与规范描述的一致即可
虚拟机实现者可以使用这种伸缩性来让Java虚拟机 获得更高的性能、更低的内存消耗或者更好的可移植性 ,选择哪种特性取决于Java虚拟机实现的目标和关注点是什么。虚拟机实现的方式主要有以下两种:
- 将输入的Java虛拟机代码在 加载或执行时翻译成另外一种虚拟机的指令集
- JIT:将输入的Java虚拟机代码在 加载或执行时翻译成宿主机CPU的本地指令集