Back
Featured image of post JVM 学习笔记 11 - JVM Bytecode

JVM 学习笔记 11 - JVM Bytecode

导航页

上篇文章讲解了类文件结构,本节将会介绍 JVM 字节码。

JVM 字节码的指令都是单字节长度的操作码(Opcode),后随 0 到多个所需的操作数(Operand)。由于 JVM 采用面向操作数栈,而非面向寄存器的架构。所以大多数指令不包含操作数,只有操作码,指令参数都放在操作数栈中。

字节码指令集比较精简,因为总数不能超过 256 条;又由于类文件放弃了编译后代码的操作数长度对齐,就意味着 VM 在处理超过单字节数据时,需要运行时重建,比如 uint16 会被拆分为 byte1 byte2 并通过 (byte1 << 8) | byte2 重建。

这种操作无疑会降低性能;但优势也很明显,放弃了操作数长度对齐,就可以省掉大量的 padding。用一个字节表示操作码,也是为了尽可能获得短小精干的编译代码。这种设计,是由 Java 语言设计之出就面向网络、智能家电的背景所决定的。

若不考虑异常处理,可以有以下执行模型:

do {
  PC寄存器值++
  根据PC寄存器位置, 取出操作码
  if (字节码存在操作数) 从字节码流中取出
  执行操作码所定义的操作
} while (字节码流长度 > 0)

数据类型

在 JVM 中,大多数指令包含其操作对应的数据类型信息。比如,iload 就是将 int 型的数据从局部变量表中,加载到操作数栈;而 fload 就加载 float 类型的数据。这两条指令的操作,在内部可能是通过同一段代码来实现的,但在类文件中,必须有各自独立的操作码。

大部分数据类型相关的字节码指令,会使用操作码助记符,表明为那种数据类型服务。

助记符类型助记符类型
iintcchar
llongffloat
sshortddouble
bbyteareference

也有一些指令的助记符中不会明确指名类型,比如 arraylength。还有一些指令,例如 goto 是数据类型无关的。

然而,JVM 操作码长度只有 1 字节,这给指令集设计带来了很大压力:如果每种指令都有对应的基本类型,那么指令的数量可能就不够用了。因此,对于特定操作,只有有限的类型。换句话说,指令集并非完全独立的。从下表可以明显看出:

opcodebyteshortintlongfloatdoublecharreference
Tipushbipushsipush
Tconsticonstlcosntfcosntdconstaconst
Tloadiloadlloadfloaddloadaload
Tstoreistorelstorefstoredstoreastore
Tinciinc
Taloadbaloadsaloadialoadlaloadfaloaddaloadcloadaaload
Tastorebastoresastoreiastorelastorefastorefastoredastoreaastore
Taddiaddladdfadddadd
Tsubisublsubfsubdsub
Tmulimullmulfmuldmul
Tremiremlremfremdrem
Tnegineglnegfnegdneg
Tshlishllshl
Tshrishrlshr
Tushriushrlushr
Tandiandland
Toriorlor
Txorixorlxor
i2Ti2bi2si2li2fi2d
l2Tl2il2fl2d
f2Tf2if2lf2d
d2Td2id2ld2f
Tcmplcmp
Tcmplfcmpldcmpl
Tcmpgfcmpgdcmpg
if_TcmpOPif_icmpOPif_acmpOP
Treturnireturnlreturnfreturndreturnareturn

可见大多数指令没有支持 byte short char,并且甚至任何指令都不支持 boolean。编译器会在编译器或运行期将 byte short 类型的数据,带符号拓展(Sign-Extend)为相应的 int 类型数据,将 booleanchar 零位拓展为相应的 int 类型数据。与之类似,在处理 boolean byte short char 类型的数组时,也会转换为使用 int 类型作为运算类型(Computational Type)。

本文受篇幅所限无法详解每一条指令,若希望了解更详细的信息,可以参见《JVM 规范》,具体而言是第六章,JVM 指令集

指令简介

加载和储存

