首先Throws(拋出)幾個本身學習過程當中一直疑惑的問題:java
一、什麼是類加載?何時進行類加載?程序員
二、什麼是類初始化?何時進行類初始化?數組
三、何時會爲變量分配內存?緩存
四、何時會爲變量賦默認初值?何時會爲變量賦程序設定的初值?安全
五、類加載器是什麼?網絡
六、如何編寫一個自定義的類加載器?數據結構
首先,在代碼編譯後,就會生成JVM(Java虛擬機)可以識別的二進制字節流文件(*.class)。而JVM把Class文件中的類描述數據從文件加載到內存,並對數據進行校驗、轉換解析、初始化,使這些數據最終成爲能夠被JVM直接使用的Java類型,這個說來簡單但實際複雜的過程叫作JVM的類加載機制。多線程
Class文件中的「類」從加載到JVM內存中,到卸載出內存過程有七個生命週期階段。類加載機制包括了前五個階段。函數
以下圖所示:學習

其中,加載、驗證、準備、初始化、卸載的開始順序是肯定的,注意,只是按順序開始,進行與結束的順序並不必定。解析階段可能在初始化以後開始。
另外,類加載無需等到程序中「首次使用」的時候纔開始,JVM預先加載某些類也是被容許的。(類加載的時機)
1、類的加載
咱們日常說的加載大多不是指的類加載機制,只是類加載機制中的第一步加載。在這個階段,JVM主要完成三件事:
一、經過一個類的全限定名(包名與類名)來獲取定義此類的二進制字節流(Class文件)。而獲取的方式,能夠經過jar包、war包、網絡中獲取、JSP文件生成等方式。
二、將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構。這裏只是轉化了數據結構,並未合併數據。(方法區就是用來存放已被加載的類信息,常量,靜態變量,編譯後的代碼的運行時內存區域)
三、在內存中生成一個表明這個類的java.lang.Class對象,做爲方法區這個類的各類數據的訪問入口。這個Class對象並無規定是在Java堆內存中,它比較特殊,雖爲對象,但存放在方法區中。
相對於類加載的其餘階段而言,加載階段(準確地說,是加載階段獲取類的二進制字節流的動做)是可控性最強的階段,由於開發人員既可使用系統提供的類加載器來完成加載,也能夠自定義本身的類加載器來完成加載。
加載階段完成後,虛擬機外部的 二進制字節流就按照虛擬機所需的格式存儲在方法區之中,並且在Java堆中也建立一個java.lang.Class類的對象,這樣即可以經過該對象訪問方法區中的這些數據。
2、類的鏈接
類的加載過程後生成了類的java.lang.Class對象,接着會進入鏈接階段,鏈接階段負責將類的二進制數據合併入JRE(Java運行時環境)中。類的鏈接大體分三個階段。
一、驗證:驗證被加載後的類(Class文件的字節流)是否有正確的結構,類數據是否會符合虛擬機的要求,確保不會危害虛擬機安全。
不一樣的虛擬機對類驗證的實現可能會有所不一樣,但大體都會完成如下四個階段的驗證:文件格式的驗證、元數據的驗證、字節碼驗證和符號引用驗證。
- 文件格式的驗證:驗證字節流是否符合Class文件格式的規範,而且能被當前版本的虛擬機處理,該驗證的主要目的是保證輸入的字節流能正確地解析並存儲於方法區以內。通過該階段的驗證後,字節流纔會進入內存的方法區中進行存儲,後面的三個驗證都是基於方法區的存儲結構進行的。
- 元數據驗證:對類的元數據信息進行語義校驗(其實就是對類中的各數據類型進行語法校驗),保證不存在不符合Java語法規範的元數據信息。
- 字節碼驗證:該階段驗證的主要工做是進行數據流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在運行時不會作出危害虛擬機安全的行爲。
- 符號引用驗證:這是最後一個階段的驗證,它發生在虛擬機將符號引用轉化爲直接引用的時候(解析階段中發生該轉化,後面會有講解),主要是對類自身之外的信息(常量池中的各類符號引用)進行匹配性的校驗。
二、準備:準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些內存都將在方法區中分配。
- 這時候進行內存分配的僅包括類變量(static),而不包括實例變量,實例變量會在對象實例化時隨着對象一塊分配在Java堆中。
- 這裏所設置的初始值一般狀況下是數據類型默認的零值(如0、0L、null、false等),而不是被在Java代碼中被顯式地賦予。
如static int a = 100; 靜態變量a就會在準備階段被賦默認值0。另外,靜態常量(static final filed)會在準備階段賦程序設定的初值,如static final int a = 666; 靜態常量a就會在準備階段被直接賦值爲666,對於靜態變量,這個操做是在初始化階段進行的。
下面列出java基本類型和引用類型的默認值:

