06-字节码指令
1. 概述#
Java 虚拟机的指令由 1 个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的 0~N 个代表此操作所需的参数(称为操作数,Operand)构成。
由于 Java 虚拟机采用“面向操作数栈”而不是“面向寄存器”的架构,所以大多数指令都不包含操作数,只有一个操作码,指令参数都存放在操作数栈中。
由于限制了 Java 虚拟机操作码的长度为一个字节(即 0~255),这意味着指令集的操作码总数不能够超过 256 条;又由于 Class 文件格式放弃了编译后代码的操作数长度对齐,这就意味着虚拟机在处理那些超过一个字节的数据时,不得不在运行时从字节中重建出具体数据的结构,譬如要将一个 16 位长度的无符号整数使用两个无符号字节存储起来(假设将它们命名为 byte1 和 byte2),那它们的值应该是这样的:(byte1 << 8) | byte2
。
这种操作在某种程度上会导致解释执行字节码时将损失一些性能,但这样做的优势也同样明显:放弃了操作数长度对齐(字节码指令流基本上都是单字节对齐的,只有“tableswitch”和“lookupswitch”两条指令例外,由于它们的操作数比较特殊,是以 4 字节为界划分开的,所以这 2 条指令也需要预留出相应的空位填充来实现对齐),就意味着可以省略掉大量的填充和间隔符号;用一个字节来代表操作码,也是为了尽可能获得短小精干的编译代码。
do {
// 自动计算 PC寄存器 的值加 1;
// 根据 PC寄存器 指示的位置,从字节码流中取出操作码;
// if (字节码存在操作数) 从字节码流中取出操作数;
// 执行操作码所定义的操作;
} while (字节码流长度 > 0);
如果不考虑异常处理的话,那 Java 虚拟机的解释器可以使用上面这段伪代码作为最基本的执行模型来理解,这个执行模型虽然很简单,但依然可以有效正确地工作。
1.1 字节码与数据类型#
在 Java 虚拟机的指令集中,大多数指令都包含其操作所对应的数据类型信息。
iload
指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload
指令加载的则是 float 类型的数据。这两条指令的操作在虚拟机内部可能会是由同一段代码来实现的,但在 Class 文件中它们必须拥有各自独立的操作码。
对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务:i 代表对 int 类型的数据操作,l 代表 long,s 代表 short,b 代表 byte,c 代表 char,f 代表 float,d 代表 double,a 代表 reference。也有一些指令的助记符中没有明确指明操作类型的字母,例如 arraylength
指令,它没有代表数据类型的特殊字符,但操作数永远只能是一个数组类型的对象。还有另外一些指令,例如无条件跳转指令 goto
则是与数据类型无关的指令。
因为 Java 虚拟机的操作码长度只有一字节,所以包含了数据类型的操作码就为指令集的设计带来了很大的压力(如果每种类型都设置一遍儿,一字节肯定不够)。因此,Java 虚拟机的指令集对于特定的操作只提供了有限的类型相关指令去支持它,换句话说,指令集将会被故意设计成非完全独立的(《Java虚拟机规范》中把这种特性称为“Not Orthogonal”,即并非每种数据类型和每一种操作都有对应的指令)。有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。
下面列举了 Java 虚拟机所支持的与数据类型相关的字节码指令,通过使用数据类型列所代表的特殊字符替换 opcode 列的指令模板中的 T,就可以得到一个具体的字节码指令。如果在表中指令模板与数据类型两列共同确定的格为空,则说明虚拟机不支持对这种数据类型执行这项操作。
从上表中可以看出,大部分指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。编译器会在编译期或运行期将 byte 和 short 类型的数据带符号扩展(Sign-Extend)为相应的 int 类型数据,将 boolean 和 char 类型数据零位扩展(Zero-Extend)为相应的 int 类型数据。与之类似,在处理 boolean、byte、short 和 char 类型的数组时,也会转换为使用对应的 int 类型的字节码指令来处理。因此,大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的对 int 类型作为运算类型(Computational Type)来进行的。
1.2 分析 HelloWorld#
- 构造方法的字节码指令:
2a b7 00 01 b1
- 2a:aload_0 加载 slot 0 的局部变量,即 this,作为方法接收者。
- b7:对应的指令为 invokespecial,预备调用构造方法/私有/父类方法,具体到底是哪个方法呢?
- 00 01:指令的参数,引用常量池中 #1 项,即:
Method java/lang/Object."<init>":()V
- b1:return,表示返回
- main 方法的字节码指令:
b2 00 02 12 03 b6 00 04 b1
- b2:
getstatic
用来加载静态变量,哪个静态变量呢? - 00 02:引用常量池中 #2 项,即
Field java/lang/System.out:Ljava/io/PrintStream;
- 12:ldc 加载参数,哪个参数呢?
- 03:引用常量池中 #3 项,即
String hello world
- b6:invokevirtual 预备调用成员方法,哪个方法呢?
- 00 04:引用常量池中 #4 项,即
Method java/io/PrintStream.println:(Ljava/lang/String;)V
- b1:return,表示返回
- b2:
调用方法过程翻译成字节码后:① 方法接收者 → ② 方法参数 → ③ 方法调用指令 → ④ 方法(对应的常量池索引)
2. 指令概览#
2.1 加载和存储指令#
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。
2.1.1 load#
将一个〈局部变量〉加载到「操作数栈」,后面跟着一个局部变量表的索引值,表示将局部变量表中索引为 n 的数据加载到操作数栈中。
iload、iload_<n>、lload、lload_<n>、fload、
fload_<n>、dload、dload_<n>、aload、aload_<n>
2.1.2 store#
将一个〈数值〉从「操作数栈」存储到「局部变量表」,后面跟着一个局部变量表的索引值,表示将操作数栈栈顶的数据存入到指定的局部变量表中索引为 n 的位置。
istore、istore_<n>、lstore、lstore_<n>、fstore、
fstore_<n>、dstore、dstore_<n>、astore、astore_<n>
2.1.3 const/push/ldc#
将一个〈常量〉加载到「操作数栈」
- 【const 系列】该系列命令主要负责把简单的数值类型送到栈顶。该系列命令不带参数。注意只把简单的数值类型送到栈顶时,才使用如下的命令。
- 对应 int 型才该方式只能把 -1,0,1,2,3,4,5(分别采用 iconst_m1, iconst_0, iconst_1, iconst_2, iconst_3, iconst_4, iconst_5)送到栈顶。
- 对于其他 int 数值,其他的数值请使用
push
系列命令(比如 bipush)。
- 【push 系列】该系列命令负责把一个整形数字(长度比较小)送到到栈顶。该系列命令有一个参数,用于指定要送到栈顶的数字。注意,该系列命令只能操作一定范围内的整形数值,超出该范围的数字将使用
ldc
命令系列。- 0x10 bipush 将单字节的常量值(-128~127)推送至栈顶
- 0x11 sipush 将一个短整型常量值(-32768~32767)推送至栈顶
- 【ldc 系列】该系列命令负责把数值常量或 String 常量值从常量池中推送至栈顶。该命令后面需要给一个表示常量在常量池中位置(编号)的参数,哪些常量是放在常量池呢?
- 比如:final static int id=32768; final static float double=6.5
- 对于 const 系列命令和 push 系列命令操作范围之外的数值类型常量,都放在常量池中。
- 另外,所有不是通过 new 创建的 String 都是放在常量池中的。
2.1.4 wide#
扩充局部变量表的访问索引的指令:
wide
存储数据的操作数栈和局部变量表主要由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
2.2 运算指令#
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
大体上运算指令可以分为 2 种:① 对整型数据进行运算的指令 ② 对浮点型数据进行运算的指令。
整数与浮点数的算术指令在溢出和被零除的时候也有各自不同的行为表现。无论是哪种算术指令,均是使用 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
2.3 类型转换指令#
类型转换指令可以将两种不同的数值类型相互转换,这些转换操作一般用于实现用户代码中的显式类型转换操作,或者用来处理本节开篇所提到的字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
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 虚拟机规范》中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。
2.4 对象创建与访问指令#
虽然类实例和数组都是对象,但 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
2.5 操作数栈管理指令#
如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令。
- 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
- 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
- 将栈最顶端的两个数值互换:swap
2.6 控制转移指令#
控制转移指令可以让 Java 虚拟机有条件或无条件地从指定位置指令(而不是控制转移指令)的下一条指令继续执行程序,从概念模型上理解,可以认为控制指令就是在有条件或无条件地修改 PC 寄存器的值。
- 复合条件分支:tableswitch、lookupswitch
- 无条件分支:goto、goto_w、jsr、jsr_w、ret(goto 用来进行跳转到指定行号的字节码)
- 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq 和 if_acmpne
在 Java 虚拟机中有专门的指令集用来处理 int 和 reference 类型的条件分支比较操作,为了可以无须明显标识一个数据的值是否 null,也有专门的指令用来检测 null 值。与前面算术运算的规则一致,对于 boolean、byte、char、short 类型的条件分支比较操作,都使用 int 类型的比较指令来完成,而对于 long、float、double 类型的条件分支比较操作,则会先执行相应类型的比较运算指令(dcmpg、dcmpl、fcmpg、fcmpl、lcmp),运算指令会返回一个整型值到操作数栈中,随后再执行 int 类型的条件分支比较操作来完成整个分支跳转。由于各种类型的比较最终都会转化为 int 类型的比较操作,int 类型比较是否方便、完善就显得尤为重要,而 Java 虚拟机提供的 int 类型的条件分支指令是最为丰富、强大的。
2.7 方法调用和返回指令#
方法调用(分派、执行过程)将在第 8 章具体讲解,这里仅列举以下 5 条指令用于方法调用:
- invokevirtual 指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。
- invokeinterface 指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
- invokespecial 指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
- invokestatic 指令:用于调用类静态方法(static 方法)。
- invokedynamic 指令:用于在运行时动态解析出调用点限定符所引用的方法并执行该方法。前面 4 条调用指令的分派逻辑都固化在 Java 虚拟机内部,用户无法改变,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,另外还有一条 return 指令供声明为 void 的方法、实例初始化方法、类和接口的类初始化方法使用。
2.8 异常处理指令#
在 Java 程序中显式抛出异常的操作(throw 语句)都由 athrow 指令来实现,除了用 throw 语句显式抛出异常的情况之外,《Java 虚拟机规范》还规定了许多运行时异常会在其他 Java 虚拟机指令检测到异常状况时自动抛出。例如前面介绍整数运算中,当除数为零时,虚拟机会在 idiv 或 ldiv 指令中抛出 ArithmeticException 异常。
而在 Java 虚拟机中,处理异常(catch 语句)不是由字节码指令来实现的(很久之前曾经使用 jsr 和 ret 指令来实现,现在已经不用了),而是采用〈异常表〉来完成。
2.9 同步指令#
Java 虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管程(Monitor,更常见的是直接将它称为“锁”)来实现的。
方法级的同步是隐式的,无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池中的方法表结构中的 ACC_SYNCHRONIZED 访问标志得知一个方法是否被声明为同步方法。
当方法调用时,调用指令将会检查方法的 ACC_SYNCHRONIZED 访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程。在方法执行期间,执行线程持有了管程,其他任何线程都无法再获取到同一个管程。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那这个同步方法所持有的管程将在异常抛到同步方法边界之外时自动释放。
同步一段指令集序列通常是由 Java 语言中的 synchronized 语句块来表示的,Java 虚拟机的指令集中有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义,正确实现 synchronized 关键字需要 Javac 编译器与 Java 虚拟机两者共同协作支持。
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条 monitorenter 指令都必须有其对应的 monitorexit 指令,而无论这个方法是正常结束还是异常结束。
从上述代码的字节码序列中可以看到,为了保证在方法异常完成时 monitorenter 和 monitorexit 指令依然可以正确配对执行,编译器会自动产生一个异常处理程序,这个异常处理程序声明可处理所有的异常,它的目的就是用来执行 monitorexit 指令。
3. javap 工具#
3.1 示例代码#
javac -g:vars Test.java
public class Test {
public static void main(String[] args) {
int a = 10;
int b = Short.MAX_VALUE + 1;
int c = a + b;
System.out.println(c);
}
}
javap -v Test.class
C:\Users\xxx\Desktop>javap -v Test.class
Classfile /C:/Users/10646/Desktop/Test.class
Last modified 2021-5-4; size 483 bytes
MD5 checksum dc9dd7452e1ae9887e687a1723a3ce55
public class Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#22 // java/lang/Object."<init>":()V
#2 = Class #23 // java/lang/Short
#3 = Integer 32768
#4 = Fieldref #24.#25 // java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #26.#27 // java/io/PrintStream.println:(I)V
#6 = Class #28 // Test
#7 = Class #29 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 LTest;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 a
#19 = Utf8 I
#20 = Utf8 b
#21 = Utf8 c
#22 = NameAndType #8:#9 // "<init>":()V
#23 = Utf8 java/lang/Short
#24 = Class #30 // java/lang/System
#25 = NameAndType #31:#32 // out:Ljava/io/PrintStream;
#26 = Class #33 // java/io/PrintStream
#27 = NameAndType #34:#35 // println:(I)V
#28 = Utf8 Test
#29 = Utf8 java/lang/Object
#30 = Utf8 java/lang/System
#31 = Utf8 out
#32 = Utf8 Ljava/io/PrintStream;
#33 = Utf8 java/io/PrintStream
#34 = Utf8 println
#35 = Utf8 (I)V
{
public Test();
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 <源码行号>: <字节码行号>
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: ldc #3 // int 32768
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
17: return
LineNumberTable:
line 3: 0
line 4: 3
line 5: 6
line 6: 10
line 7: 17
LocalVariableTable:
Start Length Slot Name Signature
0 18 0 args [Ljava/lang/String;
3 15 1 a I
6 12 2 b I
10 8 3 c I
}
3.2 运行流程#
(1) Constant pool 载入运行时常量池(比较小的数字,并不是存在常量池中的,而是存在方法字节码里,而一旦数字范围超过 Short.MAX_VALUE,就会存储在常量池中),方法字节码载入方法区。
(2) main 线程开始运行,分配栈帧内存(stack=2,locals=4)。
(3) 执行引擎开始执行字节码:bipush 10
将一个 byte 压入操作数栈
【BTW】操作数栈宽度是 4 字节,所以 byte 会补齐 4 个字节。类似的指令还有 sipush
将一个 short 压入操作数栈(其长度会补齐 4 个字节)、ldc 将一个 int 压入操作数栈、ldc2_w 将一个 long 压入操作数栈(分 2 次压入,因为 long 是 8 个字节)。这里小的数字都是和字节码指令存在一起,而超过 short 范围的数字存入了常量池(Short.MAX_VALUE 是 32767,32768 = Short.MAX_VALUE + 1 实际是在编译期间计算好的)。
(4) 执行:istore_1
将操作数栈顶数据弹出,存入局部变量表的 slot-1
(5) 执行:ldc #3
将运行时常量池中的 #3 项加载到操作数栈
(6) 执行:istore_2
(7) 执行:iload_1
(8) 执行:iload_2
(9) 执行:iadd
(10) 执行:istore_3
(11) 执行:getstatic #4
通过运行时常量池中 #4 项找到堆中的 System.out 对象,并将对象引用放入操作数栈。
(12) 执行:iload_3
(13) 执行:invokevirtual #5
- 找到运行时常量池 #5 项
- 定位到方法区
java/io/PrintStream.println:(I)V
方法 - 生成新的栈帧(分配 locals、stack 等)
- 传递参数,执行新栈帧中的字节码
(14) 执行:return
完成 main 方法调用,弹出 main 栈帧,程序结束。
4. 从字节码角度分析 i++#
4.1 示例代码#
javac -g:vars Test.java
public class Test {
public static void main(String[] args) {
int a = 10;
int b = a++ + ++a + a--;
System.out.println(a);
System.out.println(b);
}
}
javap -v Test.class
Classfile /C:/Users/xxx/Desktop/Test.class
Last modified 2021-5-4; size 415 bytes
MD5 checksum d8bf6c5f92e7ed7a107cb7624851096f
Compiled from "Test.java"
public class Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#14 // java/lang/Object."<init>":()V
#2 = Fieldref #15.#16 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #17.#18 // java/io/PrintStream.println:(I)V
#4 = Class #19 // Test
#5 = Class #20 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 main
#11 = Utf8 ([Ljava/lang/String;)V
#12 = Utf8 SourceFile
#13 = Utf8 Test.java
#14 = NameAndType #6:#7 // "<init>":()V
#15 = Class #21 // java/lang/System
#16 = NameAndType #22:#23 // out:Ljava/io/PrintStream;
#17 = Class #24 // java/io/PrintStream
#18 = NameAndType #25:#26 // println:(I)V
#19 = Utf8 Test
#20 = Utf8 java/lang/Object
#21 = Utf8 java/lang/System
#22 = Utf8 out
#23 = Utf8 Ljava/io/PrintStream;
#24 = Utf8 java/io/PrintStream
#25 = Utf8 println
#26 = Utf8 (I)V
{
public Test();
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 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LTest;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: bipush 10
2: istore_1
3: iload_1
4: iinc 1, 1
7: iinc 1, 1
10: iload_1
11: iadd
12: iload_1
13: iinc 1, -1
16: iadd
17: istore_2
18: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
21: iload_1
22: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
25: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
28: iload_2
29: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
32: return
LineNumberTable:
line 3: 0
line 4: 3
line 5: 18
line 6: 25
line 7: 32
LocalVariableTable:
Start Length Slot Name Signature
0 33 0 args [Ljava/lang/String;
3 30 1 a I
18 15 2 b I
}
4.2 运行流程#
iinc 指令是直接在局部变量 slot 上进行运算,a++ 和 ++a 的区别是先执行 iload 还是先执行 iinc。
5. 指令分析#
5.1 条件判断#
5.2 循环控制#
5.3 判断结果*#
public class Test {
public static void main(String[] args) {
int i = 0;
int x = 0;
while (i < 10) {
x = x++;
i++;
}
System.out.println(x); // 结果是 0
}
}
【Line 6】① 先执行 iload,把 0 压入操作数栈;② 再执行 iinc,直接在局部变量 slot 上进行自增运算,x = 1;③ 赋值操作,把操作数栈栈顶元素赋值给 x。
5.4 构造方法#
5.4.1 cinit#
public class Demo {
static int i = 10;
static {
i = 20;
}
static {
i = 30;
}
}
编译器会按从上至下的顺序,收集所有 static 静态代码块和静态成员赋值的代码,合并为一个特殊的方法 <cinit>()V
,该方法会在类加载的初始化阶段被调用:
0: bipush 10
2: putstatic #2 // Field i:I
5: bipush 20
7: putstatic #2 // Field i:I
10: bipush 30
12: putstatic #2 // Field i:I
15: return
5.4.2 init#
public class Demo {
private String a = "s1";
{
b = 20;
}
private int b = 10;
{
a = "s2";
}
public Demo(String a, int b) {
this.a = a;
this.b = b;
}
public static void main(String[] args) {
Demo d = new Demo("s3", 30);
System.out.println(d.a);
System.out.println(d.b);
}
}
编译器会按从上至下的顺序,收集所有 {} 代码块和成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是在最后:
5.5 方法调用#
public class Demo {
public Demo() {}
private void test1() {}
private final void test2() {}
public void test3() {}
public static void test4() {}
public static void main(String[] args) {
Demo d = new Demo();
d.test1();
d.test2();
d.test3();
d.test4();
Demo.test4();
}
}
对应的字节码指令:
0: new #2 // class cn/edu/nuist/jvm/Demo
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokespecial #4 // Method test1:()V
12: aload_1
13: invokespecial #5 // Method test2:()V
16: aload_1
17: invokevirtual #6 // Method test3:()V
20: aload_1
21: pop
22: invokestatic #7 // Method test4:()V
25: invokestatic #7 // Method test4:()V
28: return
- new 是创建对象,给对象分配堆内存,执行成功会将对象引用压入操作数栈;
- dup 是复制操作数栈栈顶的内容到栈顶,本例即为对象引用,为什么需要两份引用呢?一个是要配合 invokespecial 调用该对象的构造方法
"<init>":()V
(会消耗掉栈顶一个引用),另一个要配合 astore_1 赋值给局部变量 d; - ① final 方法 ② private 方法 ③ 构造方法 ④ 通过 super 调用父类方法,都是由 invokespecial 指令来调用,属于「静态绑定」;
- 普通成员方法是由 invokevirtual 调用,属于「动态绑定」,即支持多态;
- 成员方法与静态方法调用的另一个区别是,执行方法前是否需要“对象引用”。比较有意思的是
d.test4()
,通过对象引用调用一个静态方法,可以看到在调用 invokestatic 之前执行了 pop 指令,把对象引用从操作数栈弹出了。由此可见,以后调用静态方法不要通过对象去调用,这会产生两条没必要的虚拟机指令(aload_1 和 pop)。
5.6 多态#
5.6.1 示例代码#
/**
* 禁用指针压缩:
* -XX:-UseCompressedOops
* -XX:-UseCompressedClassPointers
*/
public class DuoTaiTest {
public static void test(Animal animal) {
animal.eat();
System.out.println(animal.toString());
}
public static void main(String[] args) throws IOException {
test(new Cat());
test(new Dog());
System.in.read();
}
}
abstract class Animal {
public abstract void eat();
@Override
public String toString() {
return "我是" + this.getClass().getSimpleName();
}
}
class Dog extends Animal {
@Override
public void eat() {
System.out.println("狗吃肉");
}
}
class Cat extends Animal {
@Override
public void eat() {
System.out.println("猫吃鱼");
}
}
5.6.2 运行时分析#
(1) 停在 System.in.read()
方法上,这时运行 jps
获取进程 id;
(2) 进入 JDK 安装目录,执行 java -cp ./lib/sa-jdi.jar sun.jvm.hotspot.HSDB
,进入图形界面 attach <进程id>;
(3) 打开 Tools → Find Object By Query,输入 select d from cn.itcast.jvm.t3.bytecode.Dog d
后点击 Execute 执行;
(4) 查看对象内存结构:点击超链接可以看到对象的内存结构,此对象没有任何属性,因此只有对象头的 16 字节,前 8 字节是 MarkWord,后 8 字节就是对象的 Class 指针,但目前看不到它的实际地址。此时,可以通过 Windows → Console 进入命令行模式,执行 mem 0x00000001299b4978 2
,参数 1 是对象地址,参数 2 是查看 2 行(即 16 字节),结果中的第 2 行 0x000000001b7d4028 即为 Class 的内存地址。
(5) 查看类的 vtable:① Alt+R 进入 Inspector 工具,输入刚才的 Class 内存地址,看到如下界面;② Tools → Class Browser 输入 Dog 查找,可以得到相同的结果。无论通过哪种方法,都可以找到 Dog.Class 的 vtable 长度为 6,意思就是 Dog 类有 6 个虚方法(多态相关的,final/static 不会列入)。那么这 6 个方法都是谁呢?
(6) 可以看到视图中并未提供 vtable 的起始地址,但是已知从 Class 的起始地址开始算,固定偏移 0x1B8
就是 vtable 的起始地址。所以,通过 Windows → Console 进入命令行模式,执行 mem 0x000000001b7d41e0 6
,就能得到 6 个虚方法的入口地址。
(7) 验证方法地址,通过 Tools → Class Browser 查看每个类的方法定义,比较可知:
5.6.3 小结#
当执行 invokevirtual
指令时:
- 先通过栈帧中的对象引用找到对象;
- 分析对象头,找到对象的实际 Class;
- Class 结构中有 vtable,它在类加载的链接阶段就已经根据方法的重写规则生成好了;
- 查表得到方法的具体地址;
- 执行方法的字节码。
5.7 异常#
5.7.1 try-catch#
public class Test {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
}
}
}
如下只摘出了部分字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 12
8: astore_2
9: bipush 20
11: istore_1
12: return
Exception table:
from to target type
2 5 8 Class java/lang/Exception
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/Exception;
0 13 0 args [Ljava/lang/String;
2 11 1 i I
- 可以看到多出来一个 Exception table 的结构,[from, to) 是前闭后开的检测范围,一旦这个范围内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号。
- L8 的字节码指令 astore_2 是将异常对象引用存入局部变量表的 slot 2 位置。
5.7.2 多个 single-catch#
public class Test {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (ArithmeticException e) {
i = 30;
} catch (NullPointerException e) {
i = 40;
} catch (Exception e) {
i = 50;
}
}
}
依旧只放部分重点字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=3, args_size=1
0: iconst_0
1: istore_1
2: bipush 10
4: istore_1
5: goto 26
8: astore_2
9: bipush 30
11: istore_1
12: goto 26
15: astore_2
16: bipush 40
18: istore_1
19: goto 26
22: astore_2
23: bipush 50
25: istore_1
26: return
Exception table:
from to target type
2 5 8 Class java/lang/ArithmeticException
2 5 15 Class java/lang/NullPointerException
2 5 22 Class java/lang/Exception
LocalVariableTable:
Start Length Slot Name Signature
9 3 2 e Ljava/lang/ArithmeticException;
16 3 2 e Ljava/lang/NullPointerException;
23 3 2 e Ljava/lang/Exception;
0 27 0 args [Ljava/lang/String;
2 25 1 i I
因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被复用。
5.7.3 multi-catch#
public class Test {
public static void main(String[] args) {
try {
Method test = Test.class.getMethod("test");
test.invoke(null);
} catch (NoSuchMethodException | IllegalAccessException | InvocationTargetException e) {
e.printStackTrace();
}
}
public static void test() {
System.out.println("TREE");
}
}
这种新型异常书写方式对应的字节码为:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=2, args_size=1
0: ldc #2 // class Test
2: ldc #3 // String test
4: iconst_0
5: anewarray #4 // class java/lang/Class (如下 * 代表全类名,省略了...)
8: invokevirtual #5 // Method java/lang/Class.getMethod:(L*String;[L*Class;)L*Method;
11: astore_1
12: aload_1
13: aconst_null
14: iconst_0
15: anewarray #6 // class java/lang/Object
18: invokevirtual #7 // Method java/lang/reflect/Method.invoke:(L*Object;[L*Object;)L*Object;
21: pop
22: goto 30
25: astore_1
26: aload_1
27: invokevirtual #11 // Method java/lang/ReflectiveOperationException.printStackTrace:()V
30: return
Exception table:
from to target type
0 22 25 Class java/lang/NoSuchMethodException
0 22 25 Class java/lang/IllegalAccessException
0 22 25 Class java/lang/reflect/InvocationTargetException
LocalVariableTable:
Start Length Slot Name Signature
12 10 1 test Ljava/lang/reflect/Method;
26 4 1 e Ljava/lang/ReflectiveOperationException;
0 31 0 args [Ljava/lang/String;
5.7.4 finally#
public class Test {
public static void main(String[] args) {
int i = 0;
try {
i = 10;
} catch (Exception e) {
i = 20;
} finally {
i = 30;
}
}
}
字节码:
- 可以看到 finally 中的代码被复制了 3 份,分别放入 try 流程,catch 流程以及 catch 剩余的异常类型流程。
- 如果抛出了“剩余的异常类型”,即从 21 开始往下执行,最后会将这个异常抛出去(26: athrow)。
下面来看两个关于 finally-return 的例子:
(1) finally 中有 return
- 由于 finally 中的 ireturn 被插入了所有可能的流程,因此返回结果肯定以 finally 的为准;
- 至于字节码中第 2 行,似乎没啥用,暂且留个伏笔,看下个例子~
- 跟上例中的 finally 相比,这里在捉到 any 之后没有 athrow,这告诉我们:如果在 finally 中出现了 return,它会吞掉异常(athrow)!
(2) finally 对返回值影响
5.8 synchronized#
先 review 上一篇《字节码指令》的 #2.9。
public class Test {
public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
System.out.println("ok");
}
}
}
字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
-------------------- Object lock = new Object() --------------------
0: new #2 // class java/lang/Object
3: dup // 复制栈顶元素,再压到栈顶(相当于栈顶有两个一样的数据)
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1 // lock_Reference -> <slot_1>(lock_Reference)
------------------------ synchronized start ------------------------
8: aload_1 // <- <slot_1>(lock_Reference) 入栈
9: dup // 复制(又成俩了)
10: astore_2 // lock_Reference -> <slot_2>
11: monitorenter // monitorenter(还剩一个 lock_Reference 配合 monitorenter 使用)
12: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
15: ldc #4 // String ok
17: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
20: aload_2 // <- <slot_2>(lock_Reference) 入栈
21: monitorexit // monitorexit (lock_Reference) 配合栈顶lock解锁
22: goto 30
25: astore_3 // any -> <slot_3>
26: aload_2 // <- <slot_2>(lock_Reference) 入栈
27: monitorexit // monitorexit (lock_Reference) 配合栈顶lock解锁
28: aload_3
29: athrow // 抛出"any"异常
30: return
Exception table:
from to target type
12 22 25 any
25 28 25 any
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 lock Ljava/lang/Object;
方法级别的 synchronized 不会在字节码指令中有所体现。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?