深刻理解Java類加載

本文目的:html

  1. 深刻理解Java類加載機制;
  2. 理解各個類加載器特別是線程上下文加載器;

Java虛擬機類加載機制

虛擬機把描述類的數據從 Class 文件加載到內存,並對數據進行校驗、轉換解析和初始化,最終造成能夠被虛擬機直接使用的 Java 類型,這就是虛擬機的類加載機制。java

在Java語言裏面,類型的加載、鏈接和初始化過程都是在程序運行期間完成的數組

類加載的過程

類的個生命週期以下圖:安全


爲支持運行時綁定,解析過程在某些狀況下可在初始化以後再開始,除解析過程外的其餘加載過程必須按照如圖順序開始。網絡

加載
  1. 經過全限定類名來獲取定義此類的二進制字節流。
  2. 將這個字節流所表明的靜態存儲結構轉化爲方法區的運行時數據結構。
  3. 在內存中生成一個表明這個類的 java.lang.Class 對象,做爲方法區這個類的各類數據的訪問入口。
驗證

驗證是鏈接階段的第一步,這一階段的目的是爲了確保 Class 文件的字節流中包含的信息符合當前虛擬機的要求,而且不會危害虛擬機自身的安全。數據結構

  1. 文件格式驗證:如是否以魔數 0xCAFEBABE 開頭、主、次版本號是否在當前虛擬機處理範圍以內、常量合理性驗證等。
    此階段保證輸入的字節流能正確地解析並存儲於方法區以內,格式上符合描述一個 Java類型信息的要求。
  2. 元數據驗證:是否存在父類,父類的繼承鏈是否正確,抽象類是否實現了其父類或接口之中要求實現的全部方法,字段、方法是否與父類產生矛盾等。
    第二階段,保證不存在不符合 Java 語言規範的元數據信息。
  3. 字節碼驗證:經過數據流和控制流分析,肯定程序語義是合法的、符合邏輯的。例如保證跳轉指令不會跳轉到方法體之外的字節碼指令上。
  4. 符號引用驗證:在解析階段中發生,保證能夠將符號引用轉化爲直接引用。

能夠考慮使用 -Xverify:none 參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。多線程

準備

類變量分配內存並設置類變量初始值,這些變量所使用的內存都將在方法區中進行分配。app

解析

虛擬機將常量池內的符號引用替換爲直接引用的過程。
解析動做主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符 7 類符號引用進行。ide

初始化

到初始化階段,才真正開始執行類中定義的 Java 程序代碼,此階段是執行 <clinit>() 方法的過程。模塊化

<clinit>() 方法是由編譯器按語句在源文件中出現的順序,依次自動收集類中的全部類變量的賦值動做和靜態代碼塊中的語句合併產生的。(不包括構造器中的語句。構造器是初始化對象的,類加載完成後,建立對象時候將調用的 <init>() 方法來初始化對象)

靜態語句塊中只能訪問到定義在靜態語句塊以前的變量,定義在它以後的變量,在前面的靜態語句塊能夠賦值,可是不能訪問,以下程序:

public class Test {
    static {
        // 給變量賦值能夠正常編譯經過
        i = 0;
        // 這句編譯器會提示"非法向前引用"
        System.out.println(i);
    }

    static int i = 1;
}

<clinit>() 不須要顯式調用父類構造器,虛擬機會保證在子類的 <clinit>() 方法執行以前,父類的 <clinit>() 方法已經執行完畢,也就意味着父類中定義的靜態語句塊要優先於子類的變量賦值操做。

<clinit>() 方法對於類或接口來講並非必需的,若是一個類中沒有靜態語句塊,也沒有對變量的賦值操做,那麼編譯器能夠不爲這個類生成 <clinit>() 方法。

虛擬機會保證一個類的 <clinit>() 方法在多線程環境中被正確地加鎖、同步,若是多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的 <clinit>() 方法,其餘線程都須要阻塞等待,直到活動線程執行 <clinit>() 方法完畢。

類加載的時機

