類與類的載入

ML李嘉圖發表於2022-03-02

類與類載入

類檔案結構

在我們學習C語言的時候,

我們的程式設計過程會經歷如下幾個階段:寫程式碼、儲存、編譯、執行。

實際上,最關鍵的一步是編譯,因為只有經歷了編譯之後,我們所編寫的程式碼才能夠翻譯為機器可以直接執行的二進位制程式碼,並且在不同的作業系統下,我們的程式碼都需要進行一次編譯之後才能執行。

如果全世界所有的計算機指令集只有x86一種,作業系統只有Windows一種,那也許就不會有Java語言的出現。

隨著時代的發展,人們迫切希望能夠在不同的作業系統、不同的計算機架構中執行同一套編譯之後的程式碼。

原生程式碼不應該是我們程式設計的唯一選擇,所以,越來越多的語言選擇了與作業系統和機器指令集無關的中立格式作為編譯後的儲存格式。

一次編寫,到處執行”,Java最引以為傲的口號,標誌著平臺不再是限制程式語言的阻礙。

實際上,Java正式利用了這樣的解決方案,將原始碼編譯為平臺無關的中間格式,並通過對應的Java虛擬機器讀取和執行這些中間格式的編譯檔案,這樣,我們只需要考慮不同平臺的虛擬機器如何編寫,而Java語言本身很輕鬆地實現了跨平臺。

現在,越來越多的開發語言都支援將原始碼編譯為.class位元組碼檔案格式,以便能夠直接交給JVM執行,包括Kotlin(安卓開發官方指定語言)、Groovy、Scala等。

image-20220223162914535

那麼,讓我們來看看,我們的原始碼編譯之後,是如何儲存在位元組碼檔案中的。


類檔案資訊

使用javap命令來對位元組碼檔案進行反編譯檢視的,那麼,它以二進位制格式是怎麼儲存呢?

我們可以使用WinHex軟體(Mac平臺可以使用010 Editor)來以十六進位制檢視位元組碼檔案。

public class Main {
    public static void main(String[] args) {
        int i = 10;
        int a = i++;
        int b = ++i;
    }
}

找到我們在IDEA中編譯出來的class檔案,將其拖動進去:

image-20220223164725971

可以看到整個檔案中,全是一個位元組一個位元組分組的樣子,從左上角開始,一行一行向下讀取。可以看到在右側中還出現了一些我們之前也許見過的字串,比如""、"Object"等。

實際上Class檔案採用了一種類似於C中結構體的偽結構來儲存資料(當然我們直接看是看不出來的),但是如果像這樣呢?

Classfile /Users/nagocoler/Develop.localized/JavaHelloWorld/target/classes/com/test/Main.class
  Last modified 2022-2-23; size 444 bytes
  MD5 checksum 8af3e63f57bcb5e3d0eec4b0468de35b
  Compiled from "Main.java"
