你不知道的Java:類加載與字節碼

Java類加載機制總結

本部分整理自《深入理解JVM虛擬機》

類的生命週期與加載時機

  1. 類的生命週期

    一個類從被加載到虛擬機內存中開始,到被卸載出內存爲止,整個生命週期包括了 加載、驗證、準備、解析、初始化、使用和卸載7個階段。其中 驗證、準備、解析 3部分統稱爲鏈接,如下圖:

     

     

    整個順序並不是完全固定的,其中解析階段可以在初始化之後再開始,這樣便可以實現Java的運行時綁定(動態綁定)機制。

  2. 類的加載時機

    JVM虛擬機規範並沒有對類的加載時機做出嚴格的要求,只規定了以下五種情況需要立刻觸發類的初始化:

    • 遇到new,getstatic,putstatic和invokestatic這四個字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。

    • 使用反射機制對類進行調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。

    • 當初始化一個類時,如果其父類還沒有進行過初始化,則需要先觸發其父類的初始化。

    • 虛擬機啓動時,用戶需要指定一個要執行的主類(包含main方法),此時會先初始化這個類

    • 使用JDK1.7的動態語言支持時,如果一個MethodHandle實例最後的解析結果包含REF_getStatic,REF_putStatic,REF_invokeStatic的方法句柄,且這個方法句柄對應的類沒有初始化,則需要先對其進行初始化。

    其餘條件下,可以由JVM虛擬機自行決定何時去加載一個類。

  3. 主動引用和被動引用

    上面五種條件也被稱爲對類的主動引用,除此之外其他引用類的方式都不會觸發初始化,即類的被動引用,舉個例子:

    public class Father {
    	static {
    		System.out.println("father init.");
    	}
    	public static int val = 123;
    }
    
    public class Son extends Father {
    	static {
    		System.out.println("son init.");
    	}
    }

    當我們訪問Son.val時,會發現並沒有輸出son init.

    對於靜態字段,只有直接定義這個字段的類纔會被初始化,因此通過子類來引用父類的靜態字段,子類相當於是被動引用,也就不會被初始化了。

類的加載過程

下面簡單的介紹一下整個加載過程中,每個階段JVM都執行了什麼操作:

加載(Loading)

加載過程是Java的一大特點,類的來源可以多種多樣,壓縮包、網絡字節流、運行時動態計算生成(reflect)等等...這也造就了Java語言強大的動態特性。

  1. 通過一個類的完整限定名來獲取定義此類的二進制字節流(注意,字節流的來源非常靈活)
  2. 將這個字節流所代表的靜態儲存結構轉換成爲方法區的運行時數據結構
  3. 在內存中生成一個代表這個類的java.lang.Class對象,作爲方法區這個類的各種數據的訪問入口

驗證(Verification)

這一過程主要是爲了確保Class的字節流中包含的信息符合虛擬機標準,以免造成破壞

  1. 文件格式驗證
  2. 元數據驗證
  3. 字節碼驗證,通過數據流和控制流分析確定程序的語義是合法的
  4. 符號引用驗證,確保解析動作能夠正常執行

準備(Preparation)

這一階段將會爲類變量分配內存並設置其初始值,注意此時進行內存分配的僅包括類變量(static修飾),並且初始值通常情況下是數據類型的零值而不是設定值,如下例

public static int val = 123;

在這一階段變量val的賦值是0而不是123,因爲此時尚未執行任何Java方法,而對val複製的putstatic指令在初始化階段後纔會執行。

當然也有特殊情況,如下

public static final int val = 123;

加上final關鍵字修飾後,Java編譯時會爲val生成ConstantValue屬性,這時準備階段就會根據設置將其值設置爲123。

解析(Resolution)

此階段虛擬機將常量池內的符號替換爲直接引用,主要包含以下動作:

  1. 類或接口的解析
  2. 字段解析
  3. 類方法解析
  4. 接口方法解析

初始化(Initialization)

這時類加載過程的最後一步,這部分開始真正的執行Java代碼,也就是說,這個階段可以由程序員參與。

此階段其實就是執行類構造器<clinit>()方法的過程。

類加載器(Class Loder)

