从 Java 字节码到 ASM 实践

5,383 阅读12分钟

1. 概述

AOP(面向切面编程)的概念现在已经应用的非常广泛了,下面是从百度百科上摘抄的一段解释,比较浅显易懂

在软件业,AOP为Aspect Oriented Programming的缩写,意为:面向切面编程,通过预编译方式和运行期动态代理实现程序功能的统一维护的一种技术。AOP是OOP的延续,是软件开发中的一个热点,也是Spring框架中的一个重要内容,是函数式编程的一种衍生范型。利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。

AOP 是一种编程思想,但是它的实现方式有很多,比如:Spring、AspectJ、JavaAssist、ASM 等。由于我是做 Android 开发的,所以会用 Android 中的一些例子。

  • JakeWhartonhugo 就是一个典型的应用,其利用了自定义 Gradle 插件 + AspectJ 的方式,将有特定注解的方法的参数、返回结果和执行时间打印到 Logcat 中,方便开发调试
  • 由于最近在学习 Java 字节码和 ASM 方面的知识,所以也照猫画虎,写了一个 Koala,实现了和 hugo 同样的功能,将特定注解的方法的参数、返回结果和执行时间打印到 Logcat 中,方便开发调试,不过我使用的是 自定义 Gradle 插件 + ASM 的方式

那 ASM 是什么呢?这儿有一篇介绍 ASM 的文章,写的不错 AOP 的利器:ASM 3.0 介绍,摘抄其中一段:

ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。

简单点说,通过 javac 将 .java 文件编译成 .class 文件,.class 文件中的内容虽然不同,但是它们都具有相同的格式,ASM 通过使用访问者(visitor)模式,按照 .class 文件特有的格式从头到尾扫描一遍 .class 文件中的内容,在扫描的过程中,就可以对 .class 文件做一些操作了,有点黑科技的感觉

二. Java 字节码 & 虚拟机

2.1 Java 字节码

提到 Java 字节码,可能很多人都不是很熟悉,大概都知道使用 javac 可以将 .java 文件编译成 .class 文件,.class 文件中存放的就是该 .java 文件对应的字节码内容,比如如下一段 Demo.java 代码很简单:

package com.lijiankun24.classpractice;

public class Demo {

    private int m;

    public int inc() {
        return m + 1;
    }
}

通过 javac 编译生成对应的 Demo.class 文件,使用纯文本文件打开 Demo.class,其中的内容是以 8 位字节为基础单位的二进制流,表面来看就是由十六进制符号组成的,这一段十六进制符号组成的长串是遵守 Java 虚拟机规范的

cafe babe 0000 0034 0013 0a00 0400 0f09
0003 0010 0700 1107 0012 0100 016d 0100
0149 0100 063c 696e 6974 3e01 0003 2829
5601 0004 436f 6465 0100 0f4c 696e 654e
756d 6265 7254 6162 6c65 0100 0369 6e63
0100 0328 2949 0100 0a53 6f75 7263 6546
696c 6501 0009 4465 6d6f 2e6a 6176 610c
0007 0008 0c00 0500 0601 0004 4465 6d6f
0100 106a 6176 612f 6c61 6e67 2f4f 626a
6563 7400 2100 0300 0400 0000 0100 0200
0500 0600 0000 0200 0100 0700 0800 0100
0900 0000 1d00 0100 0100 0000 052a b700
01b1 0000 0001 000a 0000 0006 0001 0000
0001 0001 000b 000c 0001 0009 0000 001f
0002 0001 0000 0007 2ab4 0002 0460 ac00
0000 0100 0a00 0000 0600 0100 0000 0600
0100 0d00 0000 0200 0e

如果再使用 javap -verbose Demo.class 查看该 Demo.class 中的内容,如下图所示

Demo.png

从上图中,我们可以看到,.class 文件中主要有常量池、字段表、方法表和属性表等内容。如何从以 8 位字节为基础单位的二进制流中分析出常量池、方法表的内容呢?在这篇文章中有详细的介绍 认识 .class 文件的字节码结构 ,这篇文章以一个简单的例子,手把手的分析十六进制符合表示的 .class 文件

