類加載過程已經是老生常談模式了,我曾在講解tomcat的書中、在Java基礎類書、JVM的書、在Spring框架類的書中、以及各種各樣的博客和推文中見過,我雖然看了又忘了,但總體還是有些了解,曾經自以為這不是什么大不了的過程。但時間總會教你做人,看得越多,越覺得以前理解不足。
此筆記記錄,虛擬機中Java類加載的過程,各個過程發生的時機,具體做了什么事情,例如,在方法區或者堆分配了哪些內存,創建了哪些常量等。由於Java文件會預先編譯,得到class文件,虛擬機的類加載,是對class文件進行的操作,所以不可避免的涉及到class文件的解讀,只有知道class文件中有什么,虛擬機才能加載對應的內容。
一、class文件介紹
不可能完全解讀class文件,《虛擬機規范第二版》花了一百多頁寫class文件,這是class的核心,如果要完全理解,可能還得去復習復習編譯原理,詞法分析語法分析代碼生成之類的。
1.1 文件結構
文件結構定義:u1 = 1個字節,u2 = 2個字節,u4 = 4個字節,u8 = 8個字節;
ClassFile {
u4 magic; // 魔法數
u2 minor_version; // 副版本號
u2 major_version; // 主版本號
u2 constant_pool_count; // 常量池計數,從1開始計數
cp_info constant_pool[constant_pool_count-1]; // 常量池[常量數量]
u2 access_flags; // 訪問標志
u2 this_class; // 類索引
u2 super_class; // 父類索引
u2 interfaces_count; // 接口計數器
u2 interfaces[interfaces_count] // 接口表
u2 fields_count; // 字段計數器
field_info fields[fields_count]; // 字段表
u2 methods_count; // 方法計數器
method_info methods[methods_count]; // 方法表
u2 attributes_count; // 屬性計數器
attribute_info attributes[attributes_count]; // 屬性表
}
根據這個表,一個class文件的16進制文件就可以讀取了。此處要注意幾點
- 常量池的數量【常量池計數-1】,是因為常量池計數從1開始,而不是從0開始。其他的方法,屬性之類的是從0開始
- 字段表和屬性表的區別:
- 字段表:記錄的是類級別以及實例級別(不包含父類字段,方法內字段)的字段信息,作用域(public、private)、修飾符(static)、final、字段名等等...
- 屬性表:保存的是class的屬性,讓虛擬機能夠正確解讀class文件。例如:Exception屬性指出方法拋出的受檢查異常、Signature屬性記錄范型信息(讓程序員可以通過反射讀到正確類型)、Deprecated屬性標記類、接口、方法或字段已經過期。Java虛擬機(Java虛擬機規范 Java SE 8 版)自帶23個屬性(Java虛擬機規范 Java SE 7 版只描述了21個屬性),並且支持自定義屬性。
1.2 簡單示例讀取class文件
代碼
/**
* @Author: dhcao
* @Version: 1.0
*/
public class ClassTest {
public static final String abc = "ccc";
private static String def = "fff";
public String getAbcdef(){
return abc + def;
}
}
編譯為ClassTest.class
直接使用subline或者其他軟件打開此二進制文件

