JVM字节码指令 及 反编译分析
在文章《Java前端编译:Java源代码编译成Class文件的过程》了解到javac编译的大体过程,在《Java Class文件结构解析 及 实例分析验证》中了解到了Class文件结构,我们可以知道Class文件中的各方法表后面的"code"属性存储了各方法对应的JVM字节码指令。
下面我们详细了解JVM字节码指令:先对字节码指令组成结构有个大体了解,并通过前面的"getMap"方法的字节码数据来分析JVM指令及操作码助记符,而后了解字节码指令与数据类型的关系,最后分类说明JVM指令的功能及注意事项。
一条JVM指令由一个字节长度、代表某种特定操作含义的数字(称为操作码,Opcode),以及跟随其后的零到多个代表此操作所需参数(称为操作数,Operands)构成。
1、对于JVM指令,由于JVM采用面向操作数栈而不是寄存器的架构,所以大多指令只有操作码;
2、对于操作码,由于操作码长度为一个字节,所以操作码(指令)最多不超过256条;
3、对于操作数长度超过了一个字节的情况(取决于操作码):由于Class文件格式放弃了编译后代码的操作数长度对齐,所以,当JVM处理超过一个字节长度的数据时,需要在运行时从字节中重建出具体数据的结构,如16位数据需要(byte1 << 8)|byte2操作(Big-Endian 顺序存储——即高位在前的字节序);
这种操作会使得在解释执行时损失一些性能,但这也可以省略很多填充和间隔符号,尽可能获得短小精干的编译代码,数据量小,传输效率高;
在《Java Class文件结构解析 及 实例分析验证》"3-8节、属性表集合"的Code属性分析中,可以看到"getMap"方法程序如下:
使用javac编译为Class文件后,"getMap"方法表对应的Code属性中有22个字节JVM字节码指令数据,如下:
使用javap反编译后的JVM指令如下:
JVM指令的解释:"new #4"、"dup"、"aload_1"、"invokeinterface #9, 3"等;
操作码助记符:"new"、"dup"、aload_1"、"invokeinterface等;
操作数:"new "一个操作数"#4"、"invokeinterface"两个"#9, 3";其中的"#"号表示索引常量池中的第几项常量数据。
从字节码到指令解释的"手动"翻译过程如下:
(A)、"BB0004":先是一个操作码"BB",查询《JVM操作码助记符表》可以看到,而后再《JVM指令集》的介绍中找到"new"指令的详细说明,知道后面接一个操作数,并且是两个字节长度,所以操作数是"0004",即"BB0004"就表示指令"new #4";
(B)、"59":查表得知表示"dup"指令,后面没有操作数;
(C)、"B70005":其中"B7"查指令集表得知为"invokeinterface",后面接一个两字节的操作数,即表示指令"invokespecial #5";
整理如下:
字节码指令 |
操作码 |
操作数1 |
操作数2 |
操作数3 |
助记符及解释 |
备注 |
BB0004 |
BB |
(00<<8)|04 |
new #4 |
"#"号表示索引常量池中的第几项常量数据; |
||
59 |
59 |
dup |
该指令无操作数 |
|||
B70005 |
B7 |
(00<<8)|05 |
invokespecial #5 |
|||
4C |
4C |
astore_1 |
||||
2B |
2B |
aload_1 |
||||
B20006 |
B2 |
(00<<8)|06 |
getstatic #6 |
|||
1208 |
12 |
08 |
ldc #8 |
操作数为一个字节 |
||
B900090300 |
12 |
(00<<8)|09 |
03 |
00 |
invokeinterface #9, 3 |
第3个操作数是为了给 Oracle 实现的虚拟机的额外操作数而预留的空间 |
57 |
57 |
pop |
||||
2B |
2B |
aload_1 |
||||
B0 |
B0 |
areturn |
JVM规范中《操作码助记符表》:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-7.html
JVM规范中《JVM指令集》介绍:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5
1、指令助记符与数据类型
(A)、大多数指令包含了其操作所对应的数据类型信息,如:
iload指令用于从局部变量表中加载int类型数据到操作数栈中;
fload指令加载的则是float类型数据;
(B)、对于大部分与数据类型相关的字节码指令,它们的操作码助记符中都有特殊的字符来表明专门为哪种数据类型服务,如:
i代表int类型,f代表float类型,d代表double,a代表reference;
(C)、数组类型的一般带有array字符,如:
arraylength指令;
(D)、还有一些指令与数据类型无关,如:
无条件跳转指令goto;
2、"Not Orthogonal"特性与数据类型转换
由于操作码最多不超过256条,不可能每种对数据的操作都为每种类型单独一条指令,即并非第种数据类型和每一种操作都有对应的指令,这称为"Not Orthogonal"特性;
大部分指令都没有支持boolean(没有任何支持)、byte、char和short数据类型;JVM、编译器会在编译期或运行期将byte和short类型的数据带符号扩展(Sign-Extend)为相应的Int类型数据;将boolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据;
处理这些类型的数组时,会转换为int类型的字节码指令来处理;
可以将JVM指令操作按用途分为9类,下面分别介绍。
用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,包括如下内容指令:
1、将一个局部变量加载到操作栈:iload、iload_
、lload、lload_ 、fload、fload_ 、dload、dload_ 、aload、aload_ ; 2、将一个数值从操作数栈存储到局部变量表:istore、istore_
、lstore、lstore_ 、fstore、fstore_ 、dstore、dstore_ 、astore、astore_ ; 3、将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_ml、iconst_、lconst_、fconst_、dconst_;
4、扩充局部变量表的访问索引的指令:wide;
<>结尾的代表了一组指令,如iload_
,代表了iload_1、iload_2、iload_3、iload_4; 它们省略了显式的操作数,不需要进行操作数的动作,实际上操作数据隐含在指令中,如iload_1与iload操作数为0时完全一致。
用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶;
可以分为两种:1、对整型数据进行运算的指令;2、对浮点型数据进行运算的指令;整数与浮点数算术指令在溢出和被零除时也有各自不同的行为表现。
所有算术指令如下:
1、加法指令:iadd、ladd、fadd、dadd;
2、减法指令:isub、lsub、fsub、dsub;
3、乘法指令:imul、lmul、fmul、dmul;
4、除法指令:idiv、ldiv、fdiv、ddiv;
5、求余指令:irem、lrem、frem、drem;
6、取反指令:ineg、lneg、fneg、dneg;
7、位移指令:ishl、ishr、iushr、lshl、lshr、lushr、;
8、按位或指令:ior、lor;
9、按位与指令:iand、land;
10、按位异或指令:ixor、lxor;
11、局部变量自增指令:iinc;
12、比较指令:dempg、dempl、fempg、fempl、lemp;
1、整型数据的运算
JVM规范没有定义整型数据溢出的具体运算结果。
只定义除法指令(idiv和ldiv)以及求余指令(irem和lrem)中,当出现除数为零时会导致JVM抛出ArithmethicException异常;除此外,其他任何整型数据运算都不应该抛出异常。
另外,前面说过,没有直接支持boolean、byte、char和short数据类型的算术指令,使int类型的指令代替。
2、浮点型数据的运算
VM规范要求JVM处理浮点数时,必须严格遵守IEEE 754规范中规定的行为和限制,包括非正规浮点数值(Denormalized Floating-point Number)和逐级下溢(Gradual Underflow)的运算规则。
(A)、浮点型数据的舍入模式
浮点数运算时,非精确的结果必须舍入为可被表示的最接近的精确值,采用IEEE 754默认的舍入模式,优先选择最低有效位为零的,称为向最接近数舍入模式。
而把浮点数转换为整数时,采用IEEE 754标准的向零舍入模式,即把小数部分的有效字节丢弃,如:
float f1 = (float) 1.0;
float f2 = (float) 0.8;
double d1 = 1.00000005;
double d2 = 1.00000015;
double d3 = 1.00000025;
//测试向最接近数舍入模式
float fd1 = (float) (f1+d1);
float fd2 = (float) (f1+d2);
float fd3 = (float) (f1+d3);
System.out.println("fd1 = " + fd1);
System.out.println("fd3 = " + fd2);
System.out.println("fd3 = " + fd3);
//测试向零舍入模式
int i = (int) (f1+f2);
System.out.println("i = " + i);
输出:
fd1 = 2.0
fd3 = 2.0000002
fd3 = 2.0000002
i = 1
(B)、异常、溢出、NaN值
JVM处理浮点数运算时,不会抛出任何运行时异常;
当一个操作产生溢出时,使用有符号的无穷大来表示;
没有数学定义的值,使用NaN值来表示;
(C)、比较
JVM在long类型数值比较时,采用有符号的比较方式;
而在浮点数值比较(dempg、dempl、fempg、fempl)时,采用IEEE 754定义的无符号比较(Nosignaling Comparisons)方式;
用于将两种不同的数值类型进行相互转换;
1、宽化类型转换
JVM直接支持(无需显式的转换指令)宽化类型转换(Widening Numeric Conversions,即小范围类型向大范围类型的安全转换),如下:
(A)、int类型到long、float、double类型;
(B)、long类型到float、double类型;
(C)、float类型到double类型;
2、窄化类型转换
而处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,包括:
i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2f;
这可能导致转换结果产生不同的正负号、不同的数量级的情况,转换过程中还可能导致精确度丢失,但JVM不会抛出任何运行时异常,如:
(A)、int或long类型窄化转换为整数类型T(T字节长度为N)时,转换过程仅仅是简单地丢弃除最低位N个字节以外的内容;
(B)、浮点值窄化转换为整数类型T(T限于int或long类型)时,遵循以下规则:
1)、如果浮点值是NaN,那转换结果是T为0;
2)、如果浮点值不是无穷大,采用向零舍入模式取整,获得其整数值v;
如果v在T的表示范围内,T就等于v;
否则,根据v的符号,转换为T所能表示的最大或最小正数;
(C)、double类到float类型的窄化转换采用向最接近数舍入模式,舍入得到一个可以用float表示的数字;
1)、如果该数字绝对值太小无法用float来表示,将返回float类型的正负零;
2)、如果该数字绝对值太大无法用float来表示,将返回float类型的正负无穷大;
3)、而NaN转换还是NaN;
JVM对类实例和数组创建和操作使用了不同的字节码指令,包括:
1、创建类实例的指令:new;
2、创建数组的指令:newarray、anewarray、multianewarray;
3、访问类字段(static字段或类变量)和实例字段(非static字段或实例变量)的指令:getfield、putfield、getstatic、putstatic;
4、把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload;
5、把一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore;
6、取数组长度的指令:arraylength;
7、检查类实例类型的指令:instanceof、checkcast;
JVM直接操作操作数栈的指令:
1、将操作数栈的栈顶一个或两个元素出栈:pop、pop2;
2、复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2;
3、将栈最顶端的两个数值互换:swap;
用于让JVM有条件或无条件地从指定的位置指令(而不是控制转移指令的下一条指令)继续执行程序,即有条件或无条件地修改PC寄存器的值。
控制转移指令如下:
1、条件分支:ifeq、iflt、ifle、ifgt、ifge、ifnull、ifnonnull、empeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne;
2、复合条件分支:tableswitch、lookupswitch;
3、无条件分支:goto、goto_w、jsr、jsr_w、ret;
JVM有专门处理int、reference类型和检测null值的指令;
对boolean、byte、char和short类型的条件分支比较操作,都是使用int类型的比较操作指令;
对long、float和double类型的条件分支比较操作,则先会执行相应类型的比较运算指令(dempg、dempl、fempg、fempl、lemp),然后返回一个整型值到操作数栈中,再执行int类型的条件分支比较操作完成跳转;
所以int类型的条件分支指令是最为丰富和强大的。
方法调用指令主要是的以下5条:
1、invokevirtual指令:用于调用对象的实例方法,根据实际类型进行分派(虚方法分派),最常见的分派方式;
2、invokeinterface指令:用于调用对象接口方法,运行时会搜索一个实现了该接口方法的对象,找出适合的方法进行调用;
3、invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法;
4、invokestatic指令:用于调用类方法(static方法);
5、invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法;
前面4条指令的分派逻辑都固化在JVM内,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关,而方法返回指令是根据返回值类型区分的,包括:
1、ireturn、lreturn、freturn、dreturn、areturn;
2、return:void方法、实例初始化方法以及类和接口的类初始化方法使用。
Java程序中显式抛出异常的操作(throw语句)都是由athrow指令来实现的;
还有许多运行时异常,会在JVM指令检测到异常时自动抛出,如idiv或ldiv指令除数为零时,自动抛出ArithmeticException异常;
另外,处理异常的catch语句,不是由字节码指令实现的(JDK1.4.2前是由jsr和ret指令实现),而是采用异常表来完成(方法表中Code属性有异常表)。
JVM支持方法级的同步和方法内部一段指令序列的同步,两种同步结构都使用管程(Monitor)来支持,如下:
1、方法级的同步
是隐式的,无须通过字节码指令来控制,实现在方法调用和返回操作之中;
方法调用时,先检查方法表(method_info)的ACC_SYNCHRONIZED访问标志,设置了表示该方法为同步方法;
执行线程需要先成功持有管程,才能执行该方法,方法完成返回时释放管程;
如果方法执行期间抛出异常,且方法内部无法处理,管程在异常抛出到方法外时自动释放;
2、同步一段指令序列
通常在java程序中由synchronized语句来表示,有monitorenter和monitorexit两条指令来支持;
需要javac编译器和JVM共同协作支持;
一条monitorenter指令需要一条monitorexit指令对应,所以,编译器可能会自动产生一个可以处理所有异常的异常处理器,来执行monitorexit指令;
1、三个保留操作码
有三个是保留操作码,它们是被Java虚拟机内部使用的,不能真的出现在一个有效的Class文件之中:
两个操作码值分别为 254(0xfe)和 255(0xff),助记符分别为impdep1和impdep2的两个操作码是作为"后门"和"陷阱"出现,目的是在某些硬件和软件中提供一些与实现相关的功能;
第三个操作码值分别为 202(0xca)、助记符为 breakpoint 的操作码是用于调试器实现断点功能;
2、虚拟机错误
当Java虚拟机出现了内部错误,或者由于资源限制导致虚拟机无法实现Java语言中的语义时,Java虚拟机将会抛出一个属于VirtualMachineError的子类的异常对象实例;
可能会出现在Java虚拟机运作过程中的任意时刻,主要错误如下:
(A)、InternalError
Java虚拟机实现的软件或硬件错误都会导致InternalError异常的出现,InternalError是一个典型的异步异常,它可能出现在程序中的任何位置。
(B)、OutOfMemoryError
当Java虚拟机实现耗尽了所有虚拟和物理内存,并且内存自动管理子系统无法回收到足够共新对象分配所需的内存空间时,虚拟机将抛出OutOfMemoryError异常。
(C)、StackOverflowError
当Java虚拟机实现耗尽了线程全部的栈空间,这种情况经常是由于程序执行时无限制的递归调用而导致的,虚拟机将会抛出StackOverflowError异常。
(D)、UnknownError
当某种异常或错误出现,但虚拟机实现无法确定具体实际是哪种异常或错误的时候,将会抛出UnknownError异常。
到这里,我们大体了解JVM字节码指令是什么,有些什么功能了,但是一个方法的JVM指令执行过程是怎么样的呢?这个需要先来了解JVM如何加载Class文件,JVM运行时的数据区是怎么样的,这样才能解释得清楚指令操作的是什么,如前篇文章分析的"getMap"方法,其中"ldc #8"直接将第8项常量"java"字符串加载到操作数栈顶,这个常量"java"字符串存储在哪里,操作数栈又是什么。
后面我们将分别去了解:JVM运行时数据区、JIT编译--在运行时把Class文件字节码编译成本地机器码的过程、以及JVM垃圾收集相关内容……
【参考资料】
1、《The Java Virtual Machine Specification》Java SE 8 Edition:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html
2、《深入理解Java虚拟机:JVM高级特性与最佳实践》第二版 第6章
3、《The Java Language Specification》Java SE 8 Edition:https://docs.oracle.com/javase/specs/jls/se8/html/index.html
4、Java前端编译:Java源代码编译成Class文件的过程