【JVM】類載入子系統(二)
一、記憶體結構概述
如果自己想手寫一個Java虛擬機器的話,主要考慮哪些結構呢?
-
類載入器
-
執行引擎
完整框圖:
二、類載入子系統
2.1、類載入器子系統作用
-
類載入器子系統負責從檔案系統或者網路中載入Class檔案,class檔案在檔案開頭有特定的檔案標識。
-
ClassLoader只負責class檔案的載入,至於它是否可以執行,則由Execution Engine決定。
-
載入的類資訊存放於一塊稱為方法區的記憶體空間。除了類的資訊外,方法區中還會存放執行時常量池資訊,可能還包括字串字面量和數字常量(這部分常量資訊是Class檔案中常量池部分的記憶體對映)
2.2 從 class --> Java.lang.Class
-
class file存在於本地硬碟上,可以理解為設計師畫在紙上的模板,而最終這個模板在執行的時候是要載入到JVM當中來根據這個檔案例項化出n個一模一樣的例項。
-
class file載入到JVM中,被稱為DNA元資料模板,放在方法區。
-
在.class檔案–>JVM–>最終成為元資料模板,此過程就要一個運輸工具(類裝載器Class Loader),扮演一個快遞員的角色。
三、類載入過程
3.1、類載入過程概述
- 看程式碼
1 public class HelloLoader {
-
它的載入過程是怎麼樣的呢?
-
-
執行 main() 方法(靜態方法)就需要先載入承載類 HelloLoader
-
載入成功,則進行連結、初始化等操作,完成後呼叫 HelloLoader 類中的靜態方法 main
-
載入失敗則丟擲異常
-
整的流程所示:載入 --> 連結(驗證 --> 準備 --> 解析) --> 初始化
3.2、載入階段
載入流程
-
通過一個類的全限定名獲取定義此類的二進位制位元組流
-
將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構
-
在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口
載入class檔案的方式
-
從本地系統中直接載入
-
通過網路獲取,典型場景:Web Applet
-
從zip壓縮包中讀取,成為日後jar、war格式的基礎
-
執行時計算生成,使用最多的是:動態代理技術
-
由其他檔案生成,典型場景:JSP應用從專有資料庫中提取.class檔案,比較少見
-
從加密檔案中獲取,典型的防Class檔案被反編譯的保護措施
3.3、連結階段
- 連結分為三個子階段:驗證 --> 準備 --> 解析
3.3.1、驗證(Verify)
-
目的在於確保Class檔案的位元組流中包含資訊符合當前虛擬機器要求,保證被載入類的正確性,不會危害虛擬機器自身安全
-
主要包括四種驗證,檔案格式驗證,元資料驗證,位元組碼驗證,符號引用驗證。
舉例
- 使用 工具 檢視位元組碼檔案,其開頭均為 CAFE BABE ,如果出現不合法的位元組碼檔案,那麼將會驗證不通過
3.3.2、準備(Prepare)
-
為類變數分配記憶體並且設定該類變數的預設初始值,即零值
-
這裡不包含用final修飾的static,因為final在編譯的時候就會分配好了預設值,準備階段會顯式初始化
-
注意:這裡不會為例項變數分配初始化,類變數會分配在方法區中,而例項變數是會隨著物件一起分配到Java堆中
舉例
- 程式碼:變數a在準備階段會賦初始值,但不是1,而是0,在初始化階段會被賦值為 1
1 public class { 2 private static int a = 1; //prepare:a = 0 ---> initial : a = 1 3 4 public static void main(String[] args) { 5 System.out.println(a); 6 } 7 }
3.3.3、解析(Resolve)
-
將常量池內的符號引用轉換為直接引用的過程
-
事實上,解析操作往往會伴隨著JVM在執行完初始化之後再執行
-
符號引用就是一組符號來描述所引用的目標。符號引用的字面量形式明確定義在《java虛擬機器規範》的class檔案格式中。直接引用就是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制代碼
-
解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別等。對應常量池中的CONSTANT Class info、CONSTANT Fieldref info、CONSTANT Methodref info等
符號引用
使用javap命令反編譯class檔案檢視內容,發現很多符號應用
命令:javap -v HelloApp.class
3.4、初始化階段
3.4.1、初始化階段
-
初始化階段就是執行類構造器方法
<clinit>()
的過程 -
此方法不需定義,是javac編譯器自動收集類中的所有類變數的賦值動作和靜態程式碼塊中的語句合併而來。也就是說,當我們程式碼中包含static變數的時候,就會有clinit方法
-
<clinit>()
方法中的指令按語句在原始檔中出現的順序執行 -
<clinit>()
不同於類的構造器。(關聯:構造器是虛擬機器視角下的<init>()
) -
若該類具有父類,JVM會保證子類的
<clinit>()
執行前,父類的<clinit>()
已經執行完畢 -
虛擬機器必須保證一個類的
<clinit>()
方法在多執行緒下被同步加鎖
3.4.2、IDEA 中安裝 JClassLib 外掛
在 IDEA 中安裝 JClassLib 外掛後,重啟 IDEA 生效
-
選中對應的編譯後的Java 類檔案進行,注意:不是位元組碼檔案~!
-
點選【View --> Show Bytecode With jclasslib】即可檢視反編譯後的程式碼
示例 1:無 static 變數
- 程式碼
1 public class ClinitTest { 2 private int a = 1; 3 4 public static void main(String[] args) { 5 int b = 2; 6 } 7 }
- 並沒有生成 clinit 方法
示例 2:有 static 變數
- 程式碼
public class ClinitTest2 { //任何一個類宣告以後,內部至少存在一個類的構造器 private int a = 1; private static int c = 3; public static void main(String[] args) { int b = 2; } }
- 在 clinit 方法中初始化靜態變數的值為 3
示例 3:構造器方法中指令按語句在原始檔中出現的順序執行
- 程式碼:
public class ClassInitTest { private static int num = 1; static { num = 2; //linking之prepare: number = 0 --> initial: 1 --> 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 的值變化過程如下
-
- 備階段時:0
- 執行靜態變數初始化:1
- 執行靜態程式碼塊:2
- 靜態變數 number 的值變化過程如下
- 準備階段時:0
- 執行靜態程式碼塊:20
- 執行靜態變數初始化:10
例項 4:若該類具有父類,JVM會保證子類的<clinit>()
執行前,父類的<clinit>()
已經執行完畢
- 程式碼
1 public class ClinitTest3 { 2 static class Father { 3 public static int A = 1; 4 5 static { 6 A = 2; 7 } 8 } 9 10 static class Son extends Father { 11 public static int B = A; 12 } 13 14 public static void main(String[] args) {
15 //載入Father類,其次載入Son類。 16 System.out.println(Son.B);//2 17 } 18 } -
如上程式碼,載入流程如下:
-
首先,執行 main() 方法需要載入 ClinitTest1 類
-
獲取 Son.B 靜態變數,需要載入 Son 類
-
Son 類的父類是 Father 類,所以需要先執行 Father 類的載入,再執行 Son 類的載入
-
示例 5:虛擬機器必須保證一個類的<clinit>()
方法在多執行緒下被同步加鎖
- 程式碼
1 public class DeadThreadTest { 2 public static void main(String[] args) { 3 Runnable r = () -> { 4 System.out.println(Thread.currentThread().getName() + "開始"); 5 DeadThread dead = new DeadThread(); 6 System.out.println(Thread.currentThread().getName() + "結束"); 7 }; 8 9 Thread t1 = new Thread(r, "執行緒1"); 10 Thread t2 = new Thread(r, "執行緒2"); 11 12 t1.start(); 13 t2.start(); 14 } 15 } 16 17 class DeadThread { 18 static { 19 if (true) { 20 System.out.println(Thread.currentThread().getName() + "初始化當前類"); 21 while (true) { 22 23 } 24 } 25 } 26 }
-
程式卡死,分析原因:
-
兩個執行緒同時去載入 DeadThread 類,而 DeadThread 類中靜態程式碼塊中有一處死迴圈
-
先載入 DeadThread 類的執行緒搶到了同步鎖,然後在類的靜態程式碼塊中執行死迴圈,而另一個執行緒在等待同步鎖的釋放
-
所以無論哪個執行緒先執行 DeadThread 類的載入,另外一個類也不會繼續執行
-
四、類載入器的分類
4.1、類載入器概述
4.1.1、類載入器的分類
-
JVM支援兩種型別的類載入器 。分別為引導類載入器(Bootstrap ClassLoader)和自定義類載入器(User-Defined ClassLoader)
-
從概念上來講,自定義類載入器一般指的是程式中由開發人員自定義的一類類載入器,但是Java虛擬機器規範卻沒有這麼定義,而是將所有派生於抽象類ClassLoader的類載入器都劃分為自定義類載入器
-
無論類載入器的型別如何劃分,在程式中我們最常見的類載入器始終只有3個,如下所示
-
這裡的四者之間是包含關係,不是上層和下層,也不是子父類的繼承關係。
4.1.2、為什麼 ExtClassLoader 和 AppClassLoader 都屬於自定義載入器
-
規範定義:所有派生於抽象類ClassLoader的類載入器都劃分為自定義類載入器
-
ExtClassLoader 繼承樹
- AppClassLoader 繼承樹
- 程式碼:
- 我們嘗試獲取引導類載入器,獲取到的值為 null ,這並不代表引導類載入器不存在,因為引導類載入器右 C/C++ 語言,我們獲取不到
- 兩次獲取系統類載入器的值都相同:sun.misc.Launcher$AppClassLoader@18b4aac2 ,這說明系統類載入器是全域性唯一的
1 public class ClassLoaderTest { 2 public static void main(String[] args) { 3 4 //獲取系統類載入器 5 ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader(); 6 System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2 7 8 //獲取其上層:擴充套件類載入器 9 ClassLoader extClassLoader = systemClassLoader.getParent(); 10 System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@610455d6 11 12 //獲取其上層:獲取不到引導類載入器 13 ClassLoader bootstrapClassLoader = extClassLoader.getParent(); 14 System.out.println(bootstrapClassLoader);//null 15 16 //對於使用者自定義類來說:預設使用系統類載入器進行載入 17 ClassLoader classLoader = ClassLoaderTest.class.getClassLoader(); 18 System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2 19 20 //String類使用引導類載入器進行載入的。---> Java的核心類庫都是使用引導類載入器進行載入的。 21 ClassLoader classLoader1 = String.class.getClassLoader(); 22 System.out.println(classLoader1);//null 23 24 } 25 }
4.2、虛擬機器自帶的載入器
4.2.1、啟動類載入器
啟動類載入器(引導類載入器,Bootstrap ClassLoader)
-
這個類載入使用C/C++語言實現的,巢狀在JVM內部
-
它用來載入Java的核心庫(JAVA_HOME/jre/lib/rt.jar、resources.jar或sun.boot.class.path路徑下的內容),用於提供JVM自身需要的類
-
並不繼承自java.lang.ClassLoader,沒有父載入器
-
載入擴充套件類和應用程式類載入器,並作為他們的父類載入器(當他倆的爹)
-
出於安全考慮,Bootstrap啟動類載入器只加載包名為java、javax、sun等開頭的類
4.2.2、擴充套件類載入器
擴充套件類載入器(Extension ClassLoader)
-
Java語言編寫,由sun.misc.Launcher$ExtClassLoader實現
-
派生於ClassLoader類
-
父類載入器為啟動類載入器
-
從java.ext.dirs系統屬性所指定的目錄中載入類庫,或從JDK的安裝目錄的jre/lib/ext子目錄(擴充套件目錄)下載入類庫。如果使用者建立的JAR放在此目錄下,也會自動由擴充套件類載入器載入
4.2.3、系統類載入器
應用程式類載入器(系統類載入器,AppClassLoader)
-
Java語言編寫,由sun.misc.LaunchersAppClassLoader實現
-
派生於ClassLoader類
-
父類載入器為擴充套件類載入器
-
它負責載入環境變數classpath或系統屬性java.class.path指定路徑下的類庫
-
該類載入是程式中預設的類載入器,一般來說,Java應用的類都是由它來完成載入
-
通過classLoader.getSystemclassLoader()方法可以獲取到該類載入器
程式碼舉例說明
- 程式碼
1 public class ClassLoaderTest2 { 2 public static void main(String[] args) { 3 4 System.out.println("**********啟動類載入器**************"); 5 //獲取BootstrapClassLoader能夠載入的api的路徑 6 URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs(); 7 for (URL element : urLs) { 8 System.out.println(element.toExternalForm()); 9 } 10 //從上面的路徑中隨意選擇一個類,來看看他的類載入器是什麼:引導類載入器 11 ClassLoader classLoader = Provider.class.getClassLoader(); 12 System.out.println(classLoader);//null 13 14 System.out.println("***********擴充套件類載入器*************"); 15 String extDirs = System.getProperty("java.ext.dirs"); 16 for (String path : extDirs.split(";")) { 17 System.out.println(path); 18 } 19 20 //從上面的路徑中隨意選擇一個類,來看看他的類載入器是什麼:擴充套件類載入器 21 ClassLoader classLoader1 = CurveDB.class.getClassLoader(); 22 System.out.println(classLoader1);//sun.misc.Launcher$ExtClassLoader@4dc63996 23 24 } 25 }
- System.out.println(classLoader); 輸出 null ,再次證明我們無法獲取到啟動類載入器
4.3、使用者自定義類載入器
為什麼需要自定義類載入器?
在Java的日常應用程式開發中,類的載入幾乎是由上述3種類載入器相互配合執行的,在必要時,我們還可以自定義類載入器,來定製類的載入方式。那為什麼還需要自定義類載入器?
- 隔離載入類
- 修改類載入的方式
- 擴充套件載入源
- 防止原始碼洩漏
如何自定義類載入器?
- 開發人員可以通過繼承抽象類java.lang.ClassLoader類的方式,實現自己的類載入器,以滿足一些特殊的需求
- 在JDK1.2之前,在自定義類載入器時,總會去繼承ClassLoader類並重寫loadClass()方法,從而實現自定義的類載入類,但是在JDK1.2之後已不再建議使用者去覆蓋loadClass()方法,而是建議把自定義的類載入邏輯寫在findclass()方法中
- 在編寫自定義類載入器時,如果沒有太過於複雜的需求,可以直接繼承URLClassLoader類,這樣就可以避免自己去編寫findclass()方法及其獲取位元組碼流的方式,使自定義類載入器編寫更加簡潔。
程式碼示例
1 public class CustomClassLoader extends ClassLoader { 2 @Override 3 protected Class<?> findClass(String name) throws ClassNotFoundException { 4 5 try { 6 byte[] result = getClassFromCustomPath(name); 7 if (result == null) { 8 throw new FileNotFoundException(); 9 } else { 10 return defineClass(name, result, 0, result.length); 11 } 12 } catch (FileNotFoundException e) { 13 e.printStackTrace(); 14 } 15 16 throw new ClassNotFoundException(name); 17 } 18 19 private byte[] getClassFromCustomPath(String name) { 20 //從自定義路徑中載入指定類:細節略 21 //如果指定路徑的位元組碼檔案進行了加密,則需要在此方法中進行解密操作。 22 return null; 23 } 24 25 public static void main(String[] args) { 26 CustomClassLoader customClassLoader = new CustomClassLoader(); 27 try { 28 Class<?> clazz = Class.forName("One", true, customClassLoader); 29 Object obj = clazz.newInstance(); 30 System.out.println(obj.getClass().getClassLoader()); 31 } catch (Exception e) { 32 e.printStackTrace(); 33 } 34 } 35 }
4.4、關於 ClassLoader
ClassLoader 類介紹
- ClassLoader類,它是一個抽象類,其後所有的類載入器都繼承自ClassLoader(不包括啟動類載入器)
- sun.misc.Launcher 它是一個java虛擬機器的入口應用
獲取 ClassLoader 途徑
- 程式碼示例:
1 public class ClassLoaderTest3 { 2 public static void main(String[] args) throws ClassNotFoundException { 3 4 //1.Class.forName().getClassLoader() 5 ClassLoader classLoader = Class.forName("java.lang.String").getClassLoader(); 6 System.out.println(classLoader);// String 類由啟動類載入器載入,我們無法獲取 7 8 //2.Thread.currentThread().getContextClassLoader() 9 ClassLoader threadClassLoader = Thread.currentThread().getContextClassLoader(); 10 System.out.println(threadClassLoader); 11 12 //3.ClassLoader.getSystemClassLoader().getParent() 13 ClassLoader parentClassLoader = ClassLoader.getSystemClassLoader().getParent(); 14 System.out.println(parentClassLoader); 15 16 } 17 }
輸出
五、雙親委派機制
5.1、雙親委派機制原理
Java虛擬機器對class檔案採用的是按需載入的方式,也就是說當需要使用該類時才會將它的class檔案載入到記憶體生成class物件。而且載入某個類的class檔案時,Java虛擬機器採用的是雙親委派模式,即把請求交由父類處理,它是一種任務委派模式
-
如果一個類載入器收到了類載入請求,它並不會自己先去載入,而是把這個請求委託給父類的載入器去執行;
-
如果父類載入器還存在其父類載入器,則進一步向上委託,依次遞迴,請求最終將到達頂層的啟動類載入器;
-
如果父類載入器可以完成類載入任務,就成功返回,倘若父類載入器無法完成此載入任務,子載入器才會嘗試自己去載入,這就是雙親委派模式。
-
父類載入器一層一層往下分配任務,如果子類載入器能載入,則載入此類,如果將載入任務分配至系統類載入器也無法載入此類,則丟擲異常
5.2、雙親委派機制程式碼示例
舉例 1 :
- 程式碼:我們自己建立一個 java.lang.String 類,寫上 static 程式碼塊
1 package java.lang; 2 3 public class String { 4 static{ 5 System.out.println("我是自定義的String類的靜態程式碼塊"); 6 } 7 }
- 在另外的程式中載入 String 類,看看載入的 String 類是 JDK 自帶的 String 類,還是我們自己編寫的 String 類
1 public class StringTest { 2 3 public static void main(String[] args) { 4 java.lang.String str = new java.lang.String(); 5 System.out.println("hello world"); 6 7 StringTest test = new StringTest(); 8 System.out.println(test.getClass().getClassLoader()); 9 } 10 }
- 程式並沒有輸出我們靜態程式碼塊中的內容,可見仍然載入的是 JDK 自帶的 String 類
舉例 2 :
- 程式碼:在我們自己的 String 類中整個 main() 方法
1 package java.lang; 2 3 public class String { 4 static{ 5 System.out.println("我是自定義的String類的靜態程式碼塊"); 6 } 7 8 //錯誤: 在類 java.lang.String 中找不到 main 方法 9 public static void main(String[] args) { 10 System.out.println("hello,String"); 11 } 12 }
- 由於雙親委派機制找到的是 JDK 自帶的 String 類,在那個 String 類中並沒有 main() 方法
舉例 3 :
- 程式碼:在 java.lang 包下整個 ShkStart 類
1 package java.lang; 2 3 public class ShkStart { 4 public static void main(String[] args) { 5 System.out.println("hello!"); 6 } 7 }
- 出於保護機制,java.lang 包下不允許我們自定義類
舉例 4 :
當我們載入jdbc.jar 用於實現資料庫連線的時候
- 首先我們需要知道的是 jdbc.jar是基於SPI介面進行實現的
- 所以在載入的時候,會進行雙親委派,最終從根載入器中載入 SPI核心類,然後再載入SPI介面類
- 接著在進行反向委託,通過執行緒上下文類載入器進行實現類 jdbc.jar的載入。
5.3、雙親委派機制優勢
通過上面的例子,我們可以知道,雙親機制可以
- 避免類的重複載入
- 保護程式安全,防止核心API被隨意篡改
- 自定義類:java.lang.String 沒有屌用
- 自定義類:java.lang.ShkStart(報錯:阻止建立 java.lang開頭的類)
六、沙箱安全機制
-
自定義String類時:在載入自定義String類的時候會率先使用引導類載入器載入,而引導類載入器在載入的過程中會先載入jdk自帶的檔案(rt.jar包中java.lang.String.class),報錯資訊說沒有main方法,就是因為載入的是rt.jar包中的String類。
-
這樣可以保證對java核心原始碼的保護,這就是沙箱安全機制。
七、其他
如何判斷兩個class物件是否相同?
在JVM中表示兩個class物件是否為同一個類存在兩個必要條件:
-
類的完整類名必須一致,包括包名
-
載入這個類的ClassLoader(指ClassLoader例項物件)必須相同
-
換句話說,在JVM中,即使這兩個類物件(class物件)來源同一個Class檔案,被同一個虛擬機器所載入,但只要載入它們的ClassLoader例項物件不同,那麼這兩個類物件也是不相等的
對類載入器的引用
-
JVM必須知道一個型別是由啟動載入器載入的還是由使用者類載入器載入的
-
如果一個型別是由使用者類載入器載入的,那麼JVM會將這個類載入器的一個引用作為型別資訊的一部分儲存在方法區中
-
當解析一個型別到另一個型別的引用的時候,JVM需要保證這兩個型別的類載入器是相同的
類的主動使用和被動使用
Java程式對類的使用方式分為:主動使用和被動使用。主動使用,又分為七種情況:
-
建立類的例項
-
訪問某個類或介面的靜態變數,或者對該靜態變數賦值
-
呼叫類的靜態方法
-
反射(比如:Class.forName(“com.atguigu.Test”))
-
初始化一個類的子類
-
Java虛擬機器啟動時被標明為啟動類的類
-
JDK7開始提供的動態語言支援:java.lang.invoke.MethodHandle例項的解析結果REF_getStatic、REF putStatic、REF_invokeStatic控制代碼對應的類沒有初始化,則初始化
除了以上七種情況,其他使用Java類的方式都被看作是對類的被動使用,都不會導致類的初始化,即不會執行初始化階段(不會呼叫 clinit() 方法和 init() 方法)