Java ASM學習(2)

tr1ple發表於2020-04-28

1.編譯後的方法區,其中儲存的程式碼都是一些位元組碼指令

2.Java虛擬機器執行模型:

java程式碼是在一個執行緒內部執行,每個執行緒都有自己的執行棧,棧由幀組成,每個幀表示一個方法的呼叫,每呼叫一個方法,都將將新的幀壓入執行棧,方法返回時(不管是整成return還是異常返回),該方法對應的幀都將出棧,即按照先進後出的規則。

執行棧與運算元棧不一樣,運算元棧包含在執行棧中。每一幀包括區域性變數和運算元棧兩部分,運算元棧中包括位元組碼指令用來當運算元的值。比如a.equals(b)將建立一幀,此時該幀將有一個空棧,並且a和b作為區域性變數

位元組碼指令:

由標識該指令的操作碼和固定數目的引數組成,操作碼指定要進行哪一類操作,引數指定具體精確行為。指令分為兩類,一類在區域性變數和運算元棧之間傳值,一類從運算元棧彈出值計算後再壓入

例如:

ILOAD,LLOAD,FLOAD,DLOAD,ALOAD讀取一個區域性變數,並將其值壓入運算元棧中,其對應的引數是其讀取的區域性變數索引i(因為區域性變數就是通過索引來進行隨機訪問的),LLOAD和DLOAD載入時需要兩個槽(slot),因為區域性變數部分和運算元佔部分的每個槽(slot)都可以儲存除了long和double之外的java值(long和double需要兩個槽)。

ILOAD:載入boolean、charbyteshort、int區域性變數
LLOAD:載入long
FLOAD:載入float
DLOAD:載入double
ALOAD:載入物件和陣列引用

對應的ISTORE,LSTORE,FSTORE,DSTORE,ASTORE從運算元棧彈出值並將其儲存在指定的索引i所代表的區域性變數中,所以這些操作指令是和java資料型別密切相關的。存取值和資料型別也相關,比如使用ISTORE 1 ALOAD 1,此時從運算元棧彈出一個int值存入索引1處的區域性變數中,再將該值轉為物件型別進行轉換讀取是非法的。但是對於一個區域性變數位置,我們可以在執行過程中改變其型別,比如ISTORE 1 ALOAD 1非法,但是ATORE 1 ALOAD1就合法了。具體的位元組碼指令見ASM指南附A.1

通過一個例子來進行學習,比如以下方法:

package asm;

public class bean {
    private int f;

    public bean() {
    }

    public void setF(int f) {
        this.f = f;
    }

    public int getF() {
        return this.f;
    }
}

直接通過位元組碼檔案檢視其class檔案結構,其欄位就一個int型別的f,訪問修飾符為private

setf方法的位元組碼指令如下

 其區域性變數表如下,所以有兩個值一個就是當前物件this和成員變數f,分別對應下標0和1

 這裡要設計到幾個位元組碼指令:

GETFIELD owner name desc:讀取一個欄位的值並將其值壓入運算元棧中
PUTFIELD owner name desc:從運算元彈出值存在name所代表的欄位中
owner:類的全限定名
GETSTATIC owner name desc和PUTSTATIC owner name desc類似,只是為靜態變數

aload 0,讀取區域性變數this,也就是區域性變數表下標為0處的this物件(其在呼叫這個方法的時候就已經初始化儲存在區域性變數表中),然後將其壓入運算元棧。

iload 1,讀取區域性變數f,下標為1(建立幀期間已經初始化,也就是入口引數int f),壓入運算元棧中

putfield #2 <asm/bean.f> 也就是彈出壓入的兩個值,賦值給asm/bean.f,也就是將入口的int f的值賦給this.f

return 即該方法執行完成,那麼該幀從執行棧從彈出

getf對應的位元組碼指令如下所示:

aload 0,即從區域性變數表拿到this放入運算元棧

getfield #2 <asm/bean.f> 即從運算元棧中拿出this,並將this.f的值壓入運算元棧

ireturn 返回f的值get方法的呼叫者,xreturn,x即返回變數對應的修飾符

bean構造方法,位元組碼指令如下:

aload 0: 從區域性變數表拿到this,壓入運算元棧

這裡要設計方法的呼叫相關的位元組碼指令:

INVOKEVIRTUAL owner name desc:
呼叫owner所表示的類的name方法
desc用來描述一個方法的引數型別和返回型別 INVOKESTATIC:呼叫靜態方法 INVOKESPECIAL: 呼叫私有方法和構造器 INVOKEINTERFACE: 介面中定義的方法

invokespecial #1 <java/lang/Object.<init>>: 呼叫object物件的init方法,即super()呼叫,最後return返回,如果是對於以下程式碼:

package asm;

public class bean {
    private int f;

    public void setFf(int f) {
        if(f>0){
        this.f = f;}
        else {
            throw new IllegalArgumentException();
        }
    }

    public int getF() {
        return f;
    }

}

此時setf的位元組碼指令如下:

iload  1,從區域性表量表中拿出入口引數 int f,壓入運算元棧

ifile 9:此時彈出運算元棧中的int f和0進行比較

a.如果小於等於0(這裡將大於判斷轉為小於等於的判斷),則到第12條指令 

new #2 :新建一個異常物件並壓入運算元棧

dup:重複壓入該值一次

invokespecial #4  : 彈出操作棧中兩個物件值其中之一,並呼叫其建構函式例項化該物件

athrow:彈出運算元棧中剩下的值(另一個異常物件),並將其作為異常丟擲

b.如果大於0,則依次執行

aload0 從區域性變數表拿出this物件放入運算元棧中

iload1 拿出入口int f的值壓入棧中

