JVM系列[1]-Java類的生命週期

原本是想寫一篇關於Java類加載機制的博文,後來發現這個主題有點大,其中涉及的細節點太多,一篇博文,三言兩語恐怕無法講明白,於是乎決定從整體到局部,先來談談類的生命週期,從整體把握一個類從“出生”到“凋亡”的過程,其中涉及了類加載、使用、卸載等各個階段,有了整體的認知後,再深入細節並結合具體實例,探討加載原理、類加載器等相關知識。今天就讓博主帶領你開啓第一段旅程:類的生命週期詳解。

類的生命週期

類的生命週期是指一個class從加載到內存直至卸載出內存的過程,共包含加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段,如下圖所示:

Java類的生命週期

其中驗證、準備、解析三個階段統稱爲連接(Linking),而加載、連接、初始化又可以統稱爲類加載的過程,所以我們有時又可以稱類的生命週期包含加載、連接、初始化、使用和卸載這5個階段,或者是類加載、使用、卸載這3個階段。

回到上圖,加載、驗證、準備、初始化和卸載這5個階段的開始順序是確定的,如圖中箭頭所示。之所以強調“開始順序”,是因爲這裏的先後順序僅僅是各階段開始時間的順序,而不是進行或完成的順序,這些階段通常是相互交叉地混合式進行的。比如加載和驗證,並不是說非要等到加載完成之後,纔開始驗證階段,在加載的階段中,會穿插各種檢驗動作,否則對於連格式都不符合的字節流,又怎能正確解析出其中的靜態數據結構從而轉化爲方法區中的數據結構呢?對於解析階段,其開始時間則比較特殊,既可能在加載階段就開始(對常量池中的符號引用的解析),也可能在初始化階段之後纔開始(支持Java語言的動態綁定)。

下面我們就來看看各個階段都大致做哪些事情。

一、類加載的過程

類加載的過程包含加載連接初始化三個階段。

1.1 加載

加載是類加載過程的第一階段,此時虛擬機將查找並加載類的二進制數據,具體分爲三個步驟:

  • 通過一個類的全限定名來獲取定義此類的二進制字節流。
  • 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
  • 在內存中生成一個代表此類的java.lang.class對象,作爲對方法區中此類的各種數據的訪問入口。

這三條屬於虛擬機規範的內容,只指明瞭做什麼,具體實現交由虛擬機實現自行安排,這就給了虛擬機實現和具體應用足夠的靈活度。對於第一條,並未指明定義類的二進制字節流的存儲形式(class文件、ZIP包)、來源(本地文件系統、內存或網絡)以及獲取方式(既可以從已有靜態資源讀取也可動態生成),因而就有了如下的多樣可能性:

  • 從ZIP包中讀取,這是後來支持類加載器可從JAR、EAR、WAR等格式文件中加載class的基礎。
  • 從網絡中獲取字節流,我們熟知的Applet是這種場景的典型應用。
  • 程序動態生成字節流,這種場景應用最多的就是動態代理,通過字節碼技術動態生成代理類的二進制字節流。
  • 由除了Java源文件之外的其他文件編譯而成,如JSP文件、Scala源文件等。

對於第三條中所說的“內存”,虛擬機規範並沒有明確規定是在Java堆還是方法區中,對於我們最爲熟悉的HotSpot虛擬機,是存放在Java堆的永久代中。實際上永久代是HotSpot虛擬機特有的,是它對虛擬機規範中方法區概念的具體實現(JDK1.7及以下),對於其他虛擬機(如IBM J9)是不存在永久代一說的,關於方法區和永久代的關係超出本博文的談論範疇了,點到爲止。

加載階段完成後,原本定義類的二進制字節流就按照虛擬機所需的格式存儲在方法區中,這裏的存儲格式依具體的虛擬機實現而定,各有差異,虛擬機規範並未規定此區域的具體數據結構。

