前言
一點一滴探究JVM系列,主要深入探究JVM運作機制。俗話說,知其然知其是以然。如果不懂JVM的運作機制,那麼無法了解Java這門語言最核心的東西,
也就談不上程式設計之美了,因為你根本不懂得如何使你的代碼更優雅。廢話不多說,今天的主題就是JVM的類加載機制!
開始之前
在正式開始之前,我們先來看一段小程式!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | class Singleton { private static Singleton intsance = new Singleton(); public static int counter1; public static int counter2 = 0; private Singleton() { counter1++; counter2++; } public static Singleton getInstance() { return instance; } } public class Test { Singleton instance = Singleton.getInstance(); System.out.println("counter1 = " + Singleton.counter1 + ", counter2 = " + Singleton.counter2); } |
問題
上面的小程式輸出
counter1
和
counter2
的值是多少?
現在我不會告訴你正确的答案,除非你自己在你的電腦上運作了這段小程式!下面我們開始進入正題
類的生命周期
要想搞清楚類加載機制,我們必須事先知道類的生命周期
![](https://img.laitimes.com/img/9ZDMuAjOiMmIsIjOiQnIsIyZuBnLkF2bs52bzNXYsN2LcZjQlgDOlUTRlEkQlMUOlYTRlQkQlQkQlgTRlATQlEEOlUTRlIkQlEjQlcTRlIEOlkjQlQTRl0kVKZjQlkTQlcTRlITQlUEOlYTRlQjQlIkQlYTRlADOlgjQlQTRlkjQlIDOlcTRlADOlgjQlQTRl8CX0EzLcFTMvw1NxAjMvwVZt5SYt1mcvR3cuc2bsJ2Lc9CX6MHc0RHaiojIsJye.png)
注:
類從被加載到虛拟機記憶體中開始,到解除安裝出記憶體為止,它的整個生命周期包括:加載、驗證、準備、解析、初始化、使用和解除安裝七個階段。其中的驗證、準備、
和解析這三部分稱為連接配接。上圖中的七個周期,可以簡稱為加載、連接配接、初始化。我想你會很好奇這三步究竟發生了什麼?
- 加載: 查找并加載類的二進制資料
- 連接配接:
- 驗證: 確定被加載類的正确性
- 準備: 為類的靜态變量配置設定記憶體,并将其初始化為預設值
- 解析: 把類的符号引用轉換為直接引用
- 初始化: 為類的靜态變量賦予正确的初始值
可能你還不是很了解?的某些術語,稍安勿躁,這才隻是個開始!
關于類加載
什麼情況下,會觸發類的加載過程,這确實是一個很難回答的問題,因為Java虛拟機規範中并沒有進行強制性的限制,而是交給虛拟機具體實作來把握。Java虛拟機規範允許,類不需要等到被主動使用時候才去加載它,類加載器在預料到某個類将要被使用時,就預先加載它,如果在加載的過程中遇到了.class檔案缺失或者莫名其妙的錯誤,類加載器必須在程式首次主動使用該類的時候才報告錯誤(LinkagError),如果這個類一直沒有被主動使用,那麼類加載器就不會報告錯誤!
我在上面的表述中有三個加粗的字型來着重突出主動使用這個概念,或許你會很好奇什麼是主動使用,既然有主動使用,那麼是不是也有被動使用呢?不得不說你很聰明,主動使用和被動使用的概念,我不打算在這講,因為這兩個概念和初始化階段關系密切!
類加載需要完成的事情:
- 通過一個類的全限定名來擷取其定義的二進制位元組流。(全限定名即報名+類名)
- 将這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構。
- 在Java堆中生成一個代表這個類的 java.lang.Class對象,作為對方法區中這些資料的通路入口。
注: 獲得類的二進制位元組流還可以從ZIP包讀取、網絡中擷取、JAR包擷取、或者其他檔案擷取(JSP應用)!
現在你大概清楚了類加載過程完成了哪些操作,那麼不得不說說類加載器了
類加載器
類加載器雖然隻用于實作類的加載動作,但它在Java程式中起到的作用卻遠遠不限于類的加載階段。對于任意一個類,都需要由它的類加載器和這個類本身一同确定其在Java虛拟機中的唯一性,也就是說,即使兩個類來源于同一個
Class
檔案,隻要加載它們的類加載器不同,那這兩個類就必定不相等。這裡的“相等”包括了代表類的
Class
對象的
equals()
、
isAssignableFrom()
、
isInstance()
等方法的傳回結果,也包括了使用
instanceof
關鍵字對對象所屬關系的判定結果。
我發誓我不會騙你,因為有代碼為證:
Example.java
1 2 3 | public class Example { public static final String NAME = "stormma"; } |
InstanceOfTest.java
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 | public class InstanceOfTest { @Test public void testDifferentLoaderLoadClass() throws ClassNotFoundException, IllegalAccessException, InstantiationException { ClassLoader classLoader = new ClassLoader() { @Override public Class<?> loadClass(String name) throws ClassNotFoundException { String filaneName = name.substring(name.lastIndexOf('.') + 1) + ".class"; InputStream inputStream = getClass().getResourceAsStream(filaneName); if (inputStream == null) { return super.loadClass(name); } try { byte[] b = new byte[inputStream.available()]; inputStream.read(b); return defineClass(name, b, 0, b.length); } catch (IOException e) { throw new ClassNotFoundException(name); } } }; Class clazz = classLoader.loadClass("me.stormma.chapter4.Example"); System.out.println(clazz.newInstance().getClass()); // class me.stormma.chapter4.Example System.out.println(clazz.newInstance() instanceof Example); // false System.out.println(clazz.getClassLoader()); // [email protected] System.out.println(InstanceOfTest.class.getClassLoader()); // [email protected] System.out.println(clazz.equals(Example.class)); // false System.out.println(clazz.isAssignableFrom(Example.class)); // false System.out.println(clazz.isInstance(Example.class)); // false } @Test public void testSameLoaderLoadClass() throws IllegalAccessException, InstantiationException, ClassNotFoundException { Class<?> clazz = InstanceOfTest.class.getClassLoader().loadClass("me.stormma.chapter4.Example"); System.out.println(clazz.newInstance() instanceof Example); //true System.out.println(InstanceOfTest.class.getClassLoader() == clazz.getClassLoader()); // true } } |
如果從JVM角度來看,所有的類加載器可以分為:
- 啟動類加載器(
,它負責加載存放在Bootstrap ClassLoader
下,或被$JAVA_HOME/jre/lib
參數指定的路徑中的,并且能被虛拟機識别的類庫(如 rt.jar)。啟動類加載器是無法被Java程式直接引用的。很容易可以驗證,執行-Xbootclasspath
列印結果為System.out.println(String.class.getClassLoader())
)null
- 擴充類加載器(
, 該加載器由Extension ClassLoader
實作,它負責加載sun.misc.Launcher$ExtClassLoader
目錄中,或者由$JAVA_HOME/jre/lib/ext
系統變量指定的路徑中的所有類庫(如java.ext.dirs
開頭的類),開發者可以直接使用擴充類加載器。在jdk1.9中類加載器有所變化!1.9中jdk.internal.loader.ClassLoaders$PlatformClassLoader,稱為平台類加載器)javax.*
- 應用程式加載器(Application ClassLoader,該類加載器由
來實作,它負責加載使用者類路徑sun.misc.Launcher$AppClassLoader
所指定的類,開發者可以直接使用該類加載器,如果應用程式中沒有自定義過自己的類加載器,一般情況下這個就是程式中預設的類加載器。注意在jdk1.9中,應用程式加載器由jdk.internal.loader.ClassLoaders$AppClassLoader實作)ClassPath
你的應用程式,其實就是這幾種類加載器配合使用進行加載的,如果有必要,你可以實作自己的類加載器!比如Tomcat中就有自己的類加載器的實作!
下面,我要介紹一個更重要的概念!雙親委托機制
雙親委托機制
類加載器的層次關系如下:
這種層次關系稱為類加載器的雙親委派模型。我們把每一層上面的類加載器叫做目前層類加載器的父加載器,當然,它們之間的父子關系并不是通過繼承關系來實作的,而是使用組合關系來複用父加載器中的代碼。該模型在JDK1.2期間被引入并廣泛應用于之後幾乎所有的Java程式中,但它并不是一個強制性的限制模型,而是Java設計者們推薦給開發者的一種類的加載器實作方式。
雙親委派模型的工作流程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把請求委托給父加載器去完成,依次向上,是以,所有的類加載請求最終都應該被傳遞到頂層的啟動類加載器中,隻有當父加載器在它的搜尋範圍中沒有找到所需的類時,即無法完成該加載,子加載器才會嘗試自己去加載該類。
使用雙親委派模型來組織類加載器之間的關系,有一個很明顯的好處,就是Java類随着它的類加載器(說白了,就是它所在的目錄)一起具備了一種帶有優先級的層次關系,這對于保證Java程式的穩定運作很重要。例如,類
java.lang.Object
類存放在
$JAVA_HOME/jre/lib
下的
rt.jar
之中,是以無論是哪個類加載器要加載此類,最終都會委派給啟動類加載器進行加載,這邊保證了
Object
類在程式中的各種類加載器中都是同一個類。但是試想一下,如果自定義的加載器去加載的話,那麼程式中會出現不同的
Object
類(詳細前面測試代碼)!那樣将是一片混亂。
到這,我想我們應該去看一下
ClassLoader
這個抽象類的雙親委托機制的實作了!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | 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) { // 調用自身的findClass來進行類的加載 // 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 PerfCounter.getParentDelegationTime().addTime(t1 - t0); PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); PerfCounter.getFindClasses().increment(); } } if (resolve) { resolveClass(c); } return c; } } |
話說,jdk的注釋真的好詳細!
這段代碼是不是很簡單很簡單!對照我們上面的測試代碼自定義的那個類加載器,如果是實作
findClass()
而沒有實作
loadClass()
方法,那麼加載時候先開始判斷它的父類加載器(自定義類加載器的上一級是應用程式類加載器,然後根據雙親委托機制一步一步進行判斷加載。最後加載都不成功就會調用
findClass()
方法來加載,jdk1.2之後官方不提倡實作
loadClass()
!上面的例子,為了測試兩個Class對象不相等,強制實作了
loadClass()
,因為如果隻實作
findClass()
, 就會被應用類加載器所加載)
關于驗證
驗證的目的是為了確定
Class
檔案中的位元組流包含的資訊符合目前虛拟機的要求(你可能會有疑問,Java編譯之後的class檔案,JVM為啥還不相信呢?其實,你也可以僞造一個class檔案,讓JVM去加載執行,如果這有害,那麼肯定會損害JVM,是以說
JVM很”狡猾”),而且不會危害虛拟機自身的安全。不同的虛拟機對類驗證的實作可能會有所不同,但大緻都會完成以下四個階段的驗證:檔案格式的驗證、中繼資料的驗證、位元組碼驗證和符号引用驗證。
- 檔案格式的驗證:驗證位元組流是否符合Class檔案格式的規範,并且能被目前版本的虛拟機處理,該驗證的主要目的是保證輸入的位元組流能正确地解析并存儲于方法區之内。經過該階段的驗證後,位元組流才會進入記憶體的方法區中進行存儲,後面的三個驗證都是基于方法區的存儲結構進行的。
- 中繼資料驗證:對類的中繼資料資訊進行語義校驗(其實就是對類中的各資料類型進行文法校驗),保證不存在不符合Java文法規範的中繼資料資訊。
- 位元組碼驗證:該階段驗證的主要工作是進行資料流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在運作時不會做出危害虛拟機安全的行為。
- 符号引用驗證:這是最後一個階段的驗證,它發生在虛拟機将符号引用轉化為直接引用的時候(解析階段中發生該轉化,後面會有講解),主要是對類自身以外的資訊(常量池中的各種符号引用)進行比對性的校驗。
關于驗證階段,很多東西都是和class檔案位元組碼相關的(哦,對,這是句廢話),深入探究驗證階段的前提是讀懂Class檔案位元組碼,後面的文章,我會專門對Class檔案位元組碼進行總結,力争讓看完文章的每個人都可以看懂Class檔案位元組碼, Come On! CafeBabe,什麼,CafeBabe是啥?這其實就是Class位元組碼的魔數,每個Class檔案位元組碼都是以CafeBabe開頭的,不,不對,是每個符合JVM标準的位元組碼,程式員是不是很浪漫,哈哈~
關于準備
準備階段是正式為類變量配置設定記憶體并設定初始值的階段,這些變量所使用的記憶體都将在方法區中進行配置設定。這個階段有兩個容易混淆的概念,首先,進行記憶體配置設定的僅包括類變量(被static修飾的變量),不包括執行個體變量,執行個體變量是在對象執行個體化的時候配置設定在
heap區的!看到這,你是不是想回去看看我們開頭的那道題目了,别急,還有一些東西你沒看到呢!
我剛才說了配置設定記憶體之後要設定初始值,對,你沒看錯,但是這個初始值是初值,預設值,而不是你代碼的初始值,這其實就是開頭那道題目答案不如你所想的原因!接着看吧!
假如我們定義了一個類變量 public static String NAME = "stormma";
那麼,在目前所處的準備階段,給這個變量配置設定記憶體之後,初始值是
null
而不是
stormma
,而最後的指派是發生在初始化階段,關于各種類型的初始值
關于解析
解析階段是虛拟機将常量池中的符号引用轉化為直接引用的過程。在Class類檔案結構一文中已經比較過了符号引用和直接引用的差別和關聯,這裡不再贅述。前面說解析階段可能開始于初始化之前,也可能在初始化之後開始,虛拟機會根據需要來判斷,到底是在類被加載器加載時就對常量池中的符号引用進行解析(初始化之前),還是等到一個符号引用将要被使用前才去解析它(初始化之後)。
- 類或接口的解析:判斷所要轉化成的直接引用是對數組類型,還是普通的對象類型的引用,進而進行不同的解析。
- 字段解析:對字段進行解析時,會先在本類中查找是否包含有簡單名稱和字段描述符都與目标相比對的字段,如果有,則查找結束;如果沒有,則會按照繼承關系從上往下遞歸搜尋該類所實作的各個接口和它們的父接口,還沒有,則按照繼承關系從上往下遞歸搜尋其父類,直至查找結束。
對于解析和驗證這一塊,和讀懂Class檔案有着密不可分的關系,是以這一塊的補充知識會在讀懂Class檔案位元組碼之後進行講解!
初始化
初始化是類加載過程的最後一步,到了此階段,才真正開始執行類中定義的Java程式代碼。在準備階段,類變量已經被賦過一次系統要求的初始值,而在初始化階段,則是根據程式員通過程式指定的主觀計劃去初始化類變量和其他資源。
或者可以從另一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。
- <clinit>()方法是由編譯器自動收集類中的所有類變量的指派動作和靜态語句塊中(static{})的語句合并産生的,編譯器收集的順序是由語句在源檔案中出現的順序所決定的,靜态語句塊中隻能通路到定義在靜态語句塊之前的變量,定義在它之後的變量,在前面的靜态語句中可以指派,但是不能通路。
- <clinit>()方法與執行個體構造器<clinit>()方法(類的構造函數)不同,它不需要顯式地調用父類構造器,虛拟機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。是以,在虛拟機中第一個被執行的<clinit>()方法的類肯定是
。java.lang.Object
- <clinit>()方法對于類或接口來說并不是必須的,如果一個類中沒有靜态語句塊,也沒有對類變量的指派操作,那麼編譯器可以不為這個類生成()方法。
- 接口中不能使用靜态語句塊,但仍然有類變量(final static)初始化的指派操作,是以接口與類一樣會生成<clinit>()方法。但是接口魚類不同的是:執行接口的()方法不需要先執行父接口的<clinit>()方法,隻有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實作類在初始化時也一樣不會執行接口的<clinit>()方法。
- 虛拟機會保證一個類的<clinit>()方法在多線程環境中被正确地加鎖和同步,如果多個線程同時去初始化一個類,那麼隻會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個線程阻塞,在實際應用中這種阻塞往往是很隐蔽的。
看到這,我相信你現在回頭看看我們開始的那道題目,你已經可以解釋那道題目的答案了!
是的,剛開始觸發類加載之後的一系列操作完成之後,開始進行初始化,賦初值,
counter1 = counter2 = 0,instance = null
,然後開始執行使用者的初始化,
instance = new Singleton()
,然後執行構造器,
counter1 = counter2 = 1
,
然後初始化
counter1
,因為
counter1
無使用者初始化的值,然後執行
counter2 = 0
,所有
counter2
從0變化1再變化到0。
再看個例子
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | public class FinalTest { public static void main(String[] args) { System.out.println(FinalT.NAME); } } class FinalT { public static final java.lang.String NAME = "stormma"; static { System.out.println("FinalT初始化"); } } |
答案是”stormma”
為啥沒有”FinalT初始化”呢,我們先來看一下這個class檔案的位元組碼吧!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 | [email protected]:~/coding/java-project/concurrency/target/classes/me/stormma/chapter4$ javap -verbose FinalTest.class Classfile /Users/stormma/coding/java-project/concurrency/target/classes/me/stormma/chapter4/FinalTest.class Last modified 2017-11-14; size 598 bytes MD5 checksum f6a69bfc19f0e693fdc57a9af831cfb8 Compiled from "FinalTest.java" public class me.stormma.chapter4.FinalTest minor version: 0 major version: 50 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #7.#21 // java/lang/Object."<init>":()V #2 = Fieldref #22.#23 // java/lang/System.out:Ljava/io/PrintStream; #3 = Class #24 // me/stormma/chapter4/FinalT #4 = String #25 // stormma #5 = Methodref #26.#27 // java/io/PrintStream.println:(Ljava/lang/String;)V #6 = Class #28 // me/stormma/chapter4/FinalTest #7 = Class #29 // java/lang/Object #8 = Utf8 <init> #9 = Utf8 ()V #10 = Utf8 Code #11 = Utf8 LineNumberTable #12 = Utf8 LocalVariableTable #13 = Utf8 this #14 = Utf8 Lme/stormma/chapter4/FinalTest; #15 = Utf8 main #16 = Utf8 ([Ljava/lang/String;)V #17 = Utf8 args #18 = Utf8 [Ljava/lang/String; #19 = Utf8 SourceFile #20 = Utf8 FinalTest.java #21 = NameAndType #8:#9 // "<init>":()V #22 = Class #30 // java/lang/System #23 = NameAndType #31:#32 // out:Ljava/io/PrintStream; #24 = Utf8 me/stormma/chapter4/FinalT #25 = Utf8 stormma #26 = Class #33 // java/io/PrintStream #27 = NameAndType #34:#35 // println:(Ljava/lang/String;)V #28 = Utf8 me/stormma/chapter4/FinalTest #29 = Utf8 java/lang/Object #30 = Utf8 java/lang/System #31 = Utf8 out #32 = Utf8 Ljava/io/PrintStream; #33 = Utf8 java/io/PrintStream #34 = Utf8 println #35 = Utf8 (Ljava/lang/String;)V { public me.stormma.chapter4.FinalTest(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 8: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lme/stormma/chapter4/FinalTest; public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #4 // String stormma 5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 11: 0 line 12: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 args [Ljava/lang/String; } SourceFile: "FinalTest.java" |
其中
#4 = String #25 // stormma
這個地方,是個常量,是以沒有觸發類的初始化,是以也不會執行
static
塊中的初始化
對了,差點忘了,有個重要的東西沒介紹,前面我們粗體表示的主動使用,以及被動使用
主動使用
主動使用 (這幾種第一次發生的情況下,進行類的初始化)
- 建立類的執行個體
- 通路某個類或者接口的靜态變量,或者對該靜态變量指派
- 通路類的靜态方法
- 反射Class.forName();
- 初始化一個類的子類
- Jvm啟動時被标記為啟動類的類