Class文件是如何被加載進JVM的?一篇圖文帶你徹底弄懂






導讀

一個Class文件,在加載進JVM的過程中,究竟經歷了些什麼?加載進JVM之後又會以什麼樣的形式呈現?看文本文,你可以瞭解到:

  1. Class.forName究竟是怎麼獲取Class對象的,Class對象又是什麼?
  2. Class文件是如何被加載到JVM裡面的?
  3. 類變量是存在堆中還是存在方法區中?
  4. 類構造器<clinit>方法什麼時候執行?/<clinit>

關於類加載器

Java後端技術架構 · 技術專題 · 經驗分享

1、加載一個Class文件

以下是類的生命週期:

Class文件是如何被加載進JVM的?一篇圖文帶你徹底弄懂


其中,如果是動態綁定或者晚期綁定,解析階段不會再準備階段後立刻執行。接下來我們就來看看是如何按照這個流程加載一個Class文件的。

思考:

1.有如下代碼:

public class TestLoadSubClass {

public static void main(String[] args) {

System.out.println(B.value);

}

}

class A {

static {

System.out.println("init A ...");

}

static int value = 100; static final String DESC = "test";

}

class B extends A {

static {

System.out.println("init B ...");

}

}

猜猜會不會輸出 init B

2.猜猜以下語句會不會輸出 init A

A[] arrays = new A[10];

3.猜猜以下代碼會不會輸出 init A

System.out.println(A.DESC);

1.1、加載階段

Class文件是如何被加載進JVM的?一篇圖文帶你徹底弄懂


JVM規範並沒有規定java.lang.Class類的實例要放到Java堆中,對於HotSpot虛擬機,是放到方法區裡面的。這個class對象作為程序訪問方法區中的這些類型數據的外部接口。

如上圖,加載階段主要做以下事情:

  • 通過類全限定名獲取定義此類的二進制字節流;
  • 將字節流代表的靜態存儲結構轉換為方法區的運行時數據結構;
  • 在內存中生成此類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入口。

1.1.1、如何觸發加載Class文件

如上圖,當以下任何一種情況發生的時候,會觸發加載Class文件:

  • 遇到new、getstatic、putstatic或者invokestatic字節碼指令的時候,如果類還沒有初始化。對應場景為: new一個對象; 讀取或者設置一個類的靜態字段; 調用類的靜態方法的時候;
  • 使用java.lang.reflect包的方法對類進行反射的時候,如果類還沒有初始化;
  • 初始化類的時候,如果父類還沒有初始化,則觸發父類初始化;
  • 虛擬機器啟動時,main方法所在的類會首先進行初始化;
  • JDK1.7中使用動態語言支持的時候,如果一個java.lang.invoke.MethodHandler實例最後解析為:REF_getStatic,REF_putStatic,REF_invokeStatic方法句柄的時候,並且句柄所對應的類沒有進行過初始化。

這個時候通過類的全限定名稱獲取類的二進制字節流。

此時這個字節流為靜態存儲結構,需要轉換為方法區的運行時數據結構。結構如上圖方法區中所示。每個類生成一個對應的結構,結構裡面的信息詳細介紹參考此文:The Java Virtual Machine

其中:

ClassLoader的引用指的是加載這個Class文件的ClassLoader實例的引用;

Class實例引用指的是類加載器在加載類信息並放到方法區之後,然後創建對應的Class類型的實例,並把該實例的引用保存到Class實例引用中。

1.1.2、獲取二進制流的方式

如上圖描述的,JVM規範5.3. Creation and Loading並沒有指定class文件二進制流需要從哪裡以什麼方式獲取,目前主要有以下幾種獲取方式:

  • zip包,延伸為JAR、EAR、WAR包;
  • 網絡,如Applet;
  • 動態代理;
  • JSP生成;
  • 數據庫獲取;

1.1.3、驗證二進制字節流

如上圖所示,在加載階段就已經開始做部分驗證工作了,但是驗證還是屬於連接階段的動作,下面介紹驗證階段。

