寫在前面:
該系列文章,主要是為了深入學習Java完成的一條鏈,推薦閱讀的整體順序為:Java的內存模型(根源),一個java文件被執行的歷程,一個Java類的加載,Java的垃圾回收機制及算法,Linux(六):系統運維常用命令 和 Java程序運行狀態的監控(實用,定位Java程序問題)
類的加載
我一直認為,不應該把類的加載,單獨當作一個模塊去看,那樣就是單純地去看一個知識點,不利於建立Java全體系的知識架構,更別說實際應用到開發中(閱讀優秀開源項目、寫出高質量的代碼或定位問題)。所以這里應該串聯一整個Java語言編譯的全流程。
下面說一下在Java中類加載的概念及它在整個Java程序得以運行的過程中所處的位置:
類的加載指的是將類的字節碼文件(.class文件)中數據讀入到內存中,將其放在運行時數據區的方法區內,然后在堆區創建一個java.lang.Class對象(關於這部分可以看之前的一篇關於Java反射的內容:入口),用來封裝類在方法區內的數據結構。類的加載的最終產品是位於堆區中的Class對象,Class對象封裝了類在方法區內的數據結構,並且向Java程序員提供了訪問方法區內的數據結構的接口。
類加載器並不需要等到某個類被“首次主動使用”時再加載它,JVM規范允許類加載器在預料某個類將要被使用時就預先加載它,如果在預先加載的過程中遇到了.class文件缺失或存在錯誤,類加載器必須在程序首次主動使用該類時才報告錯誤(LinkageError錯誤)如果這個類一直沒有被程序主動使用,那么類加載器就不會報告錯誤。
上面的話感覺很懵?沒事,我給你翻譯翻譯,和那些編譯時需要進行連接工作的語言不同(那些語言都是完成全部代碼的編譯連接全部放到內存中才開始運行),在Java里,類的加載、連接和初始化過程都是在程序運行起來以后進行的,或者說是在運行期間完成的(懵逼?沒事,先保留困惑,詳細的解釋會在后面類的加載時機那塊做出解釋)。它的這種設計,會在類加載時增加一定的性能開銷,但是這樣是為了滿足Java的高度靈活性,Java是天生地可以動態擴展地語言,這一特性就是依賴運行期動態加載和動態連接實現的。
類的生命周期
說類加載的過程之前,我們先來了解一下,類的整個生命周期要經歷什么
類從被加載到虛擬機的內存中開始,到卸載出內存(整個程序\系統運行結束虛擬機關閉)為止,它的整個生命周期包括:加載、鏈接、初始化、使用、卸載。
因為這里着重說類的加載這一過程,所以類的使用和卸載就不介紹了,后面就默認類的加載這個過程包含:加載、鏈接、初始化
加載(Load)
這里叫做加載,很容易讓人誤會,會覺得類的加載就是指這里,其實不是這個樣子,這里的加載二字和類的加載不是一回事,可以這么理解,加載是類加載過程的一個階段,這一階段,虛擬機主要是做三件事:
1、根據類的全路徑獲取類的二進制字節流
2、將這個字節流對應的結構轉化為方法區的運行時數據結構(把編碼的組織方式變成虛擬機運行時所能解讀的結構,存放於方法區)
3、在內存中生成一個Class對象(java.lang.Class),由這個對象來關聯方法區中的數據
這里特別注意一下,以上的三點,只是虛擬機規范定義的,至於具體如何實現,是依賴具體的虛擬機來的;例如,第一件事的獲取二進制流,並不一定是從字節碼文件(Class文件)從進行獲取,它可以是從ZIP中獲取,從網絡中獲取,利用代理在計算過程中生成等等;
還有第三件事中生成的Class對象,也並不一定是在堆區的,例如HostSpot虛擬機的實現上,Class對象就是放在方法區的。
鏈接(Link)
鏈接階段又細分為驗證、准備、解析三個步驟:
驗證
作為鏈接的第一步,它的職責就是確保Class文件的字節流中包含的信息是符合規定的,並且不會對虛擬機進行破壞;其實說白了就是它主要責任就是保證你寫的代碼是符合Java語法的,是合理可行的。如果不合理,編譯器是拒絕的。驗證主要是針對 文件格式的驗證、元數據的驗證,字節碼的驗證,符號引用的驗證;
文件格式的驗證是對字節流進行是否符合Class文件格式的驗證,元數據的驗證主要是語義語法的驗證,即驗是否符合Java語言規范,例如:一個類是否有父類(我們知道Java中處理Object,所有的類都應該有個父類),字節碼的驗證主要是對數據流和控制流進行驗證,確保程序語義是合法、合邏輯的,例如:在操作棧先放了一個Int型的數據,后面某個地方使用的時候卻用Long型來接它。符號引用的驗證是確保解析動作能夠正常執行。
整個驗證過程,保證了Java語言的安全性,不會出現不可控的情況。(這里補充一下,這里說的驗證、不可控,包括上面舉的例子,並不是我們編程中寫的類似於a != null這種,它是在我們編寫的程序更下一層的字節碼的解析上來說的),對於加載的過程來說,驗證階段很重要,但並不一定是必須的,因為它對程序運行期並沒有影響,僅僅旨在保證語言的安全性,如果所運行的全部代碼都已經被反復使用和驗證過,那么在實施階段,可以考慮使用-Xverify:none參數來關閉大部分的驗證過程,以達到縮短虛擬機加載的時間。
准備
准備階段主要作用是正式為類變量分配內存並設置類變量初始值的階段,即這些變量所使用的內存,都在方法區中進行分配。這里需要注意,這時候進行內存分配的僅僅是類變量,換句話說也就是靜態變量(static修飾的),並不包括實例變量,實例變量會在實例化時分配在堆內存中。初始值也並不是我們的賦值,
例如:
public Class A{ public String name; public static int value = 987; }
就像剛剛講的,這里在准備階段,只會對value變量進行內存分配,並不會對name進行分配,其次,在准備階段,對value分配完內存,會同時賦予初始值,但是並不會賦給它987,在准備階段,value的值是0。而賦值為987的指令,是在程序被編譯后,存放於類構造器<clinit>()方法中,所以把value賦值987的操作,會在初始化階段才會進行。(這里補充個特殊情況,如果我們寫成 public static final int value = 987,那么變量value 在准備階段就會被賦值為987,這就是為什么很多書在講final字段的時候說它一般用來定義常量,且一經使用,就不可以被更改的原因)
解析
解析階段的任務是將常量池中的符號引用替換為直接引用
常量池可以理解為存放我們代碼符號的地方,例如我們代碼中聲明的變量,它僅僅是個符號,並不具備實際內存,所有這些符號,都會放在常量池中。例如,一個類的方法為test(),則符號引用即為test,這個方法存在於內存中的地址假設為0x123456,則這個地址則為直接引用。
符號引用:
符號引用更多的是以一組符號來描述所引用的內存目標,符號和內存空間實際並沒有關系,引用的目標也不一定在內存里,只是我們在代碼中自己寫的時候區分的,例如一句 Persion one;其中one就是個’o‘,’n‘,’e‘三個符號的組合,它啥也不是。
直接引用:
直接引用可以是直接指向內存空間的指針、相對便宜量或是一個能夠簡介定位到內存目標的句柄。
解析動作主要是針對 類、接口、字段、類方法、方法類型、方法句柄和調用點限定符號的引用進行。
初始化(Initialize)
在類的加載過程中,加載、連接完全由虛擬機來主導和控制,到了初始化這一階段,才是真正開始執行類中定義的Java代碼。初始化其實我個人理解的就是該階段是為類的類變量初始化值的,在准備階段變量已經進了一次賦值,只不過那是系統要求的初始值,而在初始化階段的賦值,則是根據研發人員編寫的主觀程序去初始化變量和其他資源。在初始化這步,進行賦值的方式有兩種:
1、在聲明類變量時,直接給變量賦值
2、在靜態初始化塊為類變量賦值
使用
就是對象之間的調用通信等等
卸載(死亡)
遇到如下幾種情況,即類結束生命周期:
- 執行了System.exit()方法
- 程序正常執行結束
- 程序在執行過程中遇到了異常或錯誤而異常終止
- 由於操作系統出現錯誤而導致Java虛擬機進程終止
類加載器
之前說了那么多一個類的聲明周期,更多的是一種理論基礎,映射到具體的代碼層面,到底是什么來完成類加載這個過程的就是這里要說的——類加載器。
虛擬機在設計時,把類加載階段的 “通過一個類的全路徑名來獲取該類字節碼二進制流” 這個動作放到了 Java虛擬機之外去完成,而負責實現這個動作的模塊就叫做類加載器。
類加載器分類
啟動類加載器
擴展類加載器
應用類加載器
這個類加載器由sun.misc.Launcher$AppClassLoader實現,由於這個類加載器是ClassLoader中getSystemClassLoader()方法的返回值,所以它也成為系統類加載器。它負責加載用戶類路徑下所指定的類庫,開發者可以直接使用這個類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是系統默認的類加載器。
自定義類加載器
開發人員可以通過繼承 java.lang.ClassLoader類的方式實現自己的類加載器,以滿足一些特殊的需求。
類加載的代理——雙親委派模式
例如類java.lang.Object,它存放在rt.jart之中,無論哪一個類加載器都要加載這個類.最終都是雙親委派模型最頂端的Bootstrap類加載器去加載,因此Object類在程序的各種類加載器環境中都是同一個類。相反,如果沒有使用雙親委派模型.由各個類加載器自行去加載的話,如果用戶編寫了一個稱為“java.lang.Object”的類,並存放在程序的ClassPath中,那系統中將會出現多個不同的Object類.java類型體系中最基礎的行為也就無法保證。應用程序也將會一片混亂。
當然也並不是所有的加載機制都是雙親委派的方式,例如tomcat作為一個web服務器,它本身實現了類加載,該類加載器也使用代理模式(不同於前面說的雙親委托機制),所不同的是它是首先嘗試去加載某個類,如果找不到再代理給父類加載器。這與一般類加載器的順序是相反的。但也是為了保證安全,這樣核心庫就不在查詢范圍之內。
類的加載時機
最后說一個比較重要也是諸多困惑的地方,就是什么時候才會加載類。
加載、驗證、准備、初始化、卸載這五個步驟是確定的,類的加載過程必須按部就班地開始,但是解析階段就不一定了,它在某些情況下是可以在初始化階段之后再開始,看到這里,肯定滿腦子????,其實不必驚訝,我一開始就說了,它這是為了滿足Java語言地動態時綁定(泛型、多態的本質)這個特性來的,它是按部就班的開始,而不是按部就班的 “進行”或者“結束”,這些階段其實是相互交叉混合進行的,通常會在一個階段執行的過程中調用、激活另外一個階段。
其實上面的話有些繞,我們從類的使用上來看這個問題,類的使用分為主動引用和被動引用:
1、主動引用類(肯定會初始化)
- new一個類的對象。
- 調用類的靜態成員(除了final常量)和靜態方法。
- 使用java.lang.reflect包的方法對類進行反射調用。
- 當虛擬機啟動,java Hello,則一定會初始化Hello類。說白了就是先啟動main方法所在的類。
- 當初始化一個類,如果其父類沒有被初始化,則先會初始化他的父類
2、被動引用
- 當訪問一個靜態域時,只有真正聲明這個域的類才會被初始化。例如:通過子類引用父類的靜態變量,不會導致子類初始化。
- 通過數組定義類引用,不會觸發此類的初始化。
- 引用常量不會觸發此類的初始化(常量在編譯階段就存入調用類的常量池中了)。
首先,Java的編譯不是像其他語言一樣,都加載到內存中才開始運行,而且動態的,也就會出現:先運行了一部分,初始化了一些類,但是在這一部分運行的代碼里被動引用了未被初始化的類(例如static變量),這時候就會出現了這種違背順序的情況。總的來說就是,
- 先加載並連接當前類
- 父類沒有被加載,則去加載、連接、初始化父類,依舊是先加載並連接,然后再判斷有無父類,如此循環(所以JVM先將Object加載)
- 如果類中有初始化語句,包括聲明時賦值與靜態初始化塊,則按順序進行初始化