与 JVM 做朋友系列 (3) 又见 Class 字节码
当一个人心中有更高的山峰去攀登,就不会在意脚下的泥沼。
前提概要
至此已经更新了三部分了,我们“不忘初心,方得始终”,回归了我们第一章课题的 Class 字节码https://xie.infoq.cn/article/274ce981e1c2b4c95d01efe6d,主要是考虑到如果总是一味的去了解 class 字节码,大家可能会枯燥到蒙圈啊,所以我们用类加载器https://xie.infoq.cn/article/250568d05b4070c93c7aa683f的章节做一下过度,好了,接下来进入我们的正题,把 Class 字节码的学习贯彻到底吧!拥抱我们的“好朋友”->Java 虚拟机
字节码指令
字节码指令由一个字节长度大小并且代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。由于限制了操作码的长度为一个字节(即 0~255),这意味着指令集的操作码总数不可能超过 256 条。
大多数的指令都包含了其操作所对应的数据类型信息。例如:iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。
大部分的指令都没有支持整数类型 byte、char 和 short,甚至没有任何指令支持 boolean 类型。大多数对于 boolean、byte、short 和 char 类型数据的操作,实际上都是使用相应的 int 类型作为运算类型阅读字节码作为了解 Java 虚拟机的基础技能,请熟练掌握。请熟悉并掌握常见指令即可。
加载和存储指令
用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括如下内容。
将一个局部变量加载到操作栈 :
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、dstore_<n>、astore、astore_<n>。
将一个常量加载到操作数栈:
bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
扩充局部变量表的访问索引的指令:
wide
运算或算术指令
用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
加法指令:
iadd、ladd、fadd、dadd。
减法指令:
isub、lsub、fsub、dsub。
乘法指令:
imul、lmul、fmul、dmul 等等
类型转换的指令
可以将两种不同的数值类型进行相互转换,Java 虚拟机直接支持以下数值类型的宽化类型转换(即小范围类型向大范围类型的安全转换):
int 类型到 long、float 或者 double 类型。
long 类型到 float、double 类型。
float 类型到 double 类型。
处理窄化类型转换(Narrowing Numeric Conversions)时,必须显式地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2i、d2l 和 d2f。
创建类实例的指令:
new
创建数组的指令:
newarray、anewarray、multianewarray。
访问字段指令:
getfield、putfield、getstatic、putstatic。
数组存取相关指令
把一个数组元素加载到操作数栈的指令:
baload、caload、saload、iaload、laload、faload、daload、aaload。
将一个操作数栈的值存储到数组元素中的指令:
bastore、castore、sastore、iastore、fastore、dastore、aastore。
取数组长度的指令:
arraylength
检查类实例类型的指令:
instanceof、checkcast。
操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:将操作数栈的栈顶一个或两个元素出栈:pop、pop2。复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2。
将栈最顶端的两个数值互换:swap。
控制转移指令
控制转移指令可以让 Java 虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改 PC 寄存器的值。控制转移指令如下。
条件分支:
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。
方法调用指令
invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是 Java 语言中最常见的方法分派方式。
invokeinterface 指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
invokespecial 指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
invokestatic 指令用于调用类方法(static 方法)。
invokedynamic 指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面 4 条调用指令的分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。方法调用指令与数据类型无关。
方法返回指令
是根据返回值的类型区分的,包括 ireturn(当返回值是 boolean、byte、char、short 和 int 类型时使用)、lreturn、freturn、dreturn 和 areturn,另外还有一条 return 指令供声明为 void 的方法、实例初始化方法以及类和接口的类初始化方法使用。
异常处理指令
在 Java 程序中显式抛出异常的操作(throw 语句)都由 athrow 指令来实现
同步指令
有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义方法调用详解
解析
调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析。
在 Java 语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
静态分派
多见于方法的重载。
“Human”称为变量的静态类型(Static Type),或者叫做的外观类型(ApparentType),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;
实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。代码中定义了两个静态类型相同但实际类型不同的变量,但编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human)作为调用目标。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
动态分派
一般用于子类覆盖 overwrite 的机制功能
静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello()方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同。在实现上,最常用的手段就是为类在方法区中建立一个虚方法表。虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。
基于栈的字节码解释执行引擎
Java 编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与基于寄存器的指令集,最典型的就是 x86 的二地址指令集,说得通俗一些,就是现在我们主流 PC 机中直接支持的指令集架构,这些指令依赖寄存器进行工作。举个最简单的
例子,分别使用这两种指令集计算“1+1”的结果,基于栈的指令集会是这样子的:
iconst_1
iconst_1
iadd
istore_0
两条 iconst_1 指令连续把两个常量 1 压入栈后,iadd 指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后 istore_0 把栈顶的值放到局部变量表的第 0 个 Slot 中。
如果基于寄存器,那程序可能会是这个样子:
mov eax,1add eax,1mov 指令把 EAX 寄存器的值设为 1,然后 add 指令再把这个值加 1,结果就保存在 EAX 寄存器里面。
基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。
基于栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架
构也从侧面印证了这一点。
版权声明: 本文为 InfoQ 作者【李浩宇/Alex】的原创文章。
原文链接:【http://xie.infoq.cn/article/edad70770c45b1f2bdd0ef696】。文章转载请联系作者。
评论