一、简介
根据字节码的不同用途,可以大概分为如下几类
加载和存储指令,比如 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 代码块 为对象分配堆内存 对成员变量进行初始化(对象的实例字段在可以不赋初始值就直接使用,而局部变量中如果不赋值就直接使用,因为没有这一步操作,不赋值是属于未定义的状态,编译器会直接报错) 调用初始化代码块 调用构造器函数(可见构造器函数在初始化代码块之后执行) 弄清楚了这个流程,就很容易理解开始提出的问题了,简单来讲就是对象初始化的时候自动帮我们把未赋值的变量赋值为了初始值。