上篇文章讲解了类文件结构,本节将会介绍 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 指令集。
指令简介
加载和储存
该类指令用于将数据在帧栈中的局部变量表,和操作数栈之间来回传输:
将局部变量加载到操作栈:
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):
int
到long
、float
、double
long
到float
double
float
到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
- 创建数字:
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 中有专门的指令用来处理 int
和
reference
类型的条件分支比较操作,为了可以无需明显标识一个数值的值是否为
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 类加载机制。