jvm載入class檔案的原理機制分析
案例分析
A、B類中均包含靜態程式碼塊,非靜態程式碼塊以及構造器,A類是B類的父類。
public class A {
static {
System.out.print("A中靜態程式碼塊>>>");
}
{
System.out.print("A中非靜態程式碼塊>>>");
}
public A() {
System.out.print("A中構造器>>>");
}
}
public class B extends A{
static {
System.out.print("B中靜態程式碼塊>>>" );
}
{
System.out.print("B中非靜態程式碼塊>>>");
}
public B() {
System.out.print("B中構造器>>>");
}
}
那麼看看下面程式碼的執行結果。
public class ABTest {
public static void main(String[] args) {
A ab = new B();
System.out.println("\n==========================\n" );
ab = new B();
}
}
執行結果為:
A中靜態程式碼塊>>>B中靜態程式碼塊>>>A中非靜態程式碼塊>>>A中構造器>>>B中非靜態程式碼塊>>>B中構造器>>>
==========================
A中非靜態程式碼塊>>>A中構造器>>>B中非靜態程式碼塊>>>B中構造器>>>
總結:
1. 同一類中:靜態程式碼塊 => 非靜態程式碼塊 => 構造器
2. 父子類中:父類 => 子類;
3. 靜態程式碼塊只在第一次例項化(new)執行了,非靜態程式碼塊在每次例項化都執行。
看執行結果,上面的3條總結都沒問題,對於第3點,需要注意下:靜態程式碼塊其實不是跟著例項走的,而是跟著類走。看如下測試,通過Class.forName()動態載入類:
public static void main(String[] args) throws ClassNotFoundException {
Class.forName("B");
}
執行結果:
A中靜態程式碼塊>>>B中靜態程式碼塊>>>
這裡並沒有執行例項化過程,但是靜態程式碼塊卻執行了,這也證明了靜態static程式碼塊並不是跟著例項走。下面將簡單介紹下類載入相關概念及過程,介紹完後再看看上面的例子,印象會更深刻。首先得了解下幾個比較重要的JVM的記憶體概念。
jvm的幾個重要記憶體概念
方法區
專門用來存放已經載入的類資訊、常量、靜態變數以及方法程式碼的記憶體區域。
常量池
是方法區的一部分,主要用來存放常量和類中的符號引用等資訊。
堆區
用於存放類的物件例項,如new、陣列物件。
棧區
由一個個棧幀組成的後進先出的結構,主要存放方法執行時產生的區域性變數、方法出口等資訊。
java類的生命週期
我們編寫完一個.java
結尾的原始檔後,經過編譯後生成對應的一個或多個.class
字尾結尾的檔案。該檔案也稱為位元組碼
檔案,能在java虛擬機器中執行。而類的生命週期正是:從類(.class檔案)被載入到虛擬機器記憶體,到從記憶體中解除安裝為止。整個週期一共分為7個階段:
載入,驗證,準備,解析,初始化,使用,解除安裝
其中
- 驗證,準備,解析統稱為
連線
; - 載入,驗證,準備,初始化,解除安裝,這5個的順序是確定的。
值得注意的是,通常我們所說的類載入
指的是:載入,驗證,準備,解析,初始化,這5個階段。
載入
該階段虛擬機器的任務主要是找到需要載入的類,並把類的資訊載入到jvm的方法區中,然後堆中例項化一個java.lang.Class
物件,作為方法區中這個類的資訊的入口。
連線
連線階段有三個階段:驗證,準備,解析。主要任務是載入後的驗證工作以及一些初始化前的準備工作。
驗證
當一個類被載入後,需要驗證下該類是否合法,以保證載入的類能在虛擬機器中正常執行。
準備
該階段主要是為類的靜態變數分配記憶體並設定為jvm預設的初始值;對於非靜態變數,則不會為它們分配記憶體。這裡靜態變數的初始值,不是由我們指定的,是jvm預設的。
- 基本型別(int、long、short、char、byte、boolean、float、double)的預設值為0;
- 引用型別預設值是null;
- 常量的預設值為我們設定的值。比如我們定義final static int a = 1000,則在準備階段中a的初始值就是1000。
解析
這一階段的任務是把常量池中的符號引用轉換為直接引用,也就是具體的記憶體地址。在這一階段,jvm會將所有的類、介面名、欄位名、方法名等轉換為具體的記憶體地址。譬如:我們要在記憶體中找到一個類裡面的一個叫call的方法,顯然做不到,但是該階段,由於jvm已經將call這個名字轉換為指向方法區中的一塊記憶體地址了,也就是說我們通過call這個方法名會得到具體的記憶體地址,也就找到了call在記憶體中的位置了。
初始化
有且僅有 5種
情況必須立即對類進行“初始化”
:
- 使用
new
關鍵字例項化物件、讀取或設定一個類的靜態欄位
(被final修飾、已經在編譯器把結果放入常量池的靜態欄位除外),以及呼叫一個類的靜態方法
的時候; - 使用
java.lang.reflect
包的方法進行反射呼叫
時,若類沒有進行初始化,需要先觸發其初始化; - 當初始化一個類時,若其
父類
還沒有進行初始化,則需要先觸發其父類的初始化; - 執行
main
方法,虛擬機器會先初始化其包含的那個主類
; - 當使用JDK1.7的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法控制代碼,並且這個方法控制代碼所對應的類沒有進行初始化,則需要先觸發其初始化(這一點不是很懂)。
在類的初始化階段,只會初始化與類相關的靜態賦值語句和靜態語句,也就是有static關鍵字修飾的資訊,而沒有static修飾的賦值語句和執行語句在例項化物件的時候才會執行。(這正好解釋了案例中第3點結論)
使用
初始化
階段的5種情況用了很強烈的限定詞:有且僅有,這5種行為稱為對一個類進行“主動引用”
。其他所有引用類的方法(行為)都不會對類進行初始化,稱之為“被動引用”
。
《學習深入Java虛擬機器》一書中列舉了3個被動引用
例子,我驗證了下,確實如此,不過還得到了新的啟發。這裡列出其中的2個例子,如下:
例子1:通過子類引用父類的靜態欄位,不會導致子類初始化
package classloading;
public class SuperClass {
static {
System.out.println("SuperClass init!");
}
public static int value = 123;
}
package classloading;
public class SubClass extends SuperClass{
static {
System.out.println("SubClass init!");
}
}
package classloading;
public class NotInitialization {
public static void main(String[] args) {
System.out.println(SubClass.value);
}
}
執行結果:
SuperClass init!
123
結論:
通過子類SubClass
來引用父類SuperClass
的靜態欄位value
,初始化的只是父類,並不會觸發子類的初始化。
例子2:常量在編譯階段會存入呼叫類的常量池中,不會觸發定義常量的類的初始化
package classloading;
public class ConstClass {
static {
System.out.println("ConstClass init!");
}
public static final String HELLO_WORLD = "hello world";
}
package classloading;
public class NotInitialization {
public static void main(String[] args) {
System.out.println(ConstClass.HELLO_WORLD);
}
}
執行結果:
hello world
結論:
從列印的結果可以看到,並沒有初始化ConstClass
類;但是從原始碼上看是引用了ConstClass
類的常量。因為在NotInitialization
類的編譯期中,通過常量優化,已經將常量 "hello world"
儲存到了NotInitialization
類的常量池中了。也就是說,NotInitialization
中引用ConstClass.HELLO_WORLD
其實是對自身常量池中常量引用。
解除安裝
在使用完類後,需滿足下面,類將被解除安裝:
1. 該類所有的例項都已經被回收,也就是java隊中不存在該類的任何例項;
2. 載入該類的ClassLoader已經被回收了;
3. 該類對應的java.lang.Class物件沒有任何地方被引用,無法在任何地方通過反射訪問該類的方法。
當上面三個條件都滿足後,jvm就會在方法區垃圾回收的時候對類進行解除安裝,類的解除安裝過程本質上就是在方法區中清空類資訊,結束整個類的生命週期。
jvm載入class檔案的原理機制
面試題中經常會問到JVM載入Class檔案的原理機制,結合上面的分析,引用下面網上的分析,更加容易理解。
JVM中類的裝載是由類載入器(ClassLoader)和它的子類來實現的,Java中的類載入器是一個重要的Java執行時系統元件,它負責在執行時查詢和裝入類檔案中的類。
由於Java的跨平臺性,經過編譯的Java源程式並不是一個可執行程式,而是一個或多個類檔案。當Java程式需要使用某個類時,JVM會確保這個類已經被載入、連線(驗證、準備和解析)和初始化。類的載入是指把類的.class檔案中的資料讀入到記憶體中,通常是建立一個位元組陣列讀入.class檔案,然後產生與所載入類對應的Class物件。載入完成後,Class物件還不完整,所以此時的類還不可用。當類被載入後就進入連線階段,這一階段包括驗證、準備(為靜態變數分配記憶體並設定預設的初始值)和解析(將符號引用替換為直接引用)三個步驟。最後JVM對類進行初始化,包括: 1) 如果類存在直接的父類並且這個類還沒有被初始化,那麼就先初始化父類; 2) 如果類中存在初始化語句,就依次執行這些初始化語句。
類的載入是由類載入器完成的,類載入器包括:根載入器(BootStrap)、擴充套件載入器(Extension)、系統載入器(System)和使用者自定義類載入器(java.lang.ClassLoader的子類)。從Java 2(JDK 1.2)開始,類載入過程採取了父親委託機制(PDM)。PDM更好的保證了Java平臺的安全性,在該機制中,JVM自帶的Bootstrap是根載入器,其他的載入器都有且僅有一個父類載入器。類的載入首先請求父類載入器載入,父類載入器無能為力時才由其子類載入器自行載入。JVM不會向Java程式提供對Bootstrap的引用。
下面是關於幾個類載入器的說明:
- Bootstrap:一般用原生代碼實現,負責載入JVM基礎核心類庫(rt.jar);
- Extension:從java.ext.dirs系統屬性所指定的目錄中載入類庫,它的父載入器是Bootstrap;
- System:又叫應用類載入器,其父類是Extension。它是應用最廣泛的類載入器。它從環境變數classpath或者系統屬性java.class.path所指定的目錄中記載類,是使用者自定義載入器的預設父載入器。