深入理解Java類加載


本文目的:

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

Java虛擬機類加載機制

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

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

類加載的過程

類的個生命周期如下圖:


為支持運行時綁定,解析過程在某些情況下可在初始化之后再開始,除解析過程外的其他加載過程必須按照如圖順序開始。

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

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

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

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

准備

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

解析

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

初始化

到初始化階段,才真正開始執行類中定義的 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>() 方法,其他線程都需要阻塞等待,直到活動線程執行 <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 等。

  1. 用戶對程序動態性的追求。
    代碼熱替換(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()用法詳解
真正理解線程上下文類加載器(多案例分析)


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM