Class 文件格式
一般情況下Java代碼執行流程如下圖:
字節碼
字節碼文件 .class文件的產生是最關鍵的,是Java語言跨平臺的基礎,.class文件跟不同的操作系統之間對接的差異性由JVM後臺自動幫我們解決,我們只需要將代碼編譯成.class 字節碼文件,
Class類的本質
任何一個Class文件都對應着唯一一個類或接口的定義信息,但反過來說,Class文件實際上它並不一定以磁盤文件的形式存在。Class 文件是一組以8位字節爲基礎單位的二進制流。
Class文件格式
數據項目嚴格按照固定順序存儲在Class文件中,數據之間無空隙。Class文件格式採用一種類似於的僞結構來C語言結構體存儲數據,這種僞結構中只有兩種數據類型:無符號數和表,整個Class文件本質上就是一張表。
- 無符號數屬於基本的數據類型,以u1、u2、u4、u8來分別代表1個字節、2個字節、4個字節和8個字節的無符號數,無符號數可以用來描述數字、索引引用、數量值或者按照UTF-8編碼構成字符串值。
- 表是由多個無符號數或者其他表作爲數據項構成的複合數據類型,所有表都習慣性地以“_info”結尾。表用於描述有層次關係的複合結構的數據,
一個Class文件格式大致如下:
ClassFile {
u4 magic; // 魔法數字,表明當前文件是.class文件,固定0xCAFEBABE
u2 minor_version; // 分別爲Class文件的副版本和主版本
u2 major_version;
u2 constant_pool_count; // 常量池計數 常量池內容不一樣的要計數
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]; // 各種屬性
}
- 魔數
大多數情況下,我們都是通過擴展名來識別一個文件的類型的,比如我們看到一個.txt類型的文件我們就知道他是一個純文本文件。但是,擴展名是可以修改的,那一旦一個文件的擴展名被修改過,那麼怎麼識別一個文件的類型呢。這就用到了我們提到的“魔數”。在Java中我們用前四個字節來表明文件的格式Class文件格式必須爲
0xCAFEBABE
。
- 主次版本號
第5和第6個字節是次版本號(Minor Version),第7和第8個字節是主版本號(Major Version)。Java的版本號是從45開始的,JDK 1.1之後的每個JDK大版本發佈主版本號向上加1高版本的JDK能向下兼容以前版本的Class文件,但不能運行以後版本的Class文件,即使文件格式並未發生任何變化,虛擬機也必須拒絕執行超過其版本號的Class文件。
具體的Class文件的數據格式就直接百度或者看附錄的了。一般我們可以通過javap *.Class 文件實現字節碼的查看,也可以通過JD-GUI進行反編譯。
類加載機制
類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載(Loading)、驗證(Verification)、準備(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸載(Unloading)7個階段。其中驗證、準備、解析3個部分統稱爲連接(Linking)。
在初始化階段,虛擬機嚴格規定了有且只有5種情況必須立即對類進行初始化(而加載、驗證、準備自然需要在此之前開始):
- 遇到new、getstatic、putstatic或invokestatic這4條字節碼指令時,如果類沒有進行過初始化,則需要先觸發其初始化。生成這4條指令的最常見的Java代碼場景是:使用new關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(被final修飾、已在編譯期把結果放入常量池的靜態字段除外)的時候,以及調用一個類的靜態方法的時候。
- 使用java.lang.reflect包的方法對類進行反射調用的時候,如果類沒有進行過初始化,則需要先觸發其初始化。
- 當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
- 當虛擬機啓動時,用戶需要指定一個要執行的主類(包含main()方法的那個類),虛擬機會先初始化這個主類。
- 當使用JDK 1.7的動態語言支持時,如果一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,並且這個方法句柄所對應的類沒有進行過初始化,則需要先觸發其初始化,跟1有點類似。
其餘的引用方式是不會觸發初始化的,demo如下:
class SuperClazz {
static {
System.out.println("SuperClass init!");
}
public static int value = 123; //0
public static final String HELLOWORLD = "hello,sowhat";
public static final int WHAT = value;
}
class SubClazz extends SuperClazz {
static {
System.out.println("Subclass init!");
}
}
public class Main {
public static void main(String[] args) {
//System.out.println(SubClazz.value); // 只會直接父類,子類的初始化不會發生
//SuperClazz[] sca = new SuperClazz[10]; // 什麼都不會觸發初始化, 只是知道數組類型而已
//System.out.println(SubClazz.HELLOWORLD); // 直接從常量池的數據使用,子類 父類都不會調用
System.out.println(SubClazz.WHAT); // 這裏是引用另外一個變量,會直接夫類初始化
}
}
加載
要判斷文件格式是否OK,是否可以找到文件。
虛擬機需要完成以下3件事情:
- 通過一個類的全限定名來獲取定義此類的二進制字節流。
- 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
- 在堆內存中生成一個代表這個類的java.lang.Class對象(也就是反射),作爲方法區這個類的各種數據的訪問入口。
- 平常我們認識到的是一個class 然後new 出object。但是class 也是一個object。 這個object由JVM通過java.lang.Class來統一給我們生成。
驗證
是連接階段的第一步,這一階段的目的是爲了確保Class文件的字節流中包含的信息符合當前虛擬機的要求,並且不會危害虛擬機自身的安全。但從整體上看,驗證階段大致上會完成下面4個階段的檢驗動作:文件格式驗證、元數據驗證、字節碼驗證、符號引用驗證。大致的工作如下:
驗證Java加載進內存的二進制文件是否符合JVM以及Java規範,並且不會危害虛擬機的自身安全。比如說符號引用中的類、字段、方法的訪問性(private、protected、public、default)是否可被當前類訪問、類中的字段、方法是否與父類產生矛盾……
準備
準備階段是指準備要執行的制定的類,這包含了給這個類的靜態變量數據分配內存空間,並分配初始值(僅僅是分配內存空間,具體初始化在最後一步)。
public static int age = 14
這句代碼在初始值設置之後爲 0,因爲這時候尚未開始執行任何 Java 方法。而把 age 賦值爲 14 的 putstatic 指令是程序被編譯後,存放於 clinit() 方法中,在初始化階段纔會對 value 進行賦值。但是如果添加了final
就會在這個階段直接賦值爲14。
解析
這個階段是虛擬機將常量池內的符號引用替換爲直接引用的過程。符號引用轉換爲直接引用就發生在解析階段,解析階段可能在初始化前,也可能在初始化之後。
爲什麼要用符號引用呢?這是因爲類加載之前,javac會將源代碼編譯成.class文件,這個時候javac是不知道被編譯的類中所引用的類、方法或者變量他們的引用地址在哪裏,所以只能用符號引用來表示。在解析階段又需要根據關聯上數據。
1.符號引用
符號引用以一組符號來描述所引用的目標,符號可以使任何形式的字面量。
String str = "sowhat";
System.out.println("String" + str);
- 直接引用
直接引用可以使直接指向目標的指針、相對偏移量或是一個能間接定位到目標的句柄。直接引用和迅疾的內存佈局實現有關
String str = "sowhat";
System.out.println("String" + "sowhat");
初始化
是類加載過程的最後一步,前面的類加載過程中,除了在加載階段用戶應用程序可以通過自定義類加載器參與之外,其餘動作完全由虛擬機主導和控制。到了初始化階段,才真正開始執行類中定義的Java程序代碼在準備階段,變量已經賦過一次系統要求的初始值,而在初始化階段,則根據程序員通過程序制定的主觀計劃去初始化類變量和其他資源,或者可以從另外一個角度來表達:初始化階段是執行類構造器<clinit>()
方法的過程。<clinit>()
方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的。
<clinit>()
方法對於類或接口來說並不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那麼編譯器可以不爲這個類生成<clinit>()
方法。
虛擬機會保證一個類的<clinit>()
方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()
方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()
方法完畢。如果在一個類的<clinit>()
方法中有耗時很長的操作,就可能造成多個進程阻塞。
例如,如果一個類中包含聲明public static int age=14;
那麼變量age被賦值爲14的過程將在初始化階段進行,另外倘若靜態變量並沒有指定初值,那麼JVM會自動給靜態變量賦予一個初值,下表給出Java基本類型和引用變量的缺省值。
類加載器
前面我們說到了類加載分爲7個部分,而在鏈接階段我們一般是無法干預的,我們大部分干預的階段類加載階段(ClassLoder)。
對於任意一個類,都需要由加載它的類加載器和這個類本身一同確立其在Java虛擬機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。這句話可以表達得更通俗一些:比較兩個類是否“相等”,只有在這兩個類是由同一個類加載器加載的前提下才有意義,否則,即使這兩個類來源於同一個Class文件,被同一個虛擬機加載,只要加載它們的類加載器不同,那這兩個類就必定不相等。
這裏所指的相等,包括代表類的Class對象的 isAssignableFrom()方法,equals()方法、isInstance()方法的返回結果,也包括使用instanceof關鍵字做對象所屬關係判定等情況。
ClassLoader 裏面有三個重要的方法 loadClass()
、findClass()
和 defineClass()
,平常用到的主要函數如下:
- loadClass() 方法是加載目標類的入口,它首先會查找當前 ClassLoader 以及它的雙親裏面是否已經加載了目標類,找到直接返回。
- 如果沒有找到就會讓雙親嘗試加載,如果雙親都加載不了,就會調用 findClass() 讓自定義加載器自己來加載目標類。ClassLoader 的 findClass() 方法是需要子類來覆蓋的,不同的加載器將使用不同的邏輯來獲取目標類的字節碼。拿到這個字節碼之後再調用 defineClass() 方法將字節碼轉換成 Class 對象。
- getParent() 返回該類加載器的父類加載器。
- loadClass(String name) 加載名稱爲 name的類,返回的結果是 java.lang.Class類的實例。
- 此方法負責加載指定名字的類,首先會從已加載的類中去尋找,如果沒有找到;從parent ClassLoader[ExtClassLoader]中加載;如果沒有加載到,則從Bootstrap ClassLoader中嘗試加載(findBootstrapClassOrNull方法), 如果還是加載失敗,則自己加載。如果還不能加載,則拋出異常ClassNotFoundException。
- 如果要改變類的加載順序可以覆蓋此方法;
- findClass(String name) 查找名稱爲 name的類,返回的結果是 java.lang.Class類的實例。
- findLoadedClass(String name) 查找名稱爲 name的已經被加載過的類,返回的結果是 java.lang.Class類的實例。
- defineClass(String name, byte[] b, int off, int len) 把字節數組 b 中的內容轉換成 Java 類,返回的結果是 java.lang.Class類的實例。這個方法被聲明爲 final的。
- resolveClass(Class<?> c) 鏈接指定的 Java 類。
雙親委派機制
定義:當某個類加載器需要加載某個.class
文件時,它首先把這個任務委託給他的上級類加載器,遞歸這個操作,如果上級的類加載器沒有加載,自己纔會去加載這個類。
作用:
- 防止重複加載同一個.class。通過委託去向上面問一問,加載過了,就不用再加載一遍。保證數據安全。
- 保證核心.class不能被篡改。通過委託方式,不會去篡改核心.clas,即使篡改也不會去加載(自己寫個java.lang.String試試),即使加載也不會是同一個.class對象了。不同的加載器加載同一個.class也不是同一個Class對象。這樣保證了Class執行安全。
- BootstrapClassLoader(啓動類加載器)
c++編寫,加載java核心庫 java.*,構造ExtClassLoader和AppClassLoader。由於引導類加載器涉及到虛擬機本地實現細節,開發者無法直接獲取到啓動類加載器的引用,所以不允許直接通過引用進行操作
- ExtClassLoader (標準擴展類加載器)
java編寫,加載擴展庫,如classpath中的jre ,javax.*或者
java.ext.dir 指定位置中的類,開發者可以直接使用標準擴展類加載器。
- AppClassLoader(系統類加載器)
java編寫,加載程序所在的目錄,如user.dir所在的位置的class
- CustomClassLoader(用戶自定義類加載器)
java編寫,用戶自定義的類加載器,可加載指定路徑的class文件
總結: Java的雙親委派機制類加載只是一直Java類加載的一種模式。但是當我們使用一些第三方框架的時候比如JDBC跟具體實現的時候,反而會引發錯誤,因爲JDK自帶的JDBC接口由啓動類加載,而第三方實現接口由應用類加載。這樣相互之間是不認識的,因此JDK引入了線程上下文加載器來實現用同一個加載器加載。
Tomcat:服務器類加載器也使用代理模式,不同的是它總是先嚐試去加載某個類,如果找不到再用上一級的加載器,這跟一般類加載器順序正好相反。
棧楨
JVM中除了一些native方法是基於本地方法棧實現的,所有的Java方法幾乎都是通Java虛擬機棧來實現方法的調用和執行過程(當然,需要程序計數器、堆、方法區的配合),所以Java虛擬機棧是虛擬機執行引擎的核心之一。而Java虛擬機棧中出棧入棧的元素就稱爲棧幀。
棧幀(Stack Frame):
用於支持虛擬機進行方法調用和方法執行的數據結構。棧幀存儲了方法的局部變量表、操作數棧、動態連接和方法返回地址等信息。每一個方法從調用至執行完成的過程,都對應着一個棧幀在虛擬機棧裏從入棧到出棧的過程。
-
局部變量表
局部變量表(Local Variable Table)是一組變量值存儲空間,用於存放方法參數和方法內定義的局部變量。局部變量表的容量以變量槽(Variable Slot)爲最小單位,Java虛擬機規範並沒有定義一個槽所應該佔用內存空間的大小,但是規定了一個槽應該可以存放一個32位以內的數據類型。 -
操作數棧
操作數棧(Operand Stack)也常稱爲操作棧,它是一個後入先出棧(LIFO)。同局部變量表一樣,操作數棧的最大深度也在編譯的時候寫入到方法的Code屬性的max_stacks數據項中。 -
動態連接
在一個class文件中,一個方法要調用其他方法,需要將這些方法的符號引用轉化爲其在內存地址中的直接引用,而符號引用存在於方法區中的運行時常量池。
Java虛擬機棧中,每個棧幀都包含一個指向運行時常量池中該棧所屬方法的符號引用,持有這個引用的目的是爲了支持方法調用過程中的動態連接(Dynamic Linking)。
這些符號引用一部分會在類加載階段或者第一次使用時就直接轉化爲直接引用,這類轉化稱爲靜態解析。另一部分將在每次運行期間轉化爲直接引用,這類轉化稱爲動態連接。
4.方法返回
當一個方法開始執行時,可能有兩種方式退出該方法:
正常完成出口
異常完成出口
簡單的一個加減法:
public class ShowByteCode {
private String xx;
private static final int TEST = 1;
public ShowByteCode() {
}
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
}
直接看JVM底層棧執行過程如下:
javap -c *.class
JVM方法調用詳解
方法解析
調用目標方法在程序代碼寫好、編譯器進行編譯時就必須確定下來。這類方法的調用稱爲解析。
在Java語言中符合“編譯期可知,運行期不可變”這個要求的方法,主要包括靜態方法和私有方法兩大類,前者與類型直接關聯,後者在外部不可被訪問,這兩種方法各自的特點決定了它們都不可能通過繼承或別的方式重寫其他版本,因此它們都適合在類加載階段進行解析。
靜態分派
重載(Overload):這個是靜態分配,編譯時候就確定下來調用函數了。函數名跟參數構成方法簽名,然後調用的時候根據方法數據的靜態類型進行顯示切記。
Human稱爲變量的靜態類型(Static Type),或者叫做的外觀類型(Apparent Type),後面的Man則稱爲變量的實際類型(Actual Type),靜態類型和實際類型在程序中都可以發生一些變化,區別是靜態類型的變化僅僅在使用時發生,變量本身的靜態類型不會被改變,並且最終的靜態類型是在編譯期可知的;而實際類型變化的結果在運行期纔可確定,編譯器在編譯程序的時候並不知道一個對象的實際類型是什麼。
如下代碼中定義了兩個靜態類型相同但實際類型不同的變量,但虛擬機(準確地說是編譯器)在重載時是通過參數的靜態類型而不是實際類型作爲判定依據的。並且靜態類型是編譯期可知的,因此,在編譯階段,Javac編譯器會根據參數的靜態類型決定使用哪個重載版本,所以選擇了sayHello(Human)作爲調用目標。所有依賴靜態類型來定位方法執行版本的分派動作稱爲靜態分派。靜態分派的典型應用是方法重載。靜態分派發生在編譯階段,因此確定靜態分派的動作實際上不是由虛擬機來執行的。
public class StaticDispatch{
static abstract class Human{}
static class Man extends Human{ }
static class Woman extends Human{}
public void sayHello(Human guy){
System.out.println("hello,human!");//1
}
public void sayHello(Man guy){
System.out.println("hello,man!");//2
}
public void sayHello(Woman guy){
System.out.println("hello,woman!");//3
}
public static void main(String[]args){
Human h1 = new Man();
Human h2 = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(h1); //human
sr.sayHello(h2); //human
}
}
動態分派
表現形式爲我們熟知的多態,只有在運行時候才知道調用的具體方法。多態的實現機制就是父類跟子類各自有一個方法表(一個類有一個方法表,由虛擬機維護,維護着各個方法實際入口<方法區實際地址>),如果沒重寫那麼他們公用一個方法,如果方法重寫了那麼 Father跟Son會各自指向實際的方法。然後在對象調用的時候就直接調用真實的方法。
public class DynamicDispatch {
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
@Override
protected void sayHello() {
System.out.println("hello,gentleman!");
}
}
static class Woman extends Human{
@Override
protected void sayHello() {
System.out.println("hello,lady!");
}
}
public static void main(String[]args){
Human h1 = new Man();
Human h2 = new Woman();
h1.sayHello(); //hello gentleman
h2.sayHello(); // hello lady
}
}
PS:
- 基於寄存器的字節指令一般由於硬件不同會有些許差異不過因爲直接跟硬件打交到因此速度更快些。
- 基於棧的字節碼解釋執行指令,速度慢點但是可移植。