解讀:根據class文件結構解讀
- ca fa ba be : u4 ,魔法數。看到此魔法數開頭的文件就意味着這是個java編譯后的class文件
- 00 00 00 34:u2 + u2,副版本號 + 主版本號。根據以下對照表,是jdk1.8
| JDK版本號 | Class版本號 10進制 | 16進制 |
|---|---|---|
| 1.1 | 45.0 | 00 00 00 2D |
| 1.2 | 46.0 | 00 00 00 2E |
| 1.3 | 47.0 | 00 00 00 2F |
| 1.4 | 48.0 | 00 00 00 30 |
| 1.5 | 49.0 | 00 00 00 31 |
| 1.6 | 50.0 | 00 00 00 32 |
| 1.7 | 51.0 | 00 00 00 33 |
| 1.8 | 52.0 | 00 00 00 34 |
-
00 27:u2, 常量池計數器。十六進制27 = 十進制39,從1開始計數,代表有38項常量。
-
后續常量的解析過於復雜,不提了....
- 常量共有14種,每種常量都有自己的結構。下面看看第一個常量0a 00 0a 00 1b
- 0a :代表此常量為類型CONSTANT_Methodref_info(此結構意味着后續u4都屬於它),它代表方法的符號引用,也就是方法名。
- 00 0a:(0a = 10)指向常量池中聲明方法的類描述符CONSTANT_Class_info的索引。
- 00 1b:(1b = 27)指向常量池中名稱以及類型描述符CONSTANT_NameAndType的索引。
- 以上,第一個常量表示的是方法名,它的具體內容在常量表中,它由類名和方法名和類型組合而成。
-
貌似還可以解析第二個常量 07 00 1c
- 07:代表此常量為類型CONSTANT_Class_info(此結構意味着后續還有u2),它代表類或者接口的符號引用,也就是類名或者接口名(不僅僅指本類或本接口,其他的也一樣)。
- 00 1c:指向常量池中第28(1c = 28)項常量。
反編譯該class文件: javap -verbose ClassTest
第一部分:常量池部分
Constant pool:
#1 = Methodref #10.#27 // java/lang/Object."<init>":()V
#2 = Class #28 // java/lang/StringBuilder
#3 = Methodref #2.#27 // java/lang/StringBuilder."<init>":()V
#4 = Class #29 // org/relax/jvm/demo/ls/ClassTest
#5 = String #30 // ccc
#6 = Methodref #2.#31 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#7 = Fieldref #4.#32 // org/relax/jvm/demo/ls/ClassTest.def:Ljava/lang/String;
#8 = Methodref #2.#33 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#9 = String #34 // fff
#10 = Class #35 // java/lang/Object
#11 = Utf8 abc
#12 = Utf8 Ljava/lang/String;
#13 = Utf8 ConstantValue
#14 = Utf8 def
#15 = Utf8 <init>
#16 = Utf8 ()V
#17 = Utf8 Code
#18 = Utf8 LineNumberTable
#19 = Utf8 LocalVariableTable
#20 = Utf8 this
#21 = Utf8 Lorg/relax/jvm/demo/ls/ClassTest;
#22 = Utf8 getAbcdef
#23 = Utf8 ()Ljava/lang/String;
#24 = Utf8 <clinit>
#25 = Utf8 SourceFile
#26 = Utf8 ClassTest.java
#27 = NameAndType #15:#16 // "<init>":()V
#28 = Utf8 java/lang/StringBuilder
#29 = Utf8 org/relax/jvm/demo/ls/ClassTest
#30 = Utf8 ccc
#31 = NameAndType #36:#37 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#32 = NameAndType #14:#12 // def:Ljava/lang/String;
#33 = NameAndType #38:#23 // toString:()Ljava/lang/String;
#34 = Utf8 fff
#35 = Utf8 java/lang/Object
#36 = Utf8 append
#37 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#38 = Utf8 toString
- 可以看到一共38項常量,從二進制class文件讀取的也一樣
- 第一項常量是方法名,分別指向第10和第27項常量,組合起來的值。
- 第二項常量是類或者接口名,他指向第28項常量。那是一個StringBuilder類名。
二、類加載步驟
類文件(class文件)是Java編譯器編譯之后的結果,它遵循的是編譯原理。類加載,是指JVM將class文件加載到虛擬機的過程。只有將class文件加載到虛擬機,才能夠使用該class。
2.1 類加載過程

