能直接阅读字节码时工作中分析Java代码语义问题的基本技能

概述

  • JVM指令的构成
    • 一个字节长度
      • 0x00~0xFF,0~255
    • 代表某种特定操作含义的数字(操作码,Opcode)
    • 零到多个代表次操作所需参数(操作数,Operands)
  • 架构:面向操作数栈不是寄存器
  • 特点:
    • 大多数指令都不包含操作数,只有一个操作码
    • 非完全独立(Not Orthogonal)
      • 并非每种数据类型和每一种操作都有对应的指令
  • JVM处理超过一个字节数据的时候,会在运行时从字节中重建出具体数据的结构
    • 原因
      • 指令长度为1字节
      • Class文件格式放弃了编译后代码的操作数长度对其
    • 优点
      • 省略填充和建个符号
      • 编译代码短
    • 缺点
    • 解释执行字节码时损失一些性能

字节码与数据类型

  • 对于大部分与基本数据类型(int、long、short、byte、char、float、double)相关的字节码指令,操作码助记符在首位会以数据类型首字母开头
1
2
iload:从局部变量表中加载int型的数据到操作数栈中
fload:从局部变量表中加载float型的数据到操作数栈中
  • a代表reference
  • 大部分指令都没有支持整数类型(byte、char和short),没有任何指令支持boolean
    • 编译器会将byte和short类型数据带符号扩展(Sign-Extend)为相应的int类型数据
    • 将boolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据

加载和存储指令

  • 作用
    • 将数据在栈帧只能够的局部变量表和操作数栈之间来回传输
1
2
3
4
5
6
7
8
//将一个局部变量加载到操作数栈:
*load<_n>:iload、iload_3
//将一个数值从操作数栈存储到局部变量表
*store<_n>:astore、astore_2
//将一个常量加载到操作数栈
bipush、sipush、idc、idc_w、idc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>
//扩充局部变量表的访问索引
wide

运算指令

  • 作用
    • 对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶
  • 分类
    • 对整型数据进行运算
    • 对浮点型数据进行运算
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//加法
*add:iadd、ladd、fadd、dadd
//减法
*sub:isub、lsub、fsub、dsub
//乘法
*mul:imul、lmul、fmul、dmul
//除法
*div:idiv、ldiv、fdiv、ddiv
//求余
*rem:irem、lrem、frem、drem
//取反
*neg:ineg、lneg、fneg、dneg
//位移
ishl、ishr、iushr、lshl、lshr、lushr
//按位或
ior、lor
//按位与
iand、land
//按位异或
ixor、lxor
//局部变量自增
iinc
//比较
dcmpg、dcmpl、fcmpg、fcmpl、lcmp
  • 两个int值相加结果超过int范围,将会返回负数
  • JVM规范没有明确定义过整型数据溢出的具体运算结果,仅规定了在处理整型数据时,只有除法指令(idiv和ldiv)以及求余指令(irem和lrem) 中当出现除数为零时会导致虚拟机抛出ArithmeticException异常,其余任何整型数运算场景都不应抛出运行时异常。
  • JVM规范要求JVM必须完全支持 IEEE 754中定义的非规约形式的浮点数值(Denormalized Floating-Point Numbers)和逐级下溢(Gradual Underflow)的运算规则。
    • 对于浮点数,当最小值超过范围的时候,会逐级下溢;当最大值超过范围的时候,会发生上溢。

如果浮点数的指数部分的编码值是0,分数部分非零,那么这个浮点数将被称为非规约形式的浮点数。一般是某个数字相当接近零时才会使用非规约型式来表示。 IEEE 754标准规定:非规约形式的浮点数的指数偏移值比规约形式的浮点数的指数偏移值小1

  • 浮点数运算时,默认的舍入模式为向最接近数的精确值,优先选择最低有效位位零的。
  • 浮点数转整数的时候采用的时向零舍入,即直接将小数位舍掉。
  • 如果计算的结果没有明确定义,不抛异常,返回NaN
  • 对于long类型数值比较时,采用带符号的比较方式;对于浮点数(dcmpg、dcmpl、fcmpg、fcmpl)采用无信号比较方式。

类型转换指令

JVM支持隐式宽化转换(Widening Numberic Conversions,小范围类型向大范围类型的安全转换)

1
2
3
int  -> long、float、double
long -> float、double
float-> double

必须显式使用转换指令处理窄化类型转换(Narrowing Numeric Conversions)。导致转换结果产生不同的正负号(符号在高位)、数量级,导致精度丢失。

1
i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l、d2f
  • 将int或long类型窄化转换为类型T的时候,将丢弃除最低位数据类型长度字节以外的内容。
  • 浮点值窄化为int或long规则
    • 浮点值为NaN,返回int或long的0
    • 浮点值不是无穷大,采用向零舍入模式取整,符合范围则返回,否则根据结果值的符号,转换为目标类型可表示的最大或最小整数。
  • double>float
    • 最接近数摄入模式
    • 绝对值太小则返回float类型的正负零
    • 太大则返回float类型的正负无穷大
    • double的NaN返回float的NaN
  • 永远不会抛异常

