一起学Java虚拟机系列:
- 一起学Java虚拟机(一):内存区域和垃圾收集
- 一起学Java虚拟机(二):类文件结构
前言
了解JVM是对Java程序员的基本要求,但是有多少同学和我有一样醉心解bug堆布局,忘记了内功修炼,对JVM的理解是零碎的。系统地学习一次JVM也许能让我们在这条路走得更好更远。
字节码示例
上一集的HelloWorld字节码:
HP-ProDesk-680-G6-PCI-Microtower-PC:~/DEBUG$ javap -verbose HelloWorld.class
Classfile /home/mi/DEBUG/HelloWorld.class
Last modified May 12, 2021; size 641 bytes
MD5 checksum 1910a4531e5743c190636067d43d4bc4
Compiled from "HelloWorld.java"
public class com.wang.javavmdemo.HelloWorld
minor version: 0
major version: 52
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #3 // com/wang/javavmdemo/HelloWorld
super_class: #6 // java/lang/Object
interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #6.#23 // java/lang/Object."":()V
#2 = Fieldref #24.#25 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Class #26 // com/wang/javavmdemo/HelloWorld
#4 = String #27 // Hello World!
#5 = Methodref #28.#29 // java/io/PrintStream.println:(Ljava/lang/String;)V
#6 = Class #30 // java/lang/Object
#7 = Utf8 HELLO_WORLD
#8 = Utf8 Ljava/lang/String;
#9 = Utf8 ConstantValue
#10 = Utf8
#11 = Utf8 ()V
#12 = Utf8 Code
#13 = Utf8 LineNumberTable
#14 = Utf8 LocalVariableTable
#15 = Utf8 this
#16 = Utf8 Lcom/wang/javavmdemo/HelloWorld;
#17 = Utf8 main
#18 = Utf8 ([Ljava/lang/String;)V
#19 = Utf8 args
#20 = Utf8 [Ljava/lang/String;
#21 = Utf8 SourceFile
#22 = Utf8 HelloWorld.java
#23 = NameAndType #10:#11 // "":()V
#24 = Class #31 // java/lang/System
#25 = NameAndType #32:#33 // out:Ljava/io/PrintStream;
#26 = Utf8 com/wang/javavmdemo/HelloWorld
#27 = Utf8 Hello World!
#28 = Class #34 // java/io/PrintStream
#29 = NameAndType #35:#36 // println:(Ljava/lang/String;)V
#30 = Utf8 java/lang/Object
#31 = Utf8 java/lang/System
#32 = Utf8 out
#33 = Utf8 Ljava/io/PrintStream;
#34 = Utf8 java/io/PrintStream
#35 = Utf8 println
#36 = Utf8 (Ljava/lang/String;)V
{
public com.wang.javavmdemo.HelloWorld();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/wang/javavmdemo/HelloWorld;
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #4 // String Hello World!
5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 7: 0
line 8: 8
LocalVariableTable:
Start Length Slot Name Signature我
0 9 0 args [Ljava/lang/String;
}
SourceFile: "HelloWorld.java"
字节码指令简介
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码
,Opcode)
以及跟随其后的零至多个代表此操作所需的参数(称为操作数
,Operand)构成。
如果不考虑异常处理的话,那Java虚拟机的解释器可以使用下面这段伪代码作为最基本的执行模
型来理解,这个执行模型虽然很简单,但依然可以有效正确地工作:
do {
自动计算PC寄存器的值加1;
根据PC寄存器指示的位置,从字节码流中取出操作码;
if (字节码存在操作数) 从字节码流中取出操作数;
执行操作码所定义的操作;
} while (字节码流长度 > 0);
字节码与数据类型
在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虚拟机规范》中把这种特性称为“Not Orthogonal”,即并非每种数据类型和每一种操作都有对
应的指令。)有一些单独的指令可以在必要的时候用来将一些不支持的类型转换为可被支持的类型。
JAVA虚拟机所支持的指令集
这是oracle官方提供的java虚拟机指令集操作码与助记符的映射图,按类型进行了划分
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-7.html
加载和存储指令
加载和存储指令用于将数据在栈帧中的局部变量表和操作数栈
(见《一起学Java虚拟机(一):内存区域和垃圾收集》关于内存区域的介绍)之间来回传输,这类指令包括:
- 将一个局部变量加载到操作栈:iload、iload_
、lload、lload_ 、fload、fload_ 、dload、 dload_ 、aload、aload_ - 将一个数值从操作数栈存储到局部变量表:istore、istore_
、lstore、lstore_ 、fstore、fstore_ 、dstore、dstore_ 、astore、astore_ - 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_、lconst_
、fconst_ 、dconst_ - 扩充局部变量表的访问索引的指令: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虚拟机直接支持(即转换时无须显式的转换指令)以下数值类型的宽化类型转换(Widening
Numeric Conversion,即小范围类型向大范围类型的安全转换):
- int类型到long、float或者double类型
- long类型到float、double类型
- float类型到double类型
与之相对的,处理窄化类型转换(Narrowing Numeric Conversion)时,就必须显式地使用转换指
令来完成,这些转换指令包括i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l和d2f。窄化类型转换可能会导致
转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。
对象创建与访问指令
虽然类实例和数组都是对象,但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
操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈
的指令,包括:
- 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
- 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、
- dup2_x1、dup_x2、dup2_x2
- 将栈最顶端的两个数值互换:swap
控制转移指令
控制转移指令可以让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
方法调用和返回指令
- invokevirtual指令:用于
调用对象的实例方法
,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。 - invokeinterface指令:用于
调用接口方法
,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。 - invokespecial指令:用于
调用一些需要特殊处理的实例方法
,包括实例初始化方法、私有方法和父类方法。 - invokestatic指令:用于
调用类静态方法(static方法
)。 - invokedynamic指令:用于
在运行时动态解析出调用点限定符所引用的方法
。并执行该方法。前面四条调用指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括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虚拟机两者共同协作支持。
我们给HelloWorld增加一个持有同步锁的方法,看一下它的字节码:
void doSomethingLocked() {
synchronized (mLock) {
doSomething();
}
}
运行javap -verbose HelloWorld.class
void doSomethingLocked();
descriptor: ()V
flags: (0x0000)
Code:
stack=2, locals=3, args_size=1
0: aload_0
1: getfield #3 // Field mLock:Ljava/lang/Object; 访问mLock对象
4: dup // 复制栈顶元素(mLock的引用)
5: astore_1 // 将栈顶元素保存到局部变量槽1中
6: monitorenter // 以栈顶元素作为锁开始同步
7: aload_0 // 将局部变量槽 0(即this指针)的元素入栈
8: invokevirtual #10 // Method doSomething:()V
11: aload_1 // 将局部变量槽1(即mLock)的元素入栈
12: monitorexit // 退出同步
13: goto 21 // 方法正常结束,跳转到21返回
16: astore_2 // 从这步开始是异常路径,见下面异常表的Taget 16
17: aload_1 // 将局部变量槽1的元素(即mLock)入栈
18: monitorexit // 退出同步
19: aload_2 // 将局部变量槽2的元素入栈
20: athrow // 把异常对象重新抛出给doSomethingLocked()方法的调用者
21: return // 方法正常返回
Exception table:
from to target type
7 13 16 any
16 19 16 any
LineNumberTable:
line 16: 0
line 17: 7
line 18: 11
line 19: 21
LocalVariableTable:
Start Length Slot Name Signature
0 22 0 this Lcom/wang/javavmdemo/HelloWorld;
StackMapTable: number_of_entries = 2
frame_type = 255 /* full_frame */
offset_delta = 16
locals = [ class com/wang/javavmdemo/HelloWorld, class java/lang/Object ]
stack = [ class java/lang/Throwable ]
frame_type = 250 /* chop */
offset_delta = 4
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须有其对
应的monitorexit指令,而无论这个方法是正常结束还是异常结束。为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理程序,这个异常处理程序声明可处理所有
的异常,它的目的就是用来执行monitorexit指令。