Gradle 插件 + ASM 实战 - JVM 虚拟机加载 C

2021-01-08  本文已影响0人  红橙Darren

开篇就提到效能优化涉及的范围会很广,考虑后面需要经常用到 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 虚拟机规范中并没有规定加载的时机,但是却规定了初始化的时机,有以下五种情况需要必须立即对类进行初始化:

2.2 类的加载流程

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

2.2.1 加载
2.2.2 验证
2.2.3 准备
2.2.4 解析
2.2.5 初始化

2.3 双亲委派模型

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

    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 虚拟机执行方法一般有五种指令:

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

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

上一篇下一篇

猜你喜欢

热点阅读