Gradle 插件 + ASM 实战 - JVM 虚拟机加载 Class 原理

开篇就提到效能优化涉及的范围会很广,考虑后面需要经常用到 asm 字节码插桩,我们首先从 《Gradle 插件 + ASM 实战》开始讲,但又希望大家能知其然也知其所以然,因此我们首先得讲下 JVM 虚拟机加载 Class 字节码的原理。这往往也是我面试新同学必问的一个内容,因为如果对这个不了解的话,像插件化与热修复、性能优化、覆盖率统计等等很多功能都是不好实现的。小公司很少有人用,这也是实话,至于大家要不要学,这就看个人情况了,其实也不是用不用得上的问题,就看大家愿不愿意做一个吃螃蟹的人。我们主要从以下三个方面来说:

1. class 文件字节码结构

1.1 class 字节码示例

我们先来看一个非常简单的 HelloWorld.java

public class HelloWorld {
    public HelloWorld() {
    }

    public static void main(String[] args) {
        System.out.println("Hello World!");
    }
}

用文本编辑器打开生成的 HelloWorld.class 文件,是这样的:

cafe babe 0000 0033 0022 0a00 0600 1409
0015 0016 0800 170a 0018 0019 0700 1a07
001b 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 124c 6f63
616c 5661 7269 6162 6c65 5461 626c 6501
0004 7468 6973 0100 264c 636f 6d2f 6578
616d 706c 652f 6d79 6170 706c 6963 6174
696f 6e2f 4865 6c6c 6f57 6f72 6c64 3b01
0004 6d61 696e 0100 1628 5b4c 6a61 7661
2f6c 616e 672f 5374 7269 6e67 3b29 5601
0004 6172 6773 0100 135b 4c6a 6176 612f
6c61 6e67 2f53 7472 696e 673b 0100 0a53
6f75 7263 6546 696c 6501 000f 4865 6c6c
6f57 6f72 6c64 2e6a 6176 610c 0007 0008
0700 1c0c 001d 001e 0100 0c48 656c 6c6f
2057 6f72 6c64 2107 001f 0c00 2000 2101
0024 636f 6d2f 6578 616d 706c 652f 6d79
6170 706c 6963 6174 696f 6e2f 4865 6c6c
6f57 6f72 6c64 0100 106a 6176 612f 6c61
6e67 2f4f 626a 6563 7401 0010 6a61 7661
2f6c 616e 672f 5379 7374 656d 0100 036f
7574 0100 154c 6a61 7661 2f69 6f2f 5072
696e 7453 7472 6561 6d3b 0100 136a 6176
612f 696f 2f50 7269 6e74 5374 7265 616d
0100 0770 7269 6e74 6c6e 0100 1528 4c6a
6176 612f 6c61 6e67 2f53 7472 696e 673b
2956 0021 0005 0006 0000 0000 0002 0001
0007 0008 0001 0009 0000 002f 0001 0001
0000 0005 2ab7 0001 b100 0000 0200 0a00
0000 0600 0100 0000 0a00 0b00 0000 0c00
0100 0000 0500 0c00 0d00 0000 0900 0e00
0f00 0100 0900 0000 3700 0200 0100 0000
09b2 0002 1203 b600 04b1 0000 0002 000a
0000 000a 0002 0000 000c 0008 000d 000b
0000 000c 0001 0000 0009 0010 0011 0000
0001 0012 0000 0002 0013 

好家伙,这怎么能够看得懂?但是既然 java 虚拟机能够看懂,我们也可以想办法看懂,用 javap -verbose HelloWorld.class 看起来就稍微简单一点:

Last modified 2021-1-7; size 586 bytes
  MD5 checksum bf91e508b76a0dc7d4c0250b0e55f75b
  Compiled from "HelloWorld.java"