這裏還須要注意以下幾點:
- 對基本數據類型來講,對於類變量(static)和全局變量,若是不顯式地對其賦值而直接使用,則系統會爲其賦予默認的零值,而對於局部變量來講,在使用前必須顯式地爲其賦值,不然編譯時不經過。
- 對於同時被static和final修飾的常量,必須在聲明的時候就爲其顯式地賦值,不然編譯時不經過;而只被final修飾的常量則既能夠在聲明時顯式地爲其賦值,也能夠在類初始化時顯式地爲其賦值,總之,在使用前必須爲其顯式地賦值,系統不會爲其賦予默認零值。
- 對於引用數據類型reference來講,如數組引用、對象引用等,若是沒有對其進行顯式地賦值而直接使用,系統都會爲其賦予默認的零值,即null。
- 若是在數組初始化時沒有對數組中的各元素賦值,那麼其中的元素將根據對應的數據類型而被賦予默認的零值。
三、解析:將類的二進制數據中的符號引用換爲直接引用。
解析階段是虛擬機將常量池中的符號引用轉化爲直接引用的過程。在
Class類文件結構一文中已經比較過了符號引用和直接引用的區別和關聯,這裏再也不贅述。前面說解析階段可能開始於初始化以前,也可能在初始化以後開始,虛擬機會根據須要來判斷,究竟是在類被加載器加載時就對常量池中的符號引用進行解析(初始化以前),仍是等到一個符號引用將要被使用前纔去解析它(初始化以後)。
對同一個符號引用進行屢次解析請求時很常見的事情,虛擬機實現可能會對第一次解析的結果進行緩存(在運行時常量池中記錄直接引用,並把常量標示爲已解析狀態),從而避免解析動做重複進行。
解析動做主要針對類或接口、字段、類方法、接口方法四類符號引用進行,分別對應於常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info四種常量類型。
一、類或接口的解析:判斷所要轉化成的直接引用是對數組類型,仍是普通的對象類型的引用,從而進行不一樣的解析。
二、字段解析:對字段進行解析時,會先在本類中查找是否包含有簡單名稱和字段描述符都與目標相匹配的字段,若是有,則查找結束;若是沒有,則會按照繼承關係從上往下遞歸搜索該類所實現的各個接口和它們的父接口,尚未,則按照繼承關係從上往下遞歸搜索其父類,直至查找結束,查找流程以下圖所示:
3、類的初始化
初始化是類加載過程的最後一步,到了此階段,才真正開始執行類中定義的Java程序代碼。在準備階段,類變量已經被賦過一次系統要求的初始值,而在初始化階段,則是根據程序員經過程序指定的主觀計劃去初始化類變量和其餘資源,或者能夠從另外一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。
這裏簡單說明下<clinit>()方法的執行規則:
一、<clinit>()方法是由編譯器自動收集類中的全部類變量的賦值動做和靜態語句塊中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊以前的變量,定義在它以後的變量,在前面的靜態語句中能夠賦值,可是不能訪問。
二、<clinit>()方法與實例構造器<init>()方法(類的構造函數)不一樣,它不須要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行以前,父類的<clinit>()方法已經執行完畢。所以,在虛擬機中第一個被執行的<clinit>()方法的類確定是java.lang.Object。
三、<clinit>()方法對於類或接口來講並非必須的,若是一個類中沒有靜態語句塊,也沒有對類變量的賦值操做,那麼編譯器能夠不爲這個類生成<clinit>()方法。
四、接口中不能使用靜態語句塊,但仍然有類變量(final static)初始化的賦值操做,所以接口與類同樣會生成<clinit>()方法。可是接口魚類不一樣的是:執行接口的<clinit>()方法不須要先執行父接口的<clinit>()方法,只有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實現類在初始化時也同樣不會執行接口的<clinit>()方法。
五、虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖和同步,若是多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()方法,其餘線程都須要阻塞等待,直到活動線程執行<clinit>()方法完畢。若是在一個類的<clinit>()方法中有耗時很長的操做,那就可能形成多個線程阻塞,在實際應用中這種阻塞每每是很隱蔽的。
如下幾種狀況不會執行類初始化:
- 經過子類引用父類的靜態字段,只會觸發父類的初始化,而不會觸發子類的初始化。
- 定義對象數組,不會觸發該類的初始化。
- 常量在編譯期間會存入調用類的常量池中,本質上並無直接引用定義常量的類,不會觸發定義常量所在的類。
- 經過類名獲取Class對象,不會觸發類的初始化。
- 經過Class.forName加載指定類時,若是指定參數initialize爲false時,也不會觸發類初始化,其實這個參數是告訴虛擬機,是否要對類進行初始化。
- 經過ClassLoader默認的loadClass方法,也不會觸發初始化的動做。
下面給出一個簡單的例子,以便更清晰地說明如上規則:
class Father{
public static int a = 1;
static{
a = 2;
}
}
class Child extends Father{
public static int b = a;
}
public class ClinitTest{
public static void main(String[] args){
System.out.println(Child.b);
}
}
執行上面的代碼,會打印出2,也就是說b的值被賦爲了2。
咱們來看獲得該結果的步驟。首先在準備階段爲類變量分配內存並設置類變量初始值,這樣A和B均被賦值爲默認值0,然後再在調用<clinit>()方法時給他們賦予程序中指定的值。當咱們調用Child.b時,觸發Child的<clinit>()方法,根據規則2,在此以前,要先執行完其父類Father的<clinit>()方法,又根據規則1,在執行<clinit>()方法時,須要按static語句或static變量賦值操做等在代碼中出現的順序來執行相關的static語句,所以當觸發執行Father的<clinit>()方法時,會先將a賦值爲1,再執行static語句塊中語句,將a賦值爲2,然後再執行Child類的<clinit>()方法,這樣便會將b的賦值爲2.
若是咱們顛倒一下Father類中「public static int a = 1;」語句和「static語句塊」的順序,程序執行後,則會打印出1。很明顯是根據規則1,執行Father的<clinit>()方法時,根據順序先執行了static語句塊中的內容,後執行了「public static int a = 1;」語句。
另外,在顛倒兩者的順序以後,若是在static語句塊中對a進行訪問(好比將a賦給某個變量),在編譯時將會報錯,由於根據規則1,它只能對a進行賦值,而不能訪問。
被動引用的例子一:
經過子類引用父類的靜態字段,對於父類屬於「主動引用」的第一種狀況,對於子類,沒有符合「主動引用」的狀況,故子類不會進行初始化。代碼以下:

