可能在某種 Java 虛擬機的實現上,初始類會做爲命令行參數被提供給虛擬機。固然,虛擬機實現也能夠利用一個初始類讓類加載器依次加載整個應用。初始類固然也能夠選擇組合上述的方式來工做。html
—— 以上內容摘自《Java 虛擬機規範》(Java SE 7 版)java
在講類的加載機制前,先來看一道題目:程序員
public class ClassLoaderTest { public static void main(String[] args) { System.out.println("爸爸的歲數:" + Son.factor); //入口1 // new Son(); //入口 2 } } class Grandpa { static { System.out.println("爺爺在靜態代碼塊"); } public Grandpa() { System.out.println("我是爺爺~"); } } class Father extends Grandpa { static { System.out.println("爸爸在靜態代碼塊"); } public static int factor = 25; public Father() { System.out.println("我是爸爸~"); } } class Son extends Father { static { System.out.println("兒子在靜態代碼塊"); } public Son() { System.out.println("我是兒子~"); } }
上面的代碼中分了入口1和入口2, 二者不一樣時存在,入口不同,最後輸出的結果也是不同的。小夥伴能夠思考下這兩個入口對於類的初始化有啥不同。下面是具體結果:web
入口1 的結果:數組
爺爺在靜態代碼塊
爸爸在靜態代碼塊
爸爸的歲數:25
入口2 的結果安全
爺爺在靜態代碼塊
爸爸在靜態代碼塊
兒子在靜態代碼塊
我是爺爺~
我是爸爸~
我是兒子~
若是之前沒有遇到這種問題,如今要你解答確定是很難的。該題考察的就是你對 Java 類加載機制的理解。若是你對 Java 加載機制不理解,那麼你是沒法解答這道題目的。網絡
對比上面兩個結果,能夠發現,入口1 都是靜態代碼的初始化,入口2 既涉及到靜態代碼的初始化,也涉及到類的初始化。到此你們確定就知道對於靜態代碼和非靜態代碼的初始化邏輯是有區別的。數據結構
這篇文章,將對 Java 類加載機制的進行講解,讓你之後遇到相似問題不在犯難。jvm
當 Java 虛擬機將 Java 源碼編譯爲字節碼以後,虛擬機即可以將字節碼讀取進內存,從而進行解析、運行等整個過程,這個過程咱們叫:Java 虛擬機的類加載機制。JVM 虛擬機執行 class 字節碼的過程能夠分爲七個階段:加載、驗證、準備、解析、初始化、使用、卸載。其中加載、檢驗、準備、初始化和卸載這個五個階段的順序是固定的,而解析則未必。爲了支持動態綁定,解析這個過程能夠發生在初始化階段以後。ide
什麼狀況下須要開始類加載的第一個階段:加載。 JAVA虛擬機規範並無進行強制約束,交給虛擬機的具體實現自由把握。
加載階段是「類加載」過程當中的一個階段,這個階段一般也被稱做「裝載」,在加載階段,虛擬機主要完成如下3件事情:
經過 "類全名" 來獲取定義此類的二進制字節流
將字節流所表明的靜態存儲結構轉換爲方法區的運行時數據結構
在 java 堆中生成一個表明這個類的 java.lang.Class 對象,做爲方法區這些數據的訪問入口(因此咱們可以經過低調用類.getClass() )
注意這裏字節流不必定非得要從一個 Class 文件獲取,這裏既能夠從 ZIP 包中讀取(好比從 jar 包和 war 包中讀取),也能夠在運行時計算生成(動態代理),也能夠由其它文件生成(好比將 JSP 文件轉換成對應的 Class 類)。加載的信息存儲在 JVM 的方法區。
對於數組類來講,它並無對應的字節流,而是由 Java 虛擬機直接生成的。對於其它的類來講,Java 虛擬機則須要藉助類加載器來完成查找字節流的過程。
若是上面那麼多記不住: 請必定記住這句: 加載階段也就是查找獲取類的二進制數據(磁盤或者網絡)動做,將類的數據(Class 的信息:類的定義或者結構)放入方法區 (內存)。
一圖說明:
驗證的主要做用就是確保被加載的類的正確性。也是鏈接階段的第一步。說白了也就是咱們加載好的 .class 文件不能對咱們的虛擬機有危害,因此先檢測驗證一下。他主要是完成四個階段的驗證:
文件格式的驗證:驗證 .class 文件字節流是否符合 class 文件的格式的規範,而且可以被當前版本的虛擬機處理。這裏面主要對魔數、主版本號、常量池等等的校驗(魔數、主版本號都是 .class 文件裏面包含的數據信息、在這裏能夠不用理解)。
元數據驗證:主要是對字節碼描述的信息進行語義分析,以保證其描述的信息符合 java 語言規範的要求,好比說驗證這個類是否是有父類,類中的字段方法是否是和父類衝突等等。
字節碼驗證:這是整個驗證過程最複雜的階段,主要是經過數據流和控制流分析,肯定程序語義是合法的、符合邏輯的。在元數據驗證階段對數據類型作出驗證後,這個階段主要對類的方法作出分析,保證類的方法在運行時不會作出威海虛擬機安全的事。
符號引用驗證:它是驗證的最後一個階段,發生在虛擬機將符號引用轉化爲直接引用的時候。主要是對類自身之外的信息進行校驗。目的是確保解析動做可以完成。
對整個類加載機制而言,驗證階段是一個很重要可是非必需的階段,若是咱們的代碼可以確保沒有問題,那麼咱們就沒有必要去驗證,畢竟驗證須要花費必定的的時間。固然咱們可使用 -Xverfity:none 來關閉大部分的驗證。
當完成字節碼文件的校驗以後,JVM 便會開始爲類變量分配內存並初始化。這裏須要注意兩個關鍵點,即內存分配的對象以及初始化的類型。
例以下面的代碼在準備階段,只會爲 factor 屬性分配內存,而不會爲 website 屬性分配內存。
public static int factor = 3; public String website = "www.cnblogs.com/chanshuyi";
例以下面的代碼在準備階段以後,sector 的值將是 0,而不是 3。
public static int sector = 3;
但若是一個變量是常量(被 static final 修飾)的話,那麼在準備階段,屬性便會被賦予用戶但願的值。例以下面的代碼在準備階段以後,number 的值將是 3,而不是 0。
public static final int number = 3;
之因此 static final 會直接被複制,而 static 變量會被賦予零值。其實咱們稍微思考一下就能想明白了。
兩個語句的區別是一個有 final 關鍵字修飾,另一個沒有。而 final 關鍵字在 Java 中表明不可改變的意思,意思就是說 number 的值一旦賦值就不會在改變了。既然一旦賦值就不會再改變,那麼就必須一開始就給其賦予用戶想要的值,所以被 final 修飾的類變量在準備階段就會被賦予想要的值。而沒有被 final 修飾的類變量,其可能在初始化階段或者運行階段發生變化,因此就沒有必要在準備階段對它賦予用戶想要的值。
解析階段是虛擬機常量池內的符號引用替換爲直接引用的過程。
符號引用:符號引用是一組符號來描述所引用的目標對象,符號能夠是任何形式的字面量,只要使用時能無歧義地定位到目標便可。符號引用與虛擬機實現的內存佈局無關,引用的目標對象並不必定已經加載到內存中。Java 虛擬機明確在 Class 文件格式中定義的符號引用的字面量形式。
直接引用:直接引用能夠是直接指向目標對象的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是與虛擬機內存佈局實現相關的,同一個符號引用在不一樣虛擬機實例上翻譯出來的直接引用通常不會相同,若是有了直接引用,那引用的目標一定已經在內存中存在。
在解析的階段,解析動做主要針對7類符號引用進行,它們的名稱以及對於常量池中的常量類型和解析報錯信息以下:
| 解析動做 | 符號引用 | 解析可能的報錯 | | ---------- | ------------------------------- | -----------------------------------------------------------
| | 類或接口 | CONSTANTClassInfo | java.land.IllegalAccessError
| | 字段 | CONSTANTFieldrefInfo | java.land.IllegalAccessError 或 java.land.NoSuchFieldError
| | 類方法 | CONSTANTMethodefInfo | java.land.IllegalAccessError 或 java.land.NoSuchMethodError
| | 接口方法 | CONSTANTInterfaceMethoderInfo | java.land.IllegalAccessError 或 java.land.NoSuchMethodError
| | 方法類型 | CONSTANTMethodTypeInfo |
| | 方法句柄 | CONSTANTMethodhandlerInfo |
| | 調用限定符 | CONSTANTInvokeDynamicInfo |
解析的整個階段在虛擬機中仍是比較複雜的,遠比上面介紹的複雜的多,可是不少特別細節的東西咱們能夠暫時先忽略,先有個大概的認識和了解以後有時間在慢慢深刻了。
類初始階段是類加載過程的最後一步,在上面提到的類加載過程當中,除了加載階段用戶應用程序能夠經過自定義類加載器參與以外,其他的動做所有由虛擬機主導和控制。初始化階段,是真正開始執行類中定義的 Java 程序代碼(或者說是字節碼)。
在準備階段,變量已經賦值過一次系統要求的初始值(零值),而在初始化階段,則根據程序員經過程序制定的主觀計劃去初始化類變量和其餘資源。(或者從另外一個角度表達:初始化階段是執行類構造器 <clinit>()
方法的過程。)
在這個階段,JVM 會根據語句執行順序對類對象進行初始化,通常來講當 JVM 遇到下面 5 種狀況的時候會觸發初始化:
遇到 new、getstatic、putstatic、invokestatic 這四條字節碼指令時,若是類沒有進行過初始化,則須要先觸發其初始化。生成這4條指令的最多見的 Java 代碼場景是:使用new 關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被 final 修飾、已在編譯器把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
使用 java.lang.reflect 包的方法對類進行反射調用的時候,若是類沒有進行過初始化,則須要先觸發其初始化。
當初始化一個類的時候,若是發現其父類尚未進行過初始化,則須要先觸發其父類的初始化。
當虛擬機啓動時,用戶須要指定一個要執行的主類(包含 main() 方法的那個類),虛擬機會先初始化這個主類。
當使用 JDK1.7 動態語言支持時,若是一個 java.lang.invoke.MethodHandle 實例最後的解析結果 REF_getstatic,REF_putstatic,REF_invokeStatic 的方法句柄,而且這個方法句柄所對應的類沒有進行初始化,則須要先出觸發其初始化。
看到上面幾個條件你可能會暈了,可是沒關係,不須要背,知道一下就好,後面用到的時候回到找一下就能夠了。
注意這裏的初始化,並非說創造的類的實例,而是執行了類構造器,簡單來講就是隻對靜態變量,靜態代碼塊進行初始化。對於構造函數只有在建立實例的時候纔會執行。
當 JVM 完成初始化階段以後,JVM 便開始從入口方法開始執行用戶的程序代碼。這個階段也只是瞭解一下就能夠。
當用戶程序代碼執行完畢後,JVM 便開始銷燬建立的 Class 對象,最後負責運行的 JVM 也退出內存。這個階段也只是瞭解一下就能夠。
還記得前面的題目嘛,下面開始分析:
也許會有人問爲何沒有輸出「兒子在靜態代碼塊」這個字符串?
這是由於對於靜態字段,只有直接定義這個字段的類纔會被初始化(執行靜態代碼塊)。所以經過其子類來引用父類中定義的靜態字段,只會觸發父類的初始化而不會觸發子類的初始化。
對面上面的這個例子,咱們能夠從入口開始分析一路分析下去:
首先程序到 main 方法這裏,使用標準化輸出 Son 類中的 factor 類成員變量,可是 Son 類中並無定義這個類成員變量。因而往父類去找,咱們在 Father 類中找到了對應的類成員變量,因而觸發了 Father 的初始化。
但根據咱們上面說到的初始化的 5 種狀況中的第 3 種(當初始化一個類的時候,若是發現其父類尚未進行過初始化,則須要先觸發其父類的初始化)。咱們須要先初始化 Father 類的父類,也就是先初始化 Grandpa 類再初始化 Father 類。因而咱們先初始化 Grandpa 類輸出:「爺爺在靜態代碼塊」,再初始化 Father 類輸出:「爸爸在靜態代碼塊」。
最後,全部父類都初始化完成以後,Son 類才能調用父類的靜態變量,從而輸出:「爸爸的歲數:25」。
這裏採用 new 進行初始化,因此先進行父類得初始化。先是執行靜態變量初始化。子類建立對象的同時會先創造父類的對象,所以必須先調用父類的構造方法。
這裏我作了一些改變:
public class ClassLoaderTest { public static void main(String[] args) { // System.out.println("爸爸的歲數:" + Son.factor); //入口1 new Son(3); //入口 2 } } class Grandpa { int s = 3; public Grandpa(int s) { System.out.println("我是爺爺~" ); } static { System.out.println("爺爺在靜態代碼塊"); } } class Father extends Grandpa { static { System.out.println("爸爸在靜態代碼塊"); } public static int factor = 25; public Father(int s) { //super(s); System.out.println("我是爸爸~"); } } class Son extends Father { static { System.out.println("兒子在靜態代碼塊"); } public Son(int s ) { super(s); System.out.println("我是兒子~"); } }
這裏的變更是,父類子類都只有一個有參構造函數,在初始化子類得時候,不顯示的調用父類的構造函數,運行結果以下:
爺爺在靜態代碼塊 Exception in thread "main" 爸爸在靜態代碼塊 兒子在靜態代碼塊 Exception in thread "main" java.lang.Error: Unresolved compilation problem: Implicit super constructor Grandpa() is undefined. Must explicitly invoke another constructor at Father.<init>(ClassLoaderTest.java:27) at Son.<init>(ClassLoaderTest.java:39) at ClassLoaderTest.main(ClassLoaderTest.java:5)
簡單來講,若是子類構造函數不顯示調用父類的構造函數,這時候在初始化子類得時候,就會去父類尋找無參構造函數,若是父類只定義了有參構造函數,沒有無參構造函數,就會報錯。所以通常來講最好是顯示調用,又或者多定義幾種不一樣的構造函數,方便在不一樣場景下調用。
把類加載階段的 "經過一個類的全限定名來獲取描述此類的二進制字節流" 這個動做交給虛擬機以外的類加載器來完成。這樣的好處在於,咱們能夠自行實現類加載器來加載其餘格式的類,只要是二進制字節流就行,這就大大加強了加載器靈活性。
系統自帶的類加載器分爲三種:
啓動類加載器。其它的類加載器都是 java.lang.ClassLoader 的子類,啓動類加載器是由 C++ 實現的,沒有對應的 Java 對象,所以在 Java 中只能用 null 代替。啓動類加載器加載最爲基礎,最爲重要的類,如 JRE 的 lib 目錄下 jar 包中的類;擴展類加載器的父類是啓動類加載器,它負責加載相對次要,但又通用的類,如 JRE 的 lib/ext 目錄下jar包中的類
擴展類加載器。Java核心類庫提供,負責加載java的擴展庫(加載 JAVA_HOME/jre/ext/*.jar 中的類),開發者能夠直接使用擴展類加載器。
應用程序類加載器。Java核心類庫提供。應用類加載器的父類加載器則是擴展類加載器,它負責加載應用程序路徑下的類。開發者能夠直接使用這個類加載器,若應用程序中沒有定義過本身的類加載器,java 應用的類都是由它來完成加載的。
具體關係以下:
若是一個類加載器收到了類加載器的請求,它首先不會本身去嘗試加載這個類,而是把這個請求委派給父加載器去完成。每一個層次的類加載器都是如此,所以全部的加載請求最終都會傳送到 Bootstrap 類加載器(啓動類加載器)中,只有父類加載反饋本身沒法加載這個請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會嘗試本身去加載。
雙親委派模型的優勢:java類隨着它的加載器一塊兒具有了一種帶有優先級的層次關係.
例如類 java.lang.Object 它存放在 rt.jart 之中,不管哪個類加載器都要加載這個類.最終都是雙親委派模型最頂端的 Bootstrap 類加載器去加載.所以Object類在程序的各類類加載器環境中都是同一個類.相反.若是沒有使用雙親委派模型.由各個類加載器自行去加載的話.若是用戶編寫了一個稱爲 "java.lang.Object" 的類,並存放在程序的 ClassPath 中。那系統中將會出現多個不一樣的Object類,java類型體系中最基礎的行爲也就沒法保證,應用程序也將會一片混亂。
這裏也能夠用代碼驗證下:
public class ClassLoaderTest { public static void main(String[] args) { ClassLoader loader = Thread.currentThread().getContextClassLoader(); System.out.println(loader); System.out.println(loader.getParent()); System.out.println(loader.getParent().getParent()); } }
輸出結果爲:
sun.misc.Launcher$AppClassLoader@2a139a55 sun.misc.Launcher$ExtClassLoader@7852e922 null
跟前面的描述是一致的。啓動類加載器是由 C++ 實現的,沒有對應的 Java 對象,所以在 Java 中只能用 null 代替。
一、爲何要自定義ClassLoader
由於系統的 ClassLoader 只會加載指定目錄下的 class 文件,若是你想加載本身的 class 文件,那麼就能夠自定義一個 ClassLoader.
並且咱們能夠根據本身的需求,對 class 文件進行加密和解密。
2. 如何自定義ClassLoader
新建一個類繼承自 java.lang.ClassLoader 重寫它的 findClass 方法。將 class 字節碼數組轉換爲 Class 類的實例。調用 loadClass 方法加載便可
先是定義一個自定義類加載器
package com.hello.test; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; public class MyClassLoader extends ClassLoader { // 指定路徑 private String path ; public MyClassLoader(String classPath){ path=classPath; } /** * 重寫findClass方法 * @param name 是咱們這個類的全路徑 * @return * @throws ClassNotFoundException */ @Override protected Class<?> findClass(String name) throws ClassNotFoundException { Class log = null; // 獲取該class文件字節碼數組 byte[] classData = getData(); if (classData != null) { // 將class的字節碼數組轉換成Class類的實例 log = defineClass(name, classData, 0, classData.length); } return log; } /** * 將class文件轉化爲字節碼數組 * @return */ private byte[] getData() { File file = new File(path); if (file.exists()){ FileInputStream in = null; ByteArrayOutputStream out = null; try { in = new FileInputStream(file); out = new ByteArrayOutputStream(); byte[] buffer = new byte[1024]; int size = 0; while ((size = in.read(buffer)) != -1) { out.write(buffer, 0, size); } } catch (IOException e) { e.printStackTrace(); } finally { try { in.close(); } catch (IOException e) { e.printStackTrace(); } } return out.toByteArray(); }else{ return null; } } }
能夠再 getData 裏面作不少事情 ,好比加密解密之類的 都是能夠的。
接着建立一個試驗 class :
package com.hello.test; public class Log { public static void main(String[] args) { System.out.println("load Log class successfully from log " ); } }
執行命令行 javac Log.java 生成咱們的 Log.class 文件:
最後就是進行加載:
package com.hello.test; import java.lang.reflect.Method; public class ClassLoaderTest { public static void main(String[] args) { // 這個類class的路徑,本身複製本身電腦的路徑 String classPath = "/Users/yourname/Documents/workspace-sts-3.9.6.RELEASE/HelloWorld/src/Log.class"; MyClassLoader myClassLoader = new MyClassLoader(classPath); // 類的全稱,對應包名 String packageNamePath = "com.hello.test.Log"; try { // 加載Log這個class文件 Class<?> Log = myClassLoader.loadClass(packageNamePath); System.out.println("類加載器是:" + Log.getClassLoader()); // 利用反射獲取main方法 Method method = Log.getDeclaredMethod("main", String[].class); Object object = Log.newInstance(); String[] arg = {"ad"}; method.invoke(object, (Object) arg); } catch (Exception e) { e.printStackTrace(); } } }
輸出結果以下:
能夠看到是委託父類進行加載的。 到此,關於類加載器的內容就說完了。
最後咱們再來看一道升級事後的題目:
public class Book { static int amount1 = 112; static Book book = new Book(); // 入口1 public static void main(String[] args) { staticFunction(); } static { System.out.println("書的靜態代碼塊"); } { System.out.println("書的普通代碼塊"); } Book() { System.out.println("書的構造方法"); System.out.println("price=" + price +", amount=" + amount + ", amount1=" + amount1); } public static void staticFunction() { System.out.println("書的靜態方法");
System.out.println("amount=" + amount + ",amount1=" + amount1);
} int price = 110; static int amount = 112; // static Book book = new Book(); // 入口2 }
入口1 的結果
書的普通代碼塊
書的構造方法
price=110, amount=0, amount1=112
書的靜態代碼塊
書的靜態方法
amount=112, amount1=112
入口2 的結果
書的靜態代碼塊
書的普通代碼塊
書的構造方法
price=110, amount=112, amount1=112
書的靜態方法
amount=112, amount1=112
在上面兩個例子中,由於 main 方法所在類並無多餘的代碼,咱們都直接忽略了 main 方法所在類的初始化。
但在這個例子中,main 方法所在類有許多代碼,咱們就並不能直接忽略了。
當 JVM 在準備階段的時候,便會爲類變量分配內存和進行初始化。此時,咱們的 book 實例變量被初始化爲 null,amount,amout1 變量被初始化爲 0。
當進入初始化階段後,由於 Book 方法是程序的入口,根據咱們上面說到的類初始化的五種狀況的第四種(當虛擬機啓動時,用戶須要指定一個要執行的主類(包含 main() 方法的那個類),虛擬機會先初始化這個主類)。因此 JVM 會初始化 Book 類,即執行類構造器 。
JVM 對 Book 類進行初始化首先是執行類構造器(按順序收集類中全部靜態代碼塊和類變量賦值語句就組成了類構造器 ),
後執行對象的構造器(按順序收集成員變量賦值和普通代碼塊,最後收集對象構造器,最終組成對象構造器 )。
對於入口1,執行類構造器發現 book 實例是靜態變量,因而就會執行普通代碼塊,再去執行 book 的構造函數。執行完後,從新回到執行類構造器的路上,對剩下的靜態變量進行初始化。
入口2 的變化就是將靜態實例初始化移到了最後。從而保證優先執行類構造器,再去進行對象初始化過程。
假如把入口1,2 都註釋掉,這回結果會怎麼樣:
書的靜態代碼塊
書的靜態方法
amount=112, amount1=112
能夠發現,最終只有類構造器獲得了執行。
從上面幾個例子能夠看出,分析一個類的執行順序大概能夠按照以下步驟:
肯定類變量的初始值。在類加載的準備階段,JVM 會爲類變量初始化零值,這時候類變量會有一個初始的零值。若是是被 final 修飾的類變量,則直接會被初始成用戶想要的值。
初始化入口方法。當進入類加載的初始化階段後,JVM 會尋找整個 main 方法入口,從而初始化 main 方法所在的整個類。當須要對一個類進行初始化時,會首先初始化類構造器(),以後初始化對象構造器()。
初始化類構造器。JVM 會按順序收集類變量的賦值語句、靜態代碼塊,最終組成類構造器由 JVM 執行。
初始化對象構造器。JVM 會按照收集成員變量的賦值語句、普通代碼塊,最後收集構造方法,將它們組成對象構造器,最終由 JVM 執行。
若是在初始化 main 方法所在類的時候遇到了其餘類的初始化,那麼就先加載對應的類,加載完成以後返回。如此反覆循環,最終返回 main 方法所在類。
參考文章