【JVM】字节码指令介绍
一、简介
根据字节码的不同用途,可以大概分为如下几类
加载和存储指令,比如 iload 将一个整形值从局部变量表加载到操作数栈
控制转移指令,比如条件分支 ifeq
对象操作,比如创建类实例的指令 new
方法调用,比如 invokevirtual 指令用于调用对象的实例方法
运算指令和类型转换,比如加法指令 iadd
线程同步,monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义
异常处理,比如 athrow 显式抛出异常
官网指令集地址:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-6.html#jvms-6.5
二、指令介绍
1)控制转移指令
控制转移指令根据条件进行分支跳转,我们常见的 if-then-else、三目表达式、for 循环、异常处理等都属于这个范畴。
对应的指令集包括:
条件分支: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
1.for(item : array)
public class MyLoopTest { public static int[] numbers = new int[]{1, 2, 3}; public static void main(String[] args) { ScoreCalculator calculator = new ScoreCalculator(); for (int number : numbers) { calculator.record(number); } } } 对应的字节码 0 new #2 <c_bytecode_instruct/ScoreCalculator> 3 dup 4 invokespecial #3 <c_bytecode_instruct/ScoreCalculator.<init>> 7 astore_1 8 getstatic #4 <c_bytecode_instruct/MyLoopTest.numbers> 11 astore_2 12 aload_2 13 arraylength 14 istore_3 15 iconst_0 16 istore 4 18 iload 4 20 iload_3 21 if_icmpge 42 (+21) // 如果局部变量表4的值>=局部变量3的值,字节码执行跳转到42的位置 24 aload_2 25 iload 4 27 iaload 28 istore 5 30 aload_1 31 iload 5 33 invokevirtual #5 <c_bytecode_instruct/ScoreCalculator.record> 36 iinc 4 by 1 // 直接对局部变量进行自增操作,对4的位置加1 39 goto 18 (-21) 42 return 0 ~ 7:new、dup,invokespecial 表示创建新类实例并将对象引用存放到局部变量为1的位置 8 ~ 16:是初始化循环控制变量的一个过程。加载静态变量数组引用,存储到局部变量下标为 2 的位置上,记为$array,aload_2 加载$array到栈顶,调用 arraylength 指令 获取数组长度存储到栈顶,随后调用 istore_3 将数组长度存储到局部变量表中第 3 个位置,记为$len。 Java 虚拟机指令集使用不同的字节码来区分不同的操作数类型,比如 iconst_0、istore_1、iinc、if_icmplt 都只针对于 int 数据类型。 18 ~ 33:是真正的循环体。首先加载 $i和 $len到栈顶,然后调用 if_icmpge 进行比较,如果 $i >= $len,直接跳转到指令 43,也就是 return,函数结束。如果$i < $len,执行循环体,加载$array、$i,然后 iaload 指令把下标为 $i 的数组元素加载到操作数栈上,随后存储到局部变量表下标为 5 的位置上,记为$item。随后调用 invokevirtual 指令来执行 record 方法 36 ~ 39:执行循环后的 $i 自增操作。
从字节码的角度来看,上面的执行过程如下:
for (int i = 0; i < numbers.length; i++) {
calculator.record(numbers[i]);
}
由此可见,for(item : array) 就是一个语法糖,javac 会让它现出原形,回归到它的本质
2.switch的底层实现
如果让你来设计一个 switch-case 的底层实现,你会如何来实现?是一个个 if-else 来判断吗?
实际上编译器将使用 tableswitch 和 lookupswitch 两个指令来生成 switch 语句的编译代码。为什么会有两个呢?这充分体现了效率上的考量。
int chooseNear(int i) { switch (i) { case 100: return 0; case 101: return 1; case 104: return 4; default: return -1; } } 字节码: 0 iload_0 1 tableswitch 100 to 104 100: 36 (+35) 101: 38 (+37) 102: 42 (+41) 103: 42 (+41) 104: 40 (+39) default: 42 (+41) 36 iconst_0 37 ireturn 38 iconst_1 39 ireturn 40 iconst_4 41 ireturn 42 iconst_m1 43 ireturn 代码中的 case 中并没有出现 102、103,为什么字节码中出现了呢? 编译器会对 case 的值做分析,如果 case 的值比较紧凑,中间有少量断层或者没有断层,会采用 tableswitch 来实现 switch-case,有断层的会生成一些虚假的 case 帮忙补齐连续,这样可以实现 O(1) 时间复杂度的查找:因为 case 已经被补齐为连续的,通过游标就可以一次找到。 static int chooseFar(int i) { switch (i) { case 1: return 1; case 10: return 10; case 100: return 100; default: return -1; } } 字节码如下: 0 iload_1 1 lookupswitch 3 1: 36 (+35) 10: 38 (+37) 100: 41 (+40) default: 44 (+43) 36 iconst_1 37 ireturn 38 bipush 10 40 ireturn 41 bipush 100 43 ireturn 44 iconst_m1 45 ireturn 如果还是采用上面那种 tableswitch 补齐的方式,就会生成上百个假 case,class 文件也爆炸式增长,这种做法显然不合理。lookupswitch应运而生,它的键值都是经过排序的,在查找上可以采用二分查找的方式,时间复杂度为 O(log n) 结论是:switch-case 语句 在 case 比较稀疏的情况下,编译器会使用 lookupswitch 指令来实现,反之,编译器会使用 tableswitch 来实现
总结:
第一,for(item : array)语法糖实际上会改写为for (int i = 0; i < numbers.length; i++)的形式;
第二,switch-case 语句 在 case 稀疏程度不同的情况下会分别采用 lookupswitch 和 tableswitch 指令来实现。
2)new, <init> & <clinit>
在 Java 中 new 是一个关键字,在字节码中也有一个指令 new。当我们创建一个对象时,背后发生了哪些事情呢?
public static void main(String[] args) { Object o = new Object(); } 对应字节码: 0 new #2 <java/lang/Object> 3 dup 4 invokespecial #1 <java/lang/Object.<init>> 7 astore_1 8 return 一个对象创建的套路是这样的:new、dup、invokespecial; 为什么创建一个对象需要三条指令呢? 首先,我们需要清楚类的构造器函数是以<init>函数名出现的,被称为实例的初始化方法。调用 new 指令时,只是创建了一个类的实例,但是还没有调用构造器函数, 使用 invokespecial 调用了 <init> 后才真正调用了构造器函数,正是因为需要调用这个函数才导致中间必须要有一个 dup 指令,不然调用完<init>函数以后,操作数栈为空,就再也找不回刚刚创建的对象了。
前面我们知道 <init> 会调用构造器函数,<clinit> 是类的静态初始化 比 <init> 调用得更早一些,
<clinit> 不会直接被调用,它在下面这个四个指令触发调用:new, getstatic, putstatic or invokestatic。也就是说,初始化一个类实例、访问一个静态变量或者一个静态方法,类的静态初始化方法就会被触发。
相关问题:
public class A { static { System.out.println("A init"); } public A() { System.out.println("A Instance"); } } public class B extends A { static { System.out.println("B init"); } public B() { System.out.println("B Instance"); } } 问题 1: A a = new B(); 输出结果及正确的顺序? A init B init A Instance B Instance new触发了初始化 问题 2:B[] arr = new B[10] 会输出什么? 对应的指令: bipush 10 anewarray 'B' astore 1 什么也不会输出 问题3:如果把 B 的代码稍微改一下,新增一个静态不可变对象,调用System.out.println(B.HELLOWORD) 会输出什么? public class B extends A { public static final String HELLOWORD = "hello word"; static{ System.out.println("B init"); } public B() { System.out.println("B Instance"); } } public class InitOrderTest { public static void main(String[] args) { System.out.println(B.HELLOWORD); } } 除了hello word,什么也不输出,因为final在编译器就放入了常量池中了; public class TestLocal { private int a; private static int b = 199; static { System.out.println("log from static block"); } public TestLocal() { System.out.println("log from constructor block"); } { System.out.println("log from init block"); } public static void main(String[] args) { TestLocal testLocal = new TestLocal(); } } 如果去看源码的话,整个初始化过程简化如下(省略了若干步): 类加载校验:将类 TestLocal 加载到虚拟机 执行 static 代码块 为对象分配堆内存 对成员变量进行初始化(对象的实例字段在可以不赋初始值就直接使用,而局部变量中如果不赋值就直接使用,因为没有这一步操作,不赋值是属于未定义的状态,编译器会直接报错) 调用初始化代码块 调用构造器函数(可见构造器函数在初始化代码块之后执行) 弄清楚了这个流程,就很容易理解开始提出的问题了,简单来讲就是对象初始化的时候自动帮我们把未赋值的变量赋值为了初始值。
小结:
第一,创建一个对象通常是 new、dup、<init>的 invokespecial 三条指令一起出现;
第二,类的静态初始化<clinit> 会在下面这个四个指令触发调用:new, getstatic, putstatic or invokestatic。
3)方法调用的五个指令
invokestatic:用于调用静态方法
invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法
invokevirtual:用于调用非私有实例方法
invokeinterface:用于调用接口方法
invokedynamic:用于调用动态方法
方法的静态绑定与动态绑定 在编译时时能确定目标方法叫做静态绑定,相反地,需要在运行时根据调用者的类型动态识别的叫动态绑定。 invokestatic 和 invokespecial 这两个指令对应的方法是静态绑定的,invokestatic 调用的是类的静态方法,在编译期间确定,运行期不会修改。剩下的三个都属于动态绑定 1. invokestatic invokestatic 用来调用静态方法,即使用 static 关键字修饰的方法。 它要调用的方法在编译期间确定,运行期不会修改,属于静态绑定。它也是所有方法调用指令里面最快的 2.invokevirtual vs invokespecial invokevirtual:用来调用 public、protected、package 访问级别的方法 invokespecial:顾名思义,它是「特殊」的方法,包括实例构造方法、私有方法(private 修饰的方法)和父类方法(即 super 关键字调用的方法)。很明显,这些「特殊」的方法可以直接确定实际执行的方法的实现,与 invokestatic 一样,也属于静态绑定 invokespecial 用在在类加载时就能确定需要调用的具体方法,而不需要等到运行时去根据实际的对象值去调用该对象的方法。private 方法不会因为继承被覆写的,所以 private 方法归为了 invokespecial 这一类。 invokevirtual 用在方法要根据对象类型不同动态选择的情况,在编译期不确定。 3.invokeinterface vs invokevirtual invokeinterface 用于调用接口方法,在运行时再确定一个实现此接口的对象。 每个类文件都关联着一个「虚方法表」(virtual method table),这个表中包含了父类的方法和自己扩展的方法。 由于接口方法的位置不固定,用 invokevirtual 调用 就不能直接从固定的虚方法表索引位置拿到对应的方法链接。invokeinterface 不得不搜索整个虚方法表来找到对应方法,效率上远不如 invokevirtual; 所以用invokeinterface来调用接口方法 4.动态方法调用 invokedynamic 5.用 HSDB 来探究多态实现的原理 通过vtable实现多态 SDB 全称是:Hotspot Debugger,是内置的 JVM 工具,可以用来深入分析 JVM 运行时的内部状态。HSDB 位于 JDK 安装目录下的 lib/sa-jdi.jar 中, 启动 HSDB sudo java -cp sa-jdi.jar sun.jvm.hotspot.HSDB 在 File 菜单中可以选择 attach 到一个 Hotspot JVM 进程、或者打开一个 core 文件、或者连接到一个远程的 debug server。 Tools 选项中有很多功能可供我们选择,比如查看类列表、查看堆信息、inspect 对象内存、死锁检测等 在 HSDB 的界面中选择File->Attach to Hotspot process,输入进程号,然后选择Tools->Class Browser可以找到对象列表,找到 B 对象的内存指针地址。 B @0x00000007c0060418 然后选择Tools->Inspector输入 B 的上面的内存指针地址: 0x00000007c0060418 可以看到它的 vtable 的长度为 7。可以看到它的 vtable 的长度为 7。先说结论:有 5 个是 上帝类 Object 的 5 个方法,一个是 B 覆写的 sayHello 方法,一个是继承 A 的 printMe 方法 vtable 分配在 instanceKlass 对象实例的内存末尾,instanceKlass大小在 64 位系统的大小为 0x1b8,因此 vtable 的起始地址等于 instanceKlass 的内存首地址加上 0x1B8 等于 0x7C0060DD0 0x00000007c0060c18 + 0x1B8 = 0x7C0060DD0 在 HSDB 的 console 输入 mem 查看实际的内存分布 hsdb> mem 0x7C0060DD0 7 0x00000007c0060dd0: 0x0000000113f06c10 0x00000007c0060dd8: 0x0000000113f066e8 0x00000007c0060de0: 0x0000000113f06840 0x00000007c0060de8: 0x0000000113f06640 0x00000007c0060df0: 0x0000000113f06778 0x00000007c0060df8: 0x0000000114308f20 0x00000007c0060e00: 0x00000001143092a0 可以看到 vtable 的前 5 条一一对应 java.lang.Object 的五个方法,vtable 里存储的是指向方法内存的指针 vtable 是 Java 实现多态的基石,如果一个方法被继承和重写,会把 vtable 中指向父类的方法指针指向子类自己的实现。 Java 子类会继承父类的 vtable。Java 所有的类都会继承 java.lang.Object 类,Object 类有 5 个虚方法可以被继承和重写。当一个类不包含任何方法时,vtable 的长度也最小为 5,表示 Object 类的 5 个虚方法 final 和 static 修饰的方法不会被放到 vtable 方法表里 当子类重写了父类方法,子类 vtable 原本指向父类的方法指针会被替换为子类的方法指针 子类的 vtable 保持了父类的 vtable 的顺序 6.invokedynamic 动态方法调用指令 6.1 开始讲解 invokedynamic 之前需要先介绍一个核心的概念方法句柄(MethodHandle)。 1.MethodHandle 是什么 MethodHandle 又被称为方法句柄或方法指针, 是java.lang.invoke 包中的 一个类,它的出现使得 Java 可以像其它语言一样把函数当做参数进行传递。 MethodHandle 类似于反射中的 Method 类,但它比 Method 类要更加灵活和轻量级。 下面以一个实际的例子来看 MethodHandle 的用法 使用 MethodHandle 的方法的步骤是: 创建 MethodType 对象。MethodType 用来表示方法签名,每个 MethodHandle 都有一个 MethodType 实例,用来指定方法的返回值类型和各个参数类型 调用 MethodHandles.lookup 静态方法返回 MethodHandles.Lookup对象,这个对象是查找的上下文,根据方法的不同类型通过 findStatic、findSpecial、findVirtual 等方法查找方法签名为 MethodType 的方法句柄 拿到方法句柄以后就可以执行了。通过传入目标方法的参数,使用invoke或者invokeExact就可以进行方法的调用 6.2 什么是 invokedynamic invokedynamic是一个用户定义的字节码,您可以决定JVM如何实现它。 invokestatic: System.currentTimeMillis() Math.abs(-100) --- invokevirtual: "hello, world".toUpperCase() --- invokespecial: new ArrayList() --- invokeinterface: myRunnable.run() 这 4 条 invoke* 指令分配规则固化在了虚拟机中,invokedynamic 则把如何查找目标方法的决定权从虚拟机下放到了具体的用户代码中。 invokedynamic 的调用流程如下 JVM 首次执行 invokedynamic 调用时会调用引导方法(Bootstrap Method) 引导方法返回 CallSite 对象,CallSite 内部根据方法签名进行目标方法查找。它的 getTarget 方法返回方法句柄(MethodHandle)对象。 在 CallSite 没有变化的情况下,MethodHandle 可以一直被调用,如果 CallSite 有变化的话重新查找即可。以def add(a, b) { a + b }为例,如果在代码中一开始使用两个 int 参数进行调用,那么极有可能后面很多次调用还会继续使用两个 int,这样就不用每次都重新选择目标方法。 invokedynamic 指令的原理。invokedynamic 其实是一种调用方法的新方式,它用来告诉 JVM 可以延迟确认最终要调用的哪个方法。一开始 invokedynamic 并不知道要调用什么目标方法。第一次调用时引导方法(Bootstrap Method)会被调用,由这个引导方法决定哪个目标方法进行调用。