1. 程式人生 > >JVM詳解(二)-- 第2章 類載入器子系統

JVM詳解(二)-- 第2章 類載入器子系統

## 一、JVM記憶體結構 ### 1.1 記憶體結構---概略圖 ![Alt](https://myblog-1258060977.cos.ap-beijing.myqcloud.com/cnblog/JVM/JVM%E6%B5%81%E7%A8%8B.png) ### 1.2 記憶體結構--詳細圖 ![Alt text](https://myblog-1258060977.cos.ap-beijing.myqcloud.com/cnblog/JVM/JVM%E5%86%85%E5%AD%98%E7%BB%93%E6%9E%84%E8%AF%A6%E7%BB%86%E5%9B%BE.png) ## 二、類載入器子系統的作用 - 類載入器子系統負責從檔案系統或網路中載入`.Class`檔案,檔案需要有特定的標識(`cafe babe`)。 - `ClassLoader`只負責`.Class`檔案的載入,至於它是否可以執行,由執行引擎決定。 - 載入的類資訊存放於一塊被稱為“方法區”的記憶體空間。除了類資訊外,方法區還會存放執行時常量池資訊,可能還包括字串字面量(字面量指的是固定值,初始值)和數字常量(這部分常量資訊是`.Class`檔案中常量池部分的記憶體對映) - `.Class`檔案被解析載入到 JVM,類的物件載入到堆區,類資訊被載入到方法區(java8 中方法區的實現是“元空間”)。這部分工作是類載入子系統完成的 ## 三、類載入的過程 假設定義了一個類,名為`HelloWorld`,執行其 Main 方法,流程如圖: ![Alt text](https://myblog-1258060977.cos.ap-beijing.myqcloud.com/cnblog/JVM/%E7%B1%BB%E5%8A%A0%E8%BD%BD%E8%BF%87%E7%A8%8B.png) ### 3.1 載入(Loading) 載入(Loading)是狹義上的載入,“類載入”中的載入是廣義上的。 1. 通過一個類的全限定名獲取定義此類的二進位制位元組流; 2. 將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料機構; 3. 在記憶體中生成一個代表這個類的`java.lang.Class`物件,作為方法區這個類的各種資料的訪問入口。 **載入.class檔案的方式**: * 從本地系統中直接載入。 * 通過網路獲取,典型場景:Web Applet。 * 從zip壓縮包中讀取,成為日後Jar、war格式的基礎。 * 執行時計算生成,使用最多的是動態代理技術。 * 有其他檔案生成,典型場景是JSP應用。 * 從專有資料庫中提取.class檔案,比較少見。 * 從加密檔案中獲取,典型的防Class檔案被反編譯的保護措施。 ### 3.2 連結(Linking) 連結可細分為三步:驗證(verify)準備(prepare)、解析(resolve) #### 3.2.1 驗證 - 目的在於確保class檔案的位元組流中包含資訊符合當前虛擬機器要求,保證被載入類的正確性,不會危害虛擬機器自身安全。 - 主要包括四種驗證:檔案格式驗證、元資料驗證、位元組碼驗證、符號引用驗證。 #### 3.2.2 準備 - 為類的**變數**分配記憶體並設定預設初始值,即零值(不同資料型別的零值不同,布林值為false,引用型別為null)。下面這個語句在這個階段,a將被初始化為0。 ```java static int a = 2; ``` - 這裡不包含`final`修飾的`static`,因為`final`修飾的變數不再是變數而是不可更改的常量,在編譯期就已經分配記憶體,準備階段會將其顯示初始化。如上面的賦值語句,加上`final`後,a的值就被初始化為2。 ```java final static int a = 2; ``` - 這裡不會為**例項變數**分配記憶體和初始化,類變數會分配在方法區中,而例項變數是會隨著物件一起分配到Java堆中。 #### 3.2.3 解析 - 將常量池內的符號引用轉換為直接引用的過程。 - 事實上,解析動作往往伴隨著 JVM 在執行完初始化之後再執行。 ### 3.3 初始化(Initialization) 1. 初始化階段就是執行類構造方法`()`的過程。 2. 此方法不需要定義,是javac編譯器自動收集類中所有類靜態變數的賦值動作和靜態程式碼塊的語句合併而來。 - 只有當有靜態變數`static int a = 1;`或者靜態程式碼塊`static {}`時才會建立並執行該方法。 - 靜態方法並不會使得虛擬機器建立執行該方法。 3. `()`中指令按語句在原始檔中出現的順序執行。具體表現就是一個靜態變數的最後的取值決定於最後一行它的賦值語句。 ```java public class ClassInitTest { private static int num = 1; static{ num = 2; number = 20; System.out.println(num); //System.out.println(number);//報錯:非法的前向引用。 } private static int number = 10; //linking之prepare: number = 0 --> initial: 20 --> 10 public static void main(String[] args) { System.out.println(ClassInitTest.num);//2 System.out.println(ClassInitTest.number);//10 } } ``` 在以上程式碼中,num和number的最終值分別為2和10。 4. `()`不同於我們常說的類的建構函式。類的建構函式在虛擬機器中是`()`,在任何時候都是會建立的,因為所有的類都至少有一個預設無參建構函式。 5. 若該類具有父類,JVM會保證子類的 `()`執行前,父類的 `()`已經執行完畢。 6. 虛擬機器必須保證一個類的 `()`方法在多執行緒下被同步加鎖。保證同一個類只被虛擬機器載入一次(只調用一次 `()`),後續其它執行緒再使用類,只需要在虛擬機器的快取中獲取即可。 ## 四、類載入器的分類 類載入分為啟動類載入器、擴充套件類載入器、應用程式類載入器(系統類載入器)、自定義載入器。如下圖: ![Alt](https://myblog-1258060977.cos.ap-beijing.myqcloud.com/cnblog/JVM/%E5%8F%8C%E4%BA%B2%E5%A7%94%E6%B4%BE%E6%A8%A1%E5%9E%8B.png) 需要注意的是,它們四者並非子父類的繼承關係。以下展示瞭如何獲取類載入器: ```java public class ClassLoaderTest { public static void main(String[] args) { //獲取系統類載入器 ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader(); System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2 //獲取其上層:擴充套件類載入器 ClassLoader extClassLoader = systemClassLoader.getParent(); System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d //獲取其上層:獲取不到引導類載入器 ClassLoader bootstrapClassLoader = extClassLoader.getParent(); System.out.println(bootstrapClassLoader);//null //對於使用者自定義類來說:預設使用系統類載入器進行載入 ClassLoader classLoader = ClassLoaderTest.class.getClassLoader(); System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2 //String類使用引導類載入器進行載入的。---> Java的核心類庫都是使用引導類載入器進行載入的。 ClassLoader classLoader1 = String.class.getClassLoader(); System.out.println(classLoader1);//null } } ``` 關於幾種類載入器具體的載入物件和雙親委派機制可以參考:[玩命學JVM-類載入機制](https://www.cnblogs.com/cleverziv/p/13759175.html) ### 4.1 使用者自定義類載入器 #### 4.1.1 為什麼要自定義類載入器 1. 隔離載入類。 2. 修改類載入的方式。除了bootstrap classloader是必須要用到的,其它的類載入器都不必須。 3. 擴充套件載入源。 4. 防止原始碼洩露。java程式碼很容易被反編譯和篡改。因此有對原始碼進行加密的需求,在這個過程中就會用到自己定義的類載入器去完成解密和類載入。 #### 4.1.2 如何自定義類載入器 **步驟** 1. 繼承`java.lang.ClassLoade`r的方式,實現自己的類載入器。 2. 建議不要去覆蓋`loadClass()`,而是重寫`findClass()`。 3. 如果沒有太複雜的需求(解密、從不同的路徑下載入),那麼可直接繼承`URLClassLoade`r,這樣可以避免自己去編寫findClass()方法以及其獲取位元組碼流的方式,使其自定義類載入器編寫更加簡潔。 **樣例程式碼** ```java public class CustomClassLoader extends ClassLoader { @Override protected Class findClass(String name) throws ClassNotFoundException { try { byte[] result = getClassFromCustomPath(name); if(result == null){ throw new FileNotFoundException(); }else{ return defineClass(name,result,0,result.length); } } catch (FileNotFoundException e) { e.printStackTrace(); } throw new ClassNotFoundException(name); } private byte[] getClassFromCustomPath(String name){ //從自定義路徑中載入指定類:細節略 //如果指定路徑的位元組碼檔案進行了加密,則需要在此方法中進行解密操作。 return null; } public static void main(String[] args) { CustomClassLoader customClassLoader = new CustomClassLoader(); try { Class clazz = Class.forName("One",true,customClassLoader); Object obj = clazz.newInstance(); System.out.println(obj.getClass().getClassLoader()); } catch (Exception e) { e.printStackTrace(); } } } ``` ### 4.2 關於ClassLoader 它是一個抽象類,其後所有的類載入器都繼承自`ClassLoader`(不包括啟動類載入器)。 **API** 1. `getParent()` 返回該類載入器的超類載入器。 2. `loadClass(String name)` 載入名稱為name的類,返回結果為java.lang.Class類的例項。 3. `findClass(String name)` 查詢名稱為name的類,返回結果為java.lang.Class類的例項。 4. `findLoaderClass(String name)` 查詢名稱為name的已經被載入過的類,返回結果為java.lang.Class類的例項。 5. `defineClass(String name, byte[] b, int off, int len)` 把位元組陣列b中的內容轉換為一個Java類,返回結果為java.lang.Class類的例項。與 findClass(String name) 搭配使用 6. `resolveClass(Classc)` 連線一個指定的Java類 ### 4.3 雙親委派機制 詳見 https://www.cnblogs.com/cleverziv/p/13759175.html 自定義的一個java.lang.String不能載入到的JVM中,原因: 使用自定義的java.lang.String時,首先是應用類載入器向上委託到擴充套件類載入器,然後擴充套件類載入器向上委託給引導類載入器,引導類載入接收到類的資訊,發現該類的路徑時“java.lang.String”,這在引導類載入器的載入範圍內,因此引導類載入器開始載入“java.lang.String”,只不過此時它載入的是jdk核心類庫裡的“java.lang.String”。這就是雙親委派機制中的向上委託。在完成向上委託之後,如到了引導類載入器,引導類載入器發現待載入的類不屬於自己載入的類範圍,就會再向下委託給擴充套件類載入器,讓下面的載入器進行類的載入。 **優勢** 1. 避免類的重複載入。類載入器+類本身決定了 JVM 中的類載入,雙親委派機制保證了只會有一個類載入器去載入類。 2. 保護程式安全,防止核心api被篡改 **沙箱安全機制** 上文中提到的java.lang.String就是沙箱安全機制的表現,保證了對java核心原始碼的保護。 ## 五、幾個JVM常出現的術語解析 ### 5.1 字面量 首先來看一下百度百科的定義: > 在電腦科學中, 字面量(literal)是用於表達原始碼中一個固定值的表示法(notation)。幾乎所有計算機程式語言都具有對基本值的字面量表示, 諸如: 整數, 浮點數以及字串; 而有很多也對布林型別和字元型別的值也支援字面量表示; 還有一些甚至對列舉型別的元素以及像陣列, 記錄和物件等複合型別的值也支援字面量表示法. 這段話不太好理解,我們來拆解下(注意下面這段話純屬個人理解): > “字面量(literal)是用於表達原始碼中一個固定值的表示法(notation)”,這裡說明了兩點:第一,字面量是體現在原始碼中的;第二,字面量是對一個固定值的表示。接下來它提到了“幾乎所有計算機程式語言都具有對基本值的字面量表示”,並以一些基本資料型別、列舉、陣列等資料型別舉例。它們都有一個特點,就是它們的賦值是可以做到“程式碼視覺化的”。你可以在程式碼中給以上提到的型別進行賦值。而我們賦值時所給出的“值”,更準確來說是一種“表示”(比如給陣列賦值時,約定了需要用大括號括起來)就是字面量的含義。 舉個例子: ```java int i = 1; String s = "abs"; int[] a = {1, 3, 4}; // 以上 1,“abc”,{1,3,4}均是字面量 ``` ### 5.2 符號引用、直接引用 同樣,先來看一下書面定義: > *符號引用*:符號引用以一組符號來描述所引用的目標,符號引用可以是任何形式的字面量,只要使用時能夠無歧義的定位到目標即可。比如org.simple.People類引用了org.simple.Language類,在編譯時People類並不知道Language類的實際記憶體地址,因此只能使用符號org.simple.Language(假設是這個,當然實際中是由類似於CONSTANT_Class_info的常量來表示的)來表示Language類的地址。各種虛擬機器實現的記憶體佈局可能有所不同,但是它們能接受的符號引用都是一致的,因為符號引用的字面量形式明確定義在Java虛擬機器規範的Class檔案格式中。 > > *直接引用*: 直接引用可以是 (1)直接指向目標的指標(比如,指向“型別”【Class物件】、類變數、類方法的直接引用可能是指向方法區的指標) (2)相對偏移量(比如,指向例項變數、例項方法的直接引用都是偏移量) (3)一個能間接定位到目標的控制代碼 直接引用是和虛擬機器的佈局相關的,同一個符號引用在不同的虛擬機器例項上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經被載入入記憶體中了。 說實話看這種書面化語言抽象晦澀,下面給出一些自己的理解吧。首先找到一個.class檔案(來源:[玩命學JVM(一)]((https://www.cnblogs.com/cleverziv/p/13751488.html)))反編譯後的結果中常量池的部分: ```java Constant pool: #1 = Methodref #6.#15 // java/lang/Object."":()V #2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream; #3 = String #18 // Hello World #4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V #5 = Class #21 // Main #6 = Class #22 // java/lang/Object #7 = Utf8 #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 main #12 = Utf8 ([Ljava/lang/String;)V #13 = Utf8 SourceFile #14 = Utf8 Main.java #15 = NameAndType #7:#8 // "":()V #16 = Class #23 // java/lang/System #17 = NameAndType #24:#25 // out:Ljava/io/PrintStream; #18 = Utf8 Hello World #19 = Class #26 // java/io/PrintStream #20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V #21 = Utf8 Main #22 = Utf8 java/lang/Object #23 = Utf8 java/lang/System #24 = Utf8 out #25 = Utf8 Ljava/io/PrintStream; #26 = Utf8 java/io/PrintStream #27 = Utf8 println #28 = Utf8 (Ljava/lang/String;)V ``` 為什麼只看常量池呢,因為在“解析”的定義中提到了:解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程。我們來看看常量池中有什麼: > *常量池*:常量池指的是位元組碼檔案中的Constant pool部分。它是靜態的,當編譯生成位元組碼檔案直接就不變了。常量池中包括各種字面量和對型別、域和方法的符號引用。幾種在常量池記憶體儲的資料型別包括:數量值、字串值、類引用、欄位引用、方法引用。 由此我們可以看出,上面我們給出的常量池中都屬於“符號引用”(符號引用本身就是一種字面量)或字面量。我們不禁要問了了,那直接引用在哪呢? 我找到了《深入理解Java虛擬機器》中的一句話: > 對同一個符號引用進行多次解析請求是很常見的事情,除invokedynamic指令外,虛擬機器實現可以對第一次解析的結果進行快取(在執行時常量池中記錄直接引用,並把常量標識為已解析狀態)從而避免解析動作重複進行。 關鍵是括號中話給了啟發,說明直接引用是放在執行時常量池中的,接下來我們看看執行時常量池的一些定義或特性。 > 執行時常量池是方法區的一部分。常量池用於存放編譯期生成的各種字面量和符號引用,這部分內容將在類載入後存放到方法區的執行時常量池中。而載入類和介面到虛擬機器後,就會建立對應的執行時常量池。 執行時常量池與常量池的不同點在於: 1. 執行時常量池中包括了編譯期就已經明確的數值字面量,也包括在執行期解析後才能獲得的方法或欄位引用。但,請注意,此時的方法或欄位引用已經不再是常量池中的“符號引用”,而是“直接引用”。 2. 執行時常量池具備“動態性”。 至此,關於“符號引用”和“直接引用”的解釋就差不多了。最後我再多說一句,再分析的時候,我一直在疑惑“直接引用”到底是什麼,我能不能像看到常量池中的內容一樣看到“直接引用”。實際上,我們並不能拿到這樣一個檔案,裡面整齊地寫了直接引用的具體內容,因為直接引用不是所謂的“字面量”。但我們可以回到“直接引用”的最初定義:直接引用可以是指向目標的指標、相對偏移量或是能間接定位到目標的控制代碼,可以想象一下在執行時,在記憶體中存放的直接引用大概是什麼內容。 ## 六、其它 **JVM 中兩個Class物件是否為同一個類的必要條件** 1. 類的完整類名必須一致,包括包名。 2. 載入這個類的ClassLoader(指ClassLoader例項物件)必須相同。 **對類載入器的引用** JVM 必須知道一個型別是由啟動類載入器載入的還是由使用者類載入器載入的,如果一個型別是由使用者類載入器載入的,那麼 JVM 會將這個類載入器的一個引用作為型別資訊的一部分儲存在方法區中,當解析一個型別到另一個型別的引用的時候, JVM 需要保證這兩個型別的類載入器是相同的。 **類的主動使用和被動使用** 主動使用和被動使用的區別是,主動使用會導致類的初始化。 主動使用有以下七種情況: 1. 建立類的例項。 2. 訪問某個類或介面的靜態變數,或者對該靜態變數賦值。 3. 呼叫類的靜態方法。 4. 反射(比如:Class.forName("com,atguigu.Test"))。 5. 初始化一個類的子類。 6. Java虛擬機器啟動時被標明為啟動的類。 7. JDK 7開始提供的動態語言支援:java.lang.invoke.MethodHandle 例項的解析結果 REF_getStatic、REF_putStatic、 REF_invokeStatic控制代碼對應的類沒有初始化則初始化。 參考文獻: https://blog.csdn.net/u011069294/article/details/107489721 https://www.cnblogs.com/cleverziv/p/13751488.html