美文网首页程序员手撕 JVM虚拟机
Java代码原来是这么执行的—怒撕字节码指令

Java代码原来是这么执行的—怒撕字节码指令

作者: 源码之路 | 来源:发表于2021-02-07 15:56 被阅读0次

    Java虚拟机的指令是由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零个或多个代表此操作所需参数(称为操作数,Operand)而构成。由于一条虚拟机指令的操作码只用一个字节存储,因此Java虚拟机所能支持的指令最多256条。

    0. 始于Hello World

    使用Java代码编写的Hello World程序如代码

    public class HelloWord {
        public static void main(String[] args) {
            System.out.println("Hello Word");
        }
    }
    
    使用javap命令输出Hello World程序的字节码如下
    
    0 getstatic #2 <java/lang/System.out>
    3 ldc #3 <Hello Word>
    5 invokevirtual #4 <java/io/PrintStream.println>
    8 return
    

    getstatic指令的操作码是0xB2,该指令需要一个操作数,该操作数是常量池中某个CONSTANT_Fieldref_info常量的索引。在本例中,该指令表示获取System的out静态字段,该静态字段的类型为java.io.PrintStream。该指令执行完成后,操作数栈顶存放的就是System的out静态字段的引用



    ldc指令的操作码是0x12,该指令也需要一个操作数,值为常量池中的某个CONSTANT_String_info常量的索引。在本例中,其作用是将常量池中的“HelloWord”字符串的引用放入操作数栈顶。该指令执行完后,操作数栈顶存放的就是字符串“Hello Word”的引用,如图


    invokevirtual指令的操作码是0xB6,该指令也需要一个操作数,值为常量池中某个CONSTANT_Methodref_info常量的索引。在本例中,它的作用是调用PrintStream对象的println方法。
    invokevirtual指令要求将调用目标方法所需要的参数压入栈顶,除静态方法、类初始化方法<clinit>之外,每个类的成员方法以及类的实例初始化方法<init>的第一个参数都是this引用,在java代码中不需要传递,由编译器编译后生成。

    在本例中invokevirtual指令执行之前,操作数栈必须存在一个System.out对象的引用,和println方法所需的参数,并且顺序是严格要求的,正是前面getstatic、ldc两条指令执行的结果。invokevirtual指令执行完成后操作栈的变化如图

    1.读写局部变量表与操作数栈

    读写局部变量表与操作数栈就是将局部变量push进操作数栈与将操作数栈的栈顶元素存储到局部变量表的操作。
    将局部变量表中的元素放入操作数栈只能放入栈顶,而将操作数栈的栈顶元素存到局部变量表是可以指定存到局部变量表的位置的,这个过程其实就是给局部变量赋值。
    与汇编语言有相似之处就是字节码指令不能直接将局部变量表的某个元素赋值给局部变量表的另一个元素,必须通过操作数栈完成。这也是为什么说字节码指令集是基于栈的指令集。
    局部变量表的大小与操作数栈的深度是在Java代码编译成class字节码文件时就已经确定,使用javap -v命令可以查看当前class文件中每个方法的操作数栈深度与局部变量表大小。



    以一个给局部变量赋值的例子理解读写操作数栈与局部变量表,如代码

        public static void main(String[] args) {
            int a = 10, b = 20;
            int c = b;
            b = a;
        }
    

    对应的字节码 (javap -v classname.class)


    局部变量表的大小为4,操作数栈的大小是1。局部变量表的每个Slot分别用于存储main方法中类型为String数组的参数的引用,以及变量a、b、c的值。

    为什么局部变量表的大小为4,操作数栈的大小只是1呢?我们带着这个疑问分析这些字节码指令的执行过程。

    通过javap查看字节码,我们发现,在字节码指令的前面都会标有数字,如代码编译后的字节码所示。这些数字是每条指令在Code属性中code[]数组的索引,也可称为下标或者偏移量。把这些指令的索引连在一起看,发现不是连续的,这是因为有些指令需要操作数,在需要操作数的指令后面会存储该指令执行所需的操作数,所以指令前面的数字不是连续的。

    现在我们分析代码编译后的字节码指令的执行过程。偏移量为0的指令为bipush指令,该指令是将一个立即数10放入操作数栈顶。该指令执行完后,操作数栈与局部变量表的变化如图


    偏移量为2的指令是istore_1,该指令是将当前操作数栈顶的元素存储到局部变量表索引为1的Slot(第二个Slot)。该指令执行完成后,局部变量表索引为1的Slot存储整数10,操作数栈顶的元素已经出栈,此时操作数栈为空。


    Java虚拟机执行字节码指令并不关心局部变量表索引为1的元素在源码中叫什么名字。那我们怎么知道这个位置是局部变量a、b、c的哪个呢?这就需要通过查看LocalVariableTable属性了。使用javap命令输出此例子的LocalVariableTable属性如下。



    第一行:局部变量的作用范围为[0,11),使用局部变量表中的第一个Slot存储,该局部变量的名称为“args”,变量的类型签名为“[Ljava/lang/String”;
    第二行:局部变量的作用范围为[3,11),使用局部变量表中的第二个Slot存储,该局部变量的名称为“a”,类型签名为“I”;
    第三行:局部变量的作用范围为[6,11),使用局部变量表中的第三个Slot存储,该局部变量的名称为“b”,类型签名为“I”;
    第四行:局部变量的作用范围为[8,11),使用局部变量表中的第四个Slot存储,该局部变量的名称为“c”,类型签名为“I”。

    偏移量为3的字节码指令为bipush指令,该指令的作用是将立即数20放入操作数栈顶。该指令执行完成后,局部变量a的值还是10,操作数栈顶存储立即数20。


    偏移量为5的字节码指令为istore_2,该指令不需要操作数,作用是将当前操作数栈的栈顶元素存储到局部变量表索引为2的Slot。该指令执行完成后,a=10,b=20,操作数栈顶的元素出栈,操作数栈为空。


    偏移量为6的字节码指令为aload_2,该指令不需要操作数,作用是将局部变量表索引为2的元素放入操作数栈的栈顶。该指令执行完成后,a=10,b=20,操作数栈的栈顶存储整数20。

    偏移量为7的字节码指令为istore_3,该指令的作用是将当前操作数栈的栈顶元素存储到局部变量表索引为3的Slot。偏移量为6和7的两条指令完成将局部变量b赋值给局部变量c。该指令执行完成后,a=10,b=20,c=20,操作数栈顶元素出栈,操作数栈为空

    偏移量为8和9的两条字节码指令分别为iload_1和istore_2,这两条字节码指令的作用是完成局部变量a赋值给局部变量b的操作,这两条指令执行完成后,局部变量与操作数栈的变化如图


    从整个方法的字节码指令执行过程来看,该方法执行所需要占用操作数栈的Slot最多只有一个,因此该方法的操作数栈的大小被编译器设置为1,不浪费任何空间。而方法参数args和方法体内声明的局部变量a、b、c它们的作用域是整个方法,因此需要为args、a、b、c都分配一个局部变量槽位,局部变量表的大小被编译器设置为4。
    我们通过这个例子了解了局部变量表和操作数栈的读写,其中iload_xx指令就是将局部变量表的元素放入栈顶,istore_xx指令就是将当前操作数栈的栈顶元素存储到局部变量表。xx是局部变量表的索引,局部变量表是一个数组,需要通过索引访问数组中的元素。iload_xx和istore_xx对应的字节码指令如表



    iload_xx和istore_xx只能访问局部变量表索引为0到3的元素,那假如局部变量表的长度超过4呢,没有iload_4指令?是的,没有iload_4指令,只能使用iload和istore指令。

    其实不管访问局部变量表的哪个位置,都可以通过iload和istore指令访问,那为什么还要iload_xx和istore_xx指令呢。因为iload和istore指令需要操作数,而iload_xx和istore_xx不需要操作数,在编译后能减少Code属性的code[]字节数组的大小,而且大多数方法都不会超过3个参数。因为非静态方法的局部变量表的下标0用于保存this引用,所以是4减1个参数。

    例子中的iload_xx指令和istore_xx指令只能操作Java中int类型的变量,与之对应的还有操作float类型的fload_xx和fstore_xx指令,操作long类型的lload_xx和lstore_xx指令,操作double类型的dload_xx和dstore_xx指令,以及操作引用类型的aload_xx和astore_xx指令,还有fload、lload、dload、aload指令。

    例子中还用到了bipush指令。bipush用于将一个int型的立即数放入操作数栈的栈顶,该指令属于操作常量与立即数入栈一类的指令。除bipush之外还有将null放入操作数栈栈顶的iconst_null指令、将常量池中的常量值放入操作数栈顶的指令ldc。还有iconst_xx指令,xx可取值为-1到5,作用是将-1~5的立即数放入操作数栈顶。还有fconst_xx、dconst_xx、lconst_xx,xx代表0或1,这些指令分别是将立即数1、2作为浮点数或者双精度浮点数、长整型放入操作数栈顶,不过这几条指令不常用。

    在使用将立即数放入操作数栈栈顶的这类指令时,如果立即数大于等于-1且小于等于5,可使用对应的iconst_xx指令,如果立即数超过5,只能使用bipush指令。这也是很多人第一次接触字节码指令时很是不理解的,为什么int a=3与int a=10反编译后字节码指令会不同的原因。

    2. 基于对象的操作

    Java语言是一门纯面向对象的编程语言,除访问静态方法和静态变量以及类的初始化方法外,几乎都是基于对象的操作,如调用对象的方法,在方法中访问对象自身的字段。这节内容我们将学习new一个对象在字节码层面的实现、调用对象的set/get方法在字节码的实现、使用this获取字段或为字段赋值的字节码实现、调用父类方法的字节码的实现。

        public static void main(String[] args) {
            UserService service = new UserService();
            User user = service.getUser();
            String name = user.getName();
            System.out.println(name);
        }
    

    main方法中创建了一个UserService对象,并调用UserService对象的getUser方法获取一个User对象,调用User对象的getName方法获取该对象的name字段的值。使用javap命令输出这段代码的字节码如下。



    我们将整个方法的字节码拆分成几个部分来理解。第一部分是创建UserService对象,即“UserService service = new UserService();”,对应的字节码如下:


    1、偏移量为0的字节码指令是new指令,操作码为0xBB,该指令需要一个操作数,该操作数占两个字节,所以下一条指令的下标为3。该操作数的值为常量池中某个CONSTANT_Class_info常量的索引,#2常量表示UserService类。CONSTANT_Class_info结构的name_index存储的是常量池中某个CONSTANT_Utf8_info常量的索引,该CONSTANT_Utf8_info常量存储的就是UserService类的类名。该指令执行会创建一个UserService类型的对象,并且指令的返回值为对象的引用,保存在操作数栈的栈顶。此时操作数栈的变化如图


    2、偏移量为3的字节码指令是dup指令,操作码为0x59,该指令不需要操作数,因此下一条指令的下标为4。该指令的功能是复制当前栈顶一个Slot的数据,复制后的数据存储在操作数栈的栈顶,此时操作数栈的变化如图


    3、偏移量为4的字节码指令是invokespecial,这是一条方法调用指令,操作码为0xB7,该指令需要一个操作数,操作数的值是常量池中某个CONSTANT_Methodref_info常量的索引,#3常量表示UserService的<init>方法。

    CONSTANT_Methodref_info常量的class_index项的值是常量池中某个CONSTANT_Class_info常量的索引,表示一个类,在本例中表示UserService类。CONSTANT_Methodref_info常量的name_and_type_index项的值是常量池中某个CONSTANT_NameAndType_info常量的索引,这个常量表示当前方法的名称和方法的描述符,在本例中表示的名称为<init>,方法描述符为”()V”。所以invokespecial指令是调用UserService类的实例初始化方法<init>。

    由于UserService的实例初始化方法<init>需要隐式传递一个this引用参数,隐式传递指的是Java代码中方法没有声明此参数。因此需要在该指令执行之前,操作数栈顶存放的是一个UserService类型对象的引用,即调用UserService类的实例初始化方法<init>时将this引用传递给<init>方法,该this参数会存储在<init>方法的局部变量表索引为0的Slot。<init>方法没有返回值,因此该指令执行完后操作数栈的变化如图


    4、偏移量为7的指令是astore_1,该指令是将当前栈顶的引用类型的数据存储到局部变量表索引为1的Slot。如果查看LocalVariableTable,局部变量表下标1的位置存储的是局部变量service。该指令执行完成后,操作数栈与局部变量表的变化如图


    由此可知,new一个对象需要四条字节码指令,先创建对象存放在栈顶,然后将栈顶存放的对象复制一份,用于调用类的实例初始化方法,最后还是将new指令创建出来的那份赋值给局部变量或者字段。这也就很好理解并发编程中,为什么单例要使用双重检测,因为new一个对象在字节码层面看并不是一个原子操作。

    现在我们来分析第二部分字节码。第二部分对应的Java源代码是“User user =service.getUser()”,即调用刚刚创建的UserService对象的getUser方法获取一个user对象。第二部分对应的字节码如下。



    1、偏移量为8的字节码指令是aload_1,该指令将局部变量表索引为1的元素放到操作数栈顶,即将第一部分字节码创建出来的UserService对象的引用放入操作数栈顶。该指令执行完成后操作数栈的变化如图

    2、偏移量为9的字节码指令是invokevirtual,操作码为0xB6,该指令也是方法调用指令,需要一个操作数,该操作数必须是常量池中某个CONSTANT_Methodref_info常量的索引,在本例中#4指向的常量表示UserService的getUser方法。调用该方法只需要一个隐式参数,因此需要将一个UserService对象的引用放入操作数栈顶。UserService的getUser方法有一个返回值,返回值的类型为引用类型,即返回一个User实例的引用。该指令执行完成后操作数栈的变化如图


    3、偏移量为12的指令是astore_2指令,该指令是将当前操作数栈顶的元素User对象的引用存储到局部变量表索引为2的Slot。该指令执行完成后,此时的操作数栈又恢复到未使用状态。

    第三部分字节码对应的Java源代码是“String name = user.getName()”,即调用User对象的getName方法。这部分的字节码指令如下。



    1、偏移量为13的字节码指令是aload_2,在例子中,是将局部变量表索引为2的Slot存储的User对象的引用推送至操作数栈的栈顶。
    2、偏移量为14的字节码指令是invokevirtual,操作数#5执向常量池中索引为5的CONSTANT_Methodref_info常量,表示User类的getName方法。User的getName方法返回值类型为String,该指令执行完成后,操作数栈顶的元素是getName方法返回的String对象的引用。
    3、偏移量为17的字节码指令是astore_3,该指令是将上一步调用User对象getName方法的返回值String类型的引用存储到局部变量表索引为3的Slot,也就是给局部变量name赋值。

    分析完字节码各指令的执行过程后,我们发现,get、set方法与普通的成员方法调用并没有什么区别。我们也认识了新的指令new、dup、invokespecial和invokevirtual。new指令用于创建对象,dup指令用于复制当前栈顶元素,invokespecial和invokevirtual指令用于方法的调用。

    读写this的字段

    读写this字段最常见的还是get、set方法,而在web项目中,我们经常会在Service层注入Dao层的对象,调用Service的方法完成业务逻辑,在Service的方法中会调用Dao层的方法,此时获取Dao层对象通过”this.字段名”访问。

    public class UserService  extends BaseService {
    
        private UserDao userDao = new UserDao();
    
        public User findUser(String username) {
            return userDao.getUserByName(username);
        }
    
    }
    
    

    使用javap命令输出UserService类的findUser方法的字节码如下。



    偏移量为0和1这两条字节码指令对应的java代码就是this.userDao。首先使用aload_0将局部变量表索引为0的Slot存储的值放入操作数栈顶,对于非静态方法,局部变量表索引为0的Slot存储的变量就是this引用。接着使用getfield指令获取this的userDao字段,getfield指令要求一个操作数,操作数的值为常量池中某个CONSTANT_Fieldref_info常量的索引,本例中索引为4的常量表示userDao字段,字段的类型描述符为“Lcom/wujiuye/asmbytecode/book/third/dao/UserDao;”。该指令执行完成后返回this.userDao,存储在操作数栈顶。

    我们再来看一个给this.userDao赋值的例子

        private UserDao userDao = new UserDao();
      public void onInit() {
            this.userDao = new UserDao();
        }
    

    偏移量为0的aload_0指令是将this引用放入操作数栈栈顶。此时操作数栈的变化如图



    偏移量为1、4、5三条指令是创建一个UserDao对象,这三条指令执行完成后,操作数栈顶的变化如图

    putfield指令与getfield指令需要的操作数都是一样,偏移量为8的putfield指令是将当前栈顶元素赋值给this.userDao字段。

    实例初始化方法

    实例初始化方法是在创建对象之后调用的,Java代码中使用new关键字创建一个对象,编译成字节码后是通过两条指令来完成的,第一条是new指令,第二条是方法调用指令,即调用类的实例初始化方法<init>。我们还是以UserService为例,看UserService的init方法的字节码



    可以看出,我们在编写Java代码给UserService的userDao字段直接赋值,编译器将赋值操作放到<init>方法了。对应图中code部分偏移量为4到12的字节码指令。

    类的实例初始化方法<init>是由编译器生成的,对象的字段初始化赋值也被编译进该方法中完成,构造方法也是编译进该方法。
    <init>方法中要求必须调用父类的<init>方法。如图所示,编译器生成的<init>方法会先调用父类的<init>方法,这是我们使用ASM框架操作字节码生成<init>方法时需要注意的,否则类加载验证字节码阶段会通不过。
    如果同时存在多个构造方法,比如无参构造方法和带参数构造方法,那么编译器是如何生成<init>方法的呢?我们修改下UserService的代码,添加一个带参构造方法。

        public UserService(UserDao userDao) {
            this.userDao = userDao;
        }
    

    编译器生成的构造方法依然会先调用父类的实例初始化方法,也会将字段的初始化赋值编译进该方法,最后才是将方法传入的参数参数赋值给this对象的字段。那么如果在带参数的构建方法中调用”this()”呢?

       public UserService(UserDao userDao) {
            this();
            this.userDao = userDao;
        }
    

    编译器生成的带参数的实例初始化方法不再调用父类的实例初始化方法,因为该方法会调用本类的无参数实例初始化方法,在本类的无参实例初始化方法中已经调用了父类的无参实例初始化方法。

    调用父类的方法

    在Java代码中,如果在重写的方法中调用父类的方法需要使用super关键字,未被重写的子类可访问的方法则不需要使用super关键字,这其实是由编译器完成了,使用super关键字调用的方法编译后的字节码指令,其操作数指向父类的方法引用,即调用的方法的CONSTANT_Methodref_info常量的class_index指向的CONSTANT_Class_info常量表示的是父类。我们来看一个例子

    public abstract class BaseService {
    
        public void testInvokeSuperMethod() {
            System.out.println("BaseService testInvokeSuperMethod....");
        }
    }
    
    
    public class UserService  extends BaseService {
        @Override
        public void testInvokeSuperMethod() {
            super.testInvokeSuperMethod();
        }
    }
    
    

    UserService继承BaseService并重写了父类的testInvokeSuperMethod方法,在重写的方法中通过super关键字调用父类的方法。使用javap命令输出testInvokeSuperMethod方法的字节码如下。


    从字节码中可以看出,super关键字编译后就不存在了,调用父类的方法依然是使用this引用,aload_0是将局部变量表索引为0的元素放入操作数栈,然后执行invokespecial指令调用方法。而局部变量表索引为0的Slot存储的正是this引用。

    那么虚拟机是怎么区分是调用父类的方法的?答案就在方法描述符,super关键字调用的方法,编译器会将invokespecial指令所需的操作数指向表示父类方法的CONSTANT_Methodref_info常量。本例中,invokespecial指令后面跟的操作数是7,正是表示BaseService类的testInvokeSuperMethod方法。

    3. 访问静态字段与静态方法

    与非静态方法的调用和非静态字段的访问不同,获取静态字段、修改静态字段、调用静态方法不需要一个该类型的对象引用作为隐式参数,且静态方法的局部变量表不会存储this引用。
    静态字段的初始赋值由编译器编译后在类初始化方法<clinit>中生成赋值的字节码指令,而被声明为final的静态字段初始赋值则在类加载的准备阶段赋值。
    读写静态字段的字节码指令是getstatic与putstatic,这两条指令都要求一个操作数,操作数的值为常量池中某个CONSTANT_Fieldref_info常量的索引。getstatic指令的操作码为0xB2,putstatic指令的操作码为0xB3。
    读写静态字段的例子如代码

    public class StaticFieldMain {
        static String name;
    
        public static void main(String[] args) {
            name = "congzhizhi";
            System.out.println(name);
        }
    }
    
    

    偏移量为0和2的字节码指令完成为静态字段name赋值,先使用ldc字节码指令将putstatic指令所需要的参数放入操作数栈顶,putstatic指令将栈顶的元素赋值给类的静态字段。

    调用静态方法的例子如代码

    public class StaticMethodMain {
    
        static void show(String msg){
            System.out.println(msg);
        }
    
        public static void main(String[] args) {
            StaticMethodMain.show("hello word!");
        }
    
    }
    

    例子中,在main方法调用show静态方法,调用show方法需要传递一个参数,在show方法中打印main方法传递的参数。对应的字节码如下。



    main方法中,偏移量为0和2的字节码指令完成调用show方法,ldc指令将调用show方法所需的参数放入操作数栈的栈顶。方法需要多少个参数就将多少个参数放入操作数栈顶,如果传null则使用aconst_null指令,aconst_null指令的操作码为0x01。调用静态方法的指令是invokestatic,指令的操作码为0xB8,该指令需要一个操作数,操作数的值必须是常量池中某个CONSTANT_Methodref_info常量的索引。在show方法中,偏移量为3的aload_0指令获取到的局部变量不再是this,而是方法的第一个参数。

    4. 调用方法的四条指令

    在Java字节码指令集中有四条调用方法的指令,严格来说是五条,在JDK1.7中加入了invokedynamic指令。常用的四条方法调用指令如表


    这四条方法调用指令都需要一个执向常量池中某个CONSTANT_Methodref_info常量的操作数,即告诉jvm,该指令调用的是哪个类的哪个方法。除invokestatic指令外,其余指令都至少需要一个参数,这个参数就是隐式参数this。

    其中invokestatic指令用于调用一个静态方法,只要调用的方法是静态方法就必须要使用这条指令。invokeinterface指令用于调用接口方法,运行时再根据对象的类型找出一个实现该接口方法的适合方法进行调用。invokespecial指令用于调用实例初始化方法、私有方法和父类的子类可访问的方法。invokevirtual指令用于调用对象的实例方法,根据对象的实际类型进行分派。

    5. 不同类型返回值对应的指令

    与读写局部变量表和操作数栈一样,方法返回指令也对应有多条指令,每种基本数据类型对应一条指令,引用类型对应areturn指令,如表


    return指令用于无返回值方法,在java代码中,void方法我们可能不会写return,但编译器会自动加上return指令。以返回值为int、long基本数据类型为例,对应java代码如下。

        public static int getInt(){
            return 1000000000;
        }
    
        public static long getLong(){
            return 1000000000000000000L;
        }
    
    

    验证返回值类型为引用类型时使用的返回指令为areturn,代码如下。

        public static User getObject() {
            return new User();
        }
    

    6. 条件分支语句的实现

    Java语言提供的条件分支语句包含if语句、switch语句、三目运算符,这些条件语句是如何通过字节码实现的呢?

    if语句

    使用Java语言实现的if语句如代码

        public int ifFunc(int type) {
            if (type == 1) {
    
            } else if (type == 2) {
                return 1000;
            } else {
                return 0;
            }
            return 100;
        }
    

    使用javap命令输出ifFunc方法的字节码如下。



    偏移量为0、1、2三条字节码指令完成第一个if语句的判断。iload_1将参数type的值放入操作数栈顶,由于是非静态方法,所示局部变量表索引为0的Slot存储的是this引用,因此局部变量表索引为1的Slot存储的才是方法的第一个参数。iconst_1指令将立即数1放入操作数栈顶。if_icmpne指令完成操作数栈顶两个整数的比较,该指令的操作码为0xA0,指令执行需要一个操作数,操作数是当前方法某条字节码指令的偏移量。当栈顶的两个int类型的元素不相等时,跳转到操作数指向的字节码指令。

    if_icmpne字节码指令是判断两个值不相等才跳转,这与java代码刚好相反。在java代码中,if左右两个元素相等才执行if体内的代码,而编译后字节码指令按if与else if、else的编写顺序生成,当if左右两个元素相等时继续往下执行便是对应java语言中的if语句的代码块,因此字节码层面会看到相反的条件比较跳转。

    偏移量为8、9、10的三条字节码指令也是完成比较跳转的操作,最后一个else从偏移量为17的字节码指令开始,如果else代码块中没有返回指令,那么会继续往下执行。如果第一个if中没有返回指令呢?如代码清单

        public int ifFunc2(int type) {
            if (type == 1) {
                type = 2;
            } else {
                type = 3;
            }
            return type;
        }
    

    如字节码所示,编译器在if_icmpne指令后面为局部变量type赋值后,使用一条goto指令跳转到else结束的后面的第一条字节码指令。

    所以,当if或者else if的代码块中没有return指令时,编译器会为其添加一条goto指令用于跳出if条件分支语句。goto指令是无条件跳转指令,操作码为0xA7,操作数是当前方法的某条字节码指令的偏移量,本例中,goto指令的操作码是12,表示跳转到偏移量为12的字节码指令,偏移量为12的字节码指令是iload_1,所以goto指令之后将会指向该指令。

    if_icmpne指令用于两个int类型值比较,不相等才跳转,更多比较跳转指令如表



    与0比较的跳转指令如表


    switch语句

    使用Java语言实现的switch语句如代码

        public int switchFunc(int stat) {
            int a = 0;
            switch (stat) {
                case 5:
                    a = 0;
                    break;
                case 6:
                case 8:
                    a = 1;
                    break;
            }
            return a;
        }
    

    与if语句一样的是,switch代码块中的每个case代码块都是按顺序编译生成字节码的,switch代码块中的所有字节码都在tableswitch这条指令的后面。

    tableswitch指令的操作码为0xAA,该指令的操作数是不定长的,每个操作数的长度为四个字节,编译器会为case区间(本例中,case最小值为5,最大值为8,区间为[5,8])的每一个数字都生成一个case语句,就是添加一个操作数,操作数存放下一条字节码指令的相对偏移量,注意,是相对偏移量。以上面例子说明,tableswitch指令对应的字节码为:



    第一个字节0xAA是tableswitch指令的操作码,后面每四个字节为一个操作数。前面四个字节0x00000024转为10进制是36,由于tableswitch指令的偏移量为3,因此该操作数表示匹配default时跳转到偏移量为39的字节码指令。紧随其后的是0x00000005与0x00000008,这两个数代表表格的区间,从5到8,也就是case 5到case 8,虽然我们代码中没有case 7,编译器还是为我们生成了。后面的0x0000001d、0x00000022、0x00000024、0x00000022分别+3得到的结果就是case 5到8分别跳转到的目标字节码指令的绝对偏移量。

    从前面的例子我们可以看出,tableswitch指令生成的字节码占用的空间很大,而且当case的值不连续时,还会生成一些无用的映射。如果case的每个值都不连续呢?如代码

        public int switch2Func(int stat) {
            int a = 0;
            switch (stat) {
                case 1:
                    a = 0;
                    break;
                case 100:
                    a = 1;
                    break;
            }
            return a;
        }
    
    

    假设,编译器将代码清单3-43的switch语句生成tableswitch指令,那么这条指令将浪费掉4乘以98的字节空间,如果再加个case 1000,那么浪费的空间更大。显然,这种情况下再使用tableswitch指令是不可取的。

    正如你所看到的,编译器使用lookupswitch指令替代了tableswitch指令。lookupswitch指令的操作码为0xAB,与tableswitch指令一样,该指令的操作数也是不定长的,每个操作数的长度为四个字节,操作数存放的也是下一条字节码指令的相对偏移量,注意,还是相对偏移量。以上面例子说明,lookupswitch指令对应的字节码为。



    第一个字节0xAB是lookupswitch指令的操作码,接着后面四个字节也是匹配default时跳转的目标指令相对当前指令的偏移量,紧随其后四个字节0x00000002代表后面跟随多少个条件映射,每八个字节为一个条件映射,前四个字节为匹配条件,后四个字节为条件匹配时跳转的目标字节码指令的相对偏移量。0x00000001表示当当前操作数栈栈顶的值为1时,跳转到相对偏移量为0x00000019的字节码指令,0x00000019转为10进制是25,加上当前lookupswitch指令的偏移量3等于28;0x00000064转为十进制为100,0x0000001E转为十进制加上3等于33。

    三目运算符

    三目运算符也叫三元运算符,这是由三个操作数组成的运算符。

      public int syFunc(boolean sex) {
            return sex ? 1 : 0;
        }
    

    由于方法参数sex是boolean类型,因此使用sex作为条件表达式编译后会使用ifeq指令实现跳转,即与0比较。当前操作数栈顶元素的值等于0则跳转,不等于0继续往下执行。

    三目运算符的表达式为:<表达式1>?<表达式2>:<表达式3>。因此三目运算符也支持多层嵌套,但实际开发中不建议这么做,因为会导致代码能以理解。

    7.循环语句的实现

    Java语言提供的循环语句包括for、while和do-while,由于do-while不常用,因此本章不做介绍。Java循环语句的底层字节码实现实际上与条件分支语句的实现差不多,都是通过条件跳转指令完成。

    while循环

    我们通过一个简单的while循环例子,了解while循环在字节码层面的实现。

      public void whileDemo() {
            int count = 10;
            while (count > 0) {
                count--;
            }
        }
    

    偏移量为0的字节码指令为bipush,该指令将立即数10放到操作数栈顶,接着使用istore_1指令将操作数栈栈顶的10存储到局部变量表索引为1的Slot,也就是给局部变量count赋值。虽然只有一个局部变量,但因为索引为0的Slot用来存储this引用了,所以局部变量count存储在局部变量表的索引为1的Slot。

    偏移量为3到10的字节码指令实现while循环。iload_1将局部变量count的值放到操作数栈栈顶,接着使用ifle条件跳转指令判断栈顶的元素是否小于等于0,如果小于等于0则跳转到偏移量为13的字节码指令,也就是结束while循环。ifle后面跟的是while循环体中的代码,iinc指令是将局部变量count减1。while循环体结束处会加上一条goto指令,goto指令是无条件跳转指令,本例中用于跳转到偏移量为3的字节码指令,直到ifle指令的条件成立才跳转到return指令结束循环。

    for循环

     public int forDemo() {
            int count = 0;
    
            for(int i = 1; i <= 10; ++i) {
                count += i;
            }
    
            return count;
        }
    

    其中,偏移量为0、1的两条字节码指令实现为局部变量count赋值为0;偏移量为2、3的两条字节码指令实现为局部变量i赋值;偏移量为4、5、7的字节码指令判断局部变量i是否大于10,条件成立则跳转到偏移量为20的字节码指令执行;偏移量为10、11、12、13这四条字节码指令为局部变量count的值加1;偏移量为13的字节码指令给局部变量i的值加1;偏移量为17的字节码指令告诉虚拟机下一条指令的偏移量为4,即跳转到偏移量为4的字节码指令,而偏移量为4开始的连续3条指令就是判断局部变量i是否大于10的,这便是for循环的实现。

    8.异常处理的实现

    在Java代码中,我们可通过try-catch-finally块对异常进行捕获或处理。其中catch块可以有零个或多个,finally块可有可无。如果catch有多个,而第一个catch的异常的类型是后面catch的异常的类型的父类,那么后面的catch块不会起作用。那么我们如何在字节码层面实现try-catch-finally块呢?

    try-catch

    我们来看一个简单的try-catch使用例子

      public int tryCatchDemo() {
            try {
                int n = 100;
                int m = 0;
                return n / m;
            } catch (ArithmeticException e) {
                return -1;
            }
        }
    

    异常表存储在Code属性中,异常表每项元素的结构见第二章。tryCatchDemo方法的异常表只有一项,该项的from、to、target存储的是方法字节码指令的偏移量,从from到to的字节码对应try代码块中的代码,target指向的字节码指令是catch代码块的开始,type是该catch块捕获的异常。也就是说,在执行偏移量为0到7的字节码指令时,如果抛出类型为ArithmeticException的异常,那么虚拟机将执行偏移量为9开始的字节码指令。

    在本例中,如果try代码块中抛出的不是ArithmeticException异常,虚拟机将结束当前方法的执行,将异常往上抛出。如果直到当前线程的第一个方法都没有遇到catch代码块处理这个异常,那么当前线程将会异常结束,线程被虚拟机销毁。

    try-catch-finally

    final语意是如何实现的,为什么finally代码块的代码总能被执行到?我们来看一个例子,如代码

        public int tryCatchFinalDemo() {
            try {
                int n = 100;
                int m = 0;
                return n / m;
            } catch (ArithmeticException e) {
                return -1;
            } finally {
                System.out.println("finally");
            }
        }
    

    先看异常表。异常表的第一项对应tryCatchFinalDemo方法中的catch,当偏移量为0到9(不包括9)的字节码指令在执行过程中抛出异常时,如果异常类型为ArithmeticException则跳转到偏移量为19的字节码指令,也就是执行catch块。但后面的3项又是什么呢?

    对照tryCatchFinalDemo方法编译后的字节码指令看。偏移量为0到9的字节码对应try代码块中的Java代码,而19到22对应catch块中的Java代码,32到42的字节码指令对应finally块中的Java代码。偏移量为32的字节码指令是将异常存储到局部变量表索引为4的Slot,这是因为在执行finally块中的代码之前需要将当前异常保存,以便于在执行完finally块中的代码之后,将异常还原到操作数栈的栈顶。抛出异常的字节码指令为athrow,该指令的操作码为0xBF。

    根据异常表的理解,编译器为实现finally语意,在异常表中多生成了三个异常项,捕获的类型为any,即不管任何类型的受检异常,都会执行到target处的字节码。

    总的理解就是,当try代码块中发生异常时,如果异常类型是ArithmeticException,则跳转到偏移量为19的字节码指令,如果异常类型不是ArithmeticException,则会匹配到异常表的第二项,跳动到偏移量为32的字节码指令,也就是执行finally块的代码。异常表的第三项,如果偏移量为19到22的字节码指令在执行过程中抛出异常,不管任何受检异常都跳转到finally块执行,偏移量为19到22的字节码指令对应catch块的代码。

    从这个例子中可以看出,编译器除了为try代码块或者每个catch代码块都添加一个异常项用于捕获任意受检异常跳转到finally代码块执行之外,还把finally代码块的代码复制到try代码块的尾部,以及catch代码块的尾部。以此确保任何情况下finally代码块中的代码都会被执行。

    try-with-resource语法糖

    在JDK1.7之前,为确保访问的资源被关闭,我们需要为资源的访问代码块添加try-finally确保任何情况下资源都能被关闭,但由于关闭资源的close方法也可能抛出异常,因此也需要在finally代码块中嵌套try-catch代码块,这样写出来的代码显得非常的乱。

    JDK1.7推出了try-with-resource语法糖[1],帮助资源自动释放,不需要在finally块中显示的调用资源的close方法关闭资源,由编译器自动生成。try-with-resource语法糖的使用如代码

        public void tryWithResource() {
            try (InputStream in = new FileInputStream("/tmp/com.congzhizhi.asmbytecode.book.UseAsmModifyClass.class")) {
    
            } catch (Exception e) {
    
            }
        }
    

    从tryWithResource方法编译后的字节码可以看出,编译器为try括号内打开的输入流InputStream,在try块的尾部添加了关闭输入流的相关代码。自动添加的字节码指令实现:判断局部变量in是否为空,如果不为空则调用局部变量in的close方法,并且为调用close方法的字节码指令也添加了try-catch块。

    相关文章

      网友评论

        本文标题:Java代码原来是这么执行的—怒撕字节码指令

        本文链接:https://www.haomeiwen.com/subject/uehztltx.html