上篇文章讲解了类文件结构,本节将会介绍 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
类型的数据。这两条指令的操作,在内部可能是通过同一段代码来实现的,但在类文件中,必须有各自独立的操作码。
大部分数据类型相关的字节码指令,会使用操作码助记符,表明为那种数据类型服务。
| 助记符 | 类型 | 助记符 | 类型 |
|---|---|---|---|
| i | int | c | char |
| l | long | f | float |
| s | short | d | double |
| b | byte | a | reference |
也有一些指令的助记符中不会明确指名类型,比如
arraylength。还有一些指令,例如 goto
是数据类型无关的。
然而,JVM 操作码长度只有 1 字节,这给指令集设计带来了很大压力:如果每种指令都有对应的基本类型,那么指令的数量可能就不够用了。因此,对于特定操作,只有有限的类型。换句话说,指令集并非完全独立的。从下表可以明显看出:
| opcode | byte | short | int | long | float | double | char | reference |
|---|---|---|---|---|---|---|---|---|
| Tipush | bipush | sipush | ||||||
| Tconst | iconst | lcosnt | fcosnt | dconst | aconst | |||
| Tload | iload | lload | fload | dload | aload | |||
| Tstore | istore | lstore | fstore | dstore | astore | |||
| Tinc | iinc | |||||||
| Taload | baload | saload | iaload | laload | faload | daload | cload | aaload |
| Tastore | bastore | sastore | iastore | lastore | fastore | fastore | dastore | aastore |
| Tadd | iadd | ladd | fadd | dadd | ||||
| Tsub | isub | lsub | fsub | dsub | ||||
| Tmul | imul | lmul | fmul | dmul | ||||
| Trem | irem | lrem | frem | drem | ||||
| Tneg | ineg | lneg | fneg | dneg | ||||
| Tshl | ishl | lshl | ||||||
| Tshr | ishr | lshr | ||||||
| Tushr | iushr | lushr | ||||||
| Tand | iand | land | ||||||
| Tor | ior | lor | ||||||
| Txor | ixor | lxor | ||||||
| i2T | i2b | i2s | i2l | i2f | i2d | |||
| l2T | l2i | l2f | l2d | |||||
| f2T | f2i | f2l | f2d | |||||
| d2T | d2i | d2l | d2f | |||||
| Tcmp | lcmp | |||||||
| Tcmpl | fcmpl | dcmpl | ||||||
| Tcmpg | fcmpg | dcmpg | ||||||
| if_TcmpOP | if_icmpOP | if_acmpOP | ||||||
| Treturn | ireturn | lreturn | freturn | dreturn | areturn |
可见大多数指令没有支持 byte short
char,并且甚至任何指令都不支持
boolean。编译器会在编译器或运行期将 byte
short 类型的数据,带符号拓展(Sign-Extend)为相应的
int 类型数据,将 boolean 和 char
零位拓展为相应的 int 类型数据。与之类似,在处理
boolean byte short
char 类型的数组时,也会转换为使用 int
类型作为运算类型(Computational Type)。
本文受篇幅所限无法详解每一条指令,若希望了解更详细的信息,可以参见《JVM 规范》,具体而言是第六章,JVM 指令集。
指令简介
加载和储存
该类指令用于将数据在帧栈中的局部变量表,和操作数栈之间来回传输:
将局部变量加载到操作栈:
iloadiload_<n>lloadlload_<n>floadfload_<n>dloaddload_<n>aloadaload_<n>将数值从操作数栈存到局部变量表:
istoreistore_<n>lstorelstore_<n>fstorefstore_<n>dstoreastoreastore_<n>将常量加载到操作数表:
bipushsipushldcldc_wldc2_waconst_nulliconst_m1iconst_<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 类型的指令替代。
算术指令包括:
- 加法:
iaddladdfadddadd - 减法:
isublsubfsubdsub - 乘法:
imullmulfmuldmul - 除法:
idivldivfdivddiv - 求余:
iremlremfremdrem - 取反:
ineglnegfnegdneg - 位移:
ishlishriushrlshllshrlushr - 按位或:
iorlor - 按位与:
iandland - 按位异或:
ixorlxor - 自增:
iinc - 比较:
dcmpgdcmplfcmpgfcmpllcmp
《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):
int到long、float、doublelong到floatdoublefloat到double
反之,在处理窄化类型转换(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\) 所能表示的最大或最小整数。
double 到 float 的转换和 IEEE 754
定义的窄化转换类似。先通过 IEEE 754 用最接近数模式舍入一个能用
float 表示的数字。若绝对值太小,返回 \(\pm0\);若绝对值太大,将返回 \(\pm\text{Infinity}\)。
对象创建和访问指令
类实例和数组都是对象,但 JVM 对类实例和数组使愤慨处理的。
- 创建类实例:
new - 创建数字:
newarrayanewarraymultianewarray - 访问类字段(或
static字段):getfieldputfieldgetstaticputstatic - 把数组元素加载到操作数栈:
baloadcaloadsaloadialoadlaloadfaloaddaloudaaloud - 将操作数栈存到数组元素中:
bastorecastoresastoreiastorefastoredastoreaastore - 取数组长度:
arraylength - 检查类实例类型:
instanceofcheckcast
操作数栈管理指令
- 元素出栈:
poppop2 - 复制栈顶数值并重新压入栈顶:
dupdup2dup_x1dup2_x1dup_x2dup2_x2 - 互换栈上最顶端的两个数值:
swap
控制转移指令
- 条件分支:
ifeqifltifleifneifgtifgeifnullifnonnullif_icmpeqif_cmpneif_icmpltif_icmpgtif_icmpleif_icmpgeif_acmpeqif_acmpne - 复合条件分支:
tableswitchlookupswitch - 无条件分支:
gotogoto_wjsrjsr_wret
在 JVM 中有专门的指令用来处理 int 和
reference
类型的条件分支比较操作,为了可以无需明显标识一个数值的值是否为
null,也有专门指令来检测 null 值。
和之前类似,对于 boolean byte
char short 条件分支比较都需要用
int 的比较指令完成。
而对于 long float double
则需要先执行相应类型的比较指令(dcmpg dcmpl
fcmpg fcmpl
lcmp),运算指令会返回一个整数值,然后再通过
int 类型的条件分支比较,来完整整个分支跳转。
方法调用和返回指令
invokevirtual:调用对象实例方法,根据对象实际类型分派(虚方法分派)invokeinterface:用于调用接口方法,会在运行时搜索一个实现了该接口方法的对象invokespecial:调用一些需要特殊处理的实例方法,如初始化方法、私有方法、父类方法invokestatic:调用static方法invokedynamic:用于运行时解析出调用点限定符所引用的方法,并执行。该命令的分派逻辑可被用户定义。返回指令:
ireturnlreturnfreturndreturnareturnreturnbooleanbytecharshortint都使用ireturnreturn用于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 类加载机制。