2.2 Java 虚拟机类加载机制

上面一小节介绍了 .class 文件的结构,但是 .class 文件是静态的,它最终是会被虚拟机加载才能执行的,那么问题来了,.class 文件是什么时候会被加载呢?

一般来说,一个 .class 文件就包含一个 Java 类,.class 文件和 Java 类是息息相关的。要说 .class 文件的加载时机,就不得不提到 Java 类的生命周期了。想必大家都知道,Java 类的生命周期包含加载验证准备解析初始化使用卸载七个步骤,在 Java 虚拟机规范中并没有规定 Java 类的加载时机,但是却规定了 Java 类 初始化 的时机,而加载又一定是在初始化的前面,所以也可以说是间接地规定了 .class 文件的加载的时机。

有五种情况,是必须初始化一个类的,这五种情况被称为对 Java 类的主动引用,除了 主动引用 之外,其他的对 Java 类的引用称为 被动引用

上面也提到了 Java 类的生命周期总共分为加载验证准备解析初始化使用卸载,其中最重要的是前五个步骤加载验证准备解析初始化,那在这五个步骤中都发生了什么事情呢?

举一个简单的例子,如下所示。下面的 Constant 类中,有一个静态 static 代码块,和一个静态 static 变量, 是什么时候给 value 赋值的呢?什么时候会执行 static 代码块呢?答案是在类的 初始化 阶段。

public class Constant {

    static {
        System.out.println("Constant init!");
    }

    public static String value = "lijiankun24!";
}

在 Java 类中,如果有静态 static 代码块、静态 static 变量的话,编译器会为这个类自动生成一个类构造器(注意,不是实例构造器),在 类构造器 中会执行静态 static 代码块,初始化静态 static 变量,类构造器 就是在类的 初始化 阶段执行的

提到 Java 类的加载,就不得不说起 Java 中的类加载器 ClassLoader 了,双亲委派模型及其好处也是必须要清楚的。

上面只是粗略的介绍,更多想了解五种主动引用、类的生命周期、类构造器、类加载器、双亲委派模型,如果想了解的更详细,请看这篇文章 理解 JVM 中的类加载机制

2.3 Java 虚拟机字节码执行引擎

Java 内存模型中,非常重要的一个区域就是 Java 虚拟机栈。Java 中每一个方法执行的时候都会在 Java 虚拟机栈中压入一个栈帧,方法执行完成之后,也会将该栈帧出栈。 栈帧中最主要的是局部变量表操作数栈这两个概念,在执行一个 Java 方法的字节码时,其实就是调用 Java 字节码指令操纵局部变量表操作数栈,最后将执行的结果返回。如果想学习 Java 字节码指令的话,推荐一篇文章

除了方法的执行过程,还需要了解一下 Java 中的方法调用。方法调用就是指通过 .class 文件中方法的符号引用,确认方法的直接引用的过程,这个过程有可能发生在加载阶段,也有可能发生在运行阶段。 有一些方法是在加载阶段就已经确定了方法的直接引用,比如:静态方法、私有方法、实例构造器方法,这类方法的调用称为 解析;除了解析,方法的 静态分派 也是在加载阶段就确定了方法的直接引用,这类方法常见的就是 重载 的方法。 有一些方法是在运行阶段确认方法的直接引用的,比如:重写 的方法,调用重写 的方法时,需要具体到对象的实际类型,所以需要特定的 Java 字节码 invokevirtual 去确定合适的方法。

Java 虚拟机是基于栈的解释执行的,这里所说的 就是 Java 虚拟机栈,解释执行时相对于编译执行而言的,解释执行就是指:代码通过编译生成字节码指令集之后,通过解释器解释执行的。这个不用了解的太深,明白这几个定义就好

上面介绍了 Java 虚拟机栈中的 栈帧方法调用解析静态分派动态分派 和 Java 虚拟机基于栈的解释执行,详细的内容可以参考 虚拟机字节码执行引擎

三. 访问者模式 & ASM

3.1 访问者模式

ASM 库是一款基于 Java 字节码层面的代码分析和修改工具,那 ASM 和访问者模式有什么关系呢?访问者模式主要用于修改和操作一些数据结构比较稳定的数据,通过前面的学习,我们知道 .class 文件的结构是固定的,主要有常量池、字段表、方法表、属性表等内容,通过使用访问者模式在扫描 .class 文件中各个表的内容时,就可以修改这些内容了。在学习 ASM 之前,可以通过这篇文章学习一下访问者模式访问者模式和 ASM

3.2 ASM 库的介绍和使用

ASM 可以直接生产二进制的 .class 文件,也可以在类被加载入 JVM 之前动态修改类行为。ASM 库的介绍和使用 文章介绍了 ASM 库的结构和几个重要的 Core Api,包括 ClassVisitor、ClassReader、ClassWriter、MethodVisitor 和 AdviceAdapter 等,并且通过两个简单的例子,分别介绍了如何修改 Java 类中方法的字节码和修改属性的字节码。

在刚开始使用的时候,可能对字节码的执行不是很清楚,使用 ASM 会比较困难,ASM 官方也提供了一个帮助工具 ASMifier,我们可以先写出目标代码,然后通过 javac 编译成 .class 文件,然后通过 ASMifier 分析此 .class 文件就可以得到需要插入的代码对应的 ASM 代码了。

上面提到的内容,ASM 库的 Core Api 和 ASMifier 的使用具体请参阅这篇文章ASM 库的介绍和使用

四. Koala

最后,学习完理论知识以后,为了练手,写了一个小项目,使用自定义 Gradle 插件 + ASM 的方式实现了和 JakeWhartonhugo 库同样的功能的库,叫做 Koala,将特定注解的方法的传入参数、返回结果和执行时间打印到 Logcat 中,方便开发调试。

4.1 添加 Koala Gradle Plugin 依赖

在项目工程的 build.gradle 中添加如下代码:

    buildscript {
        repositories {
            maven {
                url "https://plugins.gradle.org/m2/"
            }
        }
        dependencies {
            classpath "gradle.plugin.com.lijiankun24:buildSrc:1.1.1"
        }
    }

在需要使用的 module 中的 build.gradle 中添加如下代码:

    apply plugin: "com.lijiankun24.koala-plugin"

4.2 添加 Koala 依赖

Gradle:

    compile 'com.lijiankun24:koala:1.1.2'

Maven:

    <dependency>
        <groupId>com.lijiankun24</groupId>
        <artifactId>koala</artifactId>
        <version>1.1.2</version>
        <type>pom</type>
    </dependency>

4.3 使用

使用起来还是非常简单的,在 Java 的方法上添加 @KoalaLog 注解,如下所示:

    @KoalaLog
    public String getName(String first, String last) {
        SystemClock.sleep(15); // Don't ever really do this!
        return first + " " + last;
    }

当上述方法被调用的时候,Logcat 中的输出如下所示:

09-04 20:51:38.008 12076-12076/com.lijiankun24.practicedemo I/0KoalaLog: ┌───────────────────────────────────------───────────────────────────────────------
09-04 20:51:38.008 12076-12076/com.lijiankun24.practicedemo I/1KoalaLog: │ The class's name: com.lijiankun24.practicedemo.MainActivity
09-04 20:51:38.008 12076-12076/com.lijiankun24.practicedemo I/2KoalaLog: │ The method's name: getName(java.lang.String, java.lang.String)
09-04 20:51:38.008 12076-12076/com.lijiankun24.practicedemo I/3KoalaLog: │ The arguments: [li, jiankun]
09-04 20:51:38.008 12076-12076/com.lijiankun24.practicedemo I/4KoalaLog: │ The result: li jiankun
09-04 20:51:38.008 12076-12076/com.lijiankun24.practicedemo I/5KoalaLog: │ The cost time: 15ms
09-04 20:51:38.008 12076-12076/com.lijiankun24.practicedemo I/6KoalaLog: └───────────────────────────────────------───────────────────────────────────------

4.4 混淆规则

 -keep class com.lijiankun24.koala.** { *; }

欢迎 star 和 fork Koala,也欢迎点赞和收藏