摘要
JDK:1.8.0_202
# 一:概述
各种不同平台的Java虚拟机,以及所有平台都统一支持的程序存储格式——字节码(Byte Code)是构成平台无关性的基石。Java的规范拆分成了《Java语言规范》(The Java Language Specification) 及《Java虚拟机规范》(The Java Virtual Machine Specification) 两部分。
时至今日,商业企业和开源机构已经在Java语言之外发展出一大批运行在Java虚拟机之上的语言,如Kotlin、Clojure、Groovy、JRuby、JPython、Scala等。
实现语言无关性的基础乃是虚拟机和字节码存储格式。Java虚拟机不与包括Java语言在内的任何程序语言绑定,它只与 "Class文件" 这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集、符号表以及若干其他辅助信息。作为一个通用的、与机器无关的执行平台,任何其他语言的实现者都可以将Java虚拟机作为他们语言的运行基础,以Class文件作为他们产品的交付媒介。例如,使用Java编译器可以把Java代码编译为存储字节码的Class文件,使用JRuby等其他语言的编译器一样可以把它们的源程序代码编译成Class文件。虚拟机丝毫不关心Class的来源是什么语言,它与程序语言之间的关系如下所示。
# 二:JVM规范
JVM规范作用:
- Java虚拟机规范为不同的硬件平台提供了一种编译Java技术代码的规范
- 该规范使Java软件独立于平台,因为编译是针对作为虚拟机的"一般机器"而做
- 这个"一般机器"可用软件模拟并运行于各种现存的计算机系统,也可用硬件来实现
JVM规范定义的主要内容:
- 字节码指令集(相当于中央处理器CPU)
- Class文件的格式
- 数据类型和值
- 运行时数据区
- 栈帧
- 特殊方法
<init>
:实例初始化方法,通过JVM的 invokespecial 指令调用<clinit>
:类或接口的初始化方法,不包含参数,返回void
- 类库
- 反射
- 加载和创建 类或接口,如ClassLoader
- 连接和初始化类和接口的类
- 安全,如Security
- 多线程
- 弱引用
- 异常
- 虚拟机的启动、加载、链接和初始化
下面内容涉及到大量JVM规范内容,推荐对照 官方文档 (opens new window) 和 书籍《Java虚拟机规范(Java SE 8版) - 周志明 译》 对比查看
# 三:字节码指令集简介
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需的参数(称为操作数,Operand)构成。由于Java虚拟机采用面向操作数栈而不是面向寄存器的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。
由于Class文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体数据的结构,譬如要将一个16位长度的无符号整数使用两个无符号字节存储起来(假设将它们命名为byte1和byte2),那它们的值应该是这样的:
(byte1 << 8) | byte2
这种操作在某种程度上会导致解释执行字节码时将损失一些性能,但这样做的优势也同样明显:放弃了操作数长度对齐 ,就意味着可以省略掉大量的填充和间隔符号;
如果不考虑异常处理的话,那Java虚拟机的解释器可以使用下面这段伪代码作为最基本的执行模型来理解,这个执行模型虽然很简单,但依然可以有效正确地工作:
do {
自动计算PC寄存器的值加1;
根据PC寄存器指示的位置,从字节码流中取出操作码;
if (字节码存在操作数) 从字节码流中取出操作数;
执行操作码所定义的操作;
} while (字节码流长度 > 0);
# 3.1 字节码与数据类型
Java虚拟机所支持的与数据类型相关的字节码指令,通过使用数据类型列所代表的特殊字符替换opcode列的指令模板中的T,就可以得到一个具体的字节码指令。例如 Tload
指令如果用于从局部变量表中加载int型的数据到操作数栈中,则 T
变成 i
,使用iload
指令,下表是Java虚拟机指令集所支持的数据类型,具体可查看 官网文档 (opens new window)
opcode | byte | short | int | long | float | double | char | reference |
---|---|---|---|---|---|---|---|---|
Tipush | bipush | sipush | ||||||
Tconst | iconst | lconst | fconst | dconst | aconst | |||
Tload | iload | lload | fload | dload | aload | |||
Tstore | istore | lstore | fstore | dstore | astore | |||
Tinc | iinc | |||||||
Taload | baload | saload | iaload | laload | faload | daload | caload | aaload |
Tastore | bastore | sastore | iastore | lastore | fastore | dastore | castore | aastore |
Tadd | iadd | ladd | fadd | dadd | ||||
Tsub | isub | lsub | fsub | dsub | ||||
Tmul | imul | lmul | fmul | dmul | ||||
Tdiv | idiv | ldiv | fdiv | ddiv | ||||
Trem | irem | lrem | frem | drem | ||||
Tneg | ineg | lneg | fneg | dneg | ||||
Tshl | ishl | lshl | ||||||
Tshr | ishr | lshr | ||||||
Tushr | iushr | lushr | ||||||
Tand | iand | land | ||||||
Tor | ior | lor | ||||||
Txor | ixor | lxor | ||||||
i2T | i2b | i2s | i2l | i2f | i2d | |||
l2T | l2i | l2f | l2d | |||||
f2T | f2i | f2l | f2d | |||||
d2T | d2i | d2l | d2f | |||||
Tcmp | lcmp | |||||||
Tcmpl | fcmpl | dcmpl | ||||||
Tcmpg | fcmpg | dcmpg | ||||||
if_TcmpOP | if_icmpOP | if_acmpOP | ||||||
Treturn | ireturn | lreturn | freturn | dreturn | areturn |
从上表的空白处可以看得出来大部分数据类型相关联的指令,都没有支持整数类型byte、char和short,而且没有任何指令支持boolean类型。编译器会在编译期或者运行期,将byte 和short 类型的数据 带符号扩展 为相应的 int 类型数据。将 boolean和char 类型数据 零位扩展 为相应的 int 类型数据。
因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的对int类型作为运算类型(Computational Type)来进行的。
Java虚拟机指令集相关信息,具体详情可查看官方文档 (opens new window),操作码助记符详情可查看 官方文档 (opens new window)
# 3.2 加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括:
- 将一个局部变量加载到操作栈: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
存储数据的操作数栈和局部变量表主要由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
上面有一部分是以尖括号结尾的(例如iload_<n>),这些指令助记符实际上代表了一组指令(例如iload_<n>,它代表了iload_0、iload_1、iload_2和iload_3这几条指令)。这几组指令都是某个带有一个操作数的通用指令(例如iload)的特殊形式,对于这几组特殊指令,它们省略掉了显式的操作数,不需要进行取操作数的动作,因为实际上操作数就隐含在指令中。除了这点不同以外,它们的语义与原生的通用指令是完全一致的。
# 3.3 运算指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。大体上运算指令可以分为两种:对整型数据进行运算的指令与对浮点型数据进行运算的指令。整数与浮点数的算术指令在溢出和被零除的时候也有各自不同的行为表现。无论是哪种算术指令,均是使用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
# 3.4 类型转换指令
Java虚拟机直接支持(即转换时无须显式的转换指令)以下数值类型的宽化类型转换(Widening Numeric Conversion,即小范围类型向大范围类型的安全转换):
- int类型到long、float或者double类型
- long类型到float、double类型
- float类型到double类型
与之相对的,处理窄化类型转换(Narrowing Numeric Conversion)时,就必须显式地使用转换指令来完成,这些转换指令包括i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。
在将int或long类型窄化转换为整数类型T的时候,转换过程仅仅是简单丢弃除最低位N字节以外的内容,N是类型T的数据类型长度,这将可能导致转换结果与输入值有不同的正负号。对于了解计算机数值存储和表示的程序员来说这点很容易理解,因为原来符号位处于数值的最高位,高位被丢弃之后,转换结果的符号就取决于低N字节的首位了。
Java虚拟机将一个浮点值窄化转换为整数类型T(T限于int或long类型之一)的时候,必须遵循以下转换规则:
- 如果浮点值是NaN,那转换结果就是int或long类型的0。
- 如果浮点值不是无穷大的话,浮点值使用IEEE 754的向零舍入模式取整,获得整数值v。如果v在目标类型T(int或long)的表示范围之类,那转换结果就是v;否则,将根据v的符号,转换为T所能表示的最大或者最小正数。
从double类型到float类型做窄化转换的过程与IEEE 754中定义的一致,通过IEEE 754向最接近数舍入模式舍入得到一个可以使用float类型表示的数字。如果转换结果的绝对值太小、无法使用float来表示的话,将返回float类型的正负零;如果转换结果的绝对值太大、无法使用float来表示的话,将返回float类型的正负无穷大。对于double类型的NaN值将按规定转换为float类型的NaN值。
尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失等情况,但是《Java虚拟机规范》中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。
# 3.5 对象创建与访问指令
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令包括:
- 创建类实例的指令:new
- 创建数组的指令:newarray、anewarray、multianewarray
- 访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic
- 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
- 将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
- 取数组长度的指令:arraylength
- 检查类实例类型的指令:instanceof、checkcast
# 3.6 操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令,包括:
- 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
- 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
- 将栈最顶端的两个数值互换:swap
# 3.7 控制转移指令
控制转移指令可以让Java虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改PC寄存器的值。控制转移指令包括:
- 条件分支: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类型的条件分支比较操作,则会先执行相应类型的比较运算指令(dcmpg、dcmpl、fcmpg、fcmpl、lcmp),运算指令会返回一个整型值到操作数栈中,随后再执行int类型的条件分支比较操作来完成整个分支跳转。由于各种类型的比较最终都会转化为int类型的比较操作,int类型比较是否方便、完善就显得尤为重要,而Java虚拟机提供的int类型的条件分支指令是最为丰富、强大的。
# 3.8 方法调用和返回指令
方法调用(分派、执行过程),这里仅列举以下五条指令用于方法调用:
invokevirtual
:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。invokeinterface
:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。invokespecial
:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。invokestatic
:用于调用类静态方法(static方法)。invokedynamic
:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。前面四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。
# 3.9 异常处理指令
在Java程序中显式抛出异常的操作(throw语句)都由 athrow
指令来实现,除了用throw语句显式抛出异常的情况之外,《Java虚拟机规范》还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常。
而在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现,现在已经不用了),而是采用异常表来完成。
# 3.10 同步指令
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为 "锁")来实现的。
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED
访问标志得知一个方法是否被声明为同步方法。当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有 monitorenter
和 monitorexit
两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持,如下测试代码:
void onlyMe(Foo f) {
synchronized(f) {
doSomething();
}
}
2
3
4
5
编译后,这段代码生成的字节码序列如下:
Method void onlyMe(Foo)
0 aload_1 // 将对象f入栈
1 dup // 复制栈顶元素(即f的引用)
2 astore_2 // 将栈顶元素存储到局部变量表变量槽 2中
3 monitorenter // 以栈定元素(即f)作为锁,开始同步
4 aload_0 // 将局部变量槽 0(即this指针)的元素入栈
5 invokevirtual #5 // 调用doSomething()方法
8 aload_2 // 将局部变量Slow 2的元素(即f)入栈
9 monitorexit // 退出同步
10 goto 18 // 方法正常结束,跳转到18返回
13 astore_3 // 从这步开始是异常路径,见下面异常表的Taget 13
14 aload_2 // 将局部变量Slow 2的元素(即f)入栈
15 monitorexit // 退出同步
16 aload_3 // 将局部变量Slow 3的元素(即异常对象)入栈
17 athrow // 把异常对象重新抛出给onlyMe()方法的调用者
18 return // 方法正常返回
Exception table:
FromTo Target Type
4 10 13 any
13 16 13 any
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都必须有其对应的 monitorexit 指令,而无论这个方法是正常结束还是异常结束。
从字节码序列中可以看到,为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理程序,这个异常处理程序声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。
# 四:Class类文件结构
# 4.1 概述
这部分内容详情可以查看官网 The class File Format (opens new window)
- Class文件是JVM的输入,Java虚拟机规范中定义了Class文件的结构。Class文件是JVM实现平台无关、技术无关的基础。
- Class文件是一组以8字节为单位的字节流,各个数据项按顺序紧凑排序,中间无任何分隔符;
- 对于占用空间大于8字节的数据项,按照高位在前的方式分隔成多个8字节进行存储;
- Class文件格式里面只有两种类型:无符号数、表。 (1)无符号数:基本数据类型,以u1、u2、u4、u8来代表几个字节的无符号数。无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值; (2)表:由多个无符号数和其他表构成的复合数据类型,命名通常以 "_info" 结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表。如下表所示的数据项按严格顺序排序构成。
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 |
# 4.2 例子
public class CTest {
private static String msg = "Hello World";
public static void main(String[] args) {
System.out.println("msg==" + msg);
}
}
2
3
4
5
6
7
8
9
查看class文件十六进制格式,结果如下:【可以参考 IDEA - 查看class文件十六进制】
查看Class文件操作码助记符形式,结果如下:【可以参考 IDEA - 查看class文件十六进制】
See More
Classfile /D:/code/jvm-demo/target/classes/top/qform/CTest.class
Last modified 2022-10-9; size 813 bytes
MD5 checksum d0f4fadceff20630c36a28766a9637b4
Compiled from "CTest.java"
public class top.qform.CTest
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #12.#29 // java/lang/Object."<init>":()V
#2 = Fieldref #30.#31 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Class #32 // java/lang/StringBuilder
#4 = Methodref #3.#29 // java/lang/StringBuilder."<init>":()V
#5 = String #33 // msg==
#6 = Methodref #3.#34 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#7 = Fieldref #11.#35 // top/qform/CTest.msg:Ljava/lang/String;
#8 = Methodref #3.#36 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#9 = Methodref #37.#38 // java/io/PrintStream.println:(Ljava/lang/String;)V
#10 = String #39 // Hello World
#11 = Class #40 // top/qform/CTest
#12 = Class #41 // java/lang/Object
#13 = Utf8 msg
#14 = Utf8 Ljava/lang/String;
#15 = Utf8 <init>
#16 = Utf8 ()V
#17 = Utf8 Code
#18 = Utf8 LineNumberTable
#19 = Utf8 LocalVariableTable
#20 = Utf8 this
#21 = Utf8 Ltop/qform/CTest;
#22 = Utf8 main
#23 = Utf8 ([Ljava/lang/String;)V
#24 = Utf8 args
#25 = Utf8 [Ljava/lang/String;
#26 = Utf8 <clinit>
#27 = Utf8 SourceFile
#28 = Utf8 CTest.java
#29 = NameAndType #15:#16 // "<init>":()V
#30 = Class #42 // java/lang/System
#31 = NameAndType #43:#44 // out:Ljava/io/PrintStream;
#32 = Utf8 java/lang/StringBuilder
#33 = Utf8 msg==
#34 = NameAndType #45:#46 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#35 = NameAndType #13:#14 // msg:Ljava/lang/String;
#36 = NameAndType #47:#48 // toString:()Ljava/lang/String;
#37 = Class #49 // java/io/PrintStream
#38 = NameAndType #50:#51 // println:(Ljava/lang/String;)V
#39 = Utf8 Hello World
#40 = Utf8 top/qform/CTest
#41 = Utf8 java/lang/Object
#42 = Utf8 java/lang/System
#43 = Utf8 out
#44 = Utf8 Ljava/io/PrintStream;
#45 = Utf8 append
#46 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#47 = Utf8 toString
#48 = Utf8 ()Ljava/lang/String;
#49 = Utf8 java/io/PrintStream
#50 = Utf8 println
#51 = Utf8 (Ljava/lang/String;)V
{
public top.qform.CTest();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Ltop/qform/CTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: ldc #5 // String msg==
12: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
15: getstatic #7 // Field msg:Ljava/lang/String;
18: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
21: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
24: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
27: return
LineNumberTable:
line 8: 0
line 9: 27
LocalVariableTable:
Start Length Slot Name Signature
0 28 0 args [Ljava/lang/String;
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: ldc #10 // String Hello World
2: putstatic #7 // Field msg:Ljava/lang/String;
5: return
LineNumberTable:
line 5: 0
}
SourceFile: "CTest.java"
Process finished with exit code 0
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
javap工具生成非正式的 "虚拟机汇编语言",格式如下
<index>: <opcode> [<operand1>[<operand2>...]][comment]
<index>
:指操作码在数组中的下标,该数组以字节形式来存储当前方法的Java虚拟机代码;也可以是相对于方法起始处的字节偏移量,如上面javap结果里面片段;<opcode>
:指令的助记码;<operand>
:操作数;<comment>
:行尾的注释。
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method
2
3
4
# 4.3 魔数与Class文件版本
Class文件格式表中,主数据第一行,u4代表4个字节,magic代表魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。不仅是Class文件,很多文件格式标准中都有使用魔数来进行身份识别的习惯,譬如图片格式,如GIF或者JPEG等在文件头中都存有魔数。Class魔数值固定为
0xCAFEBABE
Class文件格式表中,主数据第二行,u2代表2个字节,minor_version代表次版本号(Minor Version)。
Class文件格式表中,主数据第三行,u2代表2个字节,major_version代表主版本号(Major Version)。
下表是部分主版本号,具体详情可查看官网 class file format major versions (opens new window)
Java SE | 主版本号 | 支持的主版本号 |
---|---|---|
8 | 52 | 45 .. 52 |
9 | 53 | 45 .. 53 |
10 | 54 | 45 .. 54 |
11 | 55 | 45 .. 55 |
12 | 56 | 45 .. 56 |
13 | 57 | 45 .. 57 |
Java的版本号是从45开始的,JDK 1.1之后的每个JDK大版本发布主版本号向上加1,高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件,因为《Java虚拟机规范》在Class文件校验部分明确要求了即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。
关于次版本号,从JDK 1.2以后,直到JDK 12之前次版本号均未使用,全部固定为零。而到了JDK 12时期,由于JDK提供的功能集已经非常庞大,有一些复杂的新特性需要以 "公测" 的形式放出,所以设计者重新启用了副版本号,将它用于标识 "技术预览版" 功能特性的支持。如果Class文件中使用了该版本JDK尚未列入正式特性清单中的预览功能,则必须把次版本号标识为 65535
,以便Java虚拟机在加载类文件时能够区分出来。
# 4.4 常量池
紧接着主、次版本号之后的是常量池入口,由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。与Java中语言习惯不同,这个容量计数是从1而不是0开始的。这样做的目的在于,如果后面某些指向常量池的索引值的数据在特定情况下需要表达 "不引用任何一个常量池项目" 的含义,可以把索引值设置为0来表示。Class文件结构中只有常量池的容量计数是从1开始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始。
上面例子代表常量池中有51项常量,索引值范围为1~51。
常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则属于编译原理方面的概念,主要包括下面几类常量:
- 被模块导出或者开放的包(Package)
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
- 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
- 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
常量池中每一项常量都是一个表。下面是常量池项目类型汇总,详情信息可以查阅 官网文档 (opens new window)
类 型 | 标 志 | 描 述 |
---|---|---|
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_Dynamic_info | 17 | 表示一个动态计算常量 |
CONSTANT_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
CONSTANT_Module_info | 19 | 表示一个模块 |
CONSTANT_Package_info | 20 | 表示一个模块中开放或者导出的包 |
这17种常量类型各自有着完全独立的数据结构,两两之间并没有什么共性和联系。下面是常量池中17种数据类型的结构总表。
常 量 | 项 目 | 类 型 | 描 述 |
---|---|---|---|
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 的索引项 | |
CONSTANT_Methodref_info | tag | u1 | 值为10 |
index | u2 | 指向声明方法的类描述符 CONSTANT_Class_info 的索引项 | |
index | u2 | 指向名称及类型描述符 CONSTANT_NameAndType 的索引项 | |
CONSTANT_InterfaceMethodref_info | tag | u1 | 值为11 |
index | u2 | 指向声明方法的接口描述符 CONSTANT_Class_info 的索引项 | |
index | u2 | 指向名称及类型描述符 CONSTANT_NameAndType 的索引项 | |
CONSTANT_NameAndType_info | tag | u1 | 值为12 |
index | u2 | 指向该字段或方法名称常量项的索引 | |
index | u2 | 指向该字段或方法描述符常量项的索引 | |
CONSTANT_MethodHandle_info | tag | u1 | 值为15 |
reference_kind | u1 | 值必须在1至9之间(包括1和9),它决定了方法句柄的类型。方法句柄类型的值表示方法句柄的字节码行为 | |
reference_index | u2 | 值必须是对常量池的有效索引 | |
CONSTANT_MethodType_info | tag | u1 | 值为16 |
descriptor_index | u2 | 值必须是对常量池的有效索引,常量池在该索引处的项必须是CONSTANT_Utf8_info结构,表示方法的描述符 | |
CONSTANT_Dynamic_info | tag | u1 | 值为17 |
bootstrap_method_attr_index | u2 | 值必须是对当前Class文件中引导方法表的 bootstrap_method[] 数组的有效索引 | |
name_and_type_index | u2 | 值必须是对当前常量池的有效索引,常量池在该索引处的项必须是 CONSTANT_NameAndType_info 结构,表示方法名和方法描述符 | |
CONSTANT_InvokeDynamic_info | tag | u1 | 值为18 |
bootstrap_method_attr_index | u2 | 值必须是对当前 Class 文件中引导方法表的 bootstrap_method[] 数组的有效索引 | |
name_and_type_index | u2 | 值必须是对当前常量池的有效索引,常量池在该索引处的项必须是 CONSTANT_NameAndType_info 结构,表示方法名和方法描述符 | |
CONSTANT_Module_info | tag | u1 | 值为19 |
name_index | u2 | 值必须是对常量池的有效索引,常量池在该索引处的项必须是 CONSTANT_Utf8_info 结构,表示模块名字 | |
CONSTANT_Package_info | tag | u1 | 值为20 |
name_index | u2 | 值必须是对常量池的有效索引,常量池在该索引处的项必须是CONSTANT_Utf8_info结构,表示包名称 |
length值说明了这个UTF-8编码的字符串长度是多少字节,它后面紧跟着的长度为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英文字符的变量或方法名,即使规则和全部字符都是合法的,也会无法编译。
使用上面例子的Class文件,常量池第一项常量,它的标志位 0x0A,十进制为10,查看标志列可知属于 CONSTANT_Methodref_info 类型,查看对应结构,可知由三部分组成。接下来的两个部分index,指向常量池中 CONSTANT_Utf8_info 类型常量。分别为第12、29号常量。
接下来看其中一块常量,标志位 0x01,十进制为1,查看标志列可知属于 CONSTANT_Utf8_info 类型,查看对应结构,可知由三部分组成。第二部分为 length,占用了 0x10 即16个字节数,接下来的16个字节即为该常量的值为第三部分,由右侧编译结果可知内容为 java/lang/Object
。可以直接对照着 javap 的结果查看。
# 4.5 访问标志
在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final;等等。具体的标志位以及标志的含义见下表,详细可查看 官方文档 (opens new window)
标志名称 | 标志值 | 含 义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为 public 类型 |
ACC_FINAL | 0x0010 | 是否被声明为 final,只有类可设置 |
ACC_SUPER | 0x0020 | 是否允许使用 invokespecial 字节码指令的新语义,invokespecial 指令的语义是否JDK1.0.2发生过变化,为了区别这条指令使用哪种语义,JDK1.0.2之后编译出来的类的这个标志都必须为真 |
ACC_INTERFACE | 0x0200 | 标识这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为 abstract 类型,对于接口或者抽象类来说,此标志值为真,其他类型值为假 |
ACC_SYNTHETIC | 0x1000 | 标识这个类并非由用户代码产生的 |
ACC_ANNOTATION | 0x2000 | 标识这是一个注解 |
ACC_ENUM | 0x4000 | 标识这是一个枚举 |
ACC_MODULE | 0x8000 | 标识这是一个模块 |
CTest是一个普通Java类,不是接口、枚举、注解或者模块,被public关键字修饰但没有被声明为final和abstract,并且它使用了JDK 1.2之后的编译器进行编译,因此它的ACC_PUBLIC、ACC_SUPER标志应当为真,而ACC_FINAL、ACC_INTERFACE、ACC_ABSTRACT、ACC_SYNTHETIC、ACC_ANNOTATION、ACC_ENUM、ACC_MODULE这七个标志应当为假,因此它的access_flags的值应为:0x0001|0x0020=0x0021。
# 4.6 类索引、父类索引与接口索引集合
Class文件中由这三项数据来确定该类型的继承关系。
- 类索引(this_class):一个u2类型的数据,用于确定这个类的全限定名;
- 父类索引(super_class):一个u2类型的数据,用于确定这个类的父类的全限定名;
- 接口索引集合(interfaces):一组u2类型的数据的集合,用来描述这个类实现了哪些接口。
类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为 CONSTANT_Class_info 的类描述符常量,通过 CONSTANT_Class_info 类型的常量中的索引值可以找到定义在 CONSTANT_Utf8_info 类型的常量中的全限定名字符串。
对于接口索引集合,入口的第一项u2类型的数据为接口计数器(interfaces_count),表示索引表的容量。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。
由上图可以看出,类索引为0x0B即为11,父类索引为0x0C即为12,接口索引集合大小为0。
# 4.7 字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。下面是字段表结构,详细可查看 官网文档 (opens new window)
类型 | 名称 | 数量 |
---|---|---|
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项是非常类似的,都是一个u2的数据类型,其中可以设置的标志位和含义。
标志名称 | 标志值 | 含 义 |
---|---|---|
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 |
name_index和descriptor_index。它们都是对常量池项的引用,分别代表着字段的简单名称以及字段和方法的描述符。
描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、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[][]" 类型的二维数组将被记录成[[Ljava/lang/String;
,一个整型数组 "int[]" 将被记录成[I
。用描述符来描述方法时,按照先参数列表、后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号
()
之内。如方法 "void inc()" 的描述符为()V
,方法 "java.lang.String toString()" 的描述符为()Ljava/lang/String;
,方法 "int indexOf(char[]source, int sourceOffset, int sourceCount, char[]target, int targetOffset, int targetCount, int fromIndex)" 的描述符为([CII[CIII)I
。
字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段。另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于Class文件格式来讲,只要两个字段的描述符不是完全相同,那字段重名就是合法的。
- 第一个u2数据(即容量计数器)为 0x0001,说明这个类只有一个字段表数据。
- 第二个u2数据(即访问标志值)为 0x000A,代表 0x0002|0x0008,ACC_PRIVATE和ACC_STATIC标志位为真。
- 第三个u2数据(即name_index)为 0x000D,指向第13个常量一个CONSTANT_Utf8_info类型的字符串内容为
msg
。 - 第四个u2数据(即descriptor_index)为 0x000E,指向第14个常量一个CONSTANT_Utf8_info类型的字符串内容为
Ljava/lang/String;
- 第五个u2数据(即attribute_count)为 0x0000,即为0。
- 至此可以推断出,原代码定义的字段为
private static String msg
# 4.8 方法表集合
Class文件存储格式中对方法的描述与对字段的描述采用了几乎完全一致,下面是方法表结构:
类型 | 名称 | 数量 |
---|---|---|
u2 | access_flags | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
对于方法表,所有标志位及其取值如下:
标志名称 | 标志值 | 含 义 |
---|---|---|
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_STRICT | 0x0800 | 方法是否为 strictfp |
ACC_SYNTHETIC | 0x1000 | 方法是否由编译器自动产生 |
方法里的代码去哪里了?
经过Javac编译器编译成字节码指令之后,存放在方法属性表集合中一个名为 "Code" 的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目。
- 第一个u2数据(即计算器容量)为 0x0003,代表集合中有三个方法。其中自定义的仅有一个。
- 第二个u2数据(即访问标志值)为 0x0001,代表只有 ACC_PUBLIC 标志为真。
- 第三个u2数据(即name_index)为 0x000F,代表方法名为第15个常量池内容其值为
<init>
- 第四个u2数据(即descriptor_index)为 0x0010,对应的常量为
()V
- 第五个u2数据(即属性表计数器attributes_count)为 0x0001,表示该方法的属性表集合有1项属性值。
- 第六个u2数据(attribute_name_index)为 0x0011,属性名称的索引值对应第17号常量
Code
。
与字段表集合相对应地,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造器 <clinit>()
方法和实例构造器 <init>()
方法。
在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名(Java代码的方法特征签名只包括方法名称、参数顺序及参数类型,而字节码的特征签名还包括方法返回值以及受查异常表) 。特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合,也正是因为返回值不会包含在特征签名之中,所以Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式之中,特征签名的范围明显要更大一些,只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。
# 4.9 属性
Class文件格式最后一项——属性。对于每一个属性,它的名称都要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。一个符合规则的属性表应该满足下表中所定义的结构。
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
- 第一个框u2数据(即attribute_count)为 0x0001,代表Class文件属性有一个;
- 第二个框u2数据(即属性名字常量位置)为 0x001B,代表的常量为
SourceFile
; - 第三个框u4数据(即属性长度)为 0x0002,代表这个属性的长度为2,即紧随着的u2内容属于该属性;
- 第四个框u2数据(即属性内容)为 0x001C,代表的常量为
CTest.java
。
# 五:属性表集合
属性表(attribute_info),Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。
与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格顺序,Java虚拟机运行时会忽略掉它不认识的属性。为了能正确解析Class文件,目前预定义属性有29项,如下表,详细可参考 官方文档 (opens new window)
属性名称 | 使用位置 | 含 义 |
---|---|---|
Code | 方法表 | Java 代码编译成的字节码指令 |
ConstantValue | 字段表 | 由 final 关键字定义的常量值 |
Deprecated | 类、方法表、字段表 | 被声明为 deprecated 的方法和字段 |
Exceptions | 方法表 | 方法抛出的异常列表 |
EnclosingMethod | 类文件 | 仅当一个类为局部类或者匿名类时才能拥有这个属性,这个属性用于标识这个类所在的外围方法 |
InnerClasses | 类文件 | 内部类列表 |
LineNumberTable | Code 属性 | Java 源码的行号与字节码指令的对应关系 |
LocalVariableTable | Code 属性 | 方法的局部变量描述 |
StackMapTable | Code 属性 | JDK 6 中新增的属性,供新的类型检查检验器(Type Checker)检查和处理目标方法的局部变量和操作数栈有所需要的类是否匹配 |
Signature | 类、方法表、字段表 | JDK 5 中新增的属性,用于支持泛型情况下的方法签名。在 Java 语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则Signature属性会为它记录泛型签名信息。由于 Java 的泛型采用擦除法实现,为了避免类型信息被擦除后导致签名混乱,需要这个属性记录泛型中的相关信息 |
SourceFile | 类文件 | 记录源文件名称 |
SourceDebugExtension | 类文件 | JDK 5中新增的属性,用于存储额外的调试信息。譬如在进行JSP文件调试时,无法通过Java堆栈来定位到 JSP 文件的行号,JSR 45 提案为这些非Java语言编写,却需要编译成字节码并运行在 Java 虚拟机中的程序提供了一个进行调试的标准机制,使用该属性就可以用于存储这个标准锁新加入的调试信息 |
Synthetic | 类、方法表、字段表 | 标志方法或字段为编译器自动生成的 |
LocalVariableTypeTable | 类 | JDK 5 中新增的属性,它使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 |
RuntimeVisibleAnnotations | 类、方法表、字段表 | JDK 5 中新增的属性,为动态注解提供支持。该属性用于指明哪些注解是运行时(实际上运行时就是进行反射调用)可见的 |
RuntimeInvisibleAnnotations | 类、方法表、字段表 | JDK 5 中新增的属性,与 RuntimeVisibleAnnotations 属性作用刚好相反,用于指明哪些注解是运行时不可见的 |
RuntimeVisibleParameterAnnotations | 方法表 | JDK 5 中新增的属性,作用与 RuntimeVisibleAnnotations 属性类似,只不过作用对象为方法参数 |
RuntimeInvisibleParameterAnnotations | 方法表 | JDK 5 中新增的属性,作用与 RuntimeInvisibleAnnotations 属性类似,只不过作用对象为方法参数 |
AnnotationDefault | 方法表 | JDK 5 中新增的属性,用于记录注解类元素的默认值 |
BootstrapMethods | 类文件 | JDK 7 中新增的属性,用于保存 invokeddynamic 指令引用的引导方式限定符 |
RuntimeVisibleTypeAnnotations | 类、方法表、字段表,Code属性 | JDK 8 中新增的属性,为实现 JSR 308 中新增的类型注解提供的支持,用于指明哪些类注解是运行时(实际上运行时就是进行反射调用)可见的 |
RuntimeInvisibleTypeAnnotations | 类、方法表、字段表,Code属性 | JDK 8 中新增的属性,为实现 JSR 308 中新增的类型注解提供的支持,与 RuntimeVisibleTypeAnnotations 属性作用刚好相反,用于指明哪些注解是运行时不可见的 |
MethodParameters | 方法表 | JDK 8 中新增的属性,用于支持(编译时加上 -parameters 参数)将方法名称编译进 Class 文件中,并可运行时获取。此前要获取方法名称(典型的如IDE的代码提示)只能通过 JavaDoc 中得到 |
Module | 类 | JDK 9 中新增的属性,用于记录一个 Module 的名称以及相关信息 (requires、exports、opens、uses、provides) |
ModulePackages | 类 | JDK 9 中新增的属性,用于记录一个模块中所有被 exports 或者 opens 的包 |
ModuleMainClass | 类 | JDK 9 中新增的属性,用于指定一个模块的主类 |
NestHost | 类 | JDK 11 中新增的属性,用于支持嵌套类(Java 中的内部类)的反射和访问控制的 API,一个内部类通过该属性得知自己的宿主类 |
NestMembers | 类 | JDK 11 中新增的属性,用于支持嵌套类(Java中的内部类)的反射和访问控制的API,一个宿主类通过该属性得知自己有哪些内部类 |
# 5.1 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_table_length |
u2 | attributes_count | 1 |
attribute_info | attributes | attributes_count |
attribute_name_index
:是一项指向CONSTANT_Utf8_info型常量的索引,此常量值固定为 "Code";attribute_length
:指示了属性值的长度;max_stack
:代表了操作数栈(Operand Stack)深度的最大值。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度;max_locals
:代表了局部变量表所需的存储空间。单位是变量槽(Slot);code_length
:代表字节码长度;code
:用于存储字节码指令的一系列字节流。每个指令就是一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及后续的参数应当如何解析。
变量槽是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、boolean和returnAddress等长度不超过32位的数据类型,每个局部变量占用一个变量槽,而double和long这两种64位的数据类型则需要两个变量槽来存放。方法参数(包括实例方法中的隐藏参数"this")、显式异常处理程序的参数(Exception Handler Parameter,就是try-catch语句中catch块中所定义的异常)、方法体中定义的局部变量都需要依赖局部变量表来存放。注意,并不是在方法中用了多少个局部变量,就把这些局部变量所占变量槽数量之和作为max_locals的值,操作数栈和局部变量表直接决定一个该方法的栈帧所耗费的内存,不必要的操作数栈深度和变量槽数量会造成内存的浪费。Java虚拟机的做法是将局部变量表中的变量槽进行重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的变量槽可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配变量槽给各个变量使用,根据同时生存的最大局部变量数量和类型计算出max_locals的大小。
code_length虽然是一个u4类型的长度值,理论上最大值可以达到2的32次幂,但是《Java虚拟机规范》中明确限制了一个方法不允许超过65535条字节码指令,即它实际只使用了u2的长度,如果超过这个限制,Javac编译器就会拒绝编译。
- 第一个框u2数据(即attribute_name_index)为 0x0011,在上面分析过这是常量值为 "Code";
- 第二个框u4数据(即attribute_length)为 0x0000 002F,属性值长度为47;
- 第三个框u2数据(即max_stack)为 0x0001,操作数栈最大深度为1;
- 第四个框u2数据(即max_locals)为 0x0001,本地变量表所需变量槽为1;
- 第五个框u4数据(即code_length)为 0x0000 0005,字节码区域所占空间的长度为 5。虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随的5个字节,并根据 字节码指令表 (opens new window) 翻译出所对应的字节码指令。翻译“2A B7000A B1”的过程为:
- 读入2A,查表得0x2A对应的指令为aload_0,这个指令的含义是将第0个变量槽中为reference类型的本地变量推送到操作数栈顶。
- 读入B7,查表得0xB7对应的指令为invokespecial,这条指令的作用是以栈顶的reference类型的数据所指向的对象作为方法接收者,调用此对象的实例构造器方法、private方法或者它的父类的方法。这个方法有一个u2类型的参数说明具体调用哪一个方法,它指向常量池中的一个CONSTANT_Methodref_info类型常量,即此方法的符号引用。
- 读入0001,这是invokespecial指令的参数,代表一个符号引用,查常量池得0x0001对应的常量为实例构造器
<init>()
方法的符号引用。 - 读入B1,查表得0xB1对应的指令为return,含义是从方法的返回,并且返回值为void。这条指令执行后,当前方法正常结束。
使用javap验证上面的步骤:
上面框框里面明明实例构造器 <init>() 的descriptor里面并没有入参,为什么 args_size=1
?
Java语言里面的潜规则:在任何实例方法里面,都可以通过 "this" 关键字访问到此方法所属的对象。这个访问机制对Java程序的编写很重要,而它的实现非常简单,仅仅是通过在Javac编译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个变量槽位来存放对象实例的引用,所以实例方法参数值从1开始计算。这个处理只对实例方法有效。
在字节码指令之后的是这个方法的显式异常处理表集合,异常表对于Code属性来说并不是必须存在的,属性表结构如下:
类型 | 名称 | 数量 |
---|---|---|
u2 | start_pc | 1 |
u2 | end_pc | 1 |
u2 | handler_pc | 1 |
u2 | catch_type | 1 |
如果存在异常表,这些字段的含义为:如果当字节码从第 start_pc
行到第 end_pc
行之间(不含第end_pc行)出现了类型为 catch_type
或者其子类的异常(catch_type为指向一个 CONSTANT_Class_info 型常量的索引),则转到第 handler_pc
行继续处理。当catch_type的值为0时,代表任意异常情况都需要转到handler_pc处进行处理。
使用新测试用例
public class ExceptionTest {
public int inc() {
int x;
try {
x = 1;
return x;
} catch (Exception e) {
x = 2;
return x;
} finally {
x = 3;
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
使用javap查看编译后的ByteCode字节码及异常表
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=1, locals=5, args_size=1
0: iconst_1 // try块中的x=1
1: istore_1
2: iload_1 // 保存x到returnValue中,此时x=1
3: istore_2
4: iconst_3 // finaly 块中的 x=3
5: istore_1
6: iload_2 // 将returnValue中的值放到栈顶,准备给ireturn返回
7: ireturn
8: astore_2 // 给catch中定义的Exception e赋值,存储在变量槽 2中
9: iconst_2 // catch块中的x=2
10: istore_1
11: iload_1 // 保存x到returnValue中,此时x=2
12: istore_3
13: iconst_3 // finally块中的x=3
14: istore_1
15: iload_3 // 将returnValue中的值放在栈顶,准备给ireturn返回
16: ireturn
17: astore 4 // 如果出现了不属于java.lang.Exception及其子类的异常才会走到这里
19: iconst_3 // finally块中的x=3
20: istore_1
21: aload 4 // 将异常放置到栈顶,并抛出
23: athrow
Exception table:
from to target type
0 4 8 Class java/lang/Exception
0 4 17 any
8 13 17 any
17 19 17 any
LineNumberTable:
line 8: 0
line 9: 2
line 14: 4
line 9: 6
line 10: 8
line 11: 9
line 12: 11
line 14: 13
line 12: 15
line 14: 17
line 15: 21
LocalVariableTable:
Start Length Slot Name Signature
2 6 1 x I
9 8 2 e Ljava/lang/Exception;
11 6 1 x I
0 24 0 this Ltop/qform/ExceptionTest;
21 3 1 x I
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
编译器为这段Java源码生成了三条异常表记录,对应三条可能出现的代码执行路径。从Java代码的语义上讲,这三条执行路径分别为:
- 如果try语句块中出现属于Exception或其子类的异常,转到catch语句块处理;
- 如果try语句块中出现不属于Exception或其子类的异常,转到finally语句块处理;
- 如果catch语句块中出现任何异常,转到finally语句块处理。
对于上面测试代码,如果没有出现异常,返回值是1;如果出现了Exception异常,返回值是2;如果出现了Exception以外的异常,方法非正常退出,没有返回值。下面从字节码的层面上看看为何会有这样的返回结果。
字节码中第0~3行所做的操作就是将整数1赋值给变量x,并且将此时x的值复制一份副本到最后一个本地变量表的变量槽中(这个变量槽里面的值在ireturn指令执行前将会被重新读到操作栈顶,作为方法返回值使用。给这个变量槽起个名字:returnValue)。如果这时候没有出现异常,则会继续走到第4~7行,将变量x赋值为3,然后将之前保存在returnValue中的整数1读入到操作栈顶,最后ireturn指令会以int形式返回操作栈顶中的值,方法结束。如果出现了异常,PC寄存器指针转到第8行,第8~16行所做的事情是将2赋值给变量x,然后将变量x此时的值赋给returnValue,最后再将变量x的值改为3。方法返回前同样将returnValue中保留的整数2读到了操作栈顶。从第17行开始的代码,作用是将变量x的值赋为3,并将栈顶的异常抛出,方法结束。
# 5.2 其他属性
详情可查看 JVM 预定义的属性
# 六:参考文献
- 《深入理解Java虚拟机 - 周志明》