對於初始化階段,虛擬機規範規定了有且只有 5 種狀況必須當即對類進行「初始化」(而加載、驗證、準備天然須要在此以前開始):

  1. 遇到new、getstatic 和 putstatic 或 invokestatic 這4條字節碼指令時,若是類沒有進行過初始化,則須要先觸發其初始化。對應場景是:使用 new 實例化對象、讀取或設置一個類的靜態字段(被 final 修飾、已在編譯期把結果放入常量池的靜態字段除外)、以及調用一個類的靜態方法。
  2. 對類進行反射調用的時候,若是類沒有進行過初始化,則須要先觸發其初始化。
  3. 當初始化類的父類尚未進行過初始化,則須要先觸發其父類的初始化。(而一個接口在初始化時,並不要求其父接口所有都完成了初始化)
  4. 虛擬機啓動時,用戶須要指定一個要執行的主類(包含 main() 方法的那個類),
    虛擬機會先初始化這個主類。
  1. 當使用 JDK 1.7 的動態語言支持時,若是一個 java.lang.invoke.MethodHandle 實例最後的解析結果 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,而且這個方法句柄所對應的類沒有進行過初始化,則須要先觸發其初始化。

第5種狀況,我暫時看不懂。

以上這 5 種場景中的行爲稱爲對一個類進行主動引用。除此以外,全部引用類的方式都不會觸發初始化,稱爲被動引用,例如:

  1. 經過子類引用父類的靜態字段,不會致使子類初始化。
  2. 經過數組定義來引用類,不會觸發此類的初始化。MyClass[] cs = new MyClass[10];
  3. 常量在編譯階段會存入調用類的常量池中,本質上並無直接引用到定義常量的類,所以不會觸發定義常量的類的初始化。

類加載器

把實現類加載階段中的「經過一個類的全限定名來獲取描述此類的二進制字節流」這個動做的代碼模塊稱爲「類加載器」。

將 class 文件二進制數據放入方法區內,而後在堆內(heap)建立一個 java.lang.Class 對象,Class 對象封裝了類在方法區內的數據結構,而且向開發者提供了訪問方法區內的數據結構的接口。

目前類加載器卻在類層次劃分、OSGi、熱部署、代碼加密等領域很是重要,咱們運行任何一個 Java 程序都會涉及到類加載器。

類的惟一性和類加載器

對於任意一個類,都須要由加載它的類加載器和這個類自己一同確立其在Java虛擬機中的惟一性。

即便兩個類來源於同一個 Class 文件,被同一個虛擬機加載,只要加載它們的類加載器不一樣,那這兩個類也不相等。
這裏所指的「相等」,包括表明類的 Class 對象的 equals() 方法、 isAssignableFrom() 方法、isInstance() 方法的返回結果,也包括使用 instanceof 關鍵字作對象所屬關係斷定等狀況。

雙親委派模型

若是一個類加載器收到了類加載的請求,它首先不會本身去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每個層次的類加載器都是如此,所以全部的加載請求最終都應該傳送到頂層的啓動類加載器中,只有當父加載器反饋本身沒法完成這個加載請求(它的搜索範圍中沒有找到所需的類)時,子加載器纔會嘗試本身去加載。

圖摘自《碼出高效》

這裏類加載器之間的父子關係通常不會以繼承(Inheritance)的關係來實現,而是都使用組合(Composition)關係來複用父加載器的代碼。

Bootstrap 類加載器是用 C++ 實現的,是虛擬機自身的一部分,若是獲取它的對象,將會返回 null;擴展類加載器和應用類加載器是獨立於虛擬機外部,爲 Java 語言實現的,均繼承自抽象類 java.lang.ClassLoader ,開發者可直接使用這兩個類加載器。

Application 類加載器對象能夠由 ClassLoader.getSystemClassLoader() 方法的返回,因此通常也稱它爲系統類加載器。它負責加載用戶類路徑(ClassPath)上所指定的類庫,若是應用程序中沒有自定義過本身的類加載器,通常狀況下這個就是程序中默認的類加載器。

雙親委派模型對於保證 Java 程序的穩定運做很重要,例如類 java.lang.Object,它存放在 rt.jar 之中,不管哪個類加載器要加載這個類,最終都是委派給處於模型最頂端的啓動類加載器進行加載,所以 Object 類在程序的各類類加載器環境中都是同一個類。

雙親委派模型的加載類邏輯可參考以下代碼:

// 代碼摘自《深刻理解Java虛擬機》
    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 {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
            // 若是父類加載器拋出ClassNotFoundException
            // 說明父類加載器沒法完成加載請求
            }
            if (c == null) {
                // 在父類加載器沒法加載的時候
                // 再調用自己的findClass方法來進行類加載
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
破壞雙親委派模型

雙親委派模型主要出現過 3 較大規模的「被破壞」狀況。

1) 雙親委派模型在引入以前已經存在破壞它的代碼存在了。
雙親委派模型在 JDK 1.2 以後才被引入,而類加載器和抽象類 java.lang.ClassLoader 則在 JDK 1.0 時代就已經存在,JDK 1.2以後,其添加了一個新的 protected 方法 findClass(),在此以前,用戶去繼承 ClassLoader 類的惟一目的就是爲了重寫 loadClass() 方法,而雙親委派的具體邏輯就實如今這個方法之中,JDK 1.2 以後已不提倡用戶再去覆蓋 loadClass() 方法,而應當把本身的類加載邏輯寫到 findClass() 方法中,這樣就能夠保證新寫出來的類加載器是符合雙親委派規則的。