類加載器(Class Loader)是Java虛擬機的一大創舉,它將“獲取類的二進制字節流”這個過程交給了開發人員自己去實現,只要編寫不同的Class Loader,應用程序本身就可以用相應的方式來獲取自己需要的類。

類與加載器的關係

對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在虛擬機中的唯一性。

通俗的講,就是即便同一個Class文件,被不同的類加載器加載之後,得到也不是同一個“類”(equals方法返回false)。

雙親委派模型

從虛擬機角度講,只有兩種類加載器,一種是啓動類加載器(Bootstrap ClassLoader),在hotpot上使用C++實現,屬於虛擬機的一部分;另一種則是所有其他類的加載器,這些加載器是獨立於虛擬機的,由Java語言實現的,從開發者角度看,可以分爲以下兩類:

  1. 擴展類加載器(Extension ClassLoader)

  2. 應用程序類加載器(Appliaction ClassLoader)

當然開發人員也可以自己編寫類加載器,最終不同的類加載器之間的層次關係如下圖所示:

 

 

 

這就是Java中著名的雙親委派模型,它要求除了頂級的BootStrap加載器之外,其他類加載器都必須有父類加載器,工作流程如下:

如果一個類加載器收到了類加載的請求,他首先不會自己去嘗試加載這個類,而是將這個請求委派給父類加載器去完成,只有當父加載器反饋自己無法完成加載請求時,子加載器纔會自己去嘗試加載這個類。

這樣做的好處是,Java類隨着它的類加載器一起具備了一種帶有優先級的層次關係,這主要是爲了防止同名的類出現混亂。舉個例子,比如java.lang.Object這個類,無論哪個類加載器加載時,最終都會委派給Bootstrap加載器去加載,這就保證了整個系統運行過程中的Object都是同一個類。

否則,如果用戶自己編寫了一個java.lang.Object類,並放在程序的classpath中,最終系統將會出現多個不同的Object類,整個Java體系就變得一團混亂了。

順便一提,在Java中,一個類是由字節碼和類加載器所唯一確認的,同樣的class文件,如果被不同的Class Loader解析,就會在JVM中生成不同的Class對象。

Java字節碼

讓我們回顧一下Java類的生成過程

Demo.java(源碼)-- javac(編譯器)--> Demo.class(字節碼)-- ClassLoader --> JVM Class對象

可以看到,Java和其他編譯型語言最不同的一點就是,在編譯後它並不是生成機器碼或彙編,而是生成了一種名爲字節碼的中間產物,通過Classloader引入JVM後,再真正的去執行。這便是Java語言“一次編譯,到處運行”的祕密,無論是通過什麼編譯器生成的字節碼,只要內部不變,在任何平臺的JVM上執行,都可以得到同樣的效果。

接下來第一步,我們要揭開字節碼的神祕面紗

字節碼格式