public class com.test.Main
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#21         // java/lang/Object."<init>":()V
   #2 = Class              #22            // com/test/Main
   #3 = Class              #23            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               LocalVariableTable
   #9 = Utf8               this
  #10 = Utf8               Lcom/test/Main;
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               args
  #14 = Utf8               [Ljava/lang/String;
  #15 = Utf8               i
  #16 = Utf8               I
  #17 = Utf8               a
  #18 = Utf8               b
  #19 = Utf8               SourceFile
  #20 = Utf8               Main.java
  #21 = NameAndType        #4:#5          // "<init>":()V
  #22 = Utf8               com/test/Main
  #23 = Utf8               java/lang/Object
{
  public com.test.Main();
    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 11: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/test/Main;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=1, locals=4, args_size=1
         0: bipush        10
         2: istore_1
         3: iload_1
         4: iinc          1, 1
         7: istore_2
         8: iinc          1, 1
        11: iload_1
        12: istore_3
        13: return
      LineNumberTable:
        line 13: 0
        line 14: 3
        line 15: 8
        line 16: 13
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      14     0  args   [Ljava/lang/String;
            3      11     1     i   I
            8       6     2     a   I
           13       1     3     b   I
}
SourceFile: "Main.java"

乍一看,是不是感覺還真的有點結構體那味?

而結構體中,有兩種允許存在的資料型別,一個是無符號數,還有一個是

  • 無符號數一般是基本資料型別,用u1、u2、u4、u8來表示,表示1個位元組 ~ 8個位元組的無符號數。可以表示數字、索引引用、數量值或是以 UTF-8 編碼格式的字串。
  • 表包含多個無符號數,並且以"_info"結尾。

我們首先從最簡的開始看起。

image-20220223164126100

首先,我們可以看到,前4個位元組(共32位)組成了魔數(其實就是表示這個檔案是一個JVM可以執行的位元組碼檔案,除了Java以外,其他某些檔案中也採用了這種魔數機制來進行區分,這種方式比直接起個副檔名更安全)

位元組碼檔案的魔數為:CAFEBABE

緊接著魔數的後面4個位元組儲存的是位元組碼檔案的版本號

注意前兩個是次要版本號(現在基本都不用了,都是直接Java8、Java9這樣命名了),後面兩個是主要版本號,這裡我們主要看主版本號,比如上面的就是34,注意這是以16進製表示的,我們把它換算為10進位制後,得到的結果為:34 -> 3*16 + 4 = 52,其中52代表的是JDK8編譯的位元組碼檔案(51是JDK7、50是JDK6、53是JDK9,以此類推)

JVM會根據版本號決定是否能夠執行,比如JDK6只能支援版本號為1.16的版本,也就是說必須是Java6之前的環境編譯出來的位元組碼檔案,否則無法執行。又比如我們現在安裝的是JDK8版本,它能夠支援的版本號為1.18,那麼如果這時我們有一個通過Java7編譯出來的位元組碼檔案,依然是可以執行的,所以說Java版本是向下相容的。

緊接著,就是類的常量池了,這裡面存放了類中所有的常量資訊(注意這裡的常量並不是指我們手動建立的final型別常量,而是程式執行一些需要用到的常量資料,比如字面量和符號引用等)由於常量的數量不是確定的,所以在最開始的位置會存放常量池中常量的數量(是從1開始計算的,不是0,比如這裡是18,翻譯為10進位制就是24,所以實際上有23個常量)

接著再往下,就是常量池裡面的資料了,每一項常量池裡面的資料都是一個表,我們可以看到他們都是以_info結尾的:

image-20220223171746645

我們來看看一個表中定義了哪些內容:

image-20220223172031889

首先上來就會有一個1位元組的無符號數,它用於表示當前常量的型別(常量型別有很多個)這裡只列舉一部分的型別介紹:

型別 標誌 描述
CONSTANT_Utf8_info 1 UTF-8編碼格式的字串
CONSTANT_Integer_info 3 整形字面量(第一章我們演示的很大的數字,實際上就是以字面量儲存在常量池中的)
CONSTANT_Class_info 7 類或介面的符號引用
CONSTANT_String_info 8 字串型別的字面量
CONSTANT_Fieldref_info 9 欄位的符號引用
CONSTANT_Methodref_info 10 方法的符號引用
CONSTANT_MethodType_info 16 方法型別
CONSTANT_NameAndType_info 12 欄位或方法的部分符號引用

實際上這些東西,雖然我們不知道符號引用是什麼東西,我們可以觀察出來,這些東西或多或少都是存放類中一些名稱、資料之類的東西。

比如我們來看第一個CONSTANT_Methodref_info表中存放了什麼資料,這裡我只列出它的結構表(詳細的結構表可以查閱《深入理解Java虛擬機器 第三版》中222頁總表):

常量 專案 型別 描述
CONSTANT_Methodref_info tag u1 值為10
index u2 指向宣告方法的類描述父CONSTANT_Class_info索引項
index u2 指向名稱及型別描述符CONSTANT_NameAndType_info索引項

比如我們剛剛的例子中:

image-20220223190659053

可以看到,第一個索引項指向了第3號常量,我們來看看三號常量:

image-20220223190957382

常量 專案 型別 描述
CONSTANT_Class_info tag u1 值為7
index u2 指向全限定名常量項的索引

那麼我們接著來看23號常量又寫的啥:

image-20220223191325689

可以看到指向的UTF-8字串值為java/lang/Object這下搞明白了,首先這個方法是由Object類定義的,那麼接著我們來看第二項u2 name_and_type_index,指向了21號常量,也就是欄位或方法的部分符號引用:

image-20220223191921550

常量 專案 型別 描述
CONSTANT_NameAndType_info tag u1 值為12
index u2 指向欄位或方法名稱常量項的索引
index u2 指向欄位或方法描述符常量項的索引

其中第一個索引就是方法的名稱,而第二個就是方法的描述符,描述符明確了方法的引數以及返回值型別,我們分別來看看4號和5號常量:

image-20220223192332068

可以看到,方法名稱為"",一般構造方法的名稱都是,普通方法名稱是什麼就是什麼,方法描述符為"()V",表示此方法沒有任何引數,並且返回值型別為void,描述符對照表如下:

image-20220223192518999

比如這裡有一個方法public int test(double a, char c){ ... },那麼它的描述符就應該是:(DC)I,引數依次放入括號中,括號右邊是返回值型別。再比如public String test(Object obj){ ... },那麼它的描述符就應該是:(Ljava/lang/Object;)Ljava/lang/String,注意如果引數是物件型別,那麼必須在後面新增;

對於陣列型別,只需要在型別最前面加上[即可,有幾個維度,就加幾個,比如public void test(int[][] arr),引數是一個二維int型別陣列,那麼它的描述符為:([[I)V

所以,這裡表示的,實際上就是此方法是一個無參構造方法,並且是屬於Object類的。那麼,為什麼這裡需要Object類構造方法的符號引用呢?還記得我們在JavaSE中說到的,每個類都是直接或間接繼承自Object類,所有類的構造方法,必須先呼叫父類的構造方法,但是如果父類存在無參構造,預設可以不用顯示呼叫super關鍵字(當然本質上是呼叫了的)。

所以說,當前類因為沒有繼承自任何其他類,那麼就預設繼承的Object類,所以,在當前類的預設構造方法中,呼叫了父類Object類的無參構造方法,因此這裡需要符號引用的用途顯而易見,就是因為需要呼叫Object類的無參構造方法。

我們可以在反編譯結果中的方法中看到:

public com.test.Main();
    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 11: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/test/Main;

其中invokespecial(呼叫父類構造方法)指令的引數指向了1號常量,而1號常量正是代表的Object類的無參構造方法,雖然饒了這麼大一圈,但是過程理清楚,還是很簡單的。

雖然我們可以直接檢視16進位制的結果,但是還是不夠方便,但是我們也不能每次都去使用javap命令,所以我們這裡安裝一個IDEA外掛,來方便我們檢視位元組碼中的資訊,名稱為jclasslib Bytecode Viewer

image-20220223194128297

安裝完成後,我們可以在我們的IDEA右側看到它的板塊,但是還沒任何資料,那麼比如現在我們想要檢視Main類的位元組碼檔案時,可以這樣操作:

image-20220223194410699

首先在專案中選中我們的Main類,然後點選工具欄的檢視,然後點選Show Bytecode With Jclasslib,這樣右側就會出現當前類的位元組碼解析資訊了。注意如果修改了類的話,那麼需要你點選執行或是構建,然後點選重新整理按鈕來進行更新。

接著我們來看下一個內容,在常量池之後,緊接著就是訪問標誌,訪問標誌就是類的種類以及類上新增的一些關鍵字等內容:

image-20220223194942810

可以看到它只佔了2個位元組,那麼它是如何表示訪問標誌呢?

image-20220223200619811

比如我們這裡的Main類,它是一個普通的class型別,並且訪問許可權為public,那麼它的訪問標誌值是這樣計算的:

ACC_PUBLIC | ACC_SUPER = 0x0001 | 0x0020 = 0x0021(這裡進行的是按位或運算),可以看到和我們上面的結果是一致的。

再往下就是類索引、父類索引、介面索引:

image-20220223200054866

可以看到它們的值也是指向常量池中的值,其中2號常量正是儲存的當前類資訊,3號常量儲存的是父類資訊,這裡就不再倒推回去了,由於沒有介面,所以這裡介面數量為0,如果不為0還會有一個索引表來引用介面。

接著就是欄位和方法表集合了:

image-20220223200521912

由於我們這裡沒有宣告任何欄位,所以我們先給Main類新增一個欄位再重新載入一下:

public class Main {

    public static int a = 10;

    public static void main(String[] args) {
        int i = 10;
        int a = i++;
        int b = ++i;
    }
}

image-20220223200733342

現在位元組碼就新增了一個欄位表,這個欄位表實際上就是我們剛剛新增的成員欄位a的資料。

可以看到一共有四個2位元組的資料:

image-20220223200939786

首先是access_flags,這個與上面類標誌的計算規則是一樣的,表還是先列出來吧:

image-20220223201053780

第二個資料name_index表示欄位的名稱常量,這裡指向的是5號常量,那麼我們來看看5號常量是不是欄位名稱:

image-20220223201327180

沒問題,這裡就是a,下一個是descirptor_index,存放的是描述符,不過這裡因為不是方法而是變數,所以描述符直接寫對應型別的標識字元即可,比如這裡是int型別,那麼就是I

最後,attrbutes_count屬性計數器,用於描述一些額外資訊,這裡我們暫時不做介紹。

接著就是我們的方法表了:

image-20220223202153955

可以看到方法表中一共有三個方法,其中第一個方法我們剛剛已經介紹過了,它的方法名稱為<init>,表示它是一個構造方法,我們看到最後一個方法名稱為<clinit>,這個是類在初始化時會呼叫的方法(是隱式的,自動生成的),它主要是用於靜態變數初始化語句和靜態塊的執行,因為我們這裡給靜態成員變數a賦值為10,所以會在一開始為其賦值:

image-20220223202515287

而第二個方法,就是我們的main方法了,但是現在我們先不急著去看它的詳細實現過程,我們來看看它的屬性表。

屬性表實際上類中、欄位中、方法中都可以攜帶自己的屬性表,屬性表存放的正是我們的程式碼、本地變數等資料,比如main方法就存在4個本地變數,那麼它的本地變數存放在哪裡呢:

image-20220223202955858

可以看到,屬性資訊呈現套娃狀態,在此方法中的屬性包括了一個Code屬性,此屬性正是我們的Java程式碼編譯之後變成位元組碼指令,然後存放的地方,而在此屬性中,又巢狀了本地變數表和原始碼行號表。

可以看到code中存放的就是所有的位元組碼指令:

image-20220223203241262

這裡我們暫時不對位元組碼指令進行講解(其實也用不著講了,都認識的差不多了)。我們接著來看本地變數表,這裡存放了我們方法中要用到的區域性變數:

image-20220223203356129

可以看到一共有四個本地變數,而第一個變數正是main方法的形參String[] args,並且表中存放了本地變數的長度、名稱、描述符等內容。當然,除了我們剛剛認識的這幾個屬性之外,完整屬性可以查閱《深入理解Java虛擬機器 第三版》231頁。

最後,類也有一些屬性:

image-20220223203835282

此屬性記錄的是原始檔名稱。


位元組碼指令

虛擬機器的指令是由一個位元組長度的、代表某種特定操作含義的數字(操作碼,類似於機器語言),操作後面也可以攜帶0個或多個引數一起執行。

JVM實際上並不是面向暫存器架構的,而是面向運算元棧,所以大多數指令都是不帶引數的。

由於之前已經講解過大致執行流程,這裡我們就以當前的Main類中的main方法作為教材進行講解:

public static void main(String[] args) {
    int i = 10;
    int a = i++;
    int b = ++i;
}

可以看到,main方法中首先是定義了一個int型別的變數i,並賦值為10,然後變數a接收i++的值,變數b接收++i的值。

那麼我們來看看編譯成位元組碼之後,是什麼樣的:

image-20220223205928901

  • 首先第一句,bipush,將10送至運算元棧頂。
  • 接下來將運算元棧頂的數值存進1號本地變數,也就是變數i中。
  • 接著將變數i中的值又丟向運算元棧頂
  • 這裡使用iinc指令,將1號本地變數的值增加1(結束之後i的值就是11了)
  • 接著將運算元棧頂的值(運算元棧頂的值是10)存入2號本地變數(這下徹底知道i++到底幹了啥才會先返回後自增了吧,從原理角度來說,實際上i是先自增了的,但由於這裡取的是運算元棧中的值,所以說就得到了i之前的值)
  • 接著往下,我們看到++i是先直接將i的值自增1
  • 然後在將其值推向運算元棧頂

image-20220223214441621

而從結果來看,i++操作確實是先返回再自增的,而位元組碼指令層面來說,卻是截然相反的,只是結果一致罷了。


ASM位元組碼程式設計

既然位元組碼檔案結構如此清晰,那麼我們能否通過程式設計,來直接建立一個位元組碼檔案呢?

如果我們可以直接編寫一個位元組碼檔案,那麼我們就可以省去編譯的過程。ASM(某些JDK中內建)框架正是用於支援位元組碼程式設計的框架。

比如現在我們需要建立一個普通的Main類(暫時不寫任何內容)

首先我們來看看如何通過程式設計建立一個Main類的位元組碼檔案:

public class Main {
    public static void main(String[] args) {
        ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
    }
}

首先需要獲取ClassWriter物件,我們可以使用它來編輯類的位元組碼檔案,在構造時需要傳入引數:

  • 0 這種方式不會自動計算運算元棧和區域性臨時變數表大小,需要自己手動來指定
  • ClassWriter.COMPUTE_MAXS(1) 這種方式會自動計算上述運算元棧和區域性臨時變數表大小,但需要手動觸發。
  • ClassWriter.COMPUTE_FRAMES(2) 這種方式不僅會計算上述運算元棧和區域性臨時變數表大小,而且會自動計算StackMapFrames

這裡我們使用ClassWriter.COMPUTE_MAXS即可。

接著我們首先需要指定類的一些基本資訊:

public class Main {
    public static void main(String[] args) {
        ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        //因為這裡用到的常量比較多,所以說直接一次性靜態匯入:import static jdk.internal.org.objectweb.asm.Opcodes.*;
        writer.visit(V1_8, ACC_PUBLIC,"com/test/Main", null, "java/lang/Object",null);
    }
}

這裡我們將位元組碼檔案的版本設定位Java8,然後修飾符設定為ACC_PUBLIC代表public class Main,類名稱注意要攜帶包名,標籤設定為null,父類設定為Object類,然後沒有實現任何介面,所以說最後一個引數也是null

接著,一個簡單的類位元組碼檔案就建立好了,我們可以嘗試將其進行儲存:

public class Main {
    public static void main(String[] args) {
        ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
        writer.visit(V1_8, ACC_PUBLIC,"com/test/Main", null, "java/lang/Object",null);
        //呼叫visitEnd表示結束編輯
        writer.visitEnd();

        try(FileOutputStream stream = new FileOutputStream("./Main.class")){
            stream.write(writer.toByteArray());  //直接通過ClassWriter將位元組碼檔案轉換為byte陣列,並儲存到根目錄下
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

可以看到,在IDEA中反編譯的結果為:

package com.test;

public class Main {
}

我們知道,正常的類在編譯之後,如果沒有手動新增構造方法,那麼會自帶一個無參構造,但是我們這個類中還沒有,所以我們來手動新增一個無參構造方法:

//通過visitMethod方法可以新增一個新的方法
writer.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);

可以看到反編譯的結果中已經存在了我們的構造方法:

package com.test;

public class Main {
    public Main() {
    }
}

但是這樣是不合法的,因為我們的構造方法還沒有新增父類構造方法呼叫,所以說我們還需要在方法中新增父類構造方法呼叫指令:

public com.test.Main();
    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 11: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/test/Main;

我們需要對方法進行詳細編輯:

//通過MethodVisitor接收返回值,進行進一步操作
MethodVisitor visitor = writer.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
//開始編輯程式碼
visitor.visitCode();

//Label用於儲存行號
Label l1 = new Label();
//當前程式碼寫到哪行了,l1得到的就是多少行
visitor.visitLabel(l1);
//新增原始碼行數對應表(其實可以不用)
visitor.visitLineNumber(11, l1);

//注意不同型別的指令需要用不同方法來呼叫,因為運算元不一致,具體的註釋有寫
visitor.visitVarInsn(ALOAD, 0);
visitor.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V", false);
visitor.visitInsn(RETURN);

Label l2 = new Label();
visitor.visitLabel(l2);
//新增本地變數表,這裡加的是this關鍵字,但是方法中沒用到,其實可以不加
visitor.visitLocalVariable("this", "Lcom/test/Main;", null, l1, l2, 0);

//最後設定最大棧深度和本地變數數
visitor.visitMaxs(1, 1);
//結束編輯
visitor.visitEnd();

我們可以對編寫好的class檔案進行反編譯,看看是不是和IDEA編譯之後的結果差不多:

{
  public com.test.Main();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #8                  // Method java/lang/Object."<init>":()V
         4: return
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/test/Main
      LineNumberTable:
        line 11: 0
}

可以看到和之前的基本一致了,到此為止我們構造方法就編寫完成了,接著我們來寫一下main方法,一會我們就可以通過main方法來執行Java程式了。比如我們要編寫這樣一個程式:

public static void main(String[] args) {
    int a = 10;
    System.out.println(a);
}

看起來很簡單的一個程式對吧,但是我們如果手動去組裝指令,會極其麻煩!首先main方法是一個靜態方法,並且方法是public許可權,然後還有一個引數String[] args,所以說我們這裡要寫的內容有點小多:

//開始安排main方法
MethodVisitor v2 = writer.visitMethod(ACC_PUBLIC | ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
v2.visitCode();
//記錄起始行資訊
Label l3 = new Label();
v2.visitLabel(l3);
v2.visitLineNumber(13, l3);

//首先是int a = 10的操作,執行指令依次為:
// bipush 10     將10推向運算元棧頂
// istore_1      將運算元棧頂元素儲存到1號本地變數a中
v2.visitIntInsn(BIPUSH, 10);
v2.visitVarInsn(ISTORE, 1);
Label l4 = new Label();
v2.visitLabel(l4);
//記錄一下行資訊
v2.visitLineNumber(14, l4);

//這裡是獲取System類中的out靜態變數(PrintStream介面),用於列印
v2.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
//把a的值取出來
v2.visitVarInsn(ILOAD, 1);
//呼叫介面中的抽象方法println
v2.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(I)V", false);

//再次記錄行資訊
Label l6 = new Label();
v2.visitLabel(l6);
v2.visitLineNumber(15, l6);

v2.visitInsn(RETURN);
Label l7 = new Label();
v2.visitLabel(l7);

//最後是本地變數表中的各個變數
v2.visitLocalVariable("args", "[Ljava/lang/String;", null, l3, l7, 0);
v2.visitLocalVariable("a", "I", null, l4, l7, 1);
v2.visitMaxs(1, 2);
//終於OK了
v2.visitEnd();

可以看到,雖然很簡單的一個程式,但是如果我們手動去編寫位元組碼,實際上是非常麻煩的,但是要實現動態代理之類的操作(可以很方便地修改位元組碼建立子類),是不是感覺又Get到了新操作(其實Spring實現動態代理的CGLib框架底層正是呼叫了ASM框架來實現的)。


類載入機制

現在,我們已經瞭解了位元組碼檔案的結構,以及JVM如何對記憶體進行管理,現在只剩下最後一個謎團等待解開了,也就是我們的類位元組碼檔案到底是如何載入到記憶體中的,載入之後又會做什麼事情。

類載入過程

首先,要載入一個類,一定是出於某種目的的,比如我們要執行我們的Java程式,那麼就必須要載入主類才能執行主類中的主方法,又或是我們需要載入資料庫驅動,那麼可以通過反射來將對應的資料庫驅動類進行載入。

所以,一般在這些情況下,如果類沒有被載入,那麼會被自動載入:

  • 使用new關鍵字建立物件時
  • 使用某個類的靜態成員(包括方法和欄位)的時候(當然,final型別的靜態欄位有可能在編譯的時候被放到了當前類的常量池中,這種情況下是不會觸發自動載入的)
  • 使用反射對類資訊進行獲取的時候(之前的資料庫驅動就是這樣的)
  • 載入一個類的子類時
  • 載入介面的實現類,且介面帶有default的方法預設實現時

比如這種情況,那麼需要用到另一個類中的成員欄位,所以就必須將另一個類載入之後才能訪問:

public class Main {
    public static void main(String[] args) {
        System.out.println(Test.str);
    }

    public static class Test{
        static {
            System.out.println("我被初始化了!");
        }

        public static String str = "都看到這裡了,不給個關注嗎?";
    }
}

這裡我們就演示一個不太好理解的情況,我們現在將靜態成員變數修改為final型別的:

public class Main {
    public static void main(String[] args) {
        System.out.println(Test.str);
    }

    public static class Test{
        static {
            System.out.println("我被初始化了!");
        }

        public final static String str = "都看到這裡了,不給個關注嗎?";
    }
}

可以看到,在主方法中,我們使用了Test類的靜態成員變數,並且此靜態成員變數是一個final型別的,也就是說不可能再發生改變。那麼各位覺得,Test類會像上面一樣被初始化嗎?

按照正常邏輯來說,既然要用到其他類中的欄位,那麼肯定需要載入其他類,但是這裡我們結果發現,並沒有對Test類進行載入,那麼這是為什麼呢?我們來看看Main類編譯之後的位元組碼指令就知道了:

image-20220224131511381

很明顯,這裡使用的是ldc指令從常量池中將字串取出並推向運算元棧頂,也就是說,在編譯階段,整個Test.str直接被替換為了對應的字串(因為final不可能發生改變的,編譯就會進行優化,直接來個字串比你去載入類在獲取快得多不是嗎,反正結果都一樣),所以說編譯之後,實際上跟Test類半毛錢關係都沒有了。

所以說,當你在某些情況下疑惑為什麼類載入了或是沒有載入時,可以從位元組碼指令的角度去進行分析,一般情況下,只要遇到newgetstaticputstaticinvokestatic這些指令時,都會進行類載入,比如:

image-20220224132029992

這裡很明顯,是一定會將Test類進行載入的。除此之外,各位也可以試試看陣列的定義會不會導致類被載入。

好了,聊完了類的載入觸發條件,我們接著來看一下類的詳細載入流程。

image-20220224132621764

首先類的生命週期一共有7個階段,而首當其衝的就是載入,載入階段需要獲取此類的二進位制資料流,比如我們要從硬碟中讀取一個class檔案,那麼就可以通過檔案輸入流來獲取類檔案的byte[],也可以是其他各種途徑獲取類檔案的輸入流,甚至網路傳輸並載入一個類也不是不可以。

然後交給類載入器進行載入(類載入器可以是JDK內建的,也可以是開發者自己做的)類的所有資訊會被載入到方法區中,並且在堆記憶體中會生成一個代表當前類的Class類物件(那麼思考一下,同一個Class檔案載入的類,是唯一存在的嗎?),我們可以通過此物件以及反射機制來訪問這個類的各種資訊。

陣列類要稍微特殊一點,通過前面的檢驗,我沒發現陣列在建立後是不會導致類載入的,陣列型別本身不會通過類載入器進行載入的,不過你既然要往裡面丟物件進去,那最終依然是要載入類的。

接著我們來看驗證階段,驗證階段相當於是對載入的類進行一次規範校驗(因為一個類並不一定是由我們使用IDEA編譯出來的,有可能是像我們之前那樣直接用ASM框架寫的一個),如果說類的任何地方不符合虛擬機器規範,那麼這個類是不會驗證通過的,如果沒有驗證機制,那麼一旦出現危害虛擬機器的操作,整個程式會出現無法預料的後果。

驗證階段,首先是檔案格式的驗證:

  • 是否魔數為CAFEBABE開頭。
  • 主、次版本號是否可以由當前Java虛擬機器執行
  • Class檔案各個部分的完整性如何。
  • ...

有關類驗證的詳細過程,可以參考《深入理解Java虛擬機器 第三版》268頁。

接下來就是準備階段了,這個階段會為類變數分配記憶體,併為一些欄位設定初始值,注意是系統規定的初始值,不是我們手動指定的初始值。

再往下就是解析階段,此階段是將常量池內的符號引用替換為直接引用的過程,也就是說,到這個時候,所有引用變數的指向都是已經切切實實地指向了記憶體中的物件了。

到這裡,連結過程就結束了,也就是說這個時候類基本上已經完成大部分內容的初始化了。

最後就是真正的初始化階段了,從這裡開始,類中的Java程式碼部分,才會開始執行,還記得我們之前介紹的<clinit>方法嗎,它就是在這個時候執行的,比如我們的類中存在一個靜態成員變數,並且賦值為10,或是存在一個靜態程式碼塊,那麼就會自動生成一個<clinit>方法來進行賦值操作,但是這個方法是自動生成的。

全部完成之後,我們的類就算是載入完成了。


類載入器

Java提供了類載入器,以便我們自己可以更好地控制類載入,我們可以自定義類載入器,也可以使用官方自帶的類載入器去載入類。對於任意一個類,都必須由載入它的類載入器和這個類本身一起共同確立其在Java虛擬機器中的唯一性。

也就是說,一個類可以由不同的類載入器載入,並且,不同的類載入器載入的出來的類,即使來自同一個Class檔案,也是不同的,只有兩個類來自同一個Class檔案並且是由同一個類載入器載入的,才能判斷為是同一個。預設情況下,所有的類都是由JDK自帶的類載入器進行載入。

比如,我們先建立一個Test類用於測試:

package com.test;

public class Test {
    
}

接著我們自己實現一個ClassLoader來載入我們的Test類,同時使用官方預設的類載入器來載入:

public class Main {
    public static void main(String[] args) throws ReflectiveOperationException {
        Class<?> testClass1 = Main.class.getClassLoader().loadClass("com.test.Test");
        CustomClassLoader customClassLoader = new CustomClassLoader();
        Class<?> testClass2 = customClassLoader.loadClass("com.test.Test");

     	  //看看兩個類的類載入器是不是同一個
        System.out.println(testClass1.getClassLoader());
        System.out.println(testClass2.getClassLoader());
				
      	//看看兩個類是不是長得一模一樣
        System.out.println(testClass1);
        System.out.println(testClass2);

      	//兩個類是同一個嗎?
        System.out.println(testClass1 == testClass2);
      
      	//能成功實現型別轉換嗎?
        Test test = (Test) testClass2.newInstance();
    }

    static class CustomClassLoader extends ClassLoader {
        @Override
        public Class<?> loadClass(String name) throws ClassNotFoundException {
            try (FileInputStream stream = new FileInputStream("./target/classes/"+name.replace(".", "/")+".class")){
                byte[] data = new byte[stream.available()];
                stream.read(data);
                if(data.length == 0) return super.loadClass(name);
                return defineClass(name, data, 0, data.length);
            } catch (IOException e) {
                return super.loadClass(name);
            }
        }
    }
}

通過結果我們發現,即使兩個類是同一個Class檔案載入的,只要類載入器不同,那麼這兩個類就是不同的兩個類。

所以說,每個類都在堆中有一個唯一的Class物件放在這裡來看,並不完全正確,只是當前為了防止各位初學者搞混。

實際上,JDK內部提供的類載入器一共有三個,比如上面我們的Main類,其實是被AppClassLoader載入的,而JDK內部的類,都是由BootstrapClassLoader載入的,這其實就是為了實現雙親委派機制而做的。

image-20220225132629954

相關文章