2) 基礎類沒法調用類加載器加載用戶提供的代碼。
雙親委派很好地解決了各個類加載器的基礎類的統一問題(越基礎的類由越上層的加載器進行加載),但若是基礎類又要調用用戶的代碼,例如 JNDI 服務,JNDI 如今已是 Java 的標準服務,它的代碼由啓動類加載器去加載(在 JDK 1.3 時放進去的 rt.jar ),但 JNDI 的目的就是對資源進行集中管理和查找,它須要調用由獨立廠商實現並部署在應用程序的 ClassPath 下的 JNDI 接口提供者(SPI,Service Provider Interface,例如 JDBC 驅動就是由 MySQL 等接口提供者提供的)的代碼,但啓動類加載器只能加載基礎類,沒法加載用戶類。

爲此 Java 引入了線程上下文類加載器(Thread Context ClassLoader)。這個類加載器能夠經過 java.lang.Thread.setContextClassLoaser() 方法進行設置,若是建立線程時還未設置,它將會從父線程中繼承一個,若是在應用程序的全局範圍內都沒有設置過的話,那這個類加載器默認就是應用程序類加載器。
如此,JNDI 服務使用這個線程上下文類加載器去加載所須要的 SPI 代碼,也就是父類加載器請求子類加載器去完成類加載的動做,這種行爲實際上就是打通了雙親委派模型的層次結構來逆向使用類加載器,實際上已經違背了雙親委派模型的通常性原則,但這也是迫不得已的事情。Java 中全部涉及 SPI 的加載動做基本上都採用這種方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。
3) 用戶對程序動態性的追求。
代碼熱替換(HotSwap)、模塊熱部署(Hot Deployment)等,OSGi 實現模塊化熱部署的關鍵則是它自定義的類加載器機制的實現。每個程序模塊(Bundle)都有一個本身的類加載器,當須要更換一個 Bundle 時,就把 Bundle 連同類加載器一塊兒換掉以實現代碼的熱替換。

在 OSGi 環境下,類加載器再也不是雙親委派模型中的樹狀結構,而是進一步發展爲更加複雜的網狀結構,當收到類加載請求時,OSGi 將按照下面的順序進行類搜索:
1)將以 java.* 開頭的類委派給父類加載器加載。
2)不然,將委派列表名單內的類委派給父類加載器加載。
3)不然,將 Import 列表中的類委派給 Export 這個類的 Bundle 的類加載器加載。
4)不然,查找當前 Bundle 的 ClassPath,使用本身的類加載器加載。
5)不然,查找類是否在本身的 Fragment Bundle 中,若是在,則委派給 Fragment Bundle 的類加載器加載。
6)不然,查找 Dynamic Import 列表的 Bundle,委派給對應 Bundle 的類加載器加載。
7)不然,類查找失敗。
上面的查找順序中只有開頭兩點仍然符合雙親委派規則,其他的類查找都是在平級的類加載器中進行的。OSGi 的 Bundle 類加載器之間只有規則,沒有固定的委派關係。

自定義類加載器

Java 默認 ClassLoader,只加載指定目錄下的 class,若是須要動態加載類到內存,例如要從遠程網絡下來類的二進制,而後調用這個類中的方法實現個人業務邏輯,如此,就須要自定義 ClassLoader。

自定義類加載器分爲兩步:

  1. 繼承 java.lang.ClassLoader
  2. 重寫父類的 findClass() 方法

針對第 1 步,爲何要繼承 ClassLoader 這個抽象類,而不繼承 AppClassLoader 呢?
由於它和 ExtClassLoader 都是 Launcher 的靜態內部類,其訪問權限是缺省的包訪問權限。
static class AppClassLoader extends URLClassLoader{...}

第 2 步,JDK 的 loadCalss() 方法在全部父類加載器沒法加載的時候,會調用自己的 findClass() 方法來進行類加載,所以咱們只需重寫 findClass() 方法找到類的二進制數據便可。

下面我自定義了一個簡單的類加載器,並加載一個簡單的類。

首先是須要被加載的簡單類:

// 存放於D盤根目錄
public class Test {