現在有這麼一個demo的java類(Demo.java

public class Demo {
  
  private String name;
  
  private int age;
  
  public Demo() {}
  
  public Demo(String name, int age) {
    this.name = name;
    this.age = age;
  }
  
  public void setName(String name) {this.name = name;}
  
  public String getName() {return this.name;}
  
  public void setAge(int age) {this.age = age;}
  
  public int getAge() {return this.age;}
  
  public String echo() {
    return "I'm " + this.name + " , I'm " + this.age + " years old"; 
  }
}

通過執行編譯命令javac Demo.java我們可以得到字節碼文件Demo.class,這是一個二進制文件,直接通過文本編輯器打開會有一堆亂碼,我們使用vim命令%!xxd可以將其轉爲16進制顯示(右側會附加顯示轉換爲ASCII字符的結果):

 

image-20200427220400411

 

 

看到這一大串的機器語言確實讓人感到頭痛,但並不意味着它不可讀,現在我們需要一本詞典來幫助翻譯它,那就是字節碼的格式說明:

 

image-20200427220849888

 

 

有了它我們就可以嘗試去翻譯字節碼了,現在把自己想象成JVM程序,一個字節一個字節的去解析這串二進制流:

讀取4個字節:魔數(Magic Number)

用於標記這個文件是一個Java的字節碼文件,這樣做的好處是,即便文件的後綴被改了(不是.class),程序通過讀這個魔數依然能夠知道它是個字節碼文件(常見的jpg等文件也都有類似的原理),字節碼文件的魔數用十六進制表示就是CAFE BABE,你沒有想起象徵着Java的那杯咖啡?

讀取4個字節:版本信息(Version)

分別是兩個字節的minor_version和兩個字節的major_version,通過它我們可以知道這個類是在什麼版本的JDK上編譯出來的。

讀取2 + n個字節:常量池(Constant Pool)

這裏的前兩個字節聲明常量池中有多少個常量,注意該值比實際的常量數多一個,比如這裏的數值是0032,轉換爲十進制是50,那麼其實有49個常量,編號分別是1 - 50,0號位被預留了出來,不允許被使用。

那麼什麼是常量池?可以認爲它是Java類的一個“資源庫”,這裏會將類中的一些字面量和符號引用儲存下來。字面量就是類裏定義的各種各樣的字符串,包括使用final修飾的常量字符串、在代碼中直接聲明的字符串(比如Demo中的"I'm "等;而符號引用包含了類的全侷限定名、字段名、方法名、描述符等使用Java語法定義的元素。

爲了能夠看懂常量池裏的常量,我們還得搬出一張表(注:JDK 1.7之後已經不只11種了):

 

image-20200427223200702

 

 

類型U1表示佔用1個字節,U2並表示佔用2個字節

有了它我們才能看懂常量池裏的16進制代碼,可以看到每個常量都有一個tag用於標記其類型,根據類型和數據長度定義,我們能夠翻譯得出下面這張常量表:

#1  0a 00 0d 00 22
#2  09 00 0c 00 23 
#3  09 00 0c 00 24
#4  07 00 25 
#5  0a 00 04 00 22
#6  08 00 26 
#7  0a 00 04 00 27
#8  08 00 28 
#9  0a 00 04 00 29
#10 08 00 2a 
#11 0a 00 04 00 2b 
#12 07 00 2c 
#13 07 00 2d
#14 01 00 04 6e 61 6d 65
#15 01 00 12 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 
#16 01 00 03 61 67 65
#17 01 00 01 49
#18 01 00 06 3c 69 6e 69 74 3e 
#19 01 00 03 28 29 56 
#20 01 00 04 43 6f 64 65
#21 01 00 0f 4c 69 6e 65 4e 75 6d 62 65 72 54 61 62 6c 65
#22 01 00 16 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 49 29 56 
#23 01 00 07 73 65 74 4e 61 6d 65
#24 01 00 15 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56 
#25 01 00 07 67 65 74 4e 61 6d 65 
#26 01 00 14 28 29 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 
#27 01 00 06 73 65 74 41 67 65
#28 01 00 04 28 49 29 56
#29 01 00 06 67 65 74 41 67 65
#30 01 00 03 28 29 49
#31 01 00 04 65 63 68 6f
#32 01 00 0a 53 6f 75 72 63 65 46 69 6c 65 
#33 01 00 09 44 65 6d 6f 2e 6a 61 76 61 
#34 0c 00 12 00 13 
#34 0c 00 0e 00 0f 
#35 0c 00 10 00 11
#36 01 00 17 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 42 75 69 6c 64 65 72
#37 01 00 04 49 27 6d 20 0c 00 2e 00 2f
#38 01 00 07 20 2c 20 49 27 6d 20 
#39 0c 00 2e 00 30 
#40 01 00 0a 20 79 65 61 72 73 20 6f 6c 64
#41 0c 00 31 00 1a 
#42 01 00 04 44 65 6d 6f
#43 01 00 10 6a 61 76 61 2f 6c 61 6e 67 2f 4f 62 6a 65 63 74 
#44 01 00 06 61 70 70 65 6e 64
#45 01 00 2d 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 42 75 69 6c 64 65 72 3b
...(以下省略)

現在讓我們試着讀取第一個常量:

0a 00 0d 00 22

這是Demo中的第一個常量,tag爲0aCONSTANT_Methodref_info,說明它是一個方法引用說明,00 0d是指向聲明方法的類描述符的索引項,00 22是指向名稱及類型描述符的索引項。你可能在思考索引項又是個什麼東東,其實它就是常量的編號,我們目前讀取的是第一個常量,其索引項(編號)就是1。

這裏我們通過索引項“順藤摸瓜”,來還原這個常量所表達的含義。000d轉換爲十進制爲13,表示聲明該方法的類的基本信息儲存在索引項爲13的常量中,0022轉換爲十進制爲34,表示該方法的名稱和類型信息儲存在索引項爲34的常量中。

繼續看下去,先看編號爲13的常量內容是

07 00 2d

tag07,說明是一個類的定義,這個類的全名被記錄在編號爲2d(#45)的常量裏,那麼繼續看編號爲45的常量內容:

01 00 2d 28 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 42 75 69 6c 64 65 72 3b

這個常量看起來很長,編號01說明它是一個字符串,00 2d表明其長度(字節數),後面2d個字節是用utf-8編碼的字符串內容,翻譯過來就是java/lang/Object,讀到這裏一條路到了終點,還需要回頭看編號爲34的常量內容

0c 00 12 00 13 

tag0c,說明是一個NameAndType類型的變量,00 12(#18)表示指向該字段or方法名稱的常量索引,00 13(#19)表示指向該字段or方法描述符的索引,所以我們需要進一步看編號爲18和19的常量:

編號18(翻譯爲字符串是<init>

01 00 06 3c 69 6e 69 74 3e 

編號19(翻譯爲字符串是()V

01 00 03 28 29 56 

到這裏,第一個常量所表達的含義就完全清楚了,它記錄的是一個方法,這個方法被java/lang/Object這個方法所定義,方法名是<init>,方法的入參和返回結果是()V

這裏有些字面量可能看起來有點費解,用我們更熟悉的語言再表述一遍就是:由java.lang.Object類定義的入參爲空,返回值爲void的方法名爲<init>的方法,沒錯,就是一個類的無參構造函數

到這裏深吸一口氣,如果按照這個方式把所有常量都讀一遍,那豈不是要累死個人!這時候就要祭出Java官方提供的命令工具javap了,在對應字節碼文件的目錄下運行javap -verbose <類名>就可以以“可視化”的方式閱讀整個字節碼文件了,讓我們運行javap -verbose Demo看看:

 

image-20200502224200390

 

 

可以看到整個字節碼中的常量池都被打印出來,並且以非常清晰的方式展示了其實際含義,除了常量池之外,字節碼中的其他內容也會被以這種“可視化”的方式打印出來,我們繼續向下看。

讀取2個字節:訪問控制標記(Access Flag)

在所有常量信息都讀取完畢後,我們繼續往下讀取2個字節:

00 21
複製代碼

這兩個字節是當前類的‘訪問控制標記“,簡單來說,就是類的各種修飾符,是public還是private,是否是abstract,是否是interface,是否有繼承等等,具體含義還需要搬出來一張表:

 

image-20200502224824829

 

 

到這裏你可能發現了,0021並不在表中,這裏就牽扯到位運算這個黑科技了,爲了把所有的說明修飾符壓縮到2個字節的大小,這些值在二進制上都是精心處理過的,我們實際讀到的值,是所有修飾符的或運算的結果,即:

0x0021 = 0x0001 | 0x0020
複製代碼

如果你把這些具體的值翻譯回二進制,就會發現它們每一位都是不相同的,這就保證了將任意的Flag組合,或運算得到的最終結果都是唯一的。在機器實際運行過程中,只要使用與運算就可以反推出哪些Flag被聲明瞭,仍然以這個爲例:

0x0020 & 0x0021 = 1
0x0001 & 0x0021 = 1
複製代碼

因此可以得知0x0020(ACC_SUPER)和0x0001(ACC_PUBLIC)這兩個Flag被聲明瞭,說明當前的類是一個public的公開類,至於ACC_SUPER的含義,和類的動態加載機制有關,有興趣的話可以自行了解,在這裏不做過多贅述了。

讀取2個字節:當前類名(This Class Name)

00 0c
複製代碼

指向第12號常量,12號常量又指向44號常量,讀取內容爲Demo

讀取2個字節:父類名(Super Class Name)

00 0d
複製代碼

指向第13號常量,13號常量又指向45號常量,讀取內容爲java/lang/Object

讀取2+n個字節:接口(Interfaces)

00 00
複製代碼

前兩個字節表示該類實現了多少個接口,後續的若干個字節是接口表,由於這裏類並沒有實現任何接口,所以我們讀到的數字是0

讀取2+n個字節:字段(Fields)

前兩個字節聲明類中聲明瞭幾個字段,這裏是00 02,即2個(name, age)接下來的部分是字段表,主要是描述這些字段的具體含義,格式如下:

 

image-20200502231631670

 

 

  • access_flags:訪問控制符,public,private,static,final等,仍然參考上面的Flag表,用的也是位運算機制
  • name_index:名稱索引,指向常量池,可以讀取到實際聲明的屬性名
  • descriptor_index:類型描述符,表示其類型,這裏也用的簡寫機制,比如int記爲I,String[]記爲Ljava/lang/String
  • attributes_count:屬性數量
  • attributes_info: 屬性表

這裏又引出一個概念是“屬性表”,它是爲了描述一些專有信息的表,可以有很多種,比如代碼行LineNumberTable,Code等,這裏考慮到篇幅,不再一一展開了。

讀取2+n個字節:方法(Methods)

還是前兩個字節表示方法的數量,後面緊跟着方法表,上一下方法表的結構圖:

 

image-20200502231907824

 

 

可以看到和字段表的定義是一模一樣的,在方法區這裏特別提一個屬性表,那就是Code,Code屬性表就是用來儲存實際要執行的代碼的,這裏我們使用可視化的工具來看一下:

 

image-20200502233609081

 

 

上面是getAge()方法的定義,可以看到有兩個屬性表,分別是Code和LineNumberTable,Code中的代碼是虛擬機的字節碼指令,就好像彙編語言裏的指令一樣,這裏也不展開細講了

最後2+n個字節:屬性(Attributes)

該項存放了在該文件中類或接口所定義屬性的基本信息,具體不仔細展開了。

到這裏,整個字節碼的結構就讀完了,你也許會開始懷疑作爲一個面向高級語言編程的程序員,爲什麼要去了解這些枯燥無味的底層細節,那麼接下來我們就看看字節碼的實際應用。

黑科技:字節碼增強

字節碼增強技術是一類對現有字節碼進行修改或者動態生成全新字節碼文件的技術,說的具象一點,就是通過某種手段改寫類原本的字節碼,來增強類原本的功能,我們熟知的Spring AOP就是利用字節碼增強來給類動態添加各種功能的。

 

image-20200503144353502

 

 

實現字節碼增強的手段或者說工具有很多種,這裏我們用最基本,也是最直接的方式,ASM爲例來研究如何實現字節碼增強。

ASM

這裏的ASM並不是指彙編,而是Java中一個可以手動操作字節碼的類庫,使用ASM可以直接產生.class字節碼文件,也可以在類被加載進JVM前修改其行爲,它的應用非常廣泛,Spring的AOP編程、熱部署、CGLIB等都是通過ASM來實現的。當然,ASM涉及到非常底層的操作,因此實際編碼會有一定的難度。

ASM的核心是ClassReaderClassWriter,其中ClassReader可以讀取字節碼,訪問和修改其中的信息,ClassWriter則用於重新構建編譯後的類,如修改類名、屬性以及方法,也可以生成新的類的字節碼文件。

ASM的API通過訪問者模式來提供,如果你不瞭解什麼是訪問者模式,可以回想一下熟悉的xml文件,在xml文件中有非常多類型的<tag></tag>標籤,並且以樹狀結構存儲。想要讀取某個指定類型的標籤,需要先限定具體的層級,然後去遞歸查找。訪問者模式便是對此過程的一個抽象封裝,實際編程時我們只需要聲明一個特定標籤的Vistor,將其註冊進指定的環境,當文件讀取到指定位置的時候,自然會調用註冊的Vistor,從而執行編碼的操作。

光說還是太抽象,接下來我們通過幾個Demo來進一步說明。需要說明的一點是,我們要對虛擬機的字節碼指令直接操作,所以需要提前瞭解下JVM字節碼指令相關的知識。

注:asm相關操作使用的maven包座標爲org.ow2.asm:asm-all

Demo 1: 實現一個簡單的AOP

使用字節碼技術增強一個類,讓每個方法體執行開始和結束時都打印一行日誌(AOP)

Demo目標類

package com.asm.mytest;

public class Demo {

    public void echoHi() {
        System.out.println("hi");
    }

    public void echoHey() {
        System.out.println("hey");
    }
}

ClassVistor

package com.asm.mytest;

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public class MyClassVisitor extends ClassVisitor implements Opcodes {

    public MyClassVisitor(ClassVisitor cv) {
        super(ASM5, cv);
    }
    @Override
    public void visit(int version, int access, String name, String signature,
                      String superName, String[] interfaces) {
        cv.visit(version, access, name, signature, superName, interfaces);
    }
    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
                exceptions);
        // 跳過構造方法
        if (!name.equals("<init>") && mv != null) {
            mv = new MyMethodVisitor(mv);
        }
        return mv;
    }

    // 針對方法增強
    class MyMethodVisitor extends MethodVisitor implements Opcodes {

        public MyMethodVisitor(MethodVisitor mv) {
            super(Opcodes.ASM5, mv);
        }

        @Override
        // 當編譯器開始訪問一個方法的Code區時,就會調用這個方法,相當於在方法的一開始執行
        public void visitCode() {
            super.visitCode();
            // 引入靜態變量java.lang.System.out
            mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
 						// 對應字節碼指令 ldc "start",將字符串 "start" 壓棧
            mv.visitLdcInsn("start");
            // 調用println方法
            mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        }
        @Override
        public void visitInsn(int opcode) {
            // 通過opcode操作碼,在方法執行return指令之前調用(方法體返回前)
            if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)
                    || opcode == Opcodes.ATHROW) {
                mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
                mv.visitLdcInsn("end");
                mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
            }
            mv.visitInsn(opcode);
        }
    }
}

最後編寫一個自定義的classloader來加載Demo類,實現字節碼增強:

package com.asm.mytest;

import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.ClassWriter;

import java.io.*;

public class ByteCodeHackClassLoader extends ClassLoader {

    private static final String currentPath = "xxxx";

    // findClass 是classLoader的核心,當指定的類在父加載器中找不到的時候,就會調用這個方法來加載class
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            // 一些特殊處理,後面講爲什麼
            String path = name.substring(0, name.length() - 5).replace(".", "/");
            // 加載.class字節碼,在指定目錄下搜索
            File file = new File(currentPath + path + ".class");
            BufferedInputStream bi = new BufferedInputStream(
                    new FileInputStream(file));
            byte[] bytecodes = new byte[(int)file.length()];
            bi.read(bytecodes);
            bi.close();
            bytecodes = hackByteCode(bytecodes);
            // defineClass是生成class對象的關鍵方法
            // 這裏特殊處理className,後面說爲什麼
            String clazzName = name.substring(0, name.length() - 5);
            return defineClass(clazzName, bytecodes, 0, bytecodes.length);
        } catch (Throwable t) {
            t.printStackTrace();
        }
        throw new ClassNotFoundException();
    }

    private static byte[] hackByteCode(byte[] originCode) throws IOException {
        ClassReader classReader = new ClassReader(originCode);
        ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        //處理
        ClassVisitor classVisitor = new MyClassVisitor(classWriter);
        classReader.accept(classVisitor, ClassReader.SKIP_DEBUG);
        byte[] data = classWriter.toByteArray();
        return data;
    }
}

