以前在網上看到一道面試題,很形象的描述了類的加載初始化過程。要徹底理解這道題,就不得不深刻理解類加載的過程。面試題以下:html
class SingleTon { private static SingleTon singleTon = new SingleTon(); public static int count1; public static int count2 = 0; private SingleTon() { count1++; count2++; } public static SingleTon getInstance() { return singleTon; } } public class Test { public static void main(String[] args) { SingleTon singleTon = SingleTon.getInstance(); System.out.println("count1=" + singleTon.count1); System.out.println("count2=" + singleTon.count2); } }
這道題的正確答案爲 :java
count1=1面試
count2=0數組
至於爲何會是這個答案,這就涉及到了 JVM 類加載的過程。安全
類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括加載、驗證、準備、解析、初始化、使用和卸載 7 個階段,其中驗證、準備和解析 3 個階段統稱爲鏈接,這 7 個階段發生的順序以下圖所示。網絡
加載、驗證、準備、初始化和卸載這 5 個階段的順序是肯定的。解析階段則不必定,因爲支持運行時綁定,類能夠在初始化以後再開始進行解析。同時這些階段只是按照順序進行開始,並不必定會按照順序進行或者結束,由於這些階段一般都是互相交叉地混合式進行的,一般會在一個階段執行過程當中調用、激活另一個階段。數據結構
加載是類加載過程的一個階段,是根據特定名稱查找類或接口類型的二進制表示(binary representation),並由此二進制表示來建立類或接口的過程。在加載階段,虛擬機須要完成 3 件事:多線程
虛擬機加載的是類的二進制流,只是對內容格式作了限制,並無指名要從哪裏去獲取、怎樣獲取一個類的二進制流,比較常見的有一下幾種:oracle
加載階段完成後,二級制字節流就按照虛擬機所需的格式儲存在方法區之中,而後在內存中實例化一個 java.lang.Class 對象,將這個對象做爲程序訪問方法區中的這些類型數據的外部接口。jvm
驗證時鏈接階段的第一步,這一步是爲了保證 Class文件二進制字節流符合虛擬機規範,而且不會危害虛擬機自身的安全。Java 虛擬機規範有大量的約束和驗證規則,詳細的描述的驗證過程。驗證過程主要仍是圍繞 Class 文件格式對各部分進行驗證。Class 文件格式課參考另外一篇博文字節碼文件結構詳解。但從總體上看,驗證階段大體會完成下面 4 個階段的驗證動做。
第一階段要驗證字節流是否符合 Class文件格式規範,而且能被當前版本的虛擬機處理。這一階段可能包括下面驗證點:
第一階段的驗證遠不止這些,該階段的主要目的是保證輸入的字節流能正確的解析並存儲於方法區內。這階段的驗證是基於二進制字節流進行的,只有經過了這個階段的驗證後,字節流纔會進入內存的方法區中進行存儲,因此後面的3個驗證階段所有是基於方法區的存儲結構進行的,不會再直接操做字節流。
元數據驗證是對字節碼描述的信息進行語義分析,確保其描述的信息符合 Java 語言規範的要求,這個階段可能包括的驗證點以下:
字節碼驗證將對類的方法體進行校驗分析,保證被校驗類的方法在運行時不會作出危害虛擬機安全的事件。
若是一個方法經過了字節碼驗證,也不能說明其必定就是安全的。
符號引用驗證能夠看作是對類自身之外(常量池中的各類符號引用)的信息校驗,一般須要校驗一下內容:
若是一個類沒法經過符號引用驗證,那麼將會拋出一個java.lang.IncompatibleClassChangeError
異常的子類,如常見的java.lang.IllegalAccessError
、java.lang.NoSuchFieldError
、java.lang.NoSuchMethodError
等。
準備階段是正式爲類變量分配內存並設置類變量初始值的階段,這些類變量所使用的內存都將在方法區中進行分配。此處須要明確類變量
的含義,即被static
修飾的變量,而不包括實例變量,實例變量會在初始化階段隨着對象一塊兒分配在 Java 堆中。此時分配的初始值是數據類型的零值
,並非咱們定義的初始值。此處還要明確一個概念,若是變量被final
修飾,則此字段的字段屬性表存在 ConstantValue 屬性,那麼在準備階段變量就會被初始化爲 ConstantValue屬性所指定的值。可經過下例代碼來對照理解:
public static int a = 10; public static final int B = 20;
其部分彙編字節碼爲:
Constant pool: #2 = Fieldref #3.#21 // tech/techstack/blog/Test.a:I #3 = Class #22 // tech/techstack/blog/Test #5 = Utf8 a #6 = Utf8 I #21 = NameAndType #5:#6 // a:I #22 = Utf8 tech/techstack/blog/Test public static int a; descriptor: I flags: ACC_PUBLIC, ACC_STATIC public static final int B; descriptor: I flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL ConstantValue: int 20 static {}; descriptor: ()V flags: ACC_STATIC Code: stack=1, locals=0, args_size=0 0: bipush 10 2: putstatic #2 // Field a:I 5: return LineNumberTable: line 8: 0
從上述代碼能夠看出,B 字段對應的 field_info 與 a 字段對應的 field_info 相比對了一個 Constant_Value 屬性,而 Constant_Value 屬性的值 20 就會在準備階段直接賦給字段 B。同時在字節碼第 19 行有一個 static {};
方法,此方法對應的就是類的構造方法<clinit>
在初始化階段執行,它的Code
屬性中對應的字節碼指令bipush 10
爲往操做數棧壓入 10,putstatic
則是將值 10 賦值給 a 字段。
基本數據類型的零值:
數據類型 零值 byte (byte)0 char '\u0000' short (short)0 boolean false int 0 long 0l float 0.0f double 0.0 reference null
解析階段是虛擬機將常量池內的符號引用替換爲直接引用的過程,符號引用在 Class 文件中以 CONSTANT_Class_info、CONSTANT_Feildref_info、CONSTANT_Methodref_info 等類型的常量出現,具體能夠參考博文字節碼文件結構詳解。此處有符號引用和直接引用兩個概念須要瞭解一下.
符號引用以一組符號來描述所引用的目標,符號引用能夠是任何形式的字面量,只要使用能無歧義地定義到目標便可。符號引用與虛擬機實現的內存佈局無關,引用的目標不必定已經加載到內存中。各類虛擬機實現的內存佈局能夠各不相同,可是它們能接受的符號引用必須都是一致的。符號引用的字面量形式須要明確的定義在 Class 文件格式中。
直接引用能夠是直接執行目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存佈局相關的,同一個符號引用在不一樣的虛擬機實例上翻譯出來的直接引用通常不會相同。若是有了直接引用,那引用目標一定已經出如今內存中。
虛擬機規範並未規定解析發生的具體時間,只要求在執行anewarray
、checkcast
、getfield
、getstatic
、instanceof
、invokedynamic
、invokeinterface
、invokespecial
、invokestatic
、invokevirtual
、ldc
、ldc_w
、multianewarray
、new
、putfield
和 putstatic
這 16 個用於操做符號引用的字節碼指令以前,先對他們所使用的符號引用進行解析。因此虛擬機實現能夠根據須要來判斷究竟是在類被加載和加載時就對常量池中的符號引用進行解析仍是等到一個符號引用將要被使用前纔去解析它。
加載過程當中的解析階段爲靜態的將符號引用替換爲直接引用的過程。可與虛擬機棧內存中的動態連接參照記憶。
解析動做主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用限定符 7 類符號引用進行,分別對應於常量池的CONSTANT_Class_info
、CONSTANT_Fieldref_info
、CONSTANT_Methodref_info
、CONSTANT_InterfaceMethodref_info
、CONSTANT_MethodType_info
、CONSTANT_MethodHandle_info
和CONSTANT_InvokeDynamic_info
7種常量類型。
初始化階段是類加載過程當中的最後一步,此階段纔是真正執行類中定義的 Java 程序代碼。初始化階段和準備階段的初始化是不一樣概念的,準備階段的初始化是給類字段賦值零值的過程,而類加載過程當中的初始化階段能夠看作是類對象的初始化。對於類的初始化反映到字節碼中就是類的<clinit>()
方法。從另一個角度來說,能夠將初始化階段理解成是執行類構造器<clinit>()方法的過程。同時對於<clinit>()
方法,有幾個概念要弄清楚。
<clinit>()
方法是由編譯器自動收集類中的全部類變量的賦值動做和靜態語句塊(static {})中的語句合併產生的。<clinit>()
方法以前,虛擬機會確保子類的<clinit>()
方法已經執行完畢。所以在虛擬機中第一個被執行的<clinit>()方法的類確定是java.lang.Object。<clinit>()
方法。與類不一樣的是,執行接口<clinit>()
方法不須要先執行父接口的<clinit>()
方法。只有當父接口中定義的變量使用時,父接口才會初始化。接口的實現類在初始化時也不會執行接口的<clinit>()
方法。<clinit>()
方法只被一個線程調用,其它線程會被阻塞。同時,在一個類加載器下,一個類的<clinit>()
方法只會被執行一次。注:
本文所說的類對象與類實例不是一個概念。關於類對象與類實例以及 java.lang.Class 對象之間的關係,此處能夠引用
R 大
的一個回答傳送門:在HotSpot VM中,對象(類的實例對象)、類的元數據(InstanceKlass)、類的Java鏡像(java.lang.Class 實例),三者之間的關係是這樣的:
Java object InstanceKlass Java mirror [ _mark ] (java.lang.Class instance) [ _klass ] --> [ ... ] <-\ [ fields ] [ _java_mirror ] --+> [ _mark ] [ ... ] | [ _klass ] | [ fields ] \ [ klass ]每一個Java對象的對象頭裏,_klass字段會指向一個VM內部用來記錄類的元數據用的InstanceKlass對象;InsanceKlass裏有個_java_mirror字段,指向該類所對應的Java鏡像——java.lang.Class實例。HotSpot VM會給Class對象注入一個隱藏字段「klass」,用於指回到其對應的InstanceKlass對象。這樣,klass與mirror之間就有雙向引用,能夠來回導航。這個模型裏,java.lang.Class實例並不負責記錄真正的類元數據,而只是對VM內部的InstanceKlass對象的一個包裝供Java的反射訪問用。
經過上面的引用,能夠清楚的知道 Java Object, InstanceKlass, Java mirror(java.lang.Class instance)在內存中的分佈了。
對於初始化階段能夠經過代碼來理解一下:
public class SuperClass { public static int superClassField = 1; static { System.out.println("supper class static code"); } public SuperClass() { System.out.println("supper class constructor"); } } public interface SuperInterface { int superInterfaceField = 10; } public class SubClass extends SuperClass implements SuperInterface { public static int subClassField = 20; static { System.out.println("sub class static code."); } public SubClass() { System.out.println("sub class constructor"); } } public class TestClassLoad { static { System.out.println("test class load"); } } public class App { static { System.out.println("App main static code"); } public static void main(String[] args) { System.out.println(SubClass.superClassField); System.out.println("----------------------"); new Thread(SubClass::new).start(); } }
在運行 SubClass 的時候加上 -XX:+TraceClassLoading
參數,打印出來運行過程當中加載的類。上述代碼運行結果爲結果 1:
// 類加載日誌(節選) [Loaded tech.stack.App from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperInterface from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SubClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] App main static code supper class static code 1 ---------------------- sub class static code. supper class constructor sub class constructor
註釋掉new Thread(SubClass::new).start();
從新運行程序,獲得一下輸出結果 2:
[Loaded tech.stack.App from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperInterface from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SubClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] App main static code supper class static code 1 ----------------------
而後將 System.out.println(SubClass.superClassField);
替換爲 System.out.println(SubClass.subClassField);
再次運行程序,獲得輸出結果 3:
[Loaded tech.stack.App from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperInterface from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SuperClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] [Loaded tech.stack.SubClass from file:/Users/chenjianyuan/IdeaProjects/course/target/classes/] App main static code supper class static code sub class static code. 20 ----------------------
這幾段代碼信息量很大,根據上文所講慢慢分析:
TestClassLoad
類始終都沒有被加載。而App
、SuperInterface
、SuperClass
、SubClass
始終被加載,是否是能夠證實屬於 Applicatin 做用域範圍內的類會在首次使用時加載。SuperInterface
任何方法、變量能夠看出對於子類來講,在加載子類時首先要加載實現的接口以及父類。當虛擬機啓動時,用戶須要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
結果 1 結果 3 都代表父類的<clinit>()
方法在子類<clinit>()
方法以前調用。
結果 1 結果 2 對比代表經過子類調用父類的靜態的變量只會引發父類的初始化並不會使子類初始化。
對比結果 1 和結果 2 說明在多線程的狀況況下只要類加載器相同,類只初始化一次。
對比結果 一、二、3 能得出一個實例的初始化順序
<clinit>()
方法。<clinit>()
方法。<init>()
方法。<init>()
方法、注:
關於類實例的初始化過程即對象的實例化過程會專門在另外一篇博客進行講解。
關於"接口中不能使用靜態代碼塊,但仍有變量初始化的賦值操做,所以接口也會生成
<clinit>()
方法。" 在接口中變量初始化賦值操做可參考以下代碼:public interface SuperInterface { int superInterfaceField = 10; SuperClass su = new SuperClass(); } // bytecode { public static final int superInterfaceField; descriptor: I flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL ConstantValue: int 10 public static final tech.stack.SuperClass su; descriptor: Ltech/stack/SuperClass; flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL static {}; descriptor: ()V flags: ACC_STATIC Code: stack=2, locals=0, args_size=0 0: new #1 // class tech/stack/SuperClass 3: dup 4: invokespecial #2 // Method tech/stack/SuperClass."<init>":()V 7: putstatic #3 // Field su:Ltech/stack/SuperClass; 10: return LineNumberTable: line 10: 0 }
關於類在何時加載,咱們能夠有上面的代碼窺見一斑。可是這只是在JDK1.8, Hotspot 虛擬機測試的狀況下得出的結論,也不必定會是正確的,由於 Java 虛擬機規範中並無進行強制約束,關於加載階段,都是根據虛擬機的具體實現來自由把握。可是對於初始化階段,虛擬機嚴格規定了有且只有
5 種狀況必須當即對類進行初始化
(而加載、驗證、準備天然須要再次以前開始):
new
、getstatic
、putstatic
或invokestatic
這4條字節碼指令時,若是類沒有進行過初始化,則須要先觸發其初始化。生成這4條指令的最多見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候(已經過上文代碼驗證)。嘗試着補充解釋一下這幾條其中的原理,對於 new
關鍵字天然不用多說,new
關鍵字實例化類的實例對象以前天然會執行類的初始化操做,以完成 Java 程序對類的一些操做。getstatic
putstatic
指令的含義爲讀取或設置一個類的靜態字段,此處仍是應用R大
的回答,原文與上處引用出自同一處:
從JDK 1.3到JDK 6的HotSpot VM,靜態變量保存在類的元數據(InstanceKlass)的末尾。而從JDK 7開始的HotSpot VM,靜態變量則是保存在類的Java鏡像(java.lang.Class實例)的末尾。假若有這樣的A類:
class A { static int value = 1; }那麼在JDK 6或以前的HotSpot VM裏:
Java object InstanceKlass Java mirror [ _mark ] (java.lang.Class instance) [ _klass ] --> [ ... ] <-\ [ fields ] [ _java_mirror ] --+> [ _mark ] [ ... ] | [ _klass ] [ A.value ] | [ fields ] \ [ klass ]能夠看到這個A.value靜態字段就在InstanceKlass對象的末尾存着了。而在JDK 7或以後的HotSpot VM裏:
Java object InstanceKlass Java mirror [ _mark ] (java.lang.Class instance) [ _klass ] --> [ ... ] <-\ [ fields ] [ _java_mirror ] --+> [ _mark ] [ ... ] | [ _klass ] | [ fields ] \ [ klass ] [ A.value ]能夠看到這個A.value靜態字段就在java.lang.Class對象的末尾存着了。
據此咱們應該就能得出結論,在設置靜態變量的時候已經須要根據InstanceKlass
生成java.lang.Class
對象了,而靜態變量已經不能在方法區經過讀取類元信息進行獲取或者儲存。而生成 Java mirror 必然要經過完整的類元信息,所以須要進行初始化動做。對於java.lang.reflect
包的反射方法,其根據的就是 java.lang.Class
對象。對於子類初始化時,由於 Java 的繼承特性,繼承的是父類完整的類信息。父類進行初始化也是理所固然的。
上述 5 種場景中的行爲稱爲對一個類的主動引用。除此以外,全部的引用類的方式都不會觸發初始化,稱爲被動引用。例:
經過子類調用父類的靜態字段(變量+常量),不會致使子類的初始化。代碼可參考上文。
經過數組定義來引用類,不會觸發此類的初始化
public class App { public static void main(String[] args) { SuperClass[] superClasses = new SuperClass[10]; } }
常量在編譯階段會存入調用類的常量池中,本質上並無直接引用到定義常量的類,所以不會觸發定義常量的類的初始化。
public class ConstantClass { public static final String HELLO_WORLD = "hello world !"; } public class App { public static void main(String[] args) { System.out.println(ConstantClass.HELLO_WORLD); } }
這是由於雖然在Java源碼中引用了ConstClass類中的常量HELLOWORLD,但其實在編譯階段經過常量傳播優化,已經將此常量的值"hello world !"存儲到了App類的常量池中,之後App對常量HELLO_WORLD的引用實際都被轉化爲App類對自身常量池的引用了。也就是說,實際上App的Class文件之中並無ConstantClass類的符號引用入口,這兩個類在編譯成Class以後就不存在任何聯繫了。能夠看一下App的字節碼。
Constant pool: #4 = String #25 // hello world ! #25 = Utf8 hello world ! { 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 hello world ! 5: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return LineNumberTable: line 10: 0 line 11: 8 LocalVariableTable: Start Length Slot Name Signature 0 9 0 args [Ljava/lang/String; }
其實講到這裏,這道題也算是分析完了,那麼就根據上面所講,總結一下這道題:
Test
類的 main 方法,回顧上文確定要先加載、驗證、初始化 Test
類(因爲加載、驗證必然發生在初始化以前,下面分析就忽略這兩個階段)。SingleTon.getInstance()
爲 Test
類調用 SingleTon
類的靜態方法,必然引發 SingleTon
類的初始化。SingleTon
類存在 singleTon
count1
count2
三個靜態變量,所以這三個靜態變量會被編譯器順序收集值到<clinit>()
方法中。<clinit>()
開始就是 new SingleTon()
會建立 SingleTon
類的實例 singleTon
,此時 ``singleTon.count1
singleTon.count2` 值都爲 1。<clinit>()
操做完第一個變量 singleTon
以後即是對第二個變量 count1
操做,此時就會將 1 賦值給 SingleTon
變量 count1
。<clinit>()
後續操做即是執行 count2 = 0
即經過操做數棧將 0 賦值給SingleTon
變量 count2
。查看SingleTon
的字節碼:
{ static {}; descriptor: ()V flags: ACC_STATIC Code: stack=2, locals=0, args_size=0 0: new #5 // class tech/stack/SingleTon 3: dup 4: invokespecial #6 // Method "<init>":()V 7: putstatic #4 // Field singleTon:Ltech/stack/SingleTon; 10: iconst_0 11: putstatic #3 // Field count2:I 14: return LineNumberTable: line 4: 0 line 6: 10 }
其 static{}
方法執行流程正如上文分析。不妨想一下若是將private static SingleTon singleTon = new SingleTon();
移動到public static int count2 = 0;
下面將會輸出什麼結果?
參考:
[1] 周志明.深刻理解Java虛擬機:JVM高級特性與最佳實踐.北京:機械工業出版社,2013.
[2] Chapter 5. Loading, Linking, and Initializing
[3] JVM裏的符號引用如何存儲?
文章首發於陳建源的博客,歡迎訪問。
文章做者:陳建源
文章連接: https://www.techstack.tech/post/lei-jia-zai-guo-cheng/