JVM 類加載
-
- Ⅰ 前言
- Ⅱ 類加載機制概述
- Ⅲ 類加載的過程
-
- A. 加載(Loading)
- B. 連結(Linking)
-
- ① 驗證(Verification)
- ② 準備(Preparation)
- ③ 解析(Resolution)
- C. 初始化(Initialization)
- Ⅳ 類加載器
-
- A. 概念
- B. 三層類加載器與雙親委派模型
Ⅰ 前言
Java 語言的類型一共可以分為兩大類:基本類型(primitive types) 和 引用類型(reference types)。基本類型都是由 JVM 預先定義好的。
引用類型一共可以細分成四種:類, 接口, 數組類, 泛型參數。而泛型參數在編譯時會被擦除,是以 JVM 實際上隻有前三種。在 類、接口和數組類中,數組類是由 Java 虛拟機直接生成的,其他兩種則有對應的位元組流。
Java 中所謂的位元組流,最常見其實就是經過 Java 編譯器生成的 class 檔案,除此之外我們還可以從程式内部直接生成或者從網絡中擷取位元組流(比如Java applet)。這些位元組流在程式中就會被加載到 JVM 中,變成類或者接口。
無論是直接生成的數組類還是加載的類,JVM 都需要對其進行連結和初始化,這就是虛拟機的類加載機制,本篇文章将會對虛拟機的類加載做一個總結和分析。
我們可以先來看一個樣例。
package com.tyz.classloader.test;
/**
* @author tyz
*/
public class People {
private String name;
private boolean gender;
private int age;
public People(String name, boolean gender, int age) {
this.name = name;
this.gender = gender;
this.age = age;
}
@Override
public String toString() {
return this.name + " "
+ (this.gender ? "male" : "female")
+ " " + this.age;
}
}
我先定義了一個簡單的類,然後在主函數中分别初始化一下幾個類型的資料。