測試(測試前記得執行javac編譯Demo.java):

package com.asm.mytest;


import java.lang.reflect.Method;

public class Test {

    public static void main(String[] args) throws Exception {
        ClassLoader classLoader = new ByteCodeHackClassLoader();
        // 這裏不能傳入原始類名,因爲原始類名已經被編譯過了,會被雙親委派託管,不會調用自定義的classLoader
        // 這也是爲什麼上面的classLoader中對className做特殊處理的原因
        Class clazz = classLoader.loadClass("com.asm.mytest.Demo_hack");
        Object demo = clazz.newInstance();
        // 通過反射調用
        Method echoHey = clazz.getMethod("echoHey");
        Method echoHi = clazz.getMethod("echoHi");
        echoHey.invoke(demo);
        echoHi.invoke(demo);
    }
}

我們可以看到輸出結果如下:

start
hey
end
start
hi
end

Demo 2: 死循環檢測

檢測類的方法執行過程中是否出現死循環,如果出現則終止執行

這是一個很有意思的功能,思路是在循環體中插入特別的“探針”指令,當探針檢測到循環體執行超過一定次數的時候,就終止執行。

先來看下如何實現探針,我們可以使用一個ThreadLocal變量來充當計數器:

package com.asm.mytest;

import java.util.HashMap;
import java.util.Map;