將一個class文件(不管是文件還是二進制...只要是class格式)加載到虛擬機,最后移出虛擬機,通常認為有以上步驟,即類的生命周期。對於大多數時候,我們並不關注卸載過程,將“Using、使用”之前對類等處理,統稱為“類加載”。所以也有描述類加載為3個主要過程(這也是虛擬機規范定義的過程):加載 --- 連接 --- 初始化
-
說明一:區分“加載(Loading)”和“類加載”。很明顯,當我們定義類加載為“加載、連接、初始化”就知道,“加載(Loading)”只是整個“類加載”過程的一部分。
-
說明二:Resolution、解析。解析過程是連接的一部分,但是並不一定發生在“初始化”之前。虛擬機規范並沒有要求“解析”一定發生在“初始化之前”,虛擬機可根據不同情況,進行不同處理。
-
說明三:加載、驗證、准備、初始化、卸載。這五個階段是有序的,但是....其界限並非是先加載,加載完畢之后開始驗證,驗證完畢之后開始准備之類的。此有序,只是“開始時間有序”,即:加載開始時間一定在驗證開始之前。但加載和驗證可以有交集。
我理解,加載的整個過程,應該是覆蓋了“連接”。
2.1.1 Loading、加載
-
過程定義:獲取類的二進制字節流,並在方法區建立其數據結構,生成java.lang.Class對象。(這個過程可能覆蓋了“連接”)
-
關於類的二進制字節流來源
- 可以是編譯之后的.class文件
- 可以是zip、jar、war格式包
- 可以是運行時由動態代理生成的二進制流
- 可以是其他能夠被解析到二進制流,jsp等模板
- 可以是遠程調用獲取到的二進制流
-
類加載器
- 是類加載的工具。負責將二進制讀進虛擬機並按class規范處理,是加載類的類。
- 同一份class二進制文件,只有被同一個類加載,才能確定唯一性。不同的類加載器加載同一份class文件,這是2個類。在使用instanseof 和equals比較,都會得到false。
- 其他不是重點...
-
過程一:非數組類
- JVM使用類加載器讀取一個二進制流:loadClass()方法。
-
過程二:數組類String[] aa = xxx
-
我們知道數組是一串連續的內存地址。從這個定義上來看,我們就知道,這不是類加載器可以控制的。數組類的創建是由JVM直接創建的。
-
數組的每個元素(對象)依然要靠類加載器創建。也就是說,數組本身由JVM創建並分配內存,但是其元素依然依靠類加載器加載。
-
-
在加載時,可能拋出以下異常:
-
ClassNotFoundException:類加載器未找到類所對應的描述
-
LinkageError:是否已被該類加載器加載過
-
ClassFormatError:格式檢查失敗。此過程也可算是“驗證”一部分。檢查以下幾項
- 前4個字節(u4)必須是魔法數cafababe。
- 能夠辨識的屬性,都具備正確的長度。
- 文件內容不能缺失,不能有多余字節。
- 常量池必須符合約束(各個常量表的格式)。
-
unsupportedClassVersionError:檢測到class版本號不被JVM支持
-
noClassDefError:class文件與類名描述不符
-
IncompatibleClassChangeError:接口被類繼承時拋出
-
ClassCircularityError:類的父類是自己時
由上可見,加載本身也含有一定程度上的校驗,不可能啥都加載。所以加載發生在驗證開始之前, 但並非一定是結束在驗證開始之前。
-
2.1.2 Verifition、驗證
在讀入了二進制流之后,驗證就開始了,驗證的目的是保證Class文件的字節流包含的信息符合JVM的要求,並且不會危害JVM的安全。
那么需要驗證哪些呢,在《虛擬機規范 Java SE 8》中,章節目錄4.10詳細講解了JVM加載class文件需要進行的校驗,根本目的還是保證class文件的正確性和安全性。
-
文件格式驗證(參照前文:格式檢查)。
-
元數據驗證
- 是否有父類、是否繼承了final、是否實現了接口的所有方法等等。
-
字節碼驗證
- 這個實在是多,包括指令是否正確,指令是否越界,映射是否正確等等。
-
符號引用驗證
- 對類型進行匹配性校驗,是否private的方法只能被當前類訪問、通過全限定名是否找到對應的類等等。
-
靜態約束:一系列用來定義文件是否編排良好的約束
2.1.3 Preparation、准備
該階段是非常重要的,在經過前面的階段之后,一個Class文件已經加載到了JVM並驗證了其正確性,那么接下來就需要對Class文件進行處理。
虛擬機規范規定:准備階段的任務是創建類或者接口的靜態字段,並用默認值初始化這些字段。這個階段不會執行任何的虛擬機字節碼指令。
過程
- 為類變量(static)分配內存。java8以后,運行時常量池分配在堆中。
- 設置初始值
從開始接觸Java我們就一直被一些看似簡單實際有些意思的題目煩擾,例如:靜態變量,靜態塊的執行順序、父類子類的執行順序、變量賦值時間、方法傳遞的是引用還是值等等亂七八糟的問題。
關於初始值:public static int value = 123; 在准備階段,這段代碼只會得到:value = 0,這是因為int型變量的初始值為0(引用類型初始值為null)。
但是:public static final int value = 123; 在准備階段,這段代碼會得到:value = 123,這是因為final定義常量,其值在編譯時確定。
- 准備階段是給static賦初始值,為final修飾的常量賦值。
嘗試分析,如何標記常量,以及為它賦值。依然是最上述的代碼段
反編譯該class文件: javap -verbose ClassTest
第二部分:編譯碼 { public static final java.lang.String abc; descriptor: Ljava/lang/String; flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL ConstantValue: String ccc public org.relax.jvm.demo.ls.ClassTest(); descriptor: ()V flags: ACC_PUBLIC Code: stack=1, locals=1, args_size=1 0: aload_0 1: invokespecial #1 // Method java/lang/Object."<init>":()V 4: return LineNumberTable: line 7: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lorg/relax/jvm/demo/ls/ClassTest; .... ..... ...... }以上,為javap的反編譯結果第二部分。我們看源代碼中:
public static final String abc = "ccc"; 編譯之后: public static final java.lang.String abc; descriptor: Ljava/lang/String; flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL ConstantValue: String ccc重點在於ConstantValue: String ccc。
屬性ConstantValue:如果同時使用了static 和 final,javac編譯器在編譯時就在字段上著明該屬性,並在類加載的准備階段,將該屬性的值,自動賦值給靜態變量。
在最開始定義class格式的時候寫到,文件最后定義的是屬性表,ConstantValue就是屬性表中的屬性。
- 作用范圍:使用在字段上。
- 如果flags中含有ACC_STATIC和ACC_FINAL,那么ConstantValue的值將直接賦值給該字段。也就是ccc直接賦值給abc。
- 強調:虛擬機規范只要求有ACC_STATIC就可以使用ConstantValue,是sun公司的javac編譯器要求同時使用ACC_STATIC和ACC_FINAL
- 只能限於基本類型和String使用
2.1.4 Resolution、解析
解析這個過程,並沒有嚴格的規定在什么時候發生。解析的作用是將符號引用替換為直接引用的過程,只需要在使用符號之前替換這個符號就行。
-
符號引用
- 類和接口的全限定名(Fully Qualified Name)
- 字段的名稱和描述符(Descriptor)
- 方法的名稱和描述符
-
直接引用
- 直接指向目標的引用
- 相對偏移量
- 能間接定位到目標的句柄
以上描述還有些難以理解。說實話,我也不知道怎么解釋了,舉個例子描述(類方法解析):
public String getAbcdef(){
int a = 3;
int c = a + 4;
return abc + def + c;
}
執行 javap -verbose ClassTest
--------------------------------------------------------------------------------
public java.lang.String getAbcdef();
descriptor: ()Ljava/lang/String;
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=1
0: iconst_3
1: istore_1
2: iload_1
3: iconst_4
4: iadd
5: istore_2
6: new #2 // class java/lang/StringBuilder
9: dup
10: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
13: ldc #5 // String ccc
15: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
18: getstatic #7 // Field def:Ljava/lang/String;
21: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: iload_2
25: invokevirtual #8 // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
28: invokevirtual #9 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
31: areturn
LineNumberTable:
line 15: 0
line 16: 2
line 17: 6
LocalVariableTable:
Start Length Slot Name Signature
0 32 0 this Lorg/relax/jvm/demo/ls/ClassTest;
2 30 1 a I
6 26 2 c I
--------------------------------------------------------------------------------
在反編譯中,一直出現的jvm指令:invokevirtual
- 這是一條方法調用指令,調用對象的實例方法,也是常用的虛方法分派。
解析的目的:
在上文 中,該指令調用的是StringBuilder的append方法(直接字符串相加),虛擬機要求,在執行該條指令(invokevirtual)之前,需要對他們所使用的符號進行解析,也就是對StringBuilder.append進行解析。解析的結果是,該指令能夠正確的找到該方法的入口!
解析過程:(類方法解析)
- 找到該類的符號引用(StringBuilder):具體過程是,該指令后對應的常量池位置 #6 ,我們找到常量池#6,得到
#6 = Methodref #2.#31 // java/lang/StringBuilder.append:
它是一個Methodref(方法常量),這是由#2(java/lang/StringBuilder)和#31(append)組成的。
而#2:
#2 = Class #28 // java/lang/StringBuilder
它是一個Class(類)。
- 再找,該類(StringBuilder)中是否有該方法。要求返回值為String,參數為String。
- 最后返回該方法的引用!
解析的目的是將符號引用轉為能用的直接引用。主要包含以下:
- 類或接口的解析
- 字段解析
- 類方法解析(上文例子)
- 接口方法解析
2.1.5 Initializaition、初始化
Loading、加載階段讀入了Class文件
Verifition、驗證階段校驗了其正確性
Preparation、准備階段為其開辟了內存空間,並為static屬性賦初始值
Resolution、解析階段將符號引用都轉為了直接引用,使得Class中的定義都有了實際意義,不再是一串字面量
Initializaition、初始化階段,是類加載的最后一步
也是執行字節碼的過程,也是執行<clinit>()方法的過程
-
<clinit>()方法 和 <init>()方法
- <clinit>()方法是由編譯器自動收集類中所有類變量的賦值動作(不管是不是靜態)和靜態語言塊(static{})中的語句合並產生的,是類構造器。
- <init>()方法是實例化時執行的構造函數或者說實例構造器。
-
<clinit>() 順序:
編譯器收集類變量的賦值和靜態塊的語句,是按照Class文件中的語句順序來的,所有如下中,static 變量i 在定義賦值之前就想要輸出,是不行的。
public class ClassTest { static { i = 0; // 可以通過 System.out.println(i); // 編譯報錯 } static int i = 1; } -
重要:虛擬機會保證父類的<clinit>()方法在子類之前已經執行完畢。所以第一個執行<clinit>()方法的肯定是java.lang.Object。
這也意味着父類的靜態塊語句在子類靜態塊之前執行
-
<clinit>()方法不是必要的,如果沒有任何靜態塊,也沒有類變量的賦值動作,那么可以不生成<clinit>()方法。
-
接口雖然無法定義static塊,但是也可以賦值,所以接口也可以有<clinit>()方法。
-
虛擬機保證在多線程環境下,同一個類加載器中<clinit>()方法只被執行一次。
三、總結
類的加載過程,主要流程如上,但是更多的細節,沒有描述,例如更多的Class文件細節,更多的類加載的內容,更具體的棧與堆的數據結構和分配過程。在后面的筆記中將對這些進行補充。
熟悉的面試題,現在看來也顯然易見!
class Parent {
static {
System.out.println("父類靜態塊");
}
Parent(){
System.out.println("父類構造函數");
}
}
class Sub extends Parent{
static {
System.out.println("子類靜態塊");
}
Sub(){
System.out.println("子類構造函數");
}
}
// 如何輸出...
class Test{
public static void main(String[] args) {
new Sub();
}
}
