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

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

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 命令所生成的那样,若是你们只是阅读文章的话,建议你们本身要一点一点去尝试解析下,固然直播上我会带你们一块儿来看。markdown

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 初始化
  • 类构造器 () 是由编译器自动收集类中出现的类变量、静态代码块中的语句合并产生的,收集的顺序是在源文件中出现的顺序决定的,静态代码块能够访问出如今静态代码块以前的类变量,出现的静态代码块以后的类变量,只能够赋值,可是不能访问。
  • () 类构造器和()实例构造器不一样,类构造器不须要显示的父类的类构造,在子类的类构造器调用以前,会自动的调用父类的类构造器。所以虚拟机中第一个被调用的 () 方法是 java.lang.Object 的类构造器
  • 因为父类的类构造器优先于子类的类构造器执行,因此父类中的 static{} 代码块也优先于子类的 static{} 执行
  • 类构造器() 对于类来讲并非必需的,若是一个类中没有类变量,也没有 static{},那这个类不会有类构造器 ()
  • 接口中不能有 static{},可是接口中也能够有类变量,因此接口中也能够有类构造器 {},可是接口的类构造器和类的类构造器有所不一样,接口在调用类构造器的时候,若是不须要,不用调用父接口的类构造器,除非用到了父接口中的类变量,接口的实现类在初始化的时候也不会调用接口的类构造器
  • 虚拟机会保证一个类的 () 方法在多线程环境中被正确地加锁、同步,若是多个线程同时去初始化一个类,那么只有一个线程去执行这个类的类构造器 (),其余线程会被阻塞,直到活动线程执行完类构造器 () 方法

2.3 双亲委派模型

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

  • 复用独立安装App的源码:插件App的源码本来就是能够正常安装运行的。
  • 零反射无 Hack 实现插件技术:从理论上就已经肯定无需对任何系统作兼容开发,更无任何隐藏 API 调用,和 Google 限制非公开 SDK 接口访问的策略彻底不冲突。
  • 全动态插件框架:一次性实现完美的插件框架很难,但 Shadow 将这些实现所有动态化起来,使插件框架的代码成为了插件的一部分。插件的迭代再也不受宿主打包了旧版本插件框架所限制。
  • 宿主增量极小:得益于全动态实现,真正合入宿主程序的代码量极小(15KB,160方法数左右)。

Kotlin 实现:core.loader,core.transform 核心代码彻底用 Kotlin 实现,代码简洁易维护。app

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 放到局部变量表中。咱们须要清楚的知道每运行一个指令,当前栈和局部变量表中的数据是怎样变化的。

本文基本都是文字原理,你们要有耐心,若是可以理解实际上是很是简单的东西。这自己是三四次课的内容,我把其压缩到了一两次课来说。考虑到你们的水平不一,不少同窗可能会感受没有讲到位,所以你们能够去找些额外文章用来辅助理解,可是大的方向确定是这个方向。

视频地址:pan.baidu.com/s/1ozvNawIJ…

视频密码:q9kj 

相关文章
相关标签/搜索