在上一篇文章中,咱們詳細的介紹了Java類文件結構,那麼這些Class文件是如何被加載到內存,由虛擬機來直接使用的呢?這就是本篇博客將要介紹的——類加載過程。html
類從被加載到虛擬機內存開始,到卸載出內存爲止,其聲明週期流程以下:java
上圖中紅色的5個部分(加載、驗證、準備、初始化、卸載)順序是肯定的,也就是說,類的加載過程必須按照這種順序循序漸進的開始。這裏的「開始」不是循序漸進的「進行」或者「完成」,由於這些階段一般是互相交叉混合的進行的,一般會在一個階段執行過程當中調用另外一個階段。程序員
「加載」階段是「類加載」生命週期的第一個階段。在加載階段,虛擬機要完成下面三件事:數據庫
①、經過一個類的全限定名來獲取定義此類的二進制字節流。數組
②、將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構。安全
③、在Java堆中生成一個表明這個類的java.lang.Class對象,做爲方法區這些數據的訪問入口。網絡
PS:類的全限定名能夠理解爲這個類存放的絕對路徑。方法區是JDK1.7之前定義的運行時數據區,而在JDK1.8之後改成元數據區(Metaspace),主要用於存放被Java虛擬機加載的類信息、常量、靜態變量、即時編譯器編譯後的代碼等數據。詳情能夠參考這邊該系列的第二篇文章——運行時內存結構。數據結構
另外,咱們看第一點——經過類的權限定名來獲取定義此類的二進制流,這裏並無明確指明要從哪裏獲取以及怎樣獲取,也就是說並無明確規定必定要咱們從一個 Class 文件中獲取。基於此,在Java的發展過程當中,充滿創造力的開發人員在這個舞臺上玩出了各類花樣:多線程
一、從 ZIP 包中讀取。這稱爲後面的 JAR、EAR、WAR 格式的基礎。編輯器
二、從網絡中獲取。比較典型的應用就是 Applet。
三、運行時計算生成。這就是動態代理技術。
四、由其它文件生成。好比 JSP 應用。
五、從數據庫中讀取。
加載階段完成後,虛擬機外部的二進制字節流就按照虛擬機所需的格式存儲在方法區中,而後在Java堆中實例化一個 java.lang.Class 類的對象,這個對象將做爲程序訪問方法區中這些類型數據的外部接口。
注意,加載階段與鏈接階段的部份內容(如一部分字節碼文件的格式校驗)是交叉進行的,加載階段還沒有完成,鏈接階段可能已經開始了。
驗證是鏈接階段的第一步,做用是爲了確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。
咱們說Java語言自己是相對安全,由於編譯器的存在,純粹的Java代碼要訪問數組邊界外的數據、跳轉到不存在的代碼行之類的,是要被編譯器拒絕的。可是前面咱們也說過,Class 文件不必定非要從Java源碼編譯過來,可使用任何途徑,包括你很牛逼,直接用十六進制編輯器來編寫 Class 文件。
因此,若是虛擬機不檢查輸入的字節流,將會載入有害的字節流而致使系統崩潰。可是虛擬機規範對於檢查哪些方面,什麼時候檢查,怎麼檢查都沒有明確的規定,不一樣的虛擬機實現方式可能都會有所不一樣,可是大體都會完成下面四個方面的檢查。
校驗字節流是否符合Class文件格式的規範,而且可以被當前版本的虛擬機處理。
1、是否以魔數 0xCAFEBABE 開頭。
2、主、次版本號是不是當前虛擬機處理範圍以內。
3、常量池的常量中是否有不被支持的常量類型(檢查常量tag標誌)
4、指向常量的各類索引值中是否有指向不存在的常量或不符合類型的常量。
5、CONSTANT_Utf8_info 型的常量中是否有不符合 UTF8 編碼的數據。
6、Class 文件中各個部分及文件自己是否有被刪除的或附加的其餘信息。
以上是一部分校驗內容,固然遠不止這些。通過這些校驗後,字節流纔會進入內存的方法區中存儲,接下來後面的三個階段校驗都是基於方法區的存儲結構進行的。
第二個階段主要是對字節碼描述的信息進行語義分析,以保證其描述的信息符合Java語言規範要求。
1、這個類是否有父類(除了java.lang.Object 類以外,全部的類都應當有父類)。
2、這個類的父類是否繼承了不容許被繼承的類(被final修飾的類)。
3、若是這個類不是抽象類,是否實現了其父類或接口之中要求實現的全部普通方法。
4、類中的字段、方法是否與父類產生了矛盾(例如覆蓋了父類的final字段、或者出現不符合規則的重載)
第三個階段字節碼驗證是整個驗證階段中最複雜的,主要是進行數據流和控制流分析。該階段將對類的方法進行分析,保證被校驗的方法在運行時不會作出危害虛擬機安全的行爲。
1、保證任意時刻操做數棧中的數據類型與指令代碼序列都能配合工做。例如不會出如今操做數棧中放置了一個 int 類型的數據,使用時卻按照 long 類型來加載到本地變量表中。
2、保證跳轉指令不會跳轉到方法體之外的字節碼指令中。
3、保證方法體中的類型轉換是有效的。好比把一個子類對象賦值給父類數據類型,這是安全的。可是把父類對象賦值給子類數據類型,甚至賦值給徹底不相干的類型,這就是不合法的。
符號引用驗證主要是對類自身之外(常量池中的各類符號引用)的信息進行匹配性的校驗,一般須要校驗以下內容:
1、符號引用中經過字符串描述的全限定名是否可以找到相應的類。
2、在指定類中是否存在符合方法的字段描述符及簡單名稱所描述的方法和字段。
3、符號引用中的類、字段和方法的訪問性(private、protected、public、default)是否能夠被當前類訪問。
準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些內存是在方法區中進行分配。
注意:
1、上面說的是類變量,也就是被 static 修飾的變量,不包括實例變量。實例變量會在對象實例化時隨着對象一塊兒分配在堆中。
2、初始值,指的是一些數據類型的默認值。基本的數據類型初始值以下(引用類型的初始值爲null):
好比,定義 public static int value = 123 。那麼在準備階段事後,value 的值是 0 而不是 123,把 value 賦值爲123 是在程序被編譯後,存放在類的構造器方法之中,是在初始化階段纔會被執行。可是有一種特殊狀況,經過final 修飾的屬性,好比 定義 public final static int value = 123,那麼在準備階段事後,value 就被賦值爲123了。
解析階段是虛擬機將常量池中的符號引用替換爲直接引用的過程。
符號引用(Symbolic References):符號引用以一組符號來描述所引用的目標,符號能夠是任何形式的字面量,只要使用時能無歧義的定位到目標便可。符號引用與虛擬機實現的內存佈局無關,引用的目標不必定已經加載到內存中。
直接引用(Direct References):直接引用能夠是直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是與虛擬機實現內存佈局相關的,同一個符號引用在不一樣虛擬機實例上翻譯出來的直接引用通常不會相同。若是有了直接引用,那麼引用的目標一定已經在內存中存在。
解析動做主要針對類或接口、字段、類方法、接口方法四類符號引用,分別對應於常量池的 CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANTS_InterfaceMethodref_info四種類型常量。
初始化階段是類加載階段的最後一步,前面過程當中,除第一個加載階段能夠經過用戶自定義類加載器參與以外,其他過程都是徹底由虛擬機主導和控制。而到了初始化階段,則開始真正執行類中定義的Java程序代碼(或者說是字節碼)。
在前面介紹的準備階段中,類變量已經被賦值過初始值了,而初始化階段,則根據程序員的編碼去初始化變量和資源。
換句話來講,初始化階段是執行類構造器<clinit>() 方法的過程。
①、<clinit>() 方法 是由編譯器自動收集類中的全部類變量的賦值動做和靜態語句塊(static{})中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊以前的變量,定義在它以後的變量,在前面的靜態語句塊中能夠賦值,可是不能訪問。
好比以下代碼會報錯:
可是你把第 14 行代碼放到 static 靜態代碼塊的上面就不會報錯了。或者不改變代碼順序,將第 11 行代碼移除,也不會報錯。
②、<clinit>() 方法與類的構造函數(或者說是實例構造器<init>()方法)不一樣,它不須要顯示的調用父類構造器,虛擬機會保證在子類的<init>()方法執行以前,父類的<init>()方法已經執行完畢。所以虛擬機中第一個被執行的<init>()方法的類確定是 java.lang.Object。
③、因爲父類的<clinit>() 方法先執行,因此父類中定義的靜態語句塊要優先於子類的變量賦值操做。
④、<clinit>() 方法對於接口來講並非必須的,若是一個類中沒有靜態語句塊,也沒有對變量的賦值操做,那麼編譯器能夠不爲這個類生成<clinit>() 方法。
⑤、接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操做,所以接口與類同樣都會生成<clinit>() 方法。但接口與類不一樣的是,執行接口中的<clinit>() 方法不須要先執行父接口的<clinit>() 方法。只有當父接口中定義的變量被使用時,父接口才會被初始化。
⑥、接口的實現類在初始化時也同樣不會執行接口的<clinit>() 方法。
⑦、虛擬機會保證一個類的<clinit>() 方法在多線程環境中被正確的加鎖和同步。若是多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>() 方法,其餘的線程都須要阻塞等待,直到活動線程執行<clinit>() 方法完畢。若是在一個類的<clinit>() 方法中有很耗時的操做,那麼可能形成多個進程的阻塞。
好比對於以下代碼:
package com.yb.carton.controller; /** * Create by YSOcean */ public class ClassLoadInitTest { static class Hello{ static { if(true){ System.out.println(Thread.currentThread().getName() + "init"); while(true){} } } } public static void main(String[] args) { new Thread(()->{ System.out.println(Thread.currentThread().getName()+"start"); Hello h1 = new Hello(); System.out.println(Thread.currentThread().getName()+"run over"); }).start(); new Thread(()->{ System.out.println(Thread.currentThread().getName()+"start"); Hello h2 = new Hello(); System.out.println(Thread.currentThread().getName()+"run over"); }).start(); } }
運行結果以下:
線程1搶到了執行<clinit>() 方法,可是該方法是一個死循環,線程2將一直阻塞等待。
知道了類的初始化過程,那麼類的初始化什麼時候被觸發呢?JVM大概規定了以下幾種狀況:
①、當虛擬機啓動時,初始化用戶指定的類。
②、當遇到用以新建目標類實例的 new 指令時,初始化 new 指定的目標類。
③、當遇到調用靜態方法的指令時,初始化該靜態方法所在的類。
④、當遇到訪問靜態字段的指令時,初始化該靜態字段所在的類。
⑤、子類的初始化會觸發父類的初始化。
⑥、若是一個接口定義了 default 方法,那麼直接實現或間接實現該接口的類的初始化,會觸發該接口的初始化。
⑦、使用反射 API 對某個類進行反射調用時,會初始化這個類。
⑧、當初次調用 MethodHandle 實例時,初始化該 MethodHandle 指向的方法所在的類。