1.2、連接階段

Class文件是如何被加載進JVM的?一篇圖文帶你徹底弄懂

如上圖:連接階段包括:驗證,準備,解析

1.2.1、驗證階段

驗證階段做什麼事情

為了解釋這一步的作用,我們先來做一個實驗。

有如下一個類:

<code>package com.itzhai.jvm.loadclass;

/**
* Created by arthinking on 4/1/2020.
*/
public class TestVerify {

public static void main(String[] args) {
System.out.println("Hello world !!!");
}
}/<code>

我們把Java文件編譯為class文件,並執行之:

java com.itzhai.jvm.loadclass.TestVerify

可以發現輸出:

<code>Hello world !!!/<code>

現在我們使用前面Class文件16進制背後的秘密介紹的十六進制編輯方法,對class文件進行隨意編輯,這裡我們可以把常量池計數器故意調小一點,保存之後再次執行class文件:

<code>Error: A JNI error has occurred, please check your installation and try again 

Exception in thread "main" java.lang.ClassFormatError: Invalid constant pool index 33 in class file com/itzhai/jvm/loadclass/TestVerify
\tat java.lang.ClassLoader.defineClass1(Native Method)
\tat java.lang.ClassLoader.defineClass(ClassLoader.java:760)
\tat java.security.SecureClassLoader.defineClass(SecureClassLoader.java:142)
\tat java.net.URLClassLoader.defineClass(URLClassLoader.java:467)
\tat java.net.URLClassLoader.access$100(URLClassLoader.java:73)
\tat java.net.URLClassLoader$1.run(URLClassLoader.java:368)
\tat java.net.URLClassLoader$1.run(URLClassLoader.java:362)
\tat java.security.AccessController.doPrivileged(Native Method)
\tat java.net.URLClassLoader.findClass(URLClassLoader.java:361)
\tat java.lang.ClassLoader.loadClass(ClassLoader.java:424)
\tat sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
\tat java.lang.ClassLoader.loadClass(ClassLoader.java:357)
\tat sun.launcher.LauncherHelper.checkAndLoadMain(LauncherHelper.java:495)/<code>

可以發現拋出了異常:非法的常量池索引33,這正是驗證階段乾的事情。

驗證階段幹什麼事情

我們知道,class文件是可以被認為篡改的,虛擬機如果直接拿來執行,可能會把系統給搞崩潰了,所以一定要先對Class文件做嚴格的驗證。驗證階段主要完成以下檢測動作:

1.2.1.1、文件格式驗證

主要按照Class文件16進制背後的秘密文章中的闡述的格式,嚴格的進行校驗。

1.2.1.2、元數據驗證

主要是語義校驗,保證不存在不符合Java語言規範的元數據信息,如:沒有父類,繼承了final類,接口的非抽象類實現沒有完整實現方法等。

1.2.1.3、字節碼驗證

主要對數據流和控制流進行分析,確定成行語義是否合法,符合邏輯。不合法的例子:

  • 操作數棧放置了int類型數據,卻當成long類型使用;
  • 把父類對象賦值給了子類數據類型;
  • ...

1.2.1.4、符號引用驗證

解析階段發生的驗證,當把符號引用轉化為直接引用的時候進行驗證。這主要是對類自身以外的信息進行匹配性校驗。主要包括:

  • 全限定名是否可以找到對應的類;
  • 指定類是否存在符合方法的字段描述符以及簡單名稱所描述的方法和字段;
  • 校驗類,字段和方法的可見性;

1.2.2、準備階段

這個階段還並沒有開始執行類的構造方法,而只是為類變量分配內存並設置類變量初始值(零值)。這些變量所使用的內存都將在方法區中分配。

基本數據類型的零值:2.3. Primitive Types and Values

這裡只分配static變量,不包括實例變量。

注意:static final類型的常量value會在準備階段被初始化為常量指定的值。

靜態變量存儲在內存的PremGen(方法區域)空間中,其值存儲在Heap中

1.2.3、解析階段