public class LoopCounter {

    private static final ThreadLocal<ThreadLocalCounter> threadLocal = new ThreadLocal<>();

    // 單循環最大循環次數
    public static final long singleLoopMax = 1000;

    /**
     * 檢測循環體執行次數,如果執行次數超出設定的閾值,則拋出異常
     */
    public static void incr(String label) {

        // 判斷線程執行是否被中止
        if (Thread.interrupted()) {
            throw new RuntimeException(new InterruptedException("Thread execution has been interrupted!"));
        }

        // 檢查循環執行次數是否超出上限
        ThreadLocalCounter threadLocalCounter = threadLocal.get();
        if (threadLocalCounter != null) {
            if (threadLocalCounter.incrLabel(label) > singleLoopMax) {
                throw new RuntimeException(new Exception("Loop counter exceed limit"));
            }
        } else {
            threadLocal.set(new ThreadLocalCounter());
        }
    }

    // 標記類
    private static final class ThreadLocalCounter {

        // 記錄每個label計數的次數
        private Map<String, Long> labelCounter = new HashMap<>();

        // 計數器+1
        long incrLabel(String label) {
            Long counter = labelCounter.get(label);
            if (counter == null) {
                labelCounter.put(label, 1L);
                return 1;
            }
            labelCounter.put(label, ++counter);
            return counter;
        }

