java虛擬機器類載入機制學習
1、什麼是類的載入
類的載入指的是將類的.class檔案中的二進位制資料讀入到記憶體中,將其放在執行時資料區的方法區內,然後在堆區建立一個java.lang.Class物件,用來封裝類在方法區內的資料結構。類的載入的最終產品是位於堆區中的Class物件,Class物件封裝了類在方法區內的資料結構,並且向Java程式設計師提供了訪問方法區內的資料結構的介面。
類載入器並不需要等到某個類被“首次主動使用”時再載入它,JVM規範允許類載入器在預料某個類將要被使用時就預先載入它,如果在預先載入的過程中遇到了.class檔案缺失或存在錯誤,類載入器必須在程式首次主動使用該類時才報告錯誤(LinkageError錯誤)如果這個類一直沒有被程式主動使用,那麼類載入器就不會報告錯誤
載入.class檔案的方式
– 從本地系統中直接載入
– 通過網路下載.class檔案
– 從zip,jar等歸檔檔案中載入.class檔案
– 從專有資料庫中提取.class檔案
– 將Java原始檔動態編譯為.class檔案
載入.class檔案的時機
- 建立類的例項
- 訪問類的靜態變數,或者為靜態變數賦值
- 呼叫類的靜態方法
- 使用反射方式來強制建立某個類或介面對應的java.lang.Class物件
- 初始化某個類的子類
- 直接使用java.exe命令來執行某個主類
2、類的生命週期
其中類載入的過程包括了載入、驗證、準備、解析、初始化
•載入:查詢並載入類的二進位制資料
載入時類載入過程的第一個階段,在載入階段,虛擬機器需要完成以下三件事情:
1、通過一個類的全限定名來獲取其定義的二進位制位元組流。
2、將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
3、在Java堆中生成一個代表這個類的java.lang.Class物件,作為對方法區中這些資料的訪問入口。
相對於類載入的其他階段而言,載入階段(準確地說,是載入階段獲取類的二進位制位元組流的動作)是可控性最強的階段,因為開發人員既可以使用系統提供的類載入器來完成載入,也可以自定義自己的類載入器來完成載入。
載入階段完成後,虛擬機器外部的 二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,而且在Java堆中也建立一個java.lang.Class類的物件,這樣便可以通過該物件訪問方法區中的這些資料。
•連線
– 驗證:確保被載入的類的正確性
驗證是連線階段的第一步,這一階段的目的是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。驗證階段大致會完成4個階段的檢驗動作:
檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範;例如:是否以0xCAFEBABE開頭、主次版本號是否在當前虛擬機器的處理範圍之內、常量池中的常量是否有不被支援的型別。
元資料驗證:對位元組碼描述的資訊進行語義分析(注意:對比javac編譯階段的語義分析),以保證其描述的資訊符合Java語言規範的要求;例如:這個類是否有父類,除了java.lang.Object之外。
位元組碼驗證:通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。
符號引用驗證:確保解析動作能正確執行。
驗證階段是非常重要的,但不是必須的,它對程式執行期沒有影響,如果所引用的類經過反覆驗證,那麼可以考慮採用-Xverifynone引數來關閉大部分的類驗證措施,以縮短虛擬機器類載入的時間。
– 準備:為類的靜態變數分配記憶體,並將其初始化為預設值
準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些記憶體都將在方法區中分配。對於該階段有以下幾點需要注意:
1、這時候進行記憶體分配的僅包括類變數(static),而不包括例項變數,例項變數會在物件例項化時隨著物件一塊分配在Java堆中。
2、這裡所設定的初始值通常情況下是資料型別預設的零值(如0、0L、null、false等),而不是被在Java程式碼中被顯式地賦予的值。
假設一個類變數的定義為:public static int value = 3;
那麼變數value在準備階段過後的初始值為0,而不是3,因為這時候尚未開始執行任何Java方法,而把value賦值為3的putstatic指令是在程式編譯後,存放於類構造器<clinit>()方法之中的,所以把value賦值為3的動作將在初始化階段才會執行。
· 這裡還需要注意如下幾點:
· 對基本資料型別來說,對於類變數(static)和全域性變數,如果不顯式地對其賦值而直接使用,則系統會為其賦予預設的零值,而對於區域性變數來說,在使用前必須顯式地為其賦值,否則編譯時不通過。
· 對於同時被static和final修飾的常量,必須在宣告的時候就為其顯式地賦值,否則編譯時不通過;而只被final修飾的常量則既可以在宣告時顯式地為其賦值,也可以在類初始化時顯式地為其賦值,總之,在使用前必須為其顯式地賦值,系統不會為其賦予預設零值。
· 對於引用資料型別reference來說,如陣列引用、物件引用等,如果沒有對其進行顯式地賦值而直接使用,系統都會為其賦予預設的零值,即null。
· 如果在陣列初始化時沒有對陣列中的各元素賦值,那麼其中的元素將根據對應的資料型別而被賦予預設的零值。
3、如果類欄位的欄位屬性表中存在ConstantValue屬性,即同時被final和static修飾,那麼在準備階段變數value就會被初始化為ConstValue屬性所指定的值。
假設上面的類變數value被定義為: public static final int value = 3;
編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue的設定將value賦值為3。回憶上一篇博文中物件被動引用的第2個例子,便是這種情況。我們可以理解為static final常量在編譯期就將其結果放入了呼叫它的類的常量池中
– 解析:把類中的符號引用轉換為直接引用
解析階段是虛擬機器將常量池內的符號引用替換為直接引用的過程,解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符7類符號引用進行。符號引用就是一組符號來描述目標,可以是任何字面量。
直接引用就是直接指向目標的指標、相對偏移量或一個間接定位到目標的控制代碼。
•初始化
初始化,為類的靜態變數賦予正確的初始值,JVM負責對類進行初始化,主要對類變數進行初始化。在Java中對類變數進行初始值設定有兩種方式:
①宣告類變數是指定初始值
②使用靜態程式碼塊為類變數指定初始值
JVM初始化步驟
1、假如這個類還沒有被載入和連線,則程式先載入並連線該類
2、假如該類的直接父類還沒有被初始化,則先初始化其直接父類
3、假如類中有初始化語句,則系統依次執行這些初始化語句
類初始化時機:只有當對類的主動使用的時候才會導致類的初始化,類的主動使用包括以下六種:
– 建立類的例項,也就是new的方式
– 訪問某個類或介面的靜態變數,或者對該靜態變數賦值
– 呼叫類的靜態方法
– 反射(如Class.forName(“com.shengsiyuan.Test”))
– 初始化某個類的子類,則其父類也會被初始化
– Java虛擬機器啟動時被標明為啟動類的類(Java Test),直接使用java.exe命令來執行某個主類
結束生命週期
•在如下幾種情況下,Java虛擬機器將結束生命週期
– 執行了System.exit()方法
– 程式正常執行結束
– 程式在執行過程中遇到了異常或錯誤而異常終止
– 由於作業系統出現錯誤而導致Java虛擬機器程序終止
3、類載入器
尋找類載入器,先來一個小例子
package com.neo.classloader;
public class ClassLoaderTest {
public static void main(String[] args) {
ClassLoader loader = Thread.currentThread().getContextClassLoader();
System.out.println(loader);
System.out.println(loader.getParent());
System.out.println(loader.getParent().getParent());
}
}
執行後,輸出結果:
[email protected]
[email protected]
null
從上面的結果可以看出,並沒有獲取到ExtClassLoader的父Loader,原因是Bootstrap Loader(引導類載入器)是用C語言實現的,找不到一個確定的返回父Loader的方式,於是就返回null。
這幾種類載入器的層次關係如下圖所示:
注意:這裡父類載入器並不是通過繼承關係來實現的,而是採用組合實現的。
站在Java虛擬機器的角度來講,只存在兩種不同的類載入器:啟動類載入器:它使用C++實現(這裡僅限於Hotspot,也就是JDK1.5之後預設的虛擬機器,有很多其他的虛擬機器是用Java語言實現的),是虛擬機器自身的一部分;所有其他的類載入器:這些類載入器都由Java語言實現,獨立於虛擬機器之外,並且全部繼承自抽象類java.lang.ClassLoader,這些類載入器需要由啟動類載入器載入到記憶體中之後才能去載入其他的類。
站在Java開發人員的角度來看,類載入器可以大致劃分為以下三類:
啟動類載入器:Bootstrap ClassLoader,負責載入存放在JDK\jre\lib(JDK代表JDK的安裝目錄,下同)下,或被-Xbootclasspath引數指定的路徑中的,並且能被虛擬機器識別的類庫(如rt.jar,所有的java.*開頭的類均被Bootstrap ClassLoader載入)。啟動類載入器是無法被Java程式直接引用的。
擴充套件類載入器:Extension ClassLoader,該載入器由sun.misc.Launcher$ExtClassLoader實現,它負責載入DK\jre\lib\ext目錄中,或者由java.ext.dirs系統變數指定的路徑中的所有類庫(如javax.*開頭的類),開發者可以直接使用擴充套件類載入器。
應用程式類載入器:Application ClassLoader,該類載入器由sun.misc.Launcher$AppClassLoader來實現,它負責載入使用者類路徑(ClassPath)所指定的類,開發者可以直接使用該類載入器,如果應用程式中沒有自定義過自己的類載入器,一般情況下這個就是程式中預設的類載入器。
應用程式都是由這三種類載入器互相配合進行載入的,如果有必要,我們還可以加入自定義的類載入器。因為JVM自帶的ClassLoader只是懂得從本地檔案系統載入標準的java class檔案,因此如果編寫了自己的ClassLoader,便可以做到如下幾點:
1)在執行非置信程式碼之前,自動驗證數字簽名。
2)動態地建立符合使用者特定需要的定製化構建類。
3)從特定的場所取得java class,例如資料庫中和網路中。
JVM類載入機制
•全盤負責,當一個類載入器負責載入某個Class時,該Class所依賴的和引用的其他Class也將由該類載入器負責載入,除非顯示使用另外一個類載入器來載入
•父類委託,先讓父類載入器試圖載入該類,只有在父類載入器無法載入該類時才嘗試從自己的類路徑中載入該類
•快取機制,快取機制將會保證所有載入過的Class都會被快取,當程式中需要使用某個Class時,類載入器先從快取區尋找該Class,只有快取區不存在,系統才會讀取該類對應的二進位制資料,並將其轉換成Class物件,存入快取區。這就是為什麼修改了Class後,必須重啟JVM,程式的修改才會生效
4、類的載入
類載入有三種方式:
1、命令列啟動應用時候由JVM初始化載入
2、通過Class.forName()方法動態載入
3、通過ClassLoader.loadClass()方法動態載入
例子:
package com.neo.classloader;
public class loaderTest {
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader loader = HelloWorld.class.getClassLoader();
System.out.println(loader);
//使用ClassLoader.loadClass()來載入類,不會執行初始化塊
loader.loadClass("Test2");
//使用Class.forName()來載入類,預設會執行初始化塊
// Class.forName("Test2");
//使用Class.forName()來載入類,並指定ClassLoader,初始化時不執行靜態塊
// Class.forName("Test2", false, loader);
}
}
demo類
public class Test2 {
static {
System.out.println("靜態初始化塊執行了!");
}
}
分別切換載入方式,會有不同的輸出結果。
Class.forName()和ClassLoader.loadClass()區別
Class.forName():將類的.class檔案載入到jvm中之外,還會對類進行解釋,執行類中的static塊;
ClassLoader.loadClass():只幹一件事情,就是將.class檔案載入到jvm中,不會執行static中的內容,只有在newInstance才會去執行static塊。
注:
Class.forName(name, initialize, loader)帶參函式也可控制是否載入static塊。並且只有呼叫了newInstance()方法採用呼叫建構函式,建立類的物件 。
5、雙親委派模型
雙親委派模型的工作流程是:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把請求委託給父載入器去完成,依次向上,因此,所有的類載入請求最終都應該被傳遞到頂層的啟動類載入器中,只有當父載入器在它的搜尋範圍中沒有找到所需的類時,即無法完成該載入,子載入器才會嘗試自己去載入該類。
雙親委派機制:
1、當AppClassLoader載入一個class時,它首先不會自己去嘗試載入這個類,而是把類載入請求委派給父類載入器ExtClassLoader去完成。
2、當ExtClassLoader載入一個class時,它首先也不會自己去嘗試載入這個類,而是把類載入請求委派給BootStrapClassLoader去完成。
3、如果BootStrapClassLoader載入失敗(例如在$JAVA_HOME/jre/lib裡未查詢到該class),會使用ExtClassLoader來嘗試載入;
4、若ExtClassLoader也載入失敗,則會使用AppClassLoader來載入,如果AppClassLoader也載入失敗,則會報出異常ClassNotFoundException。
ClassLoader原始碼分析:
public Class<?> loadClass(String name)throws ClassNotFoundException {
return loadClass(name, false);
}
protected synchronized Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
// 首先判斷該型別是否已經被載入
Class c = findLoadedClass(name);
if (c == null) {
//如果沒有被載入,就委託給父類載入或者委派給啟動類載入器載入
try {
if (parent != null) {
//如果存在父類載入器,就委派給父類載入器載入
c = parent.loadClass(name, false);
} else {
//如果不存在父類載入器,就檢查是否是由啟動類載入器載入的類,通過呼叫本地方法native Class findBootstrapClass(String name)
c = findBootstrapClass0(name);
}
} catch (ClassNotFoundException e) {
// 如果父類載入器和啟動類載入器都不能完成載入任務,才呼叫自身的載入功能
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
雙親委派模型意義:
-系統類防止記憶體中出現多份同樣的位元組碼
-保證Java程式安全穩定執行
6、自定義類載入器
通常情況下,我們都是直接使用系統類載入器。但是,有的時候,我們也需要自定義類載入器。比如應用是通過網路來傳輸 Java 類的位元組碼,為保證安全性,這些位元組碼經過了加密處理,這時系統類載入器就無法對其進行載入,這樣則需要自定義類載入器來實現。自定義類載入器一般都是繼承自 ClassLoader 類,從上面對 loadClass 方法來分析來看,我們只需要重寫 findClass 方法即可。下面我們通過一個示例來演示自定義類載入器的流程:
package com.neo.classloader;
import java.io.*;
public class MyClassLoader extends ClassLoader {
private String root;
protected Class<?> findClass(String name) throws ClassNotFoundException {
byte[] classData = loadClassData(name);
if (classData == null) {
throw new ClassNotFoundException();
} else {
return defineClass(name, classData, 0, classData.length);
}
}
private byte[] loadClassData(String className) {
String fileName = root + File.separatorChar
+ className.replace('.', File.separatorChar) + ".class";
try {
InputStream ins = new FileInputStream(fileName);
ByteArrayOutputStream baos = new ByteArrayOutputStream();
int bufferSize = 1024;
byte[] buffer = new byte[bufferSize];
int length = 0;
while ((length = ins.read(buffer)) != -1) {
baos.write(buffer, 0, length);
}
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public String getRoot() {
return root;
}
public void setRoot(String root) {
this.root = root;
}
public static void main(String[] args) {
MyClassLoader classLoader = new MyClassLoader();
classLoader.setRoot("E:\\temp");
Class<?> testClass = null;
try {
testClass = classLoader.loadClass("com.neo.classloader.Test2");
Object object = testClass.newInstance();
System.out.println(object.getClass().getClassLoader());
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
}
}
自定義類載入器的核心在於對位元組碼檔案的獲取,如果是加密的位元組碼則需要在該類中對檔案進行解密。由於這裡只是演示,我並未對class檔案進行加密,因此沒有解密的過程。這裡有幾點需要注意:
1、這裡傳遞的檔名需要是類的全限定性名稱,即com.paddx.test.classloading.Test格式的,因為 defineClass 方法是按這種格式進行處理的。
2、最好不要重寫loadClass方法,因為這樣容易破壞雙親委託模式。
3、這類Test 類本身可以被 AppClassLoader 類載入,因此我們不能把 com/paddx/test/classloading/Test.class 放在類路徑下。否則,由於雙親委託機制的存在,會直接導致該類由AppClassLoader 載入,而不會通過我們自定義類載入器來載入。
有關檢視垃圾回收資訊的JVM常見配置方式:
-XX:+PrintGCDetails 最後介紹一下有關堆的JVM常見配置方式:
-Xss //選置棧記憶體的大小
-Xms: //初始堆大小
-Xmx: //最大堆大小
-XX:NewSize=n: //設定年輕代大小
-XX:NewRatio=n: //設定年輕代和年老代的比值。比如設定為3,表示年輕代與年老代比值為1:3
-XX:SurvivorRatio=n: //年輕代中Eden區與兩個Survivor區的比值。注意Survivor區有兩個。比如設定為3,表示Eden:Survivor=3:2,一個Survivor區佔整個年輕代的1/5。
-XX:MaxPermSize=n: //設定持久代大小
(圖注:JDK1.7已經把常量池轉移到堆裡面了!)
PC計數器(The pc Register)
(1)每一個Java執行緒都有一個PC暫存器,用以記錄比如線上程切換回來後恢復到正確的執行位置。
(2)如該執行緒正在執行一個Java方法,則計數器記錄的是正在執行的虛擬機器位元組碼地址,如執行native方法,則計數器值為空。 (3)此記憶體區域是唯一一個在JVM中沒有規定任何OutOfMemoryError情況的區域。
JVM棧(Java Virtual MachineStacks)
(1)JVM棧是執行緒私有的,並且生命週期與執行緒相同。並且當執行緒執行完畢後,相應記憶體也就被自動回收。
(2)棧裡面存放的元素叫棧幀,每個方法從呼叫到執行結束,其實是對應一個棧幀的入棧和出棧。 棧幀用於儲存執行方法時的一些資料,如區域性變量表、運算元棧(執行引擎計算時需要),方法出口等等。
(3)這個區域可能有兩種異常:如果執行緒請求的棧深度大於虛擬機器所允許的深度,將丟擲StackOverflowError異常(如:將一個函式反覆遞迴自己,最終會出現這種異常)。如果JVM棧可以動態擴充套件(大部分JVM是可以的),當擴充套件時無法申請到足夠記憶體則丟擲OutOfMemoryError異常。
本地方法棧(Native Method Stacks)
(1)本地方法棧與虛擬機器棧所發揮的作用很相似,他們的區別在於虛擬機器棧為執行Java程式碼方法服務,而本地方法棧是為Native方法服務。
(2)和JVM棧一樣,這個區域也會丟擲StackOverflowError和OutOfMemoryError異常。
方法區(Method Area)
(1)方法區域是全域性共享的,比如每個執行緒都可以訪問同一個類的靜態變數。在方法區中,儲存了已被JVM載入的類的資訊、靜態變數、編譯器編譯後的程式碼等。如,當程式中通過getName、isInterface等方法來獲取資訊時,這些資料來源於方法區。 (2)由於使用反射機制的原因,虛擬機器很難推測哪個類資訊不再使用,因此這塊區域的回收很難!另外,對這塊區域主要是針對常量池回收,值得注意的是JDK1.7已經把常量池轉移到堆裡面了。
(3)同樣,當方法區無法滿足記憶體分配需求時,會丟擲OutOfMemoryError
執行時常量池(Runtime Constant Pool)
(1)存放類中固定的常量資訊、方法引用資訊等,其空間從方法區域(JDK1.7後為堆空間)中分配。
(2)Class檔案中除了有類的版本、欄位、方法、介面等描述資訊外,還有就是常量表,用於存放編譯期已可知的常量,這部分內容將在類載入後進入方法區(永久代)存放。但是Java語言並不要求常量一定只有編譯期預置入Class的常量表的內容才能進入方法區常量池,執行期間也可將新內容放入常量池(最典型的String.intern()方法)。
(3)當常量池無法在申請到記憶體時會丟擲OutOfMemoryError異常,上面也分析過了。
Java堆
(1)Java堆是JVM所管理的最大的一塊記憶體。它是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。
(2)幾乎所有的例項物件都是在這塊區域中存放。(JIT編譯器貌似不是這樣的)。
(3)Java堆是垃圾收集管理的主要戰場。所有Java堆可以細分為:新生代和老年代。再細緻分就是把新生代分為:Eden空間、FromSurvivor空間、To Survivor空間。JVM具體的垃圾回收機制總結請檢視我的另外一篇JVM——記憶體管理和垃圾回收。
(4)根據Java虛擬機器規範的規定,Java堆可以處於物理上不連續的記憶體空間中,只要邏輯上是連續的即可。 如果在堆中沒有記憶體完成例項分配,並且堆也無法再擴充套件時,將會丟擲OutOfMemoryError異常。