JVM虛擬機 類載入過程與類載入器

前言

類裝載器子系統是JVM中非常重要的部分,是學習JVM繞不開的一關。

一般來說,Java 類的虛擬機使用 Java 方式如下:

  • Java 源程式(.java 文件)在經過 Java 編譯器編譯之後就被轉換成 Java 位元組程式碼(.class 文件)。
  • 類載入器負責讀取 Java 位元組程式碼,並轉換成 java.lang.Class類的一個實例。
  • 每個這樣的實例用來表示一個 Java 類。
  • 通過此實例的 newInstance()方法就可以創建出該類的一個對象。

類的生命周期

我們先來看下類的生命周期,包括:

  • 載入
  • 連接
  • 初始化
  • 使用
  • 卸載

其中載入連接初始化屬於類載入過程

使用是指我們new對象進行使用。

卸載指對象被GC垃圾回收掉。

image-20210517215428824

類載入過程

JVM的類載入的過程是通過引導類載入器(bootstrap class loader)創建一個初始類(initial class)來完成的,這個類是由JVM的具體實現指定的。

Class 文件需要載入到虛擬機中之後才能運行和使用,系統載入 Class 類型的文件份如下幾步:

  • 載入
  • 連接
    • 驗證
    • 準備
    • 解析
  • 初始

順序是這樣一個順序,但是載入階段和連接階段部分內容是交叉進行的,載入階段尚未結束,連接階段可能就已經開始了。

下面我們來逐步解析

載入

這裡的載入微觀上的,是類載入過程中的一小步,也是第一步,類載入過程中的載入宏觀上的。

載入的流程如下:

  • 通過全類名獲取定義此類的二進位位元組流
  • 將位元組流所代表的靜態存儲結構轉換為方法區的運行時數據結構
  • 在記憶體中生成一個代表該類的 Class 對象,作為方法區這些數據的訪問入口

簡單來說就是:載入二進位數據到記憶體 —> 映射成JVM能識別的結構—> 在記憶體中生成class文件

虛擬機規範上,對這部分的規定並不具體,所以實現方式是很靈活的。

載入階段我們可以用自定義類載入器去控制位元組流的獲取方式,是非數組類的可控性最強的階段,而數組類型不通過類載入器創建,它由 Java 虛擬機直接創建。

關於類載入器是什麼,後文再聊。

連接

連接分為三步,驗證、準備、解析,目的是將上面創建好的Class類合併至JVM中,使之能夠執行的過程。

驗證

確保class文件中的位元組流包含的資訊,符合當前虛擬機的要求,保證這個被載入的class類的正確性,不會危害到虛擬機的安全。

準備

為類中的靜態欄位分配記憶體,並設置默認的初始值,比如int類型初始值是0。

被final修飾的static欄位不會設置,因為final在編譯的時候就分配了。

解析

解析階段的目的,是將常量池內的符號引用轉換為直接引用的過程。

解析動作主要針對類、介面、欄位、類方法、介面方法、方法類型等。

如果符號引用指向一個未被載入的類,或者未被載入類的欄位或方法,那麼解析將觸發這個類的載入(但未必觸發這個類的鏈接以及初始化。)

符號引用就是一組符號來描述目標,可以是任何字面量,符號引用的字面量形式明確定在《Java 虛擬機規範》的Class文件格式中。

直接引用就是直接指向目標的指針、相對偏移量或一個間接定位到目標的句柄。

舉個例子:

在程式執行方法時,系統需要明確知道這個方法所在的位置

Java 虛擬機為每個類都準備了一張方法表來存放類中所有的方法

當需要調用一個類的方法的時候,只要知道這個方法在方法表中的偏移量就可以直接調用該方法了。

通過解析操作符號引用就可以直接轉變為目標方法在類中方法表的位置,從而使得方法可以被調用。

所以,解析階段是虛擬機將常量池內的符號引用替換為直接引用的過程,也就是得到類或者欄位、方法在記憶體中的指針或者偏移量

初始化

初始化就是執行類的構造器方法,是類載入的最後一步,這一步 JVM才開始真正執行類中定義的 Java 程式程式碼

這個方法不需要定義,是javac編譯器自動收集類中所有類變數的賦值動作靜態程式碼塊中的語句合併來的。

若該類具有父類,jvm會保證父類的init()先執行,然後在執行子類的init()