被動引用的例子之二:
經過數組來引用類,不會觸發類的初始化,由於是數組new,而類沒有被new,因此沒有觸發任何「主動引用」條款,屬於「被動引用」。代碼以下:
沒有任何結果輸出!
被動引用的例子之三:
剛剛講解時也提到,靜態常量在編譯階段就會被存入調用類的常量池中,不會引用到定義常量的類,這是一個特例,須要特別記憶,不會觸發類的初始化!
//常量類
public class ConstClass {
static{
System.out.println("常量類初始化!");
}
public static final String HELLOWORLD = "hello world!";
}
//主類、測試類
public class NotInit {
public static void main(String[] args){
System.out.println(ConstClass.HELLOWORLD);
}
}
輸出:hello world
總結
整個類加載過程當中,除了在加載階段用戶應用程序能夠自定義類加載器參與以外,其他全部的動做徹底由虛擬機主導和控制。到了初始化纔開始執行類中定義的Java程序代碼(亦及字節碼),但這裏的執行代碼只是個開端,它僅限於<clinit>()方法。類加載過程當中主要是將Class文件(準確地講,應該是類的二進制字節流)加載到虛擬機內存中,真正執行字節碼的操做,在加載完成後才真正開始。
【深刻Java虛擬機】之四:類加載機制 : https://blog.csdn.net/ns_code/article/details/17881581
【類的初始化】: https://blog.csdn.net/ns_code/article/details/17845821
【深刻java虛擬機專欄】:https://blog.csdn.net/ns_code/article/category/1823903
【JVM類加載機制詳解(一)JVM類加載過程]】https://blog.csdn.net/zhangliangzi/article/details/51319033