JVM(三)-java虛擬機類載入機制
概述:
上一篇文章,介紹了java虛擬機的運行時區域,Java虛擬機根據不同的分工,把記憶體劃分為各個不同的區域。在java程式中,最小的運行單元一般都是創建一個對象,然後調用對象的某個
方法。通過上一篇文章我們知道調用某個方法是通過虛擬機棧的棧幀並通過執行引擎來實現的,但是實際上一個方法的執行前提是,該對象對應的Class文件需要載入到記憶體的方法區,並且
要new一個對象,對象的引用存放在虛擬機棧的本地變數表,對象的實例存放在堆。本篇文章關注的重點就是Java虛擬機如何將Class文件載入到記憶體。
Java虛擬機把類描述的數據從Class文件載入到記憶體,並對數據進行校驗、轉換解析和初始化,最終形成可以被虛擬機直接使用的java類型,這個過程被稱作虛擬機的類載入機制。在java語言
里,類型的載入、連接、初始化都是在程式運行期間完成的,這種策略讓java語言進行提前編譯會面臨額外的困難,也會讓類載入時稍微增加一些性能開銷,但是確為java應用提供了極高的擴展性
和靈活性,java天生可以動態擴展的語言特性就是依賴運行期動態載入和動態連接這個特點實現的。
類載入的時機:
一個類型從被載入到虛擬機記憶體中開始,到卸載除記憶體為止,它的整個聲明周期將會經歷載入、驗證、準備、解析、初始化、使用和卸載七個階段,其中驗證、準備、解析三個部分統稱為連接。
這七個階段的發生順序如下圖所示:
上圖中,載入、驗證、準備、初始化和卸載這個五個階段的順序是確定的,而解析階段則不一定:它在某些情況下可以在初始化階段之後開始,這是為了支援java語言的運行時綁定特性(也稱為動態綁定或晚綁定)。
初始化只有在以下六種情況下才會觸發:
- 使用new關鍵字實例化對象或者讀取或設置一個類型的靜態欄位(被final修飾、已在編譯器把結果放入常量池的靜態欄位除外)以及調用一個類型的靜態方法的時候。
- 對類型進行反射調用時,如果沒有進行過初始化,則需要先觸發其初始化。
- 當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛擬機啟動時,用戶需要指定一個執行的主類,虛擬機會先初始化這個主類。
- 使用java7新加入的MethodHandle動態語言支援,在使用某些方法時,如果類沒有初始化,則需要先觸發初始化。
- 當介面中定義了JDK8新加入的默認方法,如果有這個介面的實現類發生了初始化,則需要優先初始化該介面。
這六種場景中的行為稱為對一個類型進行主動引用。除此之外,所有引用類型的方式都不會觸發初始化,稱為被動引用。來看一個主動引用的例子,這是一個典型的餓漢式單例模式:
package singleton; /** * @ClassName SingletonDemo1 * @Description 餓漢式 * 類載入到記憶體後,就實例化一個單例對象,JVM保證執行緒安全 * @Author liuyi * @Date 2020/6/7 12:22 * @Version 1.0 */ public class SingletonDemo1 { //靜態塊方式 private static final SingletonDemo1 instance; static { instance = new SingletonDemo1(); } private SingletonDemo1(){ System.out.println("我被初始化了"); } public static SingletonDemo1 getInstance(){ System.out.println("獲取單例類對象"); return instance; } public static void main(String[] args) { SingletonDemo1.getInstance(); } }
程式碼的執行結果如下:
很多java程式設計師對單例的餓漢式都有一個誤解,就是static快裡面的實例化程式碼在程式啟動的過程中就會被執行,這樣的理解是完全錯誤的。餓漢式指的是類被載入的時候初始化,而
懶漢式是使用的時候才初始化。我在概述裡面也提到了,java是在程式運行期間進行類的載入、連接和初始化。所以這裡,我在調用getInstance()方法的時候類才會被載入,而getInstance()
方法又恰好是一個靜態方法,滿足六條觸發初始化規則的第一條,所以當調用getInstance(),該類會被初始化,只有在類被初始化的時候才會執行static塊的程式碼,所以會先列印我被初始化了,
然後再列印獲取實例類對象。這裡之所以被稱為餓漢式單例是因為在獲取實例之前,對象已經先一步初始化好了。只是這裡恰好觸發初始化的方法是getInstance(),會給人一種誤解是我
調用了該方法才初始化的,但是實際上能夠觸發類初始化方式並不是只有這一種,上面列的六種情況中的任意一種都可以觸發類的初始化。當在我調用getInstance()之前,該類被初始化過,這種
情況就很好解釋它就是餓漢式了。
我們來驗證是不是這樣的,我在程式碼中加了一個靜態變數a,然後調用該靜態變數,程式碼如下:
package singleton; /** * @ClassName SingletonDemo1 * @Description 餓漢式 * 類載入到記憶體後,就實例化一個單例對象,JVM保證執行緒安全 * @Author liuyi * @Date 2020/6/7 12:22 * @Version 1.0 */ public class SingletonDemo1 { private static int a = 2; //靜態塊方式 private static final SingletonDemo1 instance; static { instance = new SingletonDemo1(); } private SingletonDemo1() { System.out.println("我被初始化了"); } public static SingletonDemo1 getInstance() { System.out.println("獲取單例類對象"); return instance; } public static void main(String[] args) { System.out.println(SingletonDemo1.a); // SingletonDemo1.getInstance(); } }
來看看執行結果:
可以看到,我沒有調用getInstance()方法,該類還是被初始化了,這裡是因為我調用了靜態變數a,同樣滿足六種情況的第一種情況,所以類在載入的時候被初始化了。
static程式碼塊同時也被執行了,所以列印了我被初始化了。
我們再了看看另外一種方法實現單例模式,靜態內部類方法,為什麼說這種方式不會提前初始化,先來看程式碼:
package singleton; /** * @ClassName SingletonDemo5 * @Description 靜態內部類方式 * JVM保證執行緒安全 * 載入外部類是不會載入內部類,實現了懶載入 * 最完美的寫法 * @Author liuyi * @Date 2020/6/7 13:52 * * @Version 1.0 */ public class SingletonDemo5 { private SingletonDemo5() { System.out.println("我被實例化了"); //防止惡意通過反射破壞單例 if (SingletonDemo5Inside.instance != null) { throw new RuntimeException("不允許創建多個實例"); } } private static class SingletonDemo5Inside { private static final SingletonDemo5 instance = new SingletonDemo5(); } public static SingletonDemo5 getInstance() { System.out.println("獲取單例類對象"); return SingletonDemo5Inside.instance; } public static void main(String[] args) { SingletonDemo5.getInstance(); } }
來看程式碼的執行結果:.
從程式碼的執行結果來看,是在調用getInstance()方法之後,類才被初始化的。為什麼會這樣呢,這是因為我們在調用該方法的時候,SingletonDemo5雖然被初始化了,
但是它並沒有被實例化,而靜態內部類SingletonDemo5Inside同樣滿足java是在程式運行期間進行類的載入、連接和初始化的原則,所以在沒有調用SingletonDemo5Inside.instance()
之前,它是不會別載入的。當我們調用SingletonDemo5Inside.instance()的時候,SingletonDemo5才被實例化,所以這種方式是除了枚舉方式之外最完美的單例寫法。
我們再來看看被動引用的例子:
package singleton; /** * @ClassName Person * @description: * @author:liuyi * @Date:2020/11/29 1:00 */ public class Person { static { System.out.println("初始化Person類"); } public static int age = 28; } class Man extends Person{ static { System.out.println("初始化Man類"); } } class PersonTest{ public static void main(String[] args) { System.out.println(Man.age); } }
程式碼執行結果:
對於靜態欄位,只有直接定義這個欄位的類才會被初始化,所以就算使用子類調用該靜態變數,也只有父類才會被初始化。
再來看一個例子:
package singleton; /** * @ClassName ConstantClass * @description: * @author:liuyi * @Date:2020/11/29 1:06 */ public class ConstantClass { static { System.out.println("初始化ConstantClass"); } public static final String text = "hello"; } class ConstantTest{ public static void main(String[] args) { System.out.println(ConstantClass.text); } }
程式碼執行結果:
可以看到,雖然我們是訪問的static修飾的變數,但是依然沒有觸發該類的初始化。這是因為text是一個常量,會被放到常量池中,我們並不會通過類去獲取,所以
不需要對類進行初始化。
類的載入過程:
接下來我們詳細了解java虛擬機中類載入的全過程,即載入、驗證、準備、解析和初始化這五個階段所執行的具體動作。
載入:
在載入階段,虛擬機需要完成以下三件事情:
- 通過一個類的完全限定名來獲取定義此類的二進位位元組流。
- 將這個位元組流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
- 在記憶體中生成一個代表整個類的java.lang.Class對象,作為方法區這個類的各種數據訪問入口。
相對於類載入過程的其他階段,非數組類型的載入階段是開發人員可控性最強的階段。開發人員可以使用java虛擬機內置的引導類載入器來實現載入階段,也可以自定義類載入
來實現。而對於數組而言,情況有所不同,因為數組類本身不通過類載入器創建,它是由java虛擬機直接在記憶體中動態構造出來的。但是如果數組的類型是引用類型,整個數組的
創建還是要依賴類載入器。
載入階段和連接階段的部分動作是交叉進行的,載入階段尚未完成,連接階段可能已經開始,但是整體上的順序還是先載入,再進入連接階段。舉個簡單的例子,比如一個類
有兩個方法,可能一個方法載入完之後,立馬就進入這個方法的連接階段,但是此時第二個方法可能才剛開始載入。
驗證:
驗證是連接階段的第一步,這一階段的目的是確保Class文件的位元組流中包含的資訊符合java虛擬機規範的全部約束要求,保證這些資訊被當作程式碼運行後不會危害虛擬機自身的安全。
從整體上看,驗證階段大致上會完成下面四個階段的檢驗動作。
-
文件格式驗證:驗證位元組流是否符合Class文件的格式的規範,並且是否能被當前版本的虛擬機處理。
-
元數據驗證:對位元組碼描述的資訊進行語義分析,以保證其描述資訊符合java虛擬機規範的要求。
- 位元組碼驗證:這個階段是整個驗證過程最複雜的一個階段,主要目的是通過數據流分析和控制流分析,確定程式語義是合法的、符合邏輯的。
-
符號引用驗證:這個階段的校驗行為發生在虛擬機符號引用轉化為直接引用的時候,這個轉化動作將在連接的第三階段——解析階段發生。符號引用驗證可看作是對類自身以外(常量池中的
各種符號引用)的各類資訊進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某這些外部類、方法、欄位等資源。
驗證階段對於虛擬機的類載入機制來說,是一個非常重要、但卻不是必須要執行的階段。如果程式的全部程式碼(都已經被反覆使用和驗證過),在生產環境的實施階段就可以考慮使用-Xverify:none
參數來關閉大部分的類驗證措施,以縮短虛擬機類載入的時間。
準備:
準備階段是正式為類中定義的變數(即靜態變數,被static修飾的變數)分配記憶體並設置類變數初始值的階段,關於準備階段,有兩個容易產生混淆的概念這裡需要著重強調,首先是這時候進行記憶體
分配的僅包括類變數,而不包括實例變數,實例變數將會在對象實例化時隨著對象一起分配在java堆中。其次這裡所說的初始值通常情況是數據類型的零值,假設一個類型變數的定義為:
public static int value = 666;那變數value在準備階段過後的初始值為0而不是666,value賦值為666要在類的初始化階段才會被執行。
解析:
解析階段是java虛擬機將常量池內的符號引用替換為直接引用的過程,先來看看符號引用和直接引用的概念:
符號引用:以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可,符號引用與虛擬機實現的記憶體布局無關,引用的目標並不一定是已經載入到虛擬機
記憶體當中的內容。各種虛擬機的記憶體布局可以各不相同,但是它們接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在java虛擬機規範的Class文件格式中。
直接引用:直接引用是可以直接指向目標的指針、相對偏移量或者一個能間接定位的目標的句柄。直接引用是和虛擬機實現的記憶體布局直接相關的,同一個符號引用在不同的虛擬機中翻譯出來的直接引用
一般不會相同。如果有了直接引用,那引用的目標必定已經在虛擬機的記憶體中存在。
解析主要包括類或者介面的解析、欄位解析、方法解析、介面方法解析,每項解析都有自己的解析步驟,這裡就不一一介紹了。
初始化:
類的初始化階段是類載入過程的最後一個步驟,初始化完成之後,java虛擬機才會正在的開始執行類中編寫的java程式程式碼,將主導權移交給應用程式。在準備階段中,變數已經賦過一次系統要求的初始
零值,而在初始化階段,則會根據程式設計師通過程式編碼制定的主觀計划去初始化類變數和其他資源。我們也可以從另外一種更直接的形式來表達:初始化階段就是執行類構造器<clinit>()方法的過程。<clinit>()
並不是指程式設計師在java程式碼中直接編寫的類構造方法,它是javac編譯器自動生成的,但是我們非常有必要了解這個方法具體是怎麼產生的,以及該方法執行過程中各種可能影響程式運行行為的細節,這部分
比起其他類載入過程更貼近於普通開發人員的實際工作。
<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器搜集的順序是由語句在源文件中出現的位置決定的,靜態語句塊中只能訪問到定義
在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句塊可以賦值,但是不能訪問,如下面程式碼:
/** * @ClassName Test1 * @description: * @author:liuyi * @Date:2020/11/29 16:51 */ public class Test1 { static { i = 0;//給變數賦值可以正常編譯通過 System.out.println(i);//會提示"非法向前引用" } static int i = 1; }
<clinit>()方法與類的構造函數不同,它不需要顯示的調用父類的構造器,java虛擬機保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法一定執行完畢,因此虛擬機中第一個被執行的<clinit>()
方法的類型肯定是java.lang.Object。所以父類中定義的靜態語句塊要優先於子類的變數賦值操作,如下程式碼的值將會是2而不是1.
package test; /** * @ClassName Parent * @description: * @author:liuyi * @Date:2020/11/29 16:57 */ public class Parent { public static int A = 1; static { A = 2; } public static void main(String[] args) { System.out.println(Sub.B); } } class Sub extends Parent{ public static int B = A; }
<clinit>()方法對於類或介面來說並不是必需的,如果一個類沒有靜態程式碼塊,也沒有對變數的賦值操作,那麼編譯器可以不為這個類生成<clinit>()方法。介面中不能使用靜態程式碼塊,但仍然有變數初始化操作,
因此介面和類一樣都會生成<clinit>()方法。但介面與類不同的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法,因為只有當父介面中定義的變數被使用時,父介面才會被初始化。此外,介面的實現
類在初始化時一樣不會執行介面的<clinit>()方法。
java虛擬機必須保證一個類的<clinit>()方法在多執行緒環境中被正確地加鎖同步,如果多執行緒同時去初始化一個類,那麼只會其中一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待。這也是為什麼說
餓漢式單例模式是由JVM保證執行緒安全的依據。
類載入器:
java虛擬機設計團隊把類載入階段的”通過一個類的完全限定名來獲取描述該類的二進位位元組流”這個動作放到java虛擬機外部去實現,以便應用程式自己決定如何去獲取所需的類。實現這個動作的程式碼被稱為「類載入器」。
需要注意的是對於同一個類,不同的類載入器載入之後,它們的類型是不同的。
站在java虛擬機的角度來看,只存在兩種不同的類載入器:一種是啟動類載入器,這個類載入器使用C++語言實現,是虛擬機自身的一部分;另外一種是其他所有的類載入器,這些類載入器都由java語言實現,獨立存在
於虛擬機外部,並且全部繼承自抽象類java.lang.Classloader。
站在java開發人員的角度來看,類載入器就應當劃分得更細緻一些。自JDK1.2以來,java一直保持著三層類載入器、雙親委派的類載入架構。我們這裡只針對java8及之前版本來介紹三層類載入器以及雙親委派模型。
啟動類載入器(Bootstrap):這個類載入器負責載入存放在lib目錄,或者被-Xbootclasspath參數所指定的路徑下存放的class類。啟動類載入程式無法被java程式直接引用,如果需要把載入器請求委派給引導類載入器去處理,直接用
null代替即可。
擴展類載入器(Extension):這個類載入器是由java實現的,負責載入\lib\ext目錄中或者被java.ext.dirs系統變數指定的路徑中所有的類庫,主要包含java用戶(公司團隊或者個人)開發的擴展類庫。
應用程式類載入器(App):這個載入器也是由java實現的,它負責載入用戶類路徑上所有的類庫,也可以理解為除了啟動類載入器和擴展載入器載入以外的所有類庫都是由應用程式類載入器來載入。
雙親委派模型:
如圖所示,雙親委派的工作過程是:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,它會先檢查請求載入的類型是否被當前載入器載入過,如果沒有則把這個請求委派給父類(注意這裡的父類
並不是真正意義上的父類,源碼中是以組合的形式來體現父子的關係的)載入器去完成,類載入實現的主要源碼在java.lang.Classloader的loadClass()方法中,如下:
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the non-null parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. long t1 = System.nanoTime(); c = findClass(name); // this is the defining class loader; record the stats sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } }
這段程式碼的邏輯清晰易懂:先檢查請求載入的類型是否已經被載入過,若沒有則調用父載入器的loadClass()方法,若父載入器為空則默認使用啟動類載入器作為父載入器。假如父載入器載入失敗,拋出ClassNotFoundException
異常的話,才調用自己的findClass()方法嘗試進行載入。
為什麼要採用雙親委派模型來實現類的載入呢?首先一個顯而易見的好處就是java中的類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。例如類java.lang.Object,它存放在rt.jar之中,無論哪一個類載入器要載入這個類
最終都會委派給模型最頂端的啟動類載入器進行載入,因此Object類在程式的各種類載入器環境中都能保證是同一個類。反之,如果沒有使用雙親委派模型,如果我自己也寫一個java.lang.Object類,那系統中就會出現多個Object類,
java類型體系中最基礎的行為也就無從保障。
總結:
本篇文章主要介紹了java虛擬機類載入的時機,主要關注類初始化的時機,哪些行為屬於主動引用,哪些行為屬於被動引用,接著介紹了類載入過程,主要包括載入、驗證、準備、解析和初始化五個階段。最後介紹了類載入器
以及雙親委派模型。下一篇文章,我們將對虛擬機(Hotspot)對象的創建進行介紹。