深入理解java虛擬機器筆記Chapter7
虛擬機器類的載入機制
概述
虛擬機器把描述類的資料從Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的Java型別,這就是虛擬機器的類的載入機制。
類載入的時機
JVM 會在程式第一次主動引用類的時候,載入該類,被動引用時並不會引發類載入的操作。也就是說,JVM 並不是在一開始就把一個程式就所有的類都載入到記憶體中,而是到不得不用的時候才把它載入進來,而且只加載一次。那麼什麼是主動引用,什麼是被動引用呢?
-
主動引用
-
遇到 new、getstatic、putstatic、invokestatic 位元組碼指令,例如:
- 使用 new 例項化物件;
- 讀取或設定一個類的 static 欄位(被 final 修飾的除外);
- 呼叫類的靜態方法。
- 對類進行反射呼叫;
-
初始化一個類時,其父類還沒初始化(需先初始化父類);
- 這點類與介面具有不同的表現,介面初始化時,不要求其父介面完成初始化,只有真正使用父介面時才初始化,如引用父介面中定義的常量。
- 虛擬機器啟動,先初始化包含 main() 函式的主類;
- JDK 1.7 動態語言支援:一個
java.lang.invoke.MethodHandle
的解析結果為REF_getStatic
、REF_putStatic
、REF_invokeStatic
。
-
遇到 new、getstatic、putstatic、invokestatic 位元組碼指令,例如:
-
被動引用
- 通過子類引用父類靜態欄位,不會導致子類初始化;
Array[] arr = new Array[10]
; 不會觸發 Array 類初始化;static final VAR
在編譯階段會存入呼叫類的常量池,通過ClassName.VAR
引用不會觸發 ClassName 初始化。
也就是說,只有發生主動引用所列出的 5 種情況,一個類才會被載入到記憶體中,也就是說類的載入是 lazy-load 的,不到必要時刻是不會提前載入的,畢竟如果將程式執行中永遠用不到的類載入進記憶體,會佔用方法區中的記憶體,浪費系統資源。
類的生命週期
類的載入過程
載入
載入(Loading)階段,虛擬機器需要完成以下三件事:
- 通過一個類的全限定名來獲取定義這個類對應的二進位制位元組流
- 將這個類的二進位制位元組流所代表的靜態儲存結構轉換為方法區的執行時資料結構
- 在Java堆中生成一個代表這個類的
java.lang.Class
物件,作為方法區這些資料的訪問入口。
分類
-
非陣列類
- 系統提供的引導類載入器
- 使用者自定義的類載入器
-
陣列類
- 不通過類載入器,由 Java 虛擬機器直接建立
- 建立動作由 newarray 指令觸發,new 實際上觸發了 [L全類名 物件的初始化
-
規則
-
陣列元素是引用型別
- 載入:遞迴載入其元件
- 可見性:與引用型別一致
-
陣列元素是非引用型別
- 載入:與引導類載入器關聯
- 可見性:public
-
陣列元素是引用型別
類的顯式載入和隱式載入
-
顯示載入:
- 呼叫
ClassLoader#loadClass(className)
或Class.forName(className)
。 -
兩種顯示載入 .class 檔案的區別:
Class.forName(className)
載入 class 的同時會初始化靜態域,ClassLoader#loadClass(className)
不會初始化靜態域;Class.forName
藉助當前呼叫者的 class 的 ClassLoader 完成 class 的載入。
- 呼叫
-
隱式載入:
- new 類物件;
- 使用類的靜態域;
- 建立子類物件;
- 使用子類的靜態域;
-
其他的隱式載入,在 JVM 啟動時:
BootStrapLoader
會載入一些 JVM 自身執行所需的 Class;ExtClassLoader
會載入指定目錄下一些特殊的 Class;AppClassLoader
會載入 classpath 路徑下的 Class,以及 main 函式所在的類的 Class 檔案。
驗證
目的: 確保 .class 檔案中的位元組流資訊符合虛擬機器的要求。
4 個驗證過程:
-
檔案格式驗證:是否符合 Class 檔案格式規範,驗證檔案開頭 4 個位元組是不是 “魔數” 0xCAFEBABE
- 魔數:每個Class檔案的頭4個位元組稱為魔數,它的唯一作用是確定這個檔案是否為一個能被虛擬機器接收的class檔案。
- 元資料驗證:保證位元組碼描述資訊符號 Java 規範(語義分析)
- 位元組碼驗證:程式語義、邏輯是否正確(通過資料流、控制流分析)
- 符號引用驗證:對類自身以外的資訊(常量池中的符號引用)進行匹配性校驗
這個操作雖然重要,但不是必要的,可以通過 -Xverify:none
關掉。
準備
描述: 為 static 變數在方法區分配記憶體。
-
static 變數準備後的初始值:
-
public static int value = 123;
- 準備後為 0,value 的賦值指令 putstatic 會被放在
<clinit>()
方法中,<clinit>()
方法會在初始化時執行,也就是說,value 變數只有在初始化後才等於 123。
- 準備後為 0,value 的賦值指令 putstatic 會被放在
-
public static final int value = 123;
- 準備後為 123,因為被 static final 賦值之後 value 就不能再修改了,所以在這裡進行了賦值之後,之後不可能再出現賦值操作,所以可以直接在準備階段就把 value 的值初始化好。
-
public static int value = 123;
解析
描述: 將常量池中的 “符號引用” 替換為 “直接引用”。
在此之前,常量池中的引用是不一定存在的,解析過之後,可以保證常量池中的引用在記憶體中一定存在。
什麼是 “符號引用” 和 “直接引用” ?
- 符號引用:以一組符號描述所引用的物件(如物件的全類名),引用的目標不一定存在於記憶體中。
- 直接引用:直接指向被引用目標在記憶體中的位置的指標等,也就是說,引用的目標一定存在於記憶體中。
初始化
描述: 執行類構造器
-
<clinit>()
方法-
包含的內容:
- 所有 static 的賦值操作;
- static 塊中的語句;
-
<clinit>()
方法中的語句順序:- 基本按照語句在原始檔中出現的順序排列;
- 靜態語句塊只能訪問定義在它前面的變數,定義在它後面的變數,可以賦值,但不能訪問。
-
與
<init>()
的不同:- 不需要顯示呼叫父類的
<clinit>()
方法; - 虛擬機器保證在子類的
<clinit>()
方法執行前,父類的<clinit>()
方法一定執行完畢。也就是說,父類的 static 塊和 static 欄位的賦值操作是要先於子類的。
- 不需要顯示呼叫父類的
-
介面與類的不同:
- 執行子介面的
<clinit>()
方法前不需要先執行父介面的<clinit>()
方法(除非用到了父介面中定義的 public static final 變數);
- 執行子介面的
-
執行過程中加鎖:
- 同一時刻只能有一個執行緒在執行
<clinit>()
方法,因為虛擬機器要保證在同一個類載入器下,一個類只被載入一次。
- 同一時刻只能有一個執行緒在執行
-
非必要性:
- 一個類如果沒有任何 static 的內容就不需要執行
() 方法。
- 一個類如果沒有任何 static 的內容就不需要執行
-
包含的內容:
本小節的補充:<clinit>
與 <init>
方法
概述
在編譯生成class檔案時,會自動產生兩個方法,一個是類的初始化方法<clinit>
, 另一個是例項的初始化方法<init>
<clinit>
:在jvm第一次載入class檔案時呼叫,包括靜態變數初始化語句和靜態塊的執行
<init>
:在例項創建出來的時候呼叫,包括呼叫new操作符;呼叫Class或java.lang.reflect.Constructor
物件的newInstance()
方法;呼叫任何現有物件的clone()方法;通過java.io.ObjectInputStream
類的getObject()
方法反序列化。
<clinit>
方法
先理解 類初始化階段 的含義: 該階段負責為類變數賦予正確的初始值, 是一個類或介面被首次使用前的最後一項工作
<clinit>
方法的執行時期: 類初始化階段(該方法只能被jvm呼叫, 專門承擔類變數的初始化工作)
<clinit>
方法的內容: 所有的類變數初始化語句和型別的靜態初始化器
類的初始化時機: 即在java程式碼中首次主動使用的時候, 包含以下情形:
- (首次)建立某個類的新例項時--new, 反射, 克隆 或 反序列化;
- (首次)呼叫某個類的靜態方法時;
- (首次)使用某個類或介面的靜態欄位或對該欄位(final 欄位除外)賦值時;
- (首次)呼叫java的某些反射方法時;
- (首次)初始化某個類的子類時;
- (首次)在虛擬機器啟動時某個含有 main() 方法的那個啟動類
注意: 並非所有的類都會擁有一個
-
該類既沒有宣告任何類變數,也沒有靜態初始化語句;
-
該類聲明瞭類變數,但沒有明確使用類變數初始化語句或靜態初始化語句初始化;
-
該類僅包含靜態 final 變數的類變數初始化語句,並且類變數初始化語句是編譯時常量表達式;
<init>
方法
<init>
方法的執行時期: 物件的初始化階段
例項化一個類的四種途徑:
- 呼叫 new 操作符
- 呼叫 Class 或 java.lang.reflect.Constructor 物件的newInstance()方法
- 呼叫任何現有物件的clone()方法
- 通過 java.io.ObjectInputStream 類的 getObject() 方法反序列化
類載入器
類載入過程中的“通過一個類的全限定名來獲取描述這個類的二進位制位元組流”這個動作是放在Java虛擬機器的外部來實現的,以便於讓應用程式自己來決定如何去獲取所需要的類,實現這個動作的程式碼模組被稱為“類載入器”。
類載入器雖然只用於實現類的載入動作,但是它的作用卻遠遠不限於此,比較兩個類是否“相等”,不僅僅要確認這兩個類是否來源於同一個class檔案,還需要載入這兩個類的類載入器相同。
如何判斷兩個類 “相等”
-
“相等” 的要求
- 同一個 .class 檔案
- 被同一個虛擬機器載入
- 被同一個類載入器載入
-
判斷 “相等” 的方法
- instanceof 關鍵字
-
Class 物件中的方法:
- equals()
- isInstance()
- isAssignableFrom()
類載入器的分類
站在虛擬機器的角度,只存在兩種類載入器:
啟動類載入器(Bootstrap ClassLoader),使用C++實現,是虛擬機器的一部分
其他類載入器,由Java語言實現,獨立於虛擬機器之外的,全部繼承自抽象類 java.lang.ClassLoader
從開發人員的角度,類載入器可以劃分得更細緻一些:
-
啟動類載入器(Bootstrap):負責將存放在
\lib 目錄中的,或者被 -Xbootclasspath 引數所指定的路徑中的,並且是虛擬機器識別的類庫載入到虛擬機器記憶體中。 <JAVA_HOME>/lib
-Xbootclasspath
引數指定的路徑
-
擴充套件類載入器(Extension):負責載入
<JAVA_HOME>\lib\ext
目錄下的,或者被java.ext.dirs
系統變數所指定的路徑中的所有類庫,開發者可以直接使用擴充套件類載入器。<JAVA_HOME>/lib/ext
java.ext.dirs
系統變數指定的路徑
-
應用程式類載入器(Application):負責載入使用者類路徑(ClassPath)上所指定的類庫,一般情況下這個就是程式中預設的類載入器。
-classpath
引數
以上載入器互相配合來載入我們自己的應用程式,如果有必要,我們還可以加入自己定義的載入器。這些載入器之間的關係一般如下圖示:
雙親委派模型
類載入器的雙親委派模型(Parent Delegation Model):要求除了頂層的啟動類載入器外,其餘的類載入器都必須有自己的父類載入器。(注意!這裡類載入器之間的父子關係一般不會以繼承(Inheritance)來實現,而是使用組合(Composition)來複用父載入器的程式碼)。這種模型被廣泛使用於幾乎所有的Java程式中,但是它並不是一個強制性的約束,只是Java設計者推薦給開發者使用的一種類載入器實現方式。
-
工作過程
- 當前類載入器收到類載入的請求後,先不自己嘗試載入類,而是先將請求委派給父類載入器。因此,所有的類載入請求,都會先被傳送到啟動類載入器。
- 只有當父類載入器載入失敗時,當前類載入器才會嘗試自己去自己負責的區域載入
-
實現
- 檢查該類是否已經被載入
-
將類載入請求委派給父類
- 如果父類載入器為 null,預設使用啟動類載入器
- parent.loadClass(name, false)
-
當父類載入器載入失敗時
- catch ClassNotFoundException 但不做任何處理
-
呼叫自己的 findClass() 去載入
- 我們在實現自己的類載入器時只需要 extends ClassLoader,然後重寫 findClass() 方法而不是 loadClass() 方法,這樣就不用重寫 loadClass() 中的雙親委派機制了
-
優點
- 自己寫的類庫同名類不會覆蓋類庫的類
- java類隨著它的類載入器一起具備了一種帶有優先層級的層次關係,保證了Java程式的穩定執行。