对象创建与访问指令

类实例和数组的创建和使用采用不同的字节码指令。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/****************类*******************/
//创建类实例
new
//访问类字段(static)和实例字段(非static)
getfield、putfield、getstatic、putstatic
//检查类实例类型
instanceof、checkcast
/****************数组*******************/
//创建数组
newarray、anewarray、nultianewarray
//把一个数组元素加载到操作数栈
baload、caload、saload、iaload、laload、faload、daload、aaload
//把一个操作数栈的值存储到数组元素中
bastore、castore、sastore、iastore、fastore、dastore、aastore
//获取数组长度
arraylength

操作数栈管理指令

JVM提供一些用于直接操作操作数栈的指令。

1
2
3
4
5
6
//栈顶一个或两个元素出栈
pop、pop2
//复制栈顶一个或两个数值,并将复制值或双份复制值重新压入栈顶
dup、dup2、dup_x1、dup_x2、dup2_1、dup2_x2
//栈顶的两个数值互换
swap

控制转移指令

可以让JVM从指定位置(而不是控制转移指令的下一条指令)继续执行程序,从概念模型上可以认为是修改PC寄存器的值。

1
2
3
4
5
6
//条件分支
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
  • 对于boolean、byte、char、short类型是通过int类型的比较指令完成
  • 对于long、float、double类型,先执行对应类型的指令,运算指令会返回一个整型值到操作数栈中,再执行int类型的条件分支比较操作来完整整个分支跳转。

方法调用和返回指令

1
2
3
4
5
6
7
8
9
10
11
12
/*****************分派逻辑固化在JVM内部*****************/
//调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派、Java中最常见的分派方式)
invokevirtual
//调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用
invokeinterface
//调用需要特殊处理的实例方法,如:实例初始化方法、私有方法、父类指令
invokespecial
//调用类方法(static method)
invokestatic
/********分派逻辑是由用户所设定的引导方法决定的********/
//在运行时动态解析出调用点限定符所引用的方法,并执行
invokedynamic

方法调用指令与数据类型无关,方法返回指令是根据返回值的类型区分的。

异常处理指令

Java程序中显示抛出异常(throw)由athrow指令完成,而处理异常(catch语句)不是由字节码指令来完成的(取消了jsrret指令,采用异常表来完成)。

同步指令

JVM支持方法级的同步方法内部一段指令序列的同步。都是由Monitor管程支持的。

方法级同步

方法级的同步是隐式的,无需通过字节码指令来控制,实现在方法调用和返回操作之中。JVM可以从方法常量池的方法表结构中的ACC_SYCHRONIZED访问标识得知一个方法是否为同步方法。

  • 方法调用,检查方法的ACC_SYCHRONIZED访问标志是否设置,如果有
    • 执行线程就要求先成功持有管程(其他任何线程都无法再获得同一管程)
    • 执行方法
    • 执行完毕
      • 没有抛出异常,释放管程
      • 抛出异常,并且在方法内部无法处理此异常,则在异常抛到同步方法之外时自动释放管程

指令序列同步

由Java语言中的synchronized语句块表示,由monitorenter(开始同步)monitorexit(退出同步)两条指令来支持。

正确实现synchronized关键字需要Javac编译器与JVM两者共同协作支持。

1
2
3
4
5
6
7
8
9
10
11
class Test{
void onlyMe(Foo f){
synchronized(f){
doSomething();
}
}
class Foo{

}
void doSomething(){}
}
  • javac Test.java
  • javap -v Test
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
void onlyMe(Test$Foo);
descriptor: (LTest$Foo;)V
flags: (0x0000)
Code:
stack=2, locals=4, args_size=2
0: aload_1 //将对象f入栈
1: dup //复制栈顶元素(f的引用)
2: astore_2 //将栈顶元素存储到局部变量表Slot 2中
3: monitorenter //以栈顶元素(f)作为锁,开始同步
4: aload_0 //将局部变量Slot 0(this)的元素入栈
5: invokevirtual #2 //调用doSomething方法
8: aload_2 //将局部变量Slot 2元素(f)入栈
9: monitorexit //退出同步
10: goto 18 //方法正常结束,跳转到18返回
13: astore_3 //异常路径,见下面异常表的Target13
14: aload_2 //将局部变量Slot 2元素(f)入栈
15: monitorexit //退出同步
16: aload_3 //将局部变量Slot 3元素(异常对象)入栈
17: athrow //吧异常对象重新抛给onlyMe方法的调用者
18: return //方法正常返回
Exception table:
from to target type
4 10 13 any
13 16 13 any

编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令。

为了保证在方法异常完成时,monitorenter和monitorexit指令依旧可以正确配对执行,编译器会自动产生一个异常处理器,它可以处理所有异常,目的是为了执行monitorexit指令


JVM规范推荐在满足规范的条件下对具体实现作出修改和优化。

JVM可以将输入JVM代码在加载和执行时翻译成两种不同的指令集

  • 另外一种虚拟机的指令集
  • 宿主机CPU的本地指令集(JIT)

参考

JVM Specifications