JVM(三):類載入機制(類載入過程和類載入器)
一、為什麼要使用類載入器?
Java語言裡,類載入都是在程式執行期間完成的,這種策略雖然會令類載入時稍微增加一些效能開銷,但是會給java應用程式提供高度的靈活性。例如:
1.編寫一個面向介面的應用程式,可能等到執行時再指定其實現的子類;
2.使用者可以自定義一個類載入器,讓程式在執行時從網路或其他地方載入一個二進位制流作為程式程式碼的一部分;(這個是Android外掛化,動態安裝更新apk的基礎)
二、類載入的過程
使用java編譯器可以把java程式碼編譯為儲存位元組碼的Class檔案,使用其他語言的編譯器一樣可以把程式程式碼翻譯成Class檔案,java虛擬機器不關心Class的來源是何種語言。如圖所示:
在Class檔案中描述的各種資訊,最終都需要載入到虛擬機器中才能執行和使用。那麼虛擬機器是如何載入這些Class檔案的呢?
JVM把描述類資料的位元組碼.Class檔案載入到記憶體,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的java型別,這就是虛擬機器的類載入機制。
類從被載入到虛擬機器記憶體中開始,到卸載出記憶體為止,它的生命週期包括了:載入(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、解除安裝(Unloading)七個階段,其中驗證、準備、解析三個部分統稱連結。
載入(裝載)、驗證、準備、初始化和解除安裝這五個階段順序是固定的,類的載入過程必須按照這種順序開始,而解析階段不一定;它在某些情況下可以在初始化之後再開始,這是為了執行時動態繫結特性(JIT例如介面只在呼叫的時候才知道具體實現的是哪個子類)。值得注意的是:這些階段通常都是互相交叉的混合式進行的,通常會在一個階段執行的過程中呼叫或啟用另外一個階段。
1.載入:(重點)
載入階段是“類載入機制”中的一個階段,這個階段通常也被稱作“裝載”,主要完成:
1.通過“類全名”來獲取定義此類的二進位制位元組流
2.將位元組流所代表的靜態儲存結構轉換為方法區的執行時資料結構
3.在java堆中生成一個代表這個類的java.lang.Class物件,作為方法區這些資料的訪問入口
相對於類載入過程的其他階段,載入階段(準備地說,是載入階段中獲取類的二進位制位元組流的動作)是開發期可控性最強的階段,因為載入階段可以使用系統提供的類載入器(ClassLoader)來完成,也可以由使用者自定義的類載入器完成,開發人員可以通過定義自己的類載入器去控制位元組流的獲取方式。
載入階段完成後,虛擬機器外部的二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,方法區中的資料儲存格式有虛擬機器實現自行定義,虛擬機器並未規定此區域的具體資料結構。然後在java堆中例項化一個java.lang.Class類的物件,這個物件作為程式訪問方法區中的這些型別資料的外部介面。
2.驗證:(瞭解)
驗證是連結階段的第一步,這一步主要的目的是確保class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身安全。
驗證階段主要包括四個檢驗過程:檔案格式驗證、元資料驗證、位元組碼驗證和符號引用驗證。
1.檔案格式驗證
驗證class檔案格式規範,例如: class檔案是否已魔術0xCAFEBABE開頭 , 主、次版本號是否在當前虛擬機器處理範圍之內等
2.元資料驗證
這個階段是對位元組碼描述的資訊進行語義分析,以保證起描述的資訊符合java語言規範要求。驗證點可能包括:這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)、這個類是否繼承了不允許被繼承的類(被final修飾的)、如果這個類的父類是抽象類,是否實現了起父類或介面中要求實現的所有方法。
3.位元組碼驗證
進行資料流和控制流分析,這個階段對類的方法體進行校驗分析,這個階段的任務是保證被校驗類的方法在執行時不會做出危害虛擬機器安全的行為。如:保證訪法體中的型別轉換有效,例如可以把一個子類物件賦值給父類資料型別,這是安全的,但不能把一個父類物件賦值給子類資料型別、保證跳轉命令不會跳轉到方法體以外的位元組碼命令上。
4.符號引用驗證
符號引用中通過字串描述的全限定名是否能找到對應的類、符號引用類中的類,欄位和方法的訪問性(private、protected、public、default)是否可被當前類訪問。
3.準備:(瞭解)
準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些記憶體都將在方法區中進行分配。這個階段中有兩個容易產生混淆的知識點,首先是這時候進行記憶體分配的僅包括類變數(static 修飾的變數),而不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在java堆中。其次是這裡所說的初始值“通常情況”下是資料型別的零值,假設一個類變數定義為:
public static int value = 12;
那麼變數value在準備階段過後的初始值為0而不是12,因為這時候尚未開始執行任何java方法,而把value賦值為123的putstatic指令是程式被編譯後,存放於類構造器<clinit>()方法之中,所以把value賦值為12的動作將在初始化階段才會被執行。
上面所說的“通常情況”下初始值是零值,那相對於一些特殊的情況,如果類欄位的欄位屬性表中存在ConstantValue屬性,那在準備階段變數value就會被初始化為ConstantValue屬性所指定的值,建設上面類變數value定義為:
public static final int value = 123;
編譯時javac將會為value生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue的設定將value設定為123。
4.解析:(瞭解)
解析階段是虛擬機器常量池內的符號引用替換為直接引用的過程。
符號引用:符號引用是一組符號來描述所引用的目標物件,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機器實現的記憶體佈局無關,引用的目標物件並不一定已經載入到記憶體中。
直接引用:直接引用可以是直接指向目標物件的指標、相對偏移量或是一個能間接定位到目標的控制代碼。直接引用是與虛擬機器記憶體佈局實現相關的,同一個符號引用在不同虛擬機器例項上翻譯出來的直接引用一般不會相同,如果有了直接引用,那引用的目標必定已經在記憶體中存在。
虛擬機器規範並沒有規定解析階段發生的具體時間,只要求了在執行anewarry、checkcast、getfield、instanceof、invokeinterface、invokespecial、invokestatic、invokevirtual、multianewarray、new、putfield和putstatic這13個用於操作符號引用的位元組碼指令之前,先對它們使用的符號引用進行解析,所以虛擬機器實現會根據需要來判斷,到底是在類被載入器載入時就對常量池中的符號引用進行解析,還是等到一個符號引用將要被使用前才去解析它。
解析的動作主要針對類或介面、欄位、類方法、介面方法四類符號引用進行。分別對應編譯後常量池內的CONSTANT_Class_Info、CONSTANT_Fieldref_Info、CONSTANT_Methodef_Info、CONSTANT_InterfaceMethoder_Info四種常量型別。
1.類、介面的解析
2.欄位解析
3.類方法解析
4.介面方法解析
5.初始化:(瞭解)
類的初始化階段是類載入過程的最後一步,在準備階段,類變數已賦過一次系統要求的初始值,而在初始化階段,則是根據程式設計師通過程式制定的主觀計劃去初始化類變數和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。在以下四種情況下初始化過程會被觸發執行:
1.遇到new、getstatic、putstatic或invokestatic這4條位元組碼指令時,如果類沒有進行過初始化,則需先觸發其初始化。生成這4條指令的最常見的java程式碼場景是:使用new關鍵字例項化物件、讀取或設定一個類的靜態欄位(被final修飾、已在編譯器把結果放入常量池的靜態欄位除外)的時候,以及呼叫類的靜態方法的時候。
2.使用java.lang.reflect包的方法對類進行反射呼叫的時候
3.當初始化一個類的時候,如果發現其父類還沒有進行過初始化、則需要先出發其父類的初始化
4.jvm啟動時,使用者指定一個執行的主類(包含main方法的那個類),虛擬機器會先初始化這個類
在上面準備階段 public static int value = 12; 在準備階段完成後 value的值為0,而在初始化階呼叫了類構造器<clinit>()方法,這個階段完成後value的值為12。
*類構造器<clinit>()方法是由編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static塊)中的語句合併產生的,編譯器收集的順序是由語句在原始檔中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變數,定義在它之後的變數,在前面的靜態語句快可以賦值,但是不能訪問。
*類構造器<clinit>()方法與類的建構函式(例項建構函式<init>()方法)不同,它不需要顯式呼叫父類構造,虛擬機器會保證在子類<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此在虛擬機器中的第一個執行的<clinit>()方法的類肯定是java.lang.Object。
*由於父類的<clinit>()方法先執行,也就意味著父類中定義的靜態語句快要優先於子類的變數賦值操作。
*<clinit>()方法對於類或介面來說並不是必須的,如果一個類中沒有靜態語句,也沒有變數賦值的操作,那麼編譯器可以不為這個類生成<clinit>()方法。
*介面中不能使用靜態語句塊,但介面與類不太能夠的是,執行介面的<clinit>()方法不需要先執行父介面的<clinit>()方法。只有當父介面中定義的變數被使用時,父接口才會被初始化。另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>()方法。
*虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確加鎖和同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。如果一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個程序阻塞。
三、類載入器
JVM設計者把類載入階段中的“通過'類全名'來獲取定義此類的二進位制位元組流”這個動作放到Java虛擬機器外部去實現,以便讓應用程式自己決定如何去獲取所需要的類。實現這個動作的程式碼模組稱為“類載入器”。
1.類與類載入器
對於任何一個類,都需要由載入它的類載入器和這個類來確立其在JVM中的唯一性。也就是說,兩個類來源於同一個Class檔案,並且被同一個類載入器載入,這兩個類才相等。
2.雙親委派模型
從虛擬機器的角度來說,只存在兩種不同的類載入器:一種是啟動類載入器(Bootstrap ClassLoader),該類載入器使用C++語言實現,屬於虛擬機器自身的一部分。另外一種就是所有其它的類載入器,這些類載入器是由Java語言實現,獨立於JVM外部,並且全部繼承自抽象類java.lang.ClassLoader。
從Java開發人員的角度來看,大部分Java程式一般會使用到以下三種系統提供的類載入器:
1)啟動類載入器(Bootstrap ClassLoader):負責載入JAVA_HOME\lib目錄中並且能被虛擬機器識別的類庫到JVM記憶體中,如果名稱不符合的類庫即使放在lib目錄中也不會被載入。該類載入器無法被Java程式直接引用。
2)擴充套件類載入器(Extension ClassLoader):該載入器主要是負責載入JAVA_HOME\lib\,該載入器可以被開發者直接使用。
3)應用程式類載入器(Application ClassLoader):該類載入器也稱為系統類載入器,它負責載入使用者類路徑(Classpath)上所指定的類庫,開發者可以直接使用該類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。
我們的應用程式都是由這三類載入器互相配合進行載入的,我們也可以加入自己定義的類載入器。這些類載入器之間的關係如下圖所示:
如上圖所示的類載入器之間的這種層次關係,就稱為類載入器的雙親委派模型(Parent Delegation Model)。該模型要求除了頂層的啟動類載入器外,其餘的類載入器都應當有自己的父類載入器。子類載入器和父類載入器不是以繼承(Inheritance)的關係來實現,而是通過組合(Composition)關係來複用父載入器的程式碼。
雙親委派模型的工作過程為:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的載入器都是如此,因此所有的類載入請求都會傳給頂層的啟動類載入器,只有當父載入器反饋自己無法完成該載入請求(該載入器的搜尋範圍中沒有找到對應的類)時,子載入器才會嘗試自己去載入。
使用這種模型來組織類載入器之間的關係的好處是Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。例如java.lang.Object類,無論哪個類載入器去載入該類,最終都是由啟動類載入器進行載入,因此Object類在程式的各種類載入器環境中都是同一個類。否則的話,如果不使用該模型的話,如果使用者自定義一個java.lang.Object類且存放在classpath中,那麼系統中將會出現多個Object類,應用程式也會變得很混亂。如果我們自定義一個rt.jar中已有類的同名Java類,會發現JVM可以正常編譯,但該類永遠無法被載入執行。
在rt.jar包中的java.lang.ClassLoader類中,我們可以檢視類載入實現過程的程式碼,具體原始碼如下:
- protectedsynchronized Class loadClass(String name, boolean resolve)
- throws ClassNotFoundException {
- // 首先檢查該name指定的class是否有被載入
- Class c = findLoadedClass(name);
- if (c == null) {
- try {
- if (parent != null) {
- // 如果parent不為null,則呼叫parent的loadClass進行載入
- c = parent.loadClass(name, false);
- } else {
- // parent為null,則呼叫BootstrapClassLoader進行載入
- c = findBootstrapClass0(name);
- }
- } catch (ClassNotFoundException e) {
- // 如果仍然無法載入成功,則呼叫自身的findClass進行載入
- c = findClass(name);
- }
- }
- if (resolve) {
- resolveClass(c);
- }
- return c;
- }
通過上面程式碼可以看出,雙親委派模型是通過loadClass()方法來實現的,根據程式碼以及程式碼中的註釋可以很清楚地瞭解整個過程其實非常簡單:先檢查是否已經被載入過,如果沒有則呼叫父載入器的loadClass()方法,如果父載入器為空則預設使用啟動類載入器作為父載入器。如果父類載入器載入失敗,則先丟擲ClassNotFoundException,然後再呼叫自己的findClass()方法進行載入。
3.自定義類載入器
若要實現自定義類載入器,只需要繼承java.lang.ClassLoader 類,並且重寫其findClass()方法即可。java.lang.ClassLoader 類的基本職責就是根據一個指定的類的名稱,找到或者生成其對應的位元組程式碼,然後從這些位元組程式碼中定義出一個 Java 類,即 java.lang.Class 類的一個例項。除此之外,ClassLoader 還負責載入 Java 應用所需的資源,如影象檔案和配置檔案等,ClassLoader 中與載入類相關的方法如下:
方法 說明
getParent() 返回該類載入器的父類載入器。
loadClass(String name) 載入名稱為 二進位制名稱為name 的類,返回的結果是 java.lang.Class 類的例項。
findClass(String name) 查詢名稱為 name 的類,返回的結果是 java.lang.Class 類的例項。
findLoadedClass(String name) 查詢名稱為 name 的已經被載入過的類,返回的結果是 java.lang.Class 類的例項。
resolveClass(Class<?> c) 連結指定的 Java 類。
注意:在JDK1.2之前,類載入尚未引入雙親委派模式,因此實現自定義類載入器時常常重寫loadClass方法,提供雙親委派邏輯,從JDK1.2之後,雙親委派模式已經被引入到類載入體系中,自定義類載入器時不需要在自己寫雙親委派的邏輯,因此不鼓勵重寫loadClass方法,而推薦重寫findClass方法。
在Java中,任意一個類都需要由載入它的類載入器和這個類本身一同確定其在java虛擬機器中的唯一性,即比較兩個類是否相等,只有在這兩個類是由同一個類載入器載入的前提之下才有意義,否則,即使這兩個類來源於同一個Class類檔案,只要載入它的類載入器不相同,那麼這兩個類必定不相等(這裡的相等包括代表類的Class物件的equals()方法、isAssignableFrom()方法、isInstance()方法和instanceof關鍵字的結果)。例子程式碼如下:
- /**
- * 一、ClassLoader載入類的順序
- * 1.呼叫 findLoadedClass(String) 來檢查是否已經載入類。
- * 2.在父類載入器上呼叫 loadClass 方法。如果父類載入器為 null,則使用虛擬機器的內建類載入器。
- * 3.呼叫 findClass(String) 方法查詢類。
- * 二、實現自己的類載入器
- * 1.獲取類的class檔案的位元組陣列
- * 2.將位元組陣列轉換為Class類的例項
- * @author lei 2011-9-1
- */
- publicclass ClassLoaderTest {
- publicstaticvoid main(String[] args) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
- //新建一個類載入器
- MyClassLoader cl = new MyClassLoader("myClassLoader");
- //載入類,得到Class物件
- Class<?> clazz = cl.loadClass("classloader.Animal");
- //得到類的例項
- Animal animal=(Animal) clazz.newInstance();
- animal.say();
- }
- }
- class Animal{
- publicvoid say(){
- System.out.println("hello world!");
- }
- }
- class MyClassLoader extends ClassLoader {
- //類載入器的名稱
- private String name;
- //類存放的路徑
- private String path = "E:\\workspace\\Algorithm\\src";
- MyClassLoader(String name) {
- this.name = name;
- }
- MyClassLoader(ClassLoader parent, String name) {
- super(parent);
- this.name = name;
- }
- /**
- * 重寫findClass方法
- */
- @Override
- public Class<?> findClass(String name) {
- byte[] data = loadClassData(name);
- returnthis.defineClass(name, data, 0, data.length);
- }
- publicbyte[] loadClassData(String name) {
- try {
- name = name.replace(".", "//");
- FileInputStream is = new FileInputStream(new File(path + name + ".class"));
- ByteArrayOutputStream baos = new ByteArrayOutputStream();
- int b = 0;
- while ((b = is.read()) != -1) {
- baos.write(b);
- }
- return baos.toByteArray();
- } catch (Exception e) {
- e.printStackTrace();
- }
- returnnull;
- }
- }
類載入器雙親委派模型是從JDK1.2以後引入的,並且只是一種推薦的模型,不是強制要求的,因此有一些沒有遵循雙親委派模型的特例:(瞭解)
(1).在JDK1.2之前,自定義類載入器都要覆蓋loadClass方法去實現載入類的功能,JDK1.2引入雙親委派模型之後,loadClass方法用於委派父類載入器進行類載入,只有父類載入器無法完成類載入請求時才呼叫自己的findClass方法進行類載入,因此在JDK1.2之前的類載入的loadClass方法沒有遵循雙親委派模型,因此在JDK1.2之後,自定義類載入器不推薦覆蓋loadClass方法,而只需要覆蓋findClass方法即可。
(2).雙親委派模式很好地解決了各個類載入器的基礎類統一問題,越基礎的類由越上層的類載入器進行載入,但是這個基礎類統一有一個不足,當基礎類想要呼叫回下層的使用者程式碼時無法委派子類載入器進行類載入。為了解決這個問題JDK引入了ThreadContext執行緒上下文,通過執行緒上下文的setContextClassLoader方法可以設定執行緒上下文類載入器。
JavaEE只是一個規範,sun公司只給出了介面規範,具體的實現由各個廠商進行實現,因此JNDI,JDBC,JAXB等這些第三方的實現庫就可以被JDK的類庫所呼叫。執行緒上下文類載入器也沒有遵循雙親委派模型。
(3).近年來的熱碼替換,模組熱部署等應用要求不用重啟java虛擬機器就可以實現程式碼模組的即插即用,催生了OSGi技術,在OSGi中類載入器體系被髮展為網狀結構。OSGi也沒有完全遵循雙親委派模型。
4.動態載入Jar && ClassLoader 隔離問題
動態載入Jar:
Java 中動態載入 Jar 比較簡單,如下:
- URL[] urls = new URL[] {new URL("file:libs/jar1.jar")};
- URLClassLoader loader = new URLClassLoader(urls, parentLoader);
表示載入 libs 下面的 jar1.jar,其中