public class com.example.myapplication.HelloWorld
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // Hello World!
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // com/example/myapplication/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcom/example/myapplication/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               Hello World!
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               com/example/myapplication/HelloWorld
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
{
  public com.example.myapplication.HelloWorld();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 10: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/example/myapplication/HelloWorld;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String Hello World!
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 12: 0
        line 13: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}

1.2 类文件结构

.class 文件是一组以 8 位字节为基础单位的二进制流,各数据项目严格按照顺序紧凑地排列在 .class 文件中,中间没有添加任何分隔符,这使得整个 .class 文件中存储的内容几乎全都是程序需要的数据,没有空隙存在。至于具体有哪些内容,这里有一张表大家可以参考。

虚拟机加载 .class 文件,就是按照上面这样的规则去解析,最终解析的结果大致就是 javap -verbose 命令所生成的那样,如果大家只是阅读文章的话,建议大家自己要一点一点去尝试解析下,当然直播上我会带大家一起来看。

2. jvm 类的加载机制

2.1 类的加载时机

在 JVM 虚拟机规范中并没有规定加载的时机,但是却规定了初始化的时机,有以下五种情况需要必须立即对类进行初始化:

  • 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令最常见的 Java 代码场景是:使用 new 关键字实例化对象、读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入到常量池的静态字段除外)以及调用一个类的静态方法的时候
  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候
  • 当初始化一个类的时候,如果发现其父类还没有被初始化过,则需要先触发其父类的初始化
  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的类),虚拟机会先初始化这个主类
  • 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic、REF_putStatic、REF_invodeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

2.2 类的加载流程

类的加载过程大致分为 5 个步骤:加载、验证、准备、解析和初始化,作为过来人早期我犯过很严重的错误,那就是为了面试习惯背,这样过段时间发现很容易忘记,而且开发中遇到类似的问题往往不知所措,因此希望大家能好好的理解理解,这样才能做到一劳永逸:

2.2.1 加载
  • 通过一个类的全限定名获取定义此类的二进制字节流
  • 将二进制字节流所代表的静态存储结构转换为方法区中的运行时数据结构
  • 在内存中生成一个代表此类的 java.lang.Class 的对象,作为方法区中这个类的访问入口
  • jvm 虚拟机并没有规定从哪里获取二进制字节流。我们可以从 .class 静态存储文件中获取,也可以从 apk、zip、jar 等包中读取,可以从数据库中读取,也可以从网络中获取,甚至我们自己可以在运行时自动生成。
  • 在内存中实例化一个代表此类的 java.lang.Class 对象之后,并没有规定此 Class 对象是方法 Java 堆中的,有些虚拟机就会将 Class 对象放到方法区中,比如 HotSpot,一个 ClassLoader 只会实例化一个 Class 对象。
2.2.2 验证
  • 文件格式验证:主要验证二进制字节流数据是否符合 .class 文件的规范,并且该 .class 文件是否在本虚拟机的处理范围之内(版本号验证)。只有通过了文件格式的验证之后,二进制的字节流才会进入到内存中的方法区进行存储。而且只有通过了文件格式验证之后,才会进行后面三个验证,后面三个验证都是基于方法区中的存储结构进行的
  • 元数据验证:主要是对类的元数据信息进行语义检查,保证不存在不符合 Java 语义规范的元数据信息
  • 字节码验证:字节码验证是整个验证中最复杂的一个过程,在元数据验证中,验证了元数据信息中的数据类型做完校验后,字节码验证主要对类的方法体进行校验分析,保证被校验的类的方法不会做出危害虚拟机的行为
  • 符号引用验证:符号引用验证发生在连接的第三个阶段解析阶段中,主要是保证解析过程可以正确地执行。符号引用验证是类本身引用的其他类的验证,包括:通过一个类的全限定名是否可以找到对应的类,访问的其他类中的字段和方法是否存在,并且访问性是否合适等