解析階段主要將常量池內的符號引用替換為直接引用。

**符號引用:**字面量,引用目標不一定已經加載到內存中;

**直接引用:**直接指向目標的指針,或者相對偏移量,或是一個能簡介定位到目標的句柄。直接引用和虛擬機實現的內存佈局相關。

**關於動態語言的支持:**通過invokedynamic指令支持動態語言。該指令會對符號引用進行解析,但是不會緩存解析的結果,每次執行指令都需要重新解析。

解析主要針對以下七類符號引用進行:

  • 類或接口 CONSTANT_Class_info
  • 字段 CONSTANT_Fieldref_info
  • 類方法 CONSTANT_Methodref_info
  • 接口方法 CONSTANT_InterfaceMethodref_info
  • 方法類型 CONSTANT_MethodType_info
  • 方法句柄 CONSTANT_MethodHandle_info
  • 調用限定符 CONSTANT_InvokeDynamic_info

常量池中的14種常量結構

符號引用解析的過程或校驗的過程中,可能又會觸發另一個類的加載。

1.3、初始化階段

Class文件是如何被加載進JVM的?一篇圖文帶你徹底弄懂

這階段開始執行Java程序代碼,這一步主要是執行類構造器<clinit>方法對類變量進行初始化的過程,注意,這個方法不是構造方法。/<clinit>

下面就來介紹一下這個方法:

1.3.1、<clinit>方法/<clinit>

此方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中的語句合併產生的方法,主要是給類變量做初始化工作的方法。

生成<clinit>方法的實例/<clinit>

有如下代碼:

<code>public class TestInit {

static {
DESC = "hello world!!!";
}

private static String DESC;

public void test() {
DESC = "a";
}

public static void main(String[] args) {
System.out.println(DESC);
}

}/<code>

這個類中有一個靜態變量DESC,並且在靜態代碼塊中進行了賦值操作,我們看看其生成的彙編代碼:

<code>Constant pool:
#1 = Methodref #8.#26 // java/lang/Object."<init>":()V
#2 = String #27 // a
#3 = Fieldref #7.#28 // com/itzhai/classes/TestInit.DESC:Ljava/lang/String;
...
#7 = Class #34 // com/itzhai/classes/TestInit
...
#9 = Utf8 DESC
#10 = Utf8 Ljava/lang/String;
...
#23 = Utf8 <clinit>
#28 = NameAndType #9:#10 // DESC:Ljava/lang/String;
...

static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=1, locals=0, args_size=0
0: ldc #6 // String hello world!!!
2: putstatic #3 // Field DESC:Ljava/lang/String;
5: return
LineNumberTable:
line 9: 0
line 10: 5/<clinit>/<init>/<code>

可以發現,生成了這樣的一個方法。此方法既是生成的<clinit>方法。這裡指令比較簡單,主要是:拿到”hello world!!!“字符串的引用,把他設置到DESC類變量中。/<clinit>

關於<clinit>方法的注意事項/<clinit>

  • **順序問題:**靜態語句塊後面的靜態變量,靜態語句塊中可以賦值,但不可以訪問;
  • **繼承執行順序:**無需顯示調用,虛擬機會保證子類的<clinit>方法執行前,父類的<clinit>方法已經執行完畢;/<clinit>/<clinit>
  • **接口的<clinit>方法:**雖然接口不能有靜態語句塊,但是可以給靜態變量初始化值,所以也可以生成<clinit>方法;/<clinit>/<clinit>
  • **接口繼承:**除非使用到父接口的變量,否則執行子接口的<clinit>方法不需要先執行父接口的<clinit>方法;/<clinit>/<clinit>
  • 在併發場景,虛擬機會保證一個類的<clinit>方法只有一個線程執行,其他線程會阻塞,所以要確保靜態代碼塊中不要寫可能回到成進程阻塞的代碼。/<clinit>


作者:arthinking_itzhai
原文鏈接:https://juejin.im/post/5e479b21518825492d4dd50b


分享到:


相關文章: