類載入流程,類載入機制及自定義類載入器詳解(面試再也不怕了)
一、引言二、類的載入、連結、初始化1、載入1.1、載入的class來源2、類的連結2.1、驗證2.2、準備2.3、解析3、類的初始化3.1、< clinit>方法相關3.2、類初始化時機3.3、final定義的初始化3.4、ClassLoader只會對類進行載入,不會進行初始化三、類載入器1、JVM類載入器分類1.1、Bootstrap ClassLoader1.2 、Extension ClassLoader1.3、 System ClassLoader四、類載入機制1.1、JVM主要的類載入機制。1.2、類載入流程圖五、建立並使用自定義類載入器1、自定義類載入分析2、實現自定義類載入器六、總結
一、引言
當程式使用某個類時,如果該類還未被載入到記憶體中,則JVM會通過載入、連結、初始化三個步驟對該類進行類載入。
二、類的載入、連結、初始化
1、載入
類載入指的是將類的class檔案讀入記憶體,併為之建立一個java.lang.Class物件。類的載入過程是由類載入器來完成,類載入器由JVM提供。我們開發人員也可以通過繼承ClassLoader來實現自己的類載入器。
1.1、載入的class來源
- 從本地檔案系統內載入class檔案
- 從JAR包載入class檔案
- 通過網路載入class檔案
- 把一個java原始檔動態編譯,並執行載入。
2、類的連結
通過類的載入,記憶體中已經建立了一個Class物件。連結負責將二進位制資料合併到 JRE中。連結需要通過驗證、準備、解析三個階段。
2.1、驗證
驗證階段用於檢查被載入的類是否有正確的內部結構,並和其他類協調一致。即是否滿足java虛擬機器的約束。
2.2、準備
類準備階段負責為類的類變數分配記憶體,並設定預設初始值。
2.3、解析
我們知道,引用其實對應於記憶體地址。思考這樣一個問題,在編寫程式碼時,使用引用,方法時,類知道這些引用方法的記憶體地址嗎?顯然是不知道的,因為類還未被載入到虛擬機器中,你無法獲得這些地址。舉例來說,對於一個方法的呼叫,編譯器會生成一個包含目標方法所在的類、目標方法名、接收引數型別以及返回值型別的符號引用,來指代要呼叫的方法。
解析階段的目的,就是將這些符號引用解析為實際引用。如果符號引用指向一個未被載入的類,或者未被載入類的欄位或方法,那麼解析將觸發這個類的載入(但未必會觸發解析與初始化)。
3、類的初始化
類的初始化階段,虛擬機器主要對類變數進行初始化。虛擬機器呼叫< clinit>方法,進行類變數的初始化。
java類中對類變數進行初始化的兩種方式:
- 在定義時初始化
- 在靜態初始化塊內初始化
3.1、< clinit>方法相關
- 虛擬機器會收集類及父類中的類變數及類方法組合為< clinit>方法,根據定義的順序進行初始化。虛擬機器會保證子類的< clinit>執行之前,父類的< clinit>方法先執行完畢。因此,虛擬機器中第一個被執行完畢的< clinit>方法肯定是java.lang.Object方法。
public class Test {
static int A = 10;
static {
A = 20;
}
}
class Test1 extends Test {
private static int B = A;
public static void main(String[] args) {
System.out.println(Test1.B);
}
}
//輸出結果
//20
從輸出中看出,父類的靜態初始化塊在子類靜態變數初始化之前初始化完畢,所以輸出結果是20,不是10。
如果類或者父類中都沒有靜態變數及方法,虛擬機器不會為其生成< clinit>方法。
介面與類不同的是,執行介面的<clinit>方法不需要先執行父介面的<clinit>方法。 只有當父介面中定義的變數使用時,父接口才會初始化。 另外,介面的實現類在初始化時也一樣不會執行介面的<clinit>方法。
public interface InterfaceInitTest {
long A = CurrentTime.getTime();
}
interface InterfaceInitTest1 extends InterfaceInitTest {
int B = 100;
}
class InterfaceInitTestImpl implements InterfaceInitTest1 {
public static void main(String[] args) {
System.out.println(InterfaceInitTestImpl.B);
System.out.println("---------------------------");
System.out.println("當前時間:"+InterfaceInitTestImpl.A);
}
}
class CurrentTime {
static long getTime() {
System.out.println("載入了InterfaceInitTest介面");
return System.currentTimeMillis();
}
}
//輸出結果
//100
//---------------------------
//載入了InterfaceInitTest介面
//當前時間:1560158880660
從輸出驗證了:對於介面,只有真正使用父介面的類變數才會真正的載入父介面。這跟普通類載入不一樣。
- 虛擬機器會保證一個類的< clinit>方法在多執行緒環境中被正確地加鎖和同步,如果多個執行緒同時去初始化一個類,那麼只有一個執行緒去執行這個類的< clinit>方法,其他執行緒都需要阻塞等待,直到活動執行緒執行< clinit>方法完畢。
public class MultiThreadInitTest {
static int A = 10;
static {
System.out.println(Thread.currentThread()+"init MultiThreadInitTest");
try {
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Runnable runnable = () -> {
System.out.println(Thread.currentThread() + "start");
System.out.println(MultiThreadInitTest.A);
System.out.println(Thread.currentThread() + "run over");
};
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
}
}
//輸出結果
//Thread[main,5,main]init MultiThreadInitTest
//Thread[Thread-0,5,main]start
//10
//Thread[Thread-0,5,main]run over
//Thread[Thread-1,5,main]start
//10
//Thread[Thread-1,5,main]run over
從輸出中看出驗證了:只有第一個執行緒對MultiThreadInitTest進行了一次初始化,第二個執行緒一直阻塞等待等第一個執行緒初始化完畢。
3.2、類初始化時機
- 當虛擬機器啟動時,初始化使用者指定的主類;
- 當遇到用以新建目標類例項的new指令時,初始化new指令的目標類;
- 當遇到呼叫靜態方法或者使用靜態變數,初始化靜態變數或方法所在的類;
- 子類初始化過程會觸發父類初始化;
- 如果一個介面定義了default方法,那麼直接實現或者間接實現該介面的類的初始化,會觸發該介面初始化;
- 使用反射API對某個類進行反射呼叫時,初始化這個類;
- Class.forName()會觸發類的初始化
3.3、final定義的初始化
注意:對於一個使用final定義的常量,如果在編譯時就已經確定了值,在引用時不會觸發初始化,因為在編譯的時候就已經確定下來,就是“巨集變數”。如果在編譯時無法確定,在初次使用才會導致初始化。
public class StaticInnerSingleton {
/**
* 使用靜態內部類實現單例:
* 1:執行緒安全
* 2:懶載入
* 3:非反序列化安全,即反序列化得到的物件與序列化時的單例物件不是同一個,違反單例原則
*/
private static class LazyHolder {
private static final StaticInnerSingleton INNER_SINGLETON = new StaticInnerSingleton();
}
private StaticInnerSingleton() {
}
public static StaticInnerSingleton getInstance() {
return LazyHolder.INNER_SINGLETON;
}
}
看這個例子,單例模式靜態內部類實現方式。我們可以看到單例例項使用final定義,但在編譯時無法確定下來,所以在第一次使用StaticInnerSingleton.getInstance()方法時,才會觸發靜態內部類的載入,也就是延遲載入。這裡想指出,如果final定義的變數在編譯時無法確定,則在使用時還是會進行類的初始化。
3.4、ClassLoader只會對類進行載入,不會進行初始化
public class Tester {
static {
System.out.println("Tester類的靜態初始化塊");
}
}
class ClassLoaderTest {
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader classLoader = ClassLoader.getSystemClassLoader();
//下面語句僅僅是載入Tester類
classLoader.loadClass("loader.Tester");
System.out.println("系統載入Tester類");
//下面語句才會初始化Tester類
Class.forName("loader.Tester");
}
}
//輸出結果
//系統載入Tester類
//Tester類的靜態初始化塊
從輸出證明:ClassLoader只會對類進行載入,不會進行初始化;使用Class.forName()會強制導致類的初始化。
三、類載入器
類載入器負責將.class檔案(不管是jar,還是本地磁碟,還是網路獲取等等)載入到記憶體中,併為之生成對應的java.lang.Class物件。一個類被載入到JVM中,就不會第二次載入了。
那怎麼判斷是同一個類呢?
每個類在JVM中使用全限定類名(包名+類名)與類載入器聯合為唯一的ID,所以如果同一個類使用不同的類載入器,可以被載入到虛擬機器,但彼此不相容。
1、JVM類載入器分類
1.1、Bootstrap ClassLoader
Bootstrap ClassLoader為根類載入器,負責載入java的核心類庫。根載入器不是ClassLoader的子類,是有C++實現的。
public class BootstrapTest {
public static void main(String[] args) {
//獲取根類載入器所載入的全部URL陣列
URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
Arrays.stream(urLs).forEach(System.out::println);
}
}
//輸出結果
//file:/C:/SorftwareInstall/java/jdk/jre/lib/resources.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/rt.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/sunrsasign.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/jsse.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/jce.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/charsets.jar
//file:/C:/SorftwareInstall/java/jdk/jre/lib/jfr.jar
//file:/C:/SorftwareInstall/java/jdk/jre/classes
根類載入器負責載入%JAVA_HOME%/jre/lib下的jar包(以及由虛擬機器引數 -Xbootclasspath 指定的類)。
我們將rt.jar解壓,可以看到我們經常使用的類庫就在這個jar包中。
1.2 、Extension ClassLoader
Extension ClassLoader為擴充套件類載入器,負責載入%JAVA_HOME%/jre/ext或者java.ext.dirs系統熟悉指定的目錄的jar包。大家可以將自己寫的工具包放到這個目錄下,可以方便自己使用。
1.3、 System ClassLoader
System ClassLoader為系統(應用)類載入器,負責載入載入來自java命令的-classpath選項、java.class.path系統屬性,或者CLASSPATH環境變數所指定的JAR包和類路徑。程式可以通過ClassLoader.getSystemClassLoader()來獲取系統類載入器。如果沒有特別指定,則使用者自定義的類載入器預設都以系統類載入器作為父載入器。
四、類載入機制
1.1、JVM主要的類載入機制。
- 全盤負責:當一個類載入器負責載入某個Class時,該Class所依賴和引用的其他Class也由該類載入器負責載入,除非顯示使用另一個類載入器來載入。
- 父類委託(雙親委派):先讓父載入器試圖載入該Class,只有在父載入器無法載入時該類載入器才會嘗試從自己的類路徑中載入該類。
- 快取機制:快取機制會將已經載入的class快取起來,當程式中需要使用某個Class時,類載入器先從快取區中搜尋該Class,只有當快取中不存在該Class時,系統才會讀取該類的二進位制資料,並將其轉換為Class物件,存入快取中。這就是為什麼更改了class後,需要重啟JVM才生效的原因。
注意:類載入器之間的父子關係並不是類繼承上的父子關係,而是例項之間的父子關係。
public class ClassloaderPropTest {
public static void main(String[] args) throws IOException {
//獲取系統類載入器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println("系統類載入器:" + systemClassLoader);
/*
獲取系統類載入器的載入路徑——通常由CLASSPATH環境變數指定,如果作業系統沒有指定
CLASSPATH環境變數,則預設以當前路徑作為系統類載入器的載入路徑
*/
Enumeration<URL> eml = systemClassLoader.getResources("");
while (eml.hasMoreElements()) {
System.out.println(eml.nextElement());
}
//獲取系統類載入器的父類載入器,得到擴充套件類載入器
ClassLoader extensionLoader = systemClassLoader.getParent();
System.out.println("系統類的父載入器是擴充套件類載入器:" + extensionLoader);
System.out.println("擴充套件類載入器的載入路徑:" + System.getProperty("java.ext.dirs"));
System.out.println("擴充套件類載入器的parant:" + extensionLoader.getParent());
}
}
//輸出結果
//系統類載入器:sun.misc.Launcher$AppClassLoader@18b4aac2
//file:/C:/ProjectTest/FengKuang/out/production/FengKuang/
//系統類的父載入器是擴充套件類載入器:sun.misc.Launcher$ExtClassLoader@1540e19d
//擴充套件類載入器的載入路徑:C:\SorftwareInstall\java\jdk\jre\lib\ext;C:\WINDOWS\Sun\Java\lib\ext
//擴充套件類載入器的parant:null
從輸出中驗證了:系統類載入器的父載入器是擴充套件類載入器。但輸出中擴充套件類載入器的父載入器是null,這是因為父載入器不是java實現的,是C++實現的,所以獲取不到。但擴充套件類載入器的父載入器是根載入器。
1.2、類載入流程圖
圖中紅色部分,可以是我們自定義實現的類載入器來進行載入。
五、建立並使用自定義類載入器
1、自定義類載入分析
除了根類載入器,所有類載入器都是ClassLoader的子類。所以我們可以通過繼承ClassLoader來實現自己的類載入器。
ClassLoader類有兩個關鍵的方法:
- protected Class loadClass(String name, boolean resolve):name為類名,resove如果為true,在載入時解析該類。
- protected Class findClass(String name) :根據指定類名來查詢類。
所以,如果要實現自定義類,可以重寫這兩個方法來實現。但推薦重寫findClass方法,而不是重寫loadClass方法,因為loadClass方法內部回撥用findClass方法。
我們來看一下loadClass的原始碼
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
//第一步,先從快取裡檢視是否已經載入
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//第二步,判斷父載入器是否為null
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
//第三步,如果前面都沒有找到,就會呼叫findClass方法
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
loadClass載入方法流程:
- 判斷此類是否已經載入;
- 如果父載入器不為null,則使用父載入器進行載入;反之,使用跟載入器進行載入;
- 如果前面都沒載入成功,則使用findClass方法進行載入。
所以,為了不影響類的載入過程,我們重寫findClass方法即可簡單方便的實現自定義類載入。
2、實現自定義類載入器
基於以上分析,我們簡單重寫findClass方法進行自定義類載入。
public class Hello {
public void test(String str){
System.out.println(str);
}
}
public class MyClassloader extends ClassLoader {
/**
* 讀取檔案內容
*
* @param fileName 檔名
* @return
*/
private byte[] getBytes(String fileName) throws IOException {
File file = new File(fileName);
long len = file.length();
byte[] raw = new byte[(int) len];
try (FileInputStream fin = new FileInputStream(file)) {
//一次性讀取Class檔案的全部二進位制資料
int read = fin.read(raw);
if (read != len) {
throw new IOException("無法讀取全部檔案");
}
return raw;
}
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
Class clazz = null;
//將包路徑的(.)替換為斜線(/)
String fileStub = name.replace(".", "/");
String classFileName = fileStub + ".class";
File classFile = new File(classFileName);
//如果Class檔案存在,系統負責將該檔案轉換為Class物件
if (classFile.exists()) {
try {
//將Class檔案的二進位制資料讀入陣列
byte[] raw = getBytes(classFileName);
//呼叫ClassLoader的defineClass方法將二進位制資料轉換為Class物件
clazz = defineClass(name, raw, 0, raw.length);
} catch (IOException e) {
e.printStackTrace();
}
}
//如果clazz為null,表明載入失敗,丟擲異常
if (null == clazz) {
throw new ClassNotFoundException(name);
}
return clazz;
}
public static void main(String[] args) throws Exception {
String classPath = "loader.Hello";
MyClassloader myClassloader = new MyClassloader();
Class<?> aClass = myClassloader.loadClass(classPath);
Method main = aClass.getMethod("test", String.class);
System.out.println(main);
main.invoke(aClass.newInstance(), "Hello World");
}
}
//輸出結果
//Hello World
ClassLoader還有一個重要的方法defineClass(String name, byte[] b, int off, int len)。此方法的作用是將class的二進位制陣列轉換為Calss物件。
此例子很簡單,我寫了一個Hello測試類,並且編譯過後放在了當前路徑下(大家可以在findClass中加入判斷,如果沒有此檔案,可以嘗試查詢.java檔案,並進行編譯得到.class檔案;或者判斷.java檔案的最後更新時間大於.class檔案最後更新時間,再進行重新編譯等邏輯)。
六、總結
本篇從類載入的三大階段:載入、連結、初始化開始細說每個階段的過程;詳細講解了JVM常用的類載入器的區別與聯絡,以及類載入機制流程,最後通過自定義的類載入器例子結束本篇。小弟能力有限,大家看出有問題請指出,讓博主學習改正。歡迎討論啊。
注意:本篇部落格總結主要來源。如有轉載,請註明出處
- 《瘋狂java講義(第3版)》
- 《深入理解java虛擬機器++JVM高階特性與最佳實踐》