2.2.3 准备
  • 在方法区中分配内存的只有类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会跟随着对象在 Java 堆中为其分配内存
  • 初始化类变量的时候,是将类变量初始化为其类型对应的 0 值,比如有如下类变量,在准备阶段完成之后,val 的值是 0 而不是设置,为 val 复制为具体值,是在初始化阶段
  • 对于常量,其对应的值会在编译阶段就存储在字段表的 ConstantValue 属性当中,所以在准备阶段结束之后,常量的值就是 ConstantValue 所指定的值了。
2.2.4 解析
  • 虚拟机规范中并未规定解析阶段发生的具体时间,只规定了在执行newarray、new、putfidle、putstatic、getfield、getstatic 等 16 个指令之前,对它们所使用的符号引用进行解析。所以虚拟机可以在类被加载器加载之后就进行解析,也可以在执行这几个指令之前才进行解析
  • 对同一个符号引用进行多次解析是很常见的事,除 invokedynamic 指令以外,虚拟机实现可以对第一次解析的结果进行缓存,以后解析相同的符号引用时,只要取缓存的结果就可以了
  • 解析动作主要对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用进行解析
2.2.5 初始化
  • 类构造器 <clinit>() 是由编译器自动收集类中出现的类变量、静态代码块中的语句合并产生的,收集的顺序是在源文件中出现的顺序决定的,静态代码块可以访问出现在静态代码块之前的类变量,出现的静态代码块之后的类变量,只可以赋值,但是不能访问。
  • <clinit>() 类构造器和<init>()实例构造器不同,类构造器不需要显示的父类的类构造,在子类的类构造器调用之前,会自动的调用父类的类构造器。因此虚拟机中第一个被调用的 <clinit>() 方法是 java.lang.Object 的类构造器
  • 由于父类的类构造器优先于子类的类构造器执行,所以父类中的 static{} 代码块也优先于子类的 static{} 执行
  • 类构造器<clinit>() 对于类来说并不是必需的,如果一个类中没有类变量,也没有 static{},那这个类不会有类构造器 <clinit>()
  • 接口中不能有 static{},但是接口中也可以有类变量,所以接口中也可以有类构造器 <clinit>{},但是接口的类构造器和类的类构造器有所不同,接口在调用类构造器的时候,如果不需要,不用调用父接口的类构造器,除非用到了父接口中的类变量,接口的实现类在初始化的时候也不会调用接口的类构造器
  • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只有一个线程去执行这个类的类构造器 <clinit>(),其他线程会被阻塞,直到活动线程执行完类构造器 <clinit>() 方法

2.3 双亲委派模型

双亲委派模型,我们看一下 ClassLoader 的源码就能明白了,我们公司的 Shadow 就是利用这个点来做插件类加载的,来公司后我自主学习看的第一个源码就是 Shadow ,顺便打个广告 Shadow 是一个腾讯自主研发的 Android 插件框架,经过线上亿级用户量检验。 Shadow 不仅开源分享了插件技术的关键代码,还完整的分享了上线部署所需要的所有设计。与市面上其他插件框架相比,Shadow 主要具有以下特点:

  • 复用独立安装App的源码:插件App的源码原本就是可以正常安装运行的。
  • 零反射无 Hack 实现插件技术:从理论上就已经确定无需对任何系统做兼容开发,更无任何隐藏 API 调用,和 Google 限制非公开 SDK 接口访问的策略完全不冲突。
  • 全动态插件框架:一次性实现完美的插件框架很难,但 Shadow 将这些实现全部动态化起来,使插件框架的代码成为了插件的一部分。插件的迭代不再受宿主打包了旧版本插件框架所限制。
  • 宿主增量极小:得益于全动态实现,真正合入宿主程序的代码量极小(15KB,160方法数左右)。
    Kotlin 实现:core.loader,core.transform 核心代码完全用 Kotlin 实现,代码简洁易维护。
    protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {
        // 是否已经被加载了
        Class<?> clazz = findLoadedClass(className);

        if (clazz == null) {
            ClassNotFoundException suppressed = null;
            try {
                // 先从 parent 中加载
                clazz = parent.loadClass(className, false);
            } catch (ClassNotFoundException e) {
                suppressed = e;
            }

            if (clazz == null) {
                try {
                    // 最后再从 this 加载
                    clazz = findClass(className);
                } catch (ClassNotFoundException e) {
                    e.addSuppressed(suppressed);
                    throw e;
                }
            }
        }

        return clazz;
    }