putfiled #2 <asm/bean.f>:將int f的值賦給this.f

goto 20: 到第20條位元組碼指令

return : 返回

感覺和彙編有點像,不過比彙編更容易理解,主要還是方法內的一些操作,能看懂基本的位元組碼指令,複雜的再去查doc,聽說面試有時候會問i++和++i的區別:

package asm;

public class testplus {

    public void plusf(){
        int i=0;
        System.out.println(i++);
    }
       public void pluse(){
        int i=0 ;
        System.out.println(++i);
       }
}

編譯後:

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by Fernflower decompiler)
//

package asm;

public class testplus {
    public testplus() {
    }
  //i++
    public void plusf() {
        int i = 0;
        byte var10001 = i;
        int var2 = i + 1;
        System.out.println(var10001);
    }
  //++i
    public void pluse() {
        int i = 0;
        int i = i + 1;
        System.out.println(i);
    }
}

首先從生成的class來看,i++編譯後竟然用位元組儲存了i的值,然後i自增1,輸出的為位元組型別i即0,所以i++,最終輸出為0,++i,直接是i自增1,然後輸出i,所以最終輸出為1所以for迴圈用i++,而不用++i

從位元組碼指令來看:

i++

iconst 0:首先運算元棧中壓入常量0

istore 1:然後彈出常量0放入區域性變數表索引1處,此時區域性變數表處1處從i變為0,運算元棧空

getstatic #2 :即拿到java.lang.System.out,即取靜態變數System.out壓入棧中,此時棧中1元素

 

#2在常量池中為第二個,關於該欄位的引用說明如下,out對應的描述符即為Ljava/io/PrintStream; 那麼類型別的描述符就是L+類的全限定名+;

 

iload 1:從區域性變數表1處取值,壓住運算元棧,即將0壓入運算元棧

iinc 1 by 1:給區域性變數1處的值+1,此時1處即從0變為1

invokevirtual:呼叫java.io.PrintStream.println,此時需要的值是從運算元棧中取的,然而此時運算元棧頂彈出的數值為0,所以輸出為0

++i

 

iconst 0:首先運算元棧中壓入常量0

istore 1:然後彈出常量0放入區域性變數表索引1處,此時區域性變數表處1處從i變為0,運算元棧空

getstatic #2 :即拿到java.lang.System.out,即取靜態變數System.out壓入棧中,此時棧中1元素

iinc 1 by 1:將區域性變數表1處的值加1,即從0變為1

iload 1:載入區域性變數表1處的值,壓入運算元棧中,即將1壓入棧中

invokevirtual:呼叫java.io.PrintStream.println,此時需要的值是從運算元棧中取的,然而此時運算元棧頂彈出的數值為1,所以輸出為1

所以i++和++i的區別從位元組碼指令上來看就是區域性變數表自增和壓入運算元棧的順序不一樣,i++是先壓棧,後區域性變數表自增,++i是先區域性變數表自增,後壓入運算元棧,這樣就完全搞懂了2333~

所以再分析一個鞏固鞏固:

package asm;

public class testplus {

       public void pluse(){
        int i=0 ;
        int p = 2 + i++ - ++i;
        System.out.println(i);
        System.out.println(p);
       }

    public static void main(String[] args) {
        testplus t = new testplus();
        t.pluse();
    }
}

main方法:

new #4 <asm/testplus>:new一個物件壓入棧中

dup:賦值一個棧頂的物件再壓入運算元棧,關於為什麼要壓入兩個重複的值原因:

首先位元組碼指令運算元值時基於棧實現的,那麼對於同一個值從棧中操作時必定要彈出,那麼如果對一個數同時操作兩次,那麼就要兩次壓棧。涉及到new一個物件操作時,java虛擬機器自動dup,在new一個物件以後,棧中放入的是該物件在堆中的地址,比如宣告以下兩個

class1 a = new class1();
a.pp()

通常在呼叫物件呼叫其類中方法前肯定要呼叫其init例項化,那麼init要用一次運算元棧中的地址,此時彈出一次地址參與方法呼叫,後面只需要再將該棧中的地址放入區域性變數表,該地址的物件已經完成了例項化操作,那麼後面每次呼叫只需要從區域性變數表從取到該物件的地址,即可任意呼叫其類中的方法。

invokespecial #5 :這裡呼叫testplus的init方法,所以從棧中彈出一個testplus的地址

astore 1:將例項化以後的該testplus物件地址放入區域性變數表1處

aload 1:取區域性變數表1處的物件地址壓入棧中

invokevirtual #6:呼叫testplus的pluse方法

return :返回

pluse方法:

 

iconst 0:壓入常量0

istore 1:彈出0存入區域性變數表1處 (完成int i=0)

iconst 2:將2壓入棧中

iload 1:取出區域性變數表1處的值0壓入棧中

iinc 1 by 1:區域性變數表1處的值加1,即從0變為1

iadd :將棧中的兩個值相加,即 stack[0] + stack[1] = 2 + 0 =2

iinc 1 by 1: 區域性變數表1處的值加1,即從1變為2

iload 1:去區域性變數表1處的值壓入棧中,即棧頂為2

isub :將棧中兩個元素相減,即stack[0] - stack[1] =  2 - 2 =0

istore 2:彈出棧中的唯一一個元素2,存入區域性變數表2處,此時棧空

getstatic # 2 :拿到Syetem.out,壓入棧中

iload 1:取出區域性表量表1處的值壓入棧中,即棧頂為2

invokevirtual  #3 : 彈出棧中兩個元素,呼叫System.out的println方法,即stack[0].print(stack[1]),即輸出2

同理壓入System.out,然後iload 2,取出區域性變數表2處的0壓入棧中,輸出0

最終輸出結果也是2和0