關於加載階段的注意點

  1. 數組類的加載比較特殊,它本身並不通過類加載器創建,而是由Java虛擬機直接創建,但數組類的元素類型(去掉所有維度後的類型,比如A[][]的元素類型,就是A)是由類加載器加載的。舉例,對於類型org.sherlockyb.test.HelloWorld,定義一維數組類HelloWorld[] hws = new HelloWorld[8],虛擬機會直接創建名爲“[Lorg.sherlockyb.test.HelloWorld”的數組類,並對其進行初始化。

類加載器

上一節中加載階段的第一步驟——“通過一個類的全限定名來獲取定義此類的二進制字節流”,就是類加載器所做的唯一工作,類加載器是Java技術體系中的重要基石,它在類層次劃分、OSGi、熱部署、代碼加密等領域扮演着重要角色,關於它我們暫且不做細緻介紹,後面會有單獨博文深入探討之。

加載時機

虛擬機規範並未強制規定加載階段具體什麼時候開始,由虛擬機實現自由把握。就我們所熟知的HotSpot虛擬機來說,有兩種情況:

  • 預加載。虛擬機在啓動時會預先加載rt.jar中的class文件,其中包括java.lang.*、java.util.*、java.io.*等運行時常用的類。
  • 運行時加載。當虛擬機在運行過程中需要某個類時,如果該類的class未被加載則加載之。

1.2 連接

連接可細分爲三個階段:驗證、準備和解析。

驗證

連接的第一個階段,確保從class文件中所加載的字節流符合當前虛擬機的要求,且不會危害虛擬機自身的安全。該階段會依次進行如下校驗:

  • 文件格式校驗:判斷當前字節流是否符合class文件格式的規範。如是否以class文件的魔數oxCAFEBABE開頭、主次版本號是否在當前虛擬機的處理範圍之內、常量池中常量的類型是否合法等等。校驗的目的是保證字節流能正確地解析並存儲於方法區內,通過驗證後,會在方法區中存儲,後面的校驗動作都是基於方法區的存儲結構進行,不再直接操作字節流。
  • 元數據校驗:語義分析,判斷其描述的信息是否符合Java語言的規範要求。如該類除了java.lang.Object之外,是否有其他父類;該類的父類是否繼承了不允許被繼承的final類等
  • 字節碼驗證:通過數據流和控制流分析,判斷程序語義是否合法、符合邏輯。如保證跳轉指令不會跳轉到方法體以外的字節碼指令上、方法體中的類型轉換是有效的等。
  • 符號引用校驗:發生在解析階段將符號引用轉爲直接引用的時候,確保解析動作能正確執行。如符號引用中通過字符串描述的全限定名是否能找到對應類。

從上面可以看出,驗證階段非常重要,關乎虛擬機的安全,但它並不是必須的,它對程序運行期沒有影響,如果所引用的類已被反覆使用和驗證過,那麼可以考慮採用-Xverifynone參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。通常來講,應用所加載的class文件都是由我們本地或服務器的JDK編譯通過的,我們都確定它是符合虛擬機要求的,對於這類class文件其實並不需要驗證,主要是像從網絡加載的class字節流或是通過動態字節碼技術生成的字節流,出於安全的考慮,是必須要經過嚴格驗證的。

準備

準備階段做的唯一一件事就是爲類的靜態變量分配內存,並將其初始化爲默認值。注意這裏的初始化和後面要講的“初始化階段”是不同的,容易混淆。這些內存都在方法區中分配。幾點注意項:

  • 對於初始化爲默認值這一點,有兩個角度的理解:從Java應用層面講,會爲不同的類型設置對應的零值,如對於int、long、byte等整數對應就是0,對於float、double等浮點數則是0.0,而對於引用類型則是null,有個零值映射表,具體就不在這一一列舉了;從JVM層面,實際上就是分配了一塊全0值的內存,只是不同的數據類型對於0值有不同的解釋含義,這是Java編譯器自動爲我們做的。
  • 如果類的靜態變量是final的,即它的字段屬性表中存在ConstantValue屬性,那麼在準備階段就會被初始化爲程序指定的值,比如對於public static final int len = 5,在準備階段len的值已經被設置爲5了。實際上對於final的類變量,在編譯時就已經將其結果放入了調用它的類的常量池中,這種類變量的訪問並不會觸發其所屬類的初始化階段。

解析

該階段把類在常量池中的符號引用轉爲直接引用。符號引用就是一組用來描述目標的字面量,說白了就是靜態的佔位符,與內存佈局無關,而直接引用則是運行時的,是指內存中直接指向目標的指針、相對偏移量或間接定位到目標的句柄。解析工作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用限定符這7類符號引用,將其替換爲直接引用。

虛擬機規範規定,在執行anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、multianewarray、new、putfieldputstatic這16個用於操作符號引用的字節碼指令之前,必須先對符號引用進行解析。至於具體時間並未要求,交由虛擬機實現自行決定:在類被加載時就對常量池中的符號引用進行解析(靜態指令,除invokedynamic之外的),或是等到一個符號引用將要被使用前纔去解析(動態指令:invokedynamic,爲了支持動態綁定)。

1.3 初始化

爲類的靜態變量賦予程序設定的初始值。在Java中對類變量設定初始值有兩種方式:聲明類變量時指定初始值和靜態代碼塊爲靜態變量賦值。我們來看下類的初始化步驟:

  • 若該類還沒有被加載和連接,則先加載並連接該類
  • 若該類的直接父類沒有被初始化,則先初始化其父類(接口沒有此規則)
  • 若該類有初始化語句(賦值語句和靜態代碼塊),則按照代碼中申明的順序依次執行初始化語句

我們可以從字節碼層面獲知上述初始化步驟的原理,

編譯器在編譯Java源文件時,自動收集類中所有類變量的賦值操作和靜態語句塊中的語句(按照源碼中聲明先後順序),將其合併產生\

初始化時機

虛擬機規範嚴格規定,當發生對一個類的主動引用時,會立即觸發類的初始化階段。主動引用有且僅有以下5種情況:

  • 遇到newgetstaticputstaticinvokestatic這4條字節碼時,如果類沒有被初始化,則先觸發其初始化。從Java代碼層面來講,就是使用new關鍵字實例化對象、讀取或設置類的靜態字段(final修飾的常量字段除外)、調用靜態方法的時候。
  • 使用java.lang.reflect包的方法對類進行反射調用時,如Class.forName(…)。
  • 當初始化一個類時,若其父類還未初始化,則先觸發其父類的初始化(接口無此規則)。
  • 當虛擬機啓動時,用戶需指定一個主類(包含main方法),虛擬機會先初始化該類。
  • 對於REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄(使用JDK1.7的動態特性),若其對應的類還未初始化,則先觸發其初始化。

除此之外,其他所有引用類的方式都屬於被動引用,不會觸發初始化。

二、類的使用

包括主動引用和被動引用,前者在上節已有說明,我們來列舉幾個被動引用的實例:

  • 通過子類調用父類的靜態字段,不會觸發子類初始化。
  • 通過數組定義來引用類,不會觸發該類的初始化。例如A[] arr = new A[8] ,並不會觸發A的初始化。
  • 在類A中調用B的常量字段,不會觸發B的初始化。因爲此常量字段在編譯階段會存入調用類A的常量池中,本質上並沒有直接引用到定義類B。

三、類的卸載

當一個類被判定爲無用類時,纔可以被卸載。條件苛刻,需要同時滿足如下條件:

  • 類的所有實例都已被回收。
  • 加載該類的ClassLoader已被回收。
  • 該類對應的java.lang.Class對象沒有在任何地方被引用。

對於滿足上述3個條件的無用類,虛擬機可以對其回收,但並不是必然的,是否回收可通過-Xnoclassgc參數控制。注意:在大量使用反射、動態代理等字節碼框架、動態生成JSP以及OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機具備類卸載的功能,以保證永久代(特指HotSpot虛擬機)不會溢出。

總結

終於算是“走馬觀花”般地把Java類的生命週期過了一遍,相信當再提起類的生命週期時,大家腦海裏就會立馬浮現出類生命週期的大綱,都有哪些階段,每個階段都大致做些什麼事情,都有些什麼注意點,這樣,本博文的目的就達到了!掌握了全局之後,接下來就是細節的探討,比如像驗證階段中的字節碼驗證,實際是非常複雜的,虛擬機專門爲此做了諸多優化;再比如解析階段,7類符號引用各自不同的解析細節又是什麼,等等之類。之後,筆者將會單獨另起博文,針對類加載器、解析階段等進行詳細分析,敬請期待。

同步更新到原文

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章