    public static void main(String[] args) {
        System.out.println("Test類已成功加載運行!");
        ClassLoader classLoader = Test.class.getClassLoader();
        System.out.println("加載個人classLoader:" + classLoader);
        System.out.println("classLoader.parent:" + classLoader.getParent());
    }
}

並使用 javac -encoding utf8 Test.java 編譯成 Test.class 文件。

類加載器代碼以下:

import java.io.*;

public class MyClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 加載D盤根目錄下指定類名的class
        String clzDir = "D:\\" + File.separatorChar
                + name.replace('.', File.separatorChar) + ".class";
        byte[] classData = getClassData(clzDir);

        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] getClassData(String path) {
        try (InputStream ins = new FileInputStream(path);
             ByteArrayOutputStream baos = new ByteArrayOutputStream()
        ) {

            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead = 0;
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

使用類加載器加載調用 Test 類:

public class MyClassLoaderTest {
    public static void main(String[] args) throws Exception {
        // 指定類加載器加載調用
        MyClassLoader classLoader = new MyClassLoader();
        classLoader.loadClass("Test").getMethod("test").invoke(null);
    }
}

輸出信息:

Test.test()已成功加載運行!
加載個人classLoader:class MyClassLoader
classLoader.parent:class sun.misc.Launcher$AppClassLoader

線程上下文類加載器

如上所說,爲解決基礎類沒法調用類加載器加載用戶提供代碼的問題,Java 引入了線程上下文類加載器(Thread Context ClassLoader)。這個類加載器默認就是 Application 類加載器,而且能夠經過 java.lang.Thread.setContextClassLoaser() 方法進行設置。

// Now create the class loader to use to launch the application
try {
    loader = AppClassLoader.getAppClassLoader(extcl);
} catch (IOException e) {
    throw new InternalError(
"Could not create application class loader" );
}
 
// Also set the context class loader for the primordial thread.
Thread.currentThread().setContextClassLoader(loader);

那麼問題來了,咱們使用 ClassLoader.getSystemClassLoader() 方法也能夠獲取到 Application 類加載器,使用它就能夠加載用戶類了呀,爲何還須要線程上下文類加載器?
其實直接使用 getSystemClassLoader() 方法獲取 AppClassLoader 加載類也能夠知足一些狀況,但有時候咱們須要使用自定義類加載器去加載某個位置的類時,例如Tomcat 使用的線程上下文類加載器並不是 AppClassLoader ,而是 Tomcat 自定義類加載器。

以 Tomcat 爲例,其每一個 Web 應用都有一個對應的類加載器實例,該類加載器使用代理模式,首先嚐試去加載某個類,若是找不到再代理給父類加載器這與通常類加載器的順序是相反的。
這是 Java Servlet 規範中的推薦作法,其目的是使得 Web 應用本身的類的優先級高於 Web 容器提供的類。

更多關於 Tomcat 類加載器的知識,這裏暫時先不講了。

new一個對象過程當中發生了什麼?

  1. 確認類元信息是否存在。當 JVM 接收到 new 指令時,首先在 metaspace 內檢查須要建立的類元信息是否存在。 若不存在,那麼在雙親委派模式下,使用當前類加載器以 ClassLoader + 包名+類名爲 Key 進行查找對應的 class 文件。 若是沒有找到文件,則拋出 ClassNotFoundException 異常 , 若是找到,則進行類加載(加載 - 驗證 - 準備 - 解析 - 初始化),並生成對應的 Class 類對象。
  2. 分配對象內存。 首先計算對象佔用空間大小,若是實例成員變量是引用變量,僅分配引用變量空間便可,即 4 個字節大小,接着在堆中劃分—塊內存給新對象。 在分配內存空間時,須要進行同步操做,好比採用 CAS (Compare And Swap) 失敗重試、 區域加鎖等方式保證分配操做的原子性。
  3. 設定默認值。 成員變量值都須要設定爲默認值, 即各類不一樣形式的零值。
  4. 設置對象頭。設置新對象的哈希碼、 GC 信息、鎖信息、對象所屬的類元信息等。這個過程的具體設置方式取決於 JVM 實現。
  5. 執行 init 方法。 初始化成員變量,執行實例化代碼塊,調用類的構造方法,並把堆內對象的首地址賦值給引用變量。

最後,推薦與感謝:
深刻理解Java虛擬機(第2版)
碼出高效:Java開發手冊
java new一個對象的過程當中發生了什麼 - 天風的文章 - 知乎
深刻探討類加載器
Class.forName()用法詳解
真正理解線程上下文類加載器(多案例分析)

相關文章
相關標籤/搜索