该类指令用于将数据在帧栈中的局部变量表,和操作数栈之间来回传输:

  • 将局部变量加载到操作栈:iload iload_<n> lload lload_<n> fload fload_<n> dload dload_<n> aload aload_<n>

  • 将数值从操作数栈存到局部变量表:istore istore_<n> lstore lstore_<n> fstore fstore_<n> dstore astore astore_<n>

  • 将常量加载到操作数表:bipush sipush ldc ldc_w ldc2_w aconst_null iconst_m1 iconst_<i> lconst_<l> fconst_<f> dconst_<d>

  • 扩充局部变量表访问索引:wide

上面所列的指令助记符中,部分指令以尖括号结尾(比如 iload_<n>),实际上代表了一组指令(比如 iload_<0> iload_<1> iload_<2> iload_<3>)。这几组指令都是带有一个操作数的通用指令的特殊形式,它们不需要取操作数的操作,因为操作数就在指令中。除了这点之外,它们的语义完全一致。

运算指令

运算指令用于对操作数栈上的两个值进行某种运算,并把结构重新存入操作栈顶。可以分为两种:1. 整数型 2. 浮点型。这两种算术指令在溢出和被 0 除时有不同的表现。无论哪种指令,均使用 JVM 的算术类型运算,也就是说,不存在直接支持 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

《JVM 规范》并未定义整数或浮点运算时溢出的行为。

在处理整数时,若除法和求余的除数为 0,会抛出 ArithmeticException

在处理浮点时,JVM 会严格遵循 IEEE 754 中所规定的行为和限制,也就是说 JVM 完全支持非正规浮点数值(Denormalized Floating-Pointer Number)和逐级下溢(Gradual Underflow)的运算规则。比如,浮点运算必须舍入到合适的精度,非精确的结果必须舍入为可被表示的、最接近的精确值;若有两种可表示形式,且差值一样,应有限选择最低有效位为 0 的。这种舍入模式是 IEEE 754 规范中的默认舍入模式,也称为最接近数舍入模式。而在浮点数转整数时,会使用向 0 舍入模式,即直接丢弃原有的小数位。

另外,JVM 在处理浮点数时不会抛出任何运行时异常,当操作溢出时,会使用有符号的无穷大来表示(\(\pm\text{Infinity}\));若某个符号无明确的数学定义,会使用 \(\text{NaN}\) (Not a Number)来表示。所有对 \(\text{NaN}\) 的操作结果都是 \(\text{NaN}\)

long 类型的比较,JVM 使用带符号比较,而对浮点数使用无信号比较(Nonsignaling Comparion)。

类型转换指令

JVM 直接支持(无需使用命令转换)宽化类型转换(Widening Numeric Conversion):

  • intlongfloatdouble
  • longfloat double
  • floatdouble

反之,在处理窄化类型转换(Narrowing Numeric Conversion)时,则需要显式指定转换指令,这些指令包括:i2b i2c i2s l2i f2i f2l d2i d2l d2f。可能会导致一些异常行为。

比如,整数从高向低转换时:\(T\),原数据类型; \(S\),转换后的数据类型;\(n\)\(S\) 的长度。转换过程就是直接丢弃高位 \(n\) 字节,这可能导致 \(T\)\(S\) 的符号不一致。

同理,当 \(T\) 为浮点型,\(S\) 为整数型时。需要遵循以下转换规则:

  • \(\text{NaN}\) 应该转换成 0
  • 若浮点数不是 \(\pm \text{Infinity}\) 的,则使用向零舍入模式取整,获得整数值 \(v\)。若 \(v\)\(S\) 的表示范围内,结果为 \(v\);否则,根据 \(v\) 的符号,转换为 \(S\) 所能表示的最大或最小整数。

doublefloat 的转换和 IEEE 754 定义的窄化转换类似。先通过 IEEE 754 用最接近数模式舍入一个能用 float 表示的数字。若绝对值太小,返回 \(\pm0\);若绝对值太大,将返回 \(\pm\text{Infinity}\)

对象创建和访问指令