        // 計數器清零
        void clearLabel() {
            this.labelCounter.clear();
        }
    }
}

接下來編寫ClassVisitor,思路很簡單,在跳轉指令執行的時候調用靜態方法incr即可:

package com.asm.mytest;

import org.objectweb.asm.ClassVisitor;
import org.objectweb.asm.Label;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

public class LoopCheckClassVisitor extends ClassVisitor implements Opcodes {

    public LoopCheckClassVisitor(ClassVisitor cv) {
        super(ASM5, cv);
    }

    @Override
    public void visit(int version, int access, String name, String signature,
                      String superName, String[] interfaces) {
        cv.visit(version, access, name, signature, superName, interfaces);
    }

    @Override
    public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
        MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
                exceptions);
        // 跳過構造方法
        if (!name.equals("<init>") && mv != null) {
            mv = new MyMethodVisitor(mv);
        }
        return mv;
    }

    // 針對方法增強
    class MyMethodVisitor extends MethodVisitor implements Opcodes {

        public MyMethodVisitor(MethodVisitor mv) {
            super(Opcodes.ASM5, mv);
        }

        @Override
        public void visitJumpInsn(int opcode,
                                  Label label) {
            if (opcode == Opcodes.GOTO && label != null) {
                // 在goto指令前插入計數器執行,統計循環體執行次數
                this.visitLdcInsn(label.toString());
                this.visitMethodInsn(Opcodes.INVOKESTATIC,
                        "com/asm/mytest/LoopCounter",
                        "incr",
                        "(Ljava/lang/String;)V",
                        false);
            }
            super.visitJumpInsn(opcode, label);
        }
    }
}

修改測試Demo類增加一個死循環方法:

package com.asm.mytest;

public class Demo {

    public void echoHi() {
        System.out.println("hi");
    }

    public void echoHey() {
        System.out.println("hey");
    }

    public void testLoop() {
        for (int i = 1; i > 0; ) {
            System.out.println("hey..");
        }
    }
}

測試代碼:

package com.asm.mytest;


import java.lang.reflect.Method;

public class Test {

    public static void main(String[] args) throws Exception {
        ClassLoader classLoader = new ByteCodeHackClassLoader();
        Class clazz = classLoader.loadClass("com.asm.mytest.Demo_hack");
        Object demo = clazz.newInstance();
        Method testLoop = clazz.getMethod("testLoop");
        testLoop.invoke(demo);
    }
}

執行結果如圖:

image-20200619165132911

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章