對於初始化階段,虛擬機嚴格規範了有且只有 5 種情況下,必須對類進行初始化,只有主動去使用類才會初始化類:

  • 當遇到 newgetstaticputstaticinvokestatic 這 4 條直接碼指令時

    • 當遇到一個類,讀取一個靜態欄位(未被 final 修飾)、或調用一個類的靜態方法時。
    • 當 JVM執行 new 指令時會初始化類。即當程式創建一個類的實例對象
    • 當 JVM執行 getstatic 指令時會初始化類。即程式訪問類的靜態變數(不是靜態常量,常量會被載入到運行時常量池)。
    • 當 JVM執行 putstatic 指令時會初始化類。即程式給類的靜態變數賦值
    • 當 JVM執行 invokestatic 指令時會初始化類。即程式調用類的靜態方法
  • 對類進行反射調用時,如果類沒初始化,需要觸發其初始化。

  • 初始化一個類,如果其父類還未初始化,則先觸發該父類的初始化

  • 當虛擬機啟動時,用戶需要定義一個要執行的主類 (包含 main 方法的那個類),虛擬機會先初始化這個類。

  • MethodHandleVarHandle 可以看作是輕量級的反射調用機制,而要想使用這 2 個調用, 就必須先使用 findStaticVarHandle 來初始化要調用的類。

  • 「補充,來自issue745 當一個介面中定義了 JDK8 新加入的默認方法(被 default 關鍵字修飾的介面方法)時,如果有這個介面的實現類發生了初始化,那該介面要在其之前被初始化。

類載入器

三大類載入器

了解了類載入過程後,我們來看看類載入器

類載入器(ClassLoader)用來載入 Java 類到 Java 虛擬機中。

JVM 中內置了三個重要的 ClassLoader,同時按如下順序進行載入:

  1. BootstrapClassLoader 啟動類載入器:最頂層的載入類,由C++實現,負責載入 %JAVA_HOME%/lib目錄下的核心jar包和類或者或被 -Xbootclasspath參數指定的路徑中的所有類。
  2. ExtensionClassLoader 擴展類載入器:主要負責載入目錄 %JRE_HOME%/lib/ext 目錄下的jar包和類,或被 java.ext.dirs 系統變數所指定的路徑下的jar包。
  3. AppClassLoader 應用程式類載入器:面向我們用戶的載入器,負責載入當前應用classpath下的所有jar包和類。

除了 BootstrapClassLoader 其他類載入器均由 Java 實現且全部繼承自java.lang.ClassLoader

類的載入幾乎是由上述3種類載入器相互配合執行的,在必要時,我們還可以自定義類載入器。

需要注意的是,Java虛擬機對Class文件採用的是按需載入的方式,也就是說當需要使用該類時才會將它的Class文件載入到記憶體生成Class對象。

雙親委派模型

概念

每一個類都有一個對應它的類載入器。在載入類的時候,是採用的雙親委派模型,即把請優求先交給父類處理的一種任務委派模式。

系統中的類載入器在協同工作的時候會默認使用 雙親委派模型

雙親委派模型的理論很簡單,分為如下幾步:

  • 即在類載入的時候,系統會首先判斷當前類是否被載入過。已經被載入的類會直接返回,否則才會嘗試載入。

  • 載入的時候,首先會把該請求委派給該父類載入器的 loadClass() 處理,因此所有的請求最終都應該傳送到頂層的啟動類載入器 BootstrapClassLoader 中。

  • 當父類載入器無法處理時,才由自己來處理

AppClassLoader的父類載入器為ExtensionClassLoader ExtensionClassLoader 的父類載入器為null,當父類載入器為null時,會使用啟動類載入器 BootstrapClassLoader 作為父類載入器。

為什麼要使用雙親委派模型

試想一種情況,我們在項目目錄下,手動創建了一個java.lang 包,並在該包下創建了一個Object,這時候我們再去啟動Java程式,原生Object會被篡改嗎?當然是不會的!

因為Object類是Java的核心庫類,由BootstrapClassLoader載入,而自定義的java.lang.Object類應該是由AppClassLoader來載入。

BootstrapClassLoader先於AppClassLoader進行載入,根據上面的雙親委派模型的概念,我們可以知道,java.lang.Object類已經被載入,並且AppClassLoader要載入類之前都要先給其父類過目,所以自己寫的野類是無法撼動核心庫類的。

結論

雙親委派模型保證了Java程式的穩定運行,可以避免類的重複載入,也保證了 Java 的核心 API 不被篡改。

源碼分析

雙親委派模型的都集中在 java.lang.ClassLoaderloadClass() 中,相關程式碼如下所示:

private final ClassLoader parent; 
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // 首先,檢查請求的類是否已經被載入過
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    //父載入器不為空,調用父載入器loadClass()方法處理
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        //父載入器為空,使用啟動類載入器 BootstrapClassLoader 載入
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                   //拋出異常說明父類載入器無法完成載入請求
                }
                
                if (c == null) {
                    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;
        }
    }

反雙親委派模型

雙親委派模型是Java默認的,假如我們不想用雙親委派,我們要怎麼辦呢?

我們可以自定義一個類載入器,除了 BootstrapClassLoader 其他類載入器均由 Java 實現且全部繼承自java.lang.ClassLoader。如果我們要自定義自己的類載入器,很明顯需要繼承 ClassLoader

從上面的源碼我們知道,雙親委派模型的都集中在 java.lang.ClassLoaderloadClass() 中,如果想打破雙親委派模型則需要重寫 loadClass() 方法。

如果我們不想打破雙親委派模型,就重寫 ClassLoader 類中的 findClass() 方法即可,無法被父類載入器載入的類最終會通過這個方法被載入。

參考

  • 《深入理解Java虛擬機》第三版,吹爆!吹爆!
Tags: