能直接阅读字节码时工作中分析Java代码语义问题的基本技能
概述
- JVM指令的构成
- 一个字节长度
- 0x00~0xFF,0~255
- 代表某种特定操作含义的数字(操作码,Opcode)
- 零到多个代表次操作所需参数(操作数,Operands)。
- 架构:面向操作数栈
不是寄存器- 特点:
- 大多数指令都不包含操作数,只有一个操作码
- 非完全独立(Not Orthogonal)
- 并非每种数据类型和每一种操作都有对应的指令
- JVM处理超过一个字节数据的时候,会在运行时从字节中重建出具体数据的结构
- 原因
- 指令长度为1字节
- Class文件格式放弃了编译后代码的操作数长度对其
- 优点
- 省略填充和建个符号
- 编译代码短
- 缺点
- 解释执行字节码时损失一些性能
- 原因
字节码与数据类型
- 对于大部分与基本数据类型(int、long、short、byte、char、float、double)相关的字节码指令,操作码助记符在首位会以数据类型首字母开头
1 | iload:从局部变量表中加载int型的数据到操作数栈中 |
- a代表reference
- 大部分指令都没有支持整数类型(byte、char和short),没有任何指令支持boolean
- 编译器会将byte和short类型数据带符号扩展(Sign-Extend)为相应的int类型数据
- 将boolean和char类型数据零位扩展(Zero-Extend)为相应的int类型数据
加载和存储指令
- 作用
- 将数据在栈帧只能够的局部变量表和操作数栈之间来回传输
1 | //将一个局部变量加载到操作数栈: |
运算指令
- 作用
- 对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶
- 分类
- 对整型数据进行运算
- 对浮点型数据进行运算
1 | //加法 |
- 两个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 | int -> long、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 | /****************类*******************/ |
操作数栈管理指令
JVM提供一些用于直接操作操作数栈的指令。
1 | //栈顶一个或两个元素出栈 |
控制转移指令
可以让JVM从指定位置(而不是控制转移指令的下一条指令)继续执行程序,从概念模型上可以认为是修改PC寄存器的值。
1 | //条件分支 |
- 对于boolean、byte、char、short类型是通过int类型的比较指令完成
- 对于long、float、double类型,先执行对应类型的指令,运算指令会返回一个整型值到操作数栈中,再执行int类型的条件分支比较操作来完整整个分支跳转。
方法调用和返回指令
1 | /*****************分派逻辑固化在JVM内部*****************/ |
方法调用指令与数据类型无关,方法返回指令是根据返回值的类型区分的。
异常处理指令
Java程序中显示抛出异常(throw
)由athrow
指令完成,而处理异常(catch语句)不是由字节码指令来完成的(取消了jsr
和ret
指令,采用异常表来完成)。
同步指令
JVM支持方法级的同步和方法内部一段指令序列的同步。都是由Monitor
管程支持的。
方法级同步
方法级的同步是隐式的,无需通过字节码指令来控制,实现在方法调用和返回操作之中。JVM可以从方法常量池的方法表结构中的
ACC_SYCHRONIZED
访问标识得知一个方法是否为同步方法。
- 方法调用,检查方法的
ACC_SYCHRONIZED
访问标志是否设置,如果有- 执行线程就要求先成功持有管程(其他任何线程都无法再获得同一管程)
- 执行方法
- 执行完毕
- 没有抛出异常,释放管程
- 抛出异常,并且在方法内部无法处理此异常,则在异常抛到同步方法之外时自动释放管程
指令序列同步
由Java语言中的synchronized
语句块表示,由monitorenter(开始同步)
和monitorexit(退出同步)
两条指令来支持。
正确实现synchronized关键字需要Javac编译器与JVM两者共同协作支持。
1 | class Test{ |
javac Test.java
javap -v Test
1 | void onlyMe(Test$Foo); |
编译器必须确保无论方法通过何种方式完成,方法中调用过的每条monitorenter指令都必须执行其对应的monitorexit指令。
为了保证在方法异常完成时,monitorenter和monitorexit指令依旧可以正确配对执行,编译器会自动产生一个异常处理器,它可以处理所有异常,目的是为了执行monitorexit指令
JVM规范推荐在满足规范的条件下对具体实现作出修改和优化。
JVM可以将输入JVM代码在加载和执行时翻译成两种不同的指令集
- 另外一种虚拟机的指令集
- 宿主机CPU的本地指令集(
JIT
)