3. jvm 虚拟机执行引擎

了解了 .class 里面有啥,了解了 .class 怎么被解析加载,最后自然得了解下字节码命令是怎么执行的。在这之前我们先得了解两个概念,什么是栈帧?什么是分派?

3.1 栈帧

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method),执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

3.2 分派

分派调用有可能是静态的,也有可能是动态的,我们如果理解了这个,就会知道 Java 中的多态性是怎么实现的,像“重载”和“重写”等。Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符。前面两个就不做过多的解释了,至于方法描述符,它是由方法的参数类型以及返回类型所构成。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错。

可以看到,Java 虚拟机与 Java 语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此 Java 虚拟机能够准确地识别目标方法。

静态分派指的是在解析时便能够直接识别目标方法的情况,而动态分派则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。Java 虚拟机中其实是不存在重载概念的,因为在编译期间我们就能确定需要执行那个方法,如果非得区分那就是:重载被称为静态绑定或者编译时多态;而重写则被称为动态绑定。确切地说,Java 虚拟机中的静态分派指的是在解析时便能够直接识别目标方法的情况,而动态分派则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。Java 虚拟机执行方法一般有五种指令:

  • invokestatic:用于调用静态方法。
  • invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
  • invokevirtual:用于调用非私有实例方法。
  • invokeinterface:用于调用接口方法。
  • invokedynamic:用于调用动态方法。

3.3 实例

有了这两个概念后,我们就需要来看一个具体的实例了:

public class HelloWorld {
    public static void main(String[] args){
        int num1 = 100;
        int num2 = 200;
        int sum = sum(num1, num2);
        System.out.println("sum = "+sum);
    }

    private static final int sum(int num1, int num2){
        return num1 + num2;
    }
}

javap -verbose HelloWorld.class:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=4, args_size=1
         0: bipush        100
         2: istore_1
         3: sipush        200
         6: istore_2
         7: iload_1
         8: iload_2
         9: invokestatic  #2                  // Method sum:(II)I
        12: istore_3
        13: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: new           #4                  // class java/lang/StringBuilder
        19: dup
        20: invokespecial #5                  // Method java/lang/StringBuilder."<init>":()V
        23: ldc           #6                  // String sum =
        25: invokevirtual #7                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
        28: iload_3
        29: invokevirtual #8                  // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
        32: invokevirtual #9                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
        35: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        38: return
      LineNumberTable:
        line 12: 0
        line 13: 3
        line 14: 7
        line 15: 13
        line 16: 38
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      39     0  args   [Ljava/lang/String;
            3      36     1  num1   I
            7      32     2  num2   I
           13      26     3   sum   I

这个理解是比较重要的,虽然我们在后面讲 asm 的时候会有傻瓜式操作,但是能不能理解怎么写为什么要那么写,就靠我们对着每一条指令集的理解了。我们需要知道每个指令代表的是什么意思,比如 bipush 100 代表把数字 100 压入栈中,istore_1 代表把刚压入栈的 100 放到局部变量表中。我们需要清楚的知道每运行一个指令,当前栈和局部变量表中的数据是怎样变化的。

本文基本都是文字原理,大家要有耐心,如果能够理解其实是非常简单的东西。这本身是三四次课的内容,我把其压缩到了一两次课来讲。考虑到大家的水平不一,很多同学可能会感觉没有讲到位,因此大家可以去找些额外文章用来辅助理解,但是大的方向肯定是这个方向。

推荐阅读更多精彩内容