接下來我們可以看看它編譯成位元組碼之後的樣子。
public static void main(java.lang.String[]);
Code:
0: bipush 97
2: istore_1
3: bipush 26
5: newarray int
7: astore_2
8: new #2 // class java/util/ArrayList
11: dup
12: invokespecial #3 // Method java/util/ArrayList."<init>":()V
15: astore_3
16: new #4 // class com/tyz/classloader/test/People
19: dup
20: ldc #5 // String Jessica
22: iconst_1
23: bipush 26
25: invokespecial #6 // Method com/tyz/classloader/test/People."<init>":(Ljava/lang/String;ZI)V
28: astore 4
30: return
}
可以看到初始化
char
類型時,是直接
bipush 97
,這裡備注一下
istore
指令就是存儲的意思。包括數組在定義的時候,我們是定義了一個大小為26的數組,是以在加載數組之前,會先通過
bipush
加載一個26,再通過
newarray
指令加載數組。
再往下看,要初始化
ArrayList
和我們定義的
People
類就不一樣了,它們有一個共有的操作就是調用類加載器。
看到引用類型和基本類型,引用類型中的類類型和數組之間的差異之後,我們來看看類加載。
Ⅱ 類加載機制概述
首先我們先來定義一下類加載機制:Java 虛拟機把描述類的資料從Class檔案加載到記憶體,并對資料進行校驗、轉換解析和初始化,最終形成可以被虛拟機直接使用的Java類型,這個過程被稱作虛拟機的類加載機制。
和那些編譯時需要進行連結的語言不同,在Java中,類型的加載、連結和初始化過程都是在運作期間完成的,這種政策讓Java語言進行提前編譯會面臨更多的困難,也會讓類加載時稍微增加一些性能開銷,但是卻為Java的應用提供了極高的擴充性和靈活性。Java天生可以動态擴充的語言特性就是依賴運作時動态加載和動态連結這個特點實作的。
注意,這裡的class檔案并不隻指代存在磁盤中的某個檔案,而是指一串二進制位元組流,類也并不是僅指類,還包括接口。
Ⅲ 類加載的過程
一般來說,我們會把類加載的過程分成三個主要步驟:加載(Loading), 連結(Linking), 初始化(Initialization)。
其中,連結又可分成三個步驟,、驗證(Verification)、準備(Preparation)、解析(Resolution)。這是最核心的步驟。
也就是說一個類型從被加載到虛拟機記憶體中開始,到解除安裝出記憶體中為止,它的生命周期将會經曆七個階段:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和解除安裝(Unloading)。
上圖中,加載、驗證、準備、初始化和解除安裝這五個階段的順序是确定的,類型的加載過程必須按照這種順序按部就班地開始,而解析階段則不一定:它在某些情況下可以在初始化階段之後再開始,這是為了支援Java語言的運作時綁定特性(也稱為動态綁定或晚期綁定)。
這裡要強調一下是按部就班地開始,而不是進行,因為這些階段通常都是互相交叉地混合進行的,會在一個階段執行的過程中調用、激活另一個階段。
接下來我們就來看看詳細的類加載過程。
A. 加載(Loading)
“加載”(Loading)階段是整個“類加載”(Class Loading)過程中的一個階段,它是 Java 将位元組碼資料從不同的資料源讀取到 JVM 中,并映射為 JVM 認可的資料結構(Class 對象),這裡的資料源可能是各種各樣的形态,如 jar 檔案、class 檔案,甚至是網絡資料源等;如果輸入資料不是 ClassFile 的結構,則會抛出
ClassFormatError
。
簡單來說加載就是查找位元組流,并據此建立對象的過程。
加載階段是使用者參與的階段,我們可以自定義類加載器,去實作自己的類加載過程。
這個過程中就涉及到了一個著名的東西了,它就是雙親委派模型。這個我們放到類加載器的子產品去說。
B. 連結(Linking)
連結就是把原始的類定義資訊平滑地轉化入 JVM 運作的過程中的這個過程,簡單說就是将建立成的類合并到 Java 虛拟機中。這個過程分為了三個步驟:驗證、準備以及解析。
① 驗證(Verification)
驗證階段的目的在于保證被加載類能夠滿足 Java 虛拟機的限制條件,這是虛拟機安全的重要保障,JVM 需要核驗位元組資訊是否符合規範,否則就會抛出一個
java.lang.VerifyError
異常或其子類異常。 這樣就防止了惡意資訊或者不合規的資訊危害 JVM 的運作。
Java 本身相較于C/C++ 來說是相對安全的,一般的錯誤是過不了編譯的,是以經由 Java 程式編譯生成的Class檔案幾乎不會有問題,但是前面我們也說了,Class 檔案并不一定是從 Java 程式中來的,它甚至有可能是有人直接用0和1在檔案中直接敲出來的,
是以Java虛拟機如果不檢查輸入的位元組流,對其完全信任的話,很可能會因為載入了有錯誤或有惡意企圖的位元組碼流而導緻整個系統受攻擊甚至崩潰,是以驗證位元組碼是Java虛拟機保護自身的一項必要措施。
驗證階段大緻上會完成下面四個階段的檢驗動作:檔案格式驗證、中繼資料驗證、位元組碼驗證和符号引用驗證。這部分我不再贅述,大家有興趣可以去看周志明的《深入了解Java虛拟機》。
② 準備(Preparation)
準備階段是正式為類中定義的變量(即靜态變量,被static修飾的變量)配置設定記憶體并設定類變量初始值的階段,注意這裡的類也包括接口。
這裡一定要注意,準備階段對靜态變量指派為初始值,比如你定義了一個
準備階段會将
num
指派為0,而不是710,畢竟這時候還沒有執行任何方法。而這個 710 會在類的初始化階段被指派。
除了上面的通常情況,還有一種特殊情況,初始值就不是0值了。如果某些字段的屬性表中存在 ConstantValue 屬性,那準備階段變量值就會被初始化為 ConstantValue 指定的值。
比如我們重新定義一下num:
編譯時Javac将會為value生成ConstantValue屬性,在準備階段虛拟機就會根據Con-stantValue的設定将value指派為123。
這裡我再補充一個Java中0值的表,大家以做參考。
資料類型 | 零值 |
---|---|
int | |
boolean | false |
long | 0L |
short | (short) 0 |
char | ’\u0000’ |
byte | (byte) 0 |
float | 0.0f |
double | 0.0d |
reference | null |
③ 解析(Resolution)
解析階段是Java虛拟機将常量池内的符号引用替換為直接引用的過程。
那麼什麼是符号引用,什麼是替換引用呢?我先寫出專業的定義:
符号引用(Symbolic References):符号引用以一組符号來描述所引用的目标,符号可以是任何形式的字面量,隻要使用時能無歧義地定位到目标即可。符号引用與虛拟機實作的記憶體布局無關,引用的目标并不一定是已經加載到虛拟機記憶體當中的内容。各種虛拟機實作的記憶體布局可以各不相同,但是它們能接受的符号引用必須都是一緻的,因為符号引用的字面量形式明确定義在《Java虛拟機規範》的Class檔案格式中。
直接引用(Direct References):直接引用是可以直接指向目标的指針、相對偏移量或者是一個能間接定位到目标的句柄。直接引用是和虛拟機實作的記憶體布局直接相關的,同一個符号引用在不同虛拟機執行個體上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目标必定已經在虛拟機的記憶體中存在。
在 class 檔案被加載至 Java 虛拟機之前,這個類無法知道其他類及其方法、字段所對應的具體位址,甚至不知道自己方法、字段的位址。是以,每當需要引用這些成員時,Java 編譯器會生成一個符号引用。在運作階段,這個符号引用一般都能夠無歧義地定位到具體目标上。
就比如我們最前面定義的那個類
這個類中又有
String
又有
boolean
,我還可以在這個類中初始一個
ArrayList
,但是在People類的class檔案被加載至 JVM 之前,它并不知道其他的類或者方法,是以在這個類中對其他類的引用,都用别的符号來指代,這就是符号引用。
比如有個孩子還在媽媽肚子裡,媽媽可能都不知道是男是女,名字也沒起,這時候要指代它媽媽可能就會叫寶寶之類的,在這個寶寶出生以後,符号引用就可以轉化成直接引用了,之前的那個代稱“寶寶”可以精準定位到這個剛出生的嬰兒。
是以,如果符号引用指向一個未被加載的類,或者未被加載類的字段或方法,那麼解析将觸發這個類的加載(但未必觸發這個類的連結以及初始化。)
C. 初始化(Initialization)
類的初始化階段是類加載過程的最後一個步驟,這一步真正去執行類初始化的代碼邏輯,包括靜态字段指派的動作,以及執行類定義中的靜态初始化塊内的邏輯,編譯器在編譯階段就會把這部分邏輯整理好,父類型的初始化邏輯優先于目前類型的邏輯。
在上面我們舉的
static
和
static final
的例子,除了
static final
以外的直接指派方法(隻标記了
static
或在靜态塊中指派),這些指派操作都将會被 Java 編譯器置于同一個方法中,并把它命名為
<clinit>
(類構造器方法),最後這步初始化給标記為常量的字段指派,就是執行這個
<clinit>
方法的過程,JVM 會通過加鎖來保證
<clinit>
方法隻被執行一次。
這裡還會出現一個方法
<init>
,它是一個構造器方法。差別于上面的
<clinit>
,任何一個類聲明以後,内部至少存在一個類的構造器。
我舉個 小例子。
這裡我并沒有寫什麼語句,是以
<init>
執行的是父類構造器方法,也就是
Object
的
<init>
。我們可以随便寫幾個語句看看。
這時候
<init>
方法就執行了類聲明中的語句。
在
<init>
方法我們看到首先執行的一定是父類的
<init>
方法,
<clinit>
也一樣,若該類具有父類,JVM會保證子類的
<clinit>
執行前,父類的
<clinit>
已經執行完畢。
關于初始化要記住一句話:隻有當初始化完成之後,類才正式成為可執行的狀态。
那麼,在什麼時候會觸發類的初始化呢?JVM 給了如下的觸發情況:
- 當虛拟機啟動時,初始化使用者指定的主類;
- 當遇到用以建立目标類執行個體的 new 指令時,初始化 new 指令的目标類;
- 當遇到調用靜态方法的指令時,初始化該靜态方法所在的類;
- 當遇到通路靜态字段的指令時,初始化該靜态字段所在的類;
- 子類的初始化會觸發父類的初始化;
- 如果一個接口定義了 default 方法,那麼直接實作或者間接實作該接口的類的初始化,會觸發該接口的初始化;
- 使用反射 API 對某個類進行反射調用時,初始化這個類;
- 當初次調用 MethodHandle 執行個體時,初始化該 MethodHandle 指向的方法所在的類。
類加載的過程大概就是這些了,類加載中還有一個非常重要的東西,就是類加載器,我們接着來看看類加載器到底是什麼。
Ⅳ 類加載器
A. 概念
在前面的加載(Loading)中,我們說了它就是一個通過位元組流來建立對象的過程,這個 “通過一個類的全限定名來擷取描述該類的二進制位元組流” 的動作,是 Java虛拟機團隊有意将其放到 JVM 外部 去實作的,以便讓應用程式自己決定如何去獲得所需的類,那麼,實作這個動作的代碼就被撐做是類加載器(Class Loader)。
放到外部意思就是說我們可以自己實作一個類加載器,因為類加載器本來就不是 Java虛拟機做的,而是由 Java 本身實作的。
類加載器雖然隻用于實作類的加載動作,但它在Java程式中起到的作用卻遠超類加載階段。對于任意一個類,都必須由加載它的類加載器和這個類本身一起共同确立其在Java虛拟機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。這句話可以表達得更通俗一些:比較兩個類是否“相等”,隻有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源于同一個Class檔案,被同一個Java虛拟機加載,隻要加載它們的類加載器不同,那這兩個類就必定不相等。
就比如我們自己實作一個類加載器的話,和用 Java 本身的類加載器加載同一個類的話,所生成的對象是不相等的,用
equals()
,
isAssignableFrom()
方法或者
instanceof
關鍵字等等判斷都不相等。
知道了什麼是類加載器,我們來看看雙親委派模型。
B. 三層類加載器與雙親委派模型
在 Java 虛拟機中,隻存在兩種不同的類加載器,一種是啟動類加載器(Bootstrap ClassLoader),這個類加載器是由 C++ 實作的,是虛拟機自身的一部分。另一種就是其他所有的類加載器,這些類加載器都是由 Java 實作的,獨立存在于虛拟機的外部,并且全都繼承自抽象類
java.lang.ClassLoader
。
自JDK 1.2以來,Java一直保持着三層類加載器、雙親委派的類加載架構,盡管這套架構在Java子產品化系統出現後有了一些調整變動,但依然未改變其主體結構。
絕大多數Java程式都會用到以下三種系統所提供的類加載器來進行加載,分别是啟動類加載器(Bootstrap Class Loader), 擴充類加載器(Extension Class Loader) 以及 應用程式類加載器(Application Class Loader)。
啟動類加載器負責加載最為基礎、最為重要的類,比如存放在 JRE 的 lib 目錄下 jar 包中的類(以及由虛拟機參數 -Xbootclasspath 指定的類),它無法法被Java程式直接引用,在編寫自定義類加載器時,如果需要把加載請求委派給引導類加載器去處理,那直接使用null代替即可。
擴充類加載器的父類加載器是啟動類加載器。它負責加載相對次要、但又通用的類,比如存放在 JRE 的 lib/ext 目錄下 jar 包中的類(以及由系統變量 java.ext.dirs 指定的類)。
應用類加載器的父類加載器則是擴充類加載器。它負責加載應用程式路徑下的類。(這裡的應用程式路徑,便是指虛拟機參數 -cp/-classpath、系統變量 java.class.path 或環境變量 CLASSPATH 所指定的路徑。)預設情況下,應用程式中包含的類便是由應用類加載器加載的。
重要的我們記住這三層結構就好,還要記住啟動類加載器由于是 C++ 寫的,是以在 Java 中沒有對象,隻能用
null
來指代它。
上圖就展現了三個系統之間的關系,這個圖也被稱為類加載器的 雙親委派模型(Parents Delegation Model).
親委派模型要求除了頂層的啟動類加載器外,其餘的類加載器都應有自己的父類加載器。不過這裡類加載器之間的父子關系一般不是以繼承(Inheritance)的關系來實作的,而是通常使用組合(Composition)關系來複用父加載器的代碼。正如程式屆一直倡導的,多組合,少繼承。
雙親委派模型的工作過程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,是以所有的加載請求最終都應該傳送到最頂層的啟動類加載器中,隻有當父加載器回報自己無法完成這個加載請求(它的搜尋範圍中沒有找到所需的類)時,子加載器才會嘗試自己去完成加載。
使用委派模型的目的是避免重複加載 Java 類型。
最後我們可以來看一個樣例。
分别輸出我們自己定義的
People
類的類加載器,以及 Java 中已經有的一個類
Logging
類的類加載器,最後是非常常用的
ArrayList
類的類加載器。
輸出結果如下:
我們可以看到,自己定義的
People
類,這個肯定原本是不存在的,是以父類加載器無法加載,最終傳遞到最下層的應用程式類加載器中;
Logging
類雖然是 Java 中自帶的,但是相較之下用的不是太頻繁,使用的是擴充類加載器;
而使用非常頻繁的
ArrayList
,則是被啟動類加載器加載的。