寫在前面:
該系列文章,主要是為了深入學習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載入)
- 如果類中有初始化語句,包括宣告時賦值與靜態初始化塊,則按順序進行初始化