类实例和数组都是对象,但 JVM 对类实例和数组使愤慨处理的。

  • 创建类实例:new
  • 创建数字:newarray anewarray multianewarray
  • 访问类字段(或 static 字段):getfield putfield getstatic putstatic
  • 把数组元素加载到操作数栈:baload caload saload iaload laload faload daloud aaloud
  • 将操作数栈存到数组元素中:bastore castore sastore iastore fastore dastore aastore
  • 取数组长度:arraylength
  • 检查类实例类型:instanceof checkcast

操作数栈管理指令

  • 元素出栈:pop pop2
  • 复制栈顶数值并重新压入栈顶:dup dup2 dup_x1 dup2_x1 dup_x2 dup2_x2
  • 互换栈上最顶端的两个数值:swap

控制转移指令

  • 条件分支:ifeq iflt ifle ifne ifgt ifge ifnull ifnonnull if_icmpeq if_cmpne if_icmplt if_icmpgt if_icmple if_icmpge if_acmpeq if_acmpne
  • 复合条件分支:tableswitch lookupswitch
  • 无条件分支:goto goto_w jsr jsr_w ret

在 JVM 中有专门的指令用来处理 intreference 类型的条件分支比较操作,为了可以无需明显标识一个数值的值是否为 null,也有专门指令来检测 null 值。

和之前类似,对于 boolean byte char short 条件分支比较都需要用 int 的比较指令完成。

而对于 long float double 则需要先执行相应类型的比较指令(dcmpg dcmpl fcmpg fcmpl lcmp),运算指令会返回一个整数值,然后再通过 int 类型的条件分支比较,来完整整个分支跳转。

方法调用和返回指令

  • invokevirtual:调用对象实例方法,根据对象实际类型分派(虚方法分派)

  • invokeinterface:用于调用接口方法,会在运行时搜索一个实现了该接口方法的对象

  • invokespecial:调用一些需要特殊处理的实例方法,如初始化方法、私有方法、父类方法

  • invokestatic:调用 static 方法

  • invokedynamic:用于运行时解析出调用点限定符所引用的方法,并执行。该命令的分派逻辑可被用户定义。

  • 返回指令:ireturn lreturn freturn dreturn areturn return

    • boolean byte char short int 都使用 ireturn
    • return 用于 void 方法

异常处理指令

  • 抛出:athrow
    • 部分指令可直接抛出异常,无需 athrow 比如 idiv 中抛出的 ArithmeticException
  • 处理异常不由字节码指令实现,而是使用异常表

同步指令

JVM 支持方法级的同步,和方法内部一段指令序列的同步,都是使用锁(Monitor)实现的。

方法级的同步是隐式的,无需字节码指令,实现在调用和返回操作中。通过 ACC_SYNCHRONIZED 可知一个方法是否为同步的,当方法调用时,会尝试持有。当完成时,则释放。

同步一段指令集,会使用 synchronized 语句块来表示,JVM 中有 monitorenter monitorexit 来支持该语义。

例如:

void onlyMe(Foo f) { 
  synchronized(f) {
    doSomething(); 
  }
}
Method void onlyMe(Foo) 
0 aload_1              # 将 f 入栈
1 dup                  # 复制栈顶元素(f的引用)
2 astore_2             # 将栈顶元素存储到局部变量表变量槽 2 中
3 monitorenter         # 以栈顶元素 f 为锁,开始同步
4 aload_0              # 将局部变量槽 0(this 指针)的元素入栈
5 invokevirtual #5     # 调用 doSomething() 虚方法
8 aload_2              # 将局部变量 slot 2 的元素(f)入栈
9 monitorexit          # 退出同步
10 goto 18             # 正常结束,跳转到 18 返回
13 astore_3            # 异常路径
14 aload_2             # 将 slot 2 的元素入栈
15 monitorexit         # 退出同步
16 aload_3             # 将 slot 3 入栈(即异常对象)
17 athrow              # 抛出
18 return              # 返回
Exception table: 
From  To Target Type
  4   10   13   any 
  13  16   13   any

以上就是对字节码的简介了。下一节将会介绍 JVM 类加载机制

comments powered by Disqus