首先,需要就 术语 进行一些基础性的约定:
通常,我们借助于 javap 命令来对 .class 文件的字节码内容进行查阅。
类似于汇编代码,javap 打印的JVM 指令将以下列格式进行展示:
[ [ ...]] [ ]
其中
-
-
-
-
首先,Java 代码经编译后的所有指令都基于 方法(Method) 被定义在 Code 属性中。
在 ClassFile 的 Code 属性,结构定义如下:
Code_attribute { // 其中 u1, u2, u4 分别表示这个变量所占的字节长度 u2 attribute_name_index; // 属性名在常量池中的 index (执行常量池中 Code 的位置) u4 attribute_length;// 属性长度,不包括开始的六个字节 u2 max_stack; // 运行时操作数栈的最大深度 u2 max_locals;// 运行时所需的局部变量表的大小 u4 code_length; // code 数组的长度 u1 code[code_length]; // code 数组,编译后方法体的内容都通过字节码指令存储在这里 u2 exception_table_length;// 异常表的长度 {u2 start_pc; u2 end_pc; u2 handler_pc; u2 catch_type; } exception_table[exception_table_length]; // 异常表,Java 代码中所有的 catch, finally 的捕获都将由此表进行实现 u2 attributes_count; // 属性计数 attribute_info attributes[attributes_count]; }
Java 虚拟机的指令是由一个字节长度的 操作码 配合上其后的 0 个或多个 操作数 所构成的。
其中,操作数 的数量取决于 操作码,不同的 操作码 需要不同数量的 操作数。
按照类型划分,操作数 主要包括下列几类:
加载与存储指令,例如 iload, istore 等 运算指令,例如 iadd, isub, imul 等 类型转换指令,例如 i2b, i2s 等 对象创建与操作指令,例如 new, newarray 等 操作数栈管理指令,例如 dup, pop 等 控制转移指令,例如 if_icmpeq 等 方法调用与返回指令,例如 invokevirtual, invokestatic 等 抛出异常指令,例如 athrow 等 同步指令,例如 monitorenter 等举几个简单的例子:
iadd 指令,表示对两个 int 数的相加指令。将从操作数栈顶依次取出两个数,并相加,再重新压入操作数栈中。
bipush 100 ,其中 bipush 是指令,后随一个操作数,表示把 操作数 100 这个 byte 类型的数 压入操作数栈顶
JVM 定义了若干种运行期间会使用到的运行时数据区,见下图:
至于每一个的具体意义,在此不做详细展开,可用参考:
由周志明等翻译的 《Java 虚拟机规范(Java SE 7版) 》 2.5节内容 链接 JVMS 2.5. Run-Time Data Areas首先,有必要提及,想要得到 .class 可视且友好的展示结果,可以使用 JDK 自带的 javap 命令。
本节要展示的内容是常见的 POJO 的 get, set 方法的指令代码。使用的示例代码如下:
public class Test { private int number; public int getNumber() { return number; } public void setNumber(int number) { this.number = number; } }
在经过 javac 编译,javap 解析之后,我们将看到下列内容
Classfile /Users/fangfeng/WorkPkg/lab/src/main/java/me/fangfeng/asm/Test.class Last modified Jul 23, 2018; size 357 bytes MD5 checksum bb1940cc6534d789359295b8dc80233b Compiled from "Test.java" public class me.fangfeng.asm.Test minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #4.#17// java/lang/Object."":()V #2 = Fieldref #3.#18// me/fangfeng/asm/Test.number:I #3 = Class #19// me/fangfeng/asm/Test #4 = Class #20// java/lang/Object #5 = Utf8number #6 = Utf8I #7 = Utf8 #8 = Utf8()V #9 = Utf8Code #10 = Utf8LineNumberTable #11 = Utf8getNumber #12 = Utf8()I #13 = Utf8setNumber #14 = Utf8(I)V #15 = Utf8SourceFile #16 = Utf8Test.java #17 = NameAndType #7:#8 // " ":()V #18 = NameAndType #5:#6 // number:I #19 = Utf8me/fangfeng/asm/Test #20 = Utf8java/lang/Object { public me.fangfeng.asm.Test(); descriptor: ()V flags: 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 public int getNumber(); descriptor: ()I flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield#2// Field number:I 4: ireturn LineNumberTable: line 8: 0 public void setNumber(int); descriptor: (I)V flags: ACC_PUBLIC Code: stack=2, locals=2, args_size=2 0: aload_0 1: iload_1 2: putfield#2// Field number:I 5: return LineNumberTable: line 12: 0 line 13: 5 } SourceFile: "Test.java"
打印出来的内容包括有 类的全限定名,访问控制权限,父类,接口,常量池以及各个方法。
以 getNumber 为例:
public int getNumber(); descriptor: ()I flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield#2// Field number:I 4: ireturn LineNumberTable: line 8: 0descriptor: 表示方法描述符,其中 () 内容表示入参,I 表示返回值的类型 flags : 表示方法的访问权限,当前限定为 public Code: 存储有当前方法体指令码的一种方法内部属性。
0: aload_0 // 从局部变量表中加载 0 号元素到操作数栈中 1: getfield #2 // 获取常量池中 2 号元素(即 Field number:I) 的值,并加载到操作数栈中 4: ireturn // 抛出当前操作数栈顶元素作为返回值
其中,每条指令前的 0, 1, 4 指当前指令作为 Code 属性的内容的偏移量。
换一句话说,aload_0 是 Code 属性 code[] 的第 0 个字节的内容
getfield #2 的是从 code[] 的第 1 个字节开始的。
ireturn 是从 code[] 的第 4 个字节开始。
至于为什么每条指令的开始位置不同,这取决于每条指令的长度。aload_0 指令本身为 1 字节的长度,且不要求附带操作数。
getfield 指令自长 1 个字节,但需要一个长度为 2 字节的常量池索引。因此 ireturn 将从第 4 字节开始
同时,可能有人会有所疑问,aload_0 加载的 0 号元素是什么?它貌似没有被用到?
首先,在每个方法被触发,在构建新的栈帧时,this 将自动作为 0 号元素被存入新栈帧的局部变量表作为 0 号元素。
同时,如果方法有入参,则入参按入参声明顺序依次作为 1, 2, 3, … 元素存入。
(当然,这里存在特殊情况,诸如 long, double 这样长为 8 字节的元素,将占用 2 个局部变量表的数组下标,而其他元素顺延)。
至于看似 0 号元素 this 并没有被用到。事实上,它是作为 getfield 的一个限定被使用的。
试想,getfield 虽然通过 #2 能够知道需要获取到的变量名为 number 类型为 I(即 int) 的元素。但是,这个元素究竟属于哪个实例?
而操作数栈顶的 this 恰恰是指明,需要使用当前方法所在的类的 number 变量。
类似的,我们看一下 setNumber 方法
0: aload_0 // 从局部变量表中加载 0 号元素到操作数栈中 (用于确定字段所属的类) 1: iload_1 // 从局部变量表中加载 1 号元素到操作数栈中 (用于确定将要给字段赋的值) 2: putfield#2 // 获取常量池中 2 号元素(即 Field number:I) 的地址,并赋值 5: return// 无返回值的 return 指令来结束当前栈帧的执行
经常会见到在方法体内部有类似这样的声明 int score = 100 ,那么这样的内容翻译成指令会是如何?
对于较小的值,例如 100,将通过 bipush 100, istore_1(假设用局部变量表 1 号元素存储 score 变量 类似的形式进行赋值。
类似的,还是 sipush ,两者的区别是 bipush 可以支持 1 字节大小的整数,sipush 支持 2 字节大小的整数。
但是,int 最大可是可以到达 4 字节,更甚者,long 将达到 8 字节。
这时候,将要借助的就是 ldc #
那么,超出 128 编号的?使用 ldc_w #
同时,区别于将 byte, short, int, float 统一作为 4 字节结果读取,对于 long, double 这样的八字节元素,使用 ldc2_w
作为一门图灵完备的语言,至少,控制结构是必不可少的元素。
那么,类似 for(int i=0;i<10;i++) 的 Java 代码编译成指令到底是什么样的呢?
0: iconst_0// 赋值指令,往操作数栈顶添加整数值 0 1: istore_1// 将操作数栈顶的元素存入局部变量表 1 号位置 2: iload_1 // 从局部变量表 1 号位置加载元素到操作数栈 3: bipush 10 // 往操作数栈顶压入 byte 型值 10 5: if_icmpge 21 // 将操作数栈顶的两个元素进行比较, 如果 次顶部元素 >= 顶部元素,则重定向到偏移量为 21 的指令 // for (...) {} 语句块的内容 15: iinc 1, 1 // 将局部变量表的 1 号位置元素 +1 18: goto 2 // 跳转到偏移量为 2 的指令 21: return // 调用无返回值的 return
类似的,if(...) 语句的比较较之 for(;;) 就更为简单。类比偏移量为 5 的指令即可。
JVM 指令集中总计有 4 种调用方法的指令,包括有:
invokevirtual, 对普通实例方法的调用,将根据对象类型进行分发调用 invokestatic, 对静态方法的调用 invokespecial, 用于调用类的初始化方法,也用于调用父类方法和私有方法 invokeinterface, 用于调用接口方法以执行 System.out.println() 为例
假设常量池内容存在目标元素(具体以相应注释为准)
getstatic #3// Field java/lang/System.out:Ljava/io/PrintStream; invokevirtual #4// Method java/io/PrintStream.println:()V