從 Java 位元組碼到 ASM 實踐

李劍昆發表於2018-11-26

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,也歡迎點贊和收藏

相關文章