天天看點

【JVM】執行子系統 —— 類加載

在之前文章中總結了
  • JVM 的記憶體模型
  • JVM 的垃圾處理機制
  • 位元組碼檔案的結構以及位元組碼指令的大緻分類
本章總結一下 JVM 是怎麼将靜态的 class 檔案(或者說靜态代碼資料)放到運作時記憶體區中的?

【JVM】執行子系統 —— 類加載

      • 類加載的時機
        • 類的生命周期
        • 加載階段的開始時機
        • 解析階段的開始時機
        • 初始化階段的開始時機(有且隻有)
      • 類加載的過程
        • 加載
        • 驗證
        • 準備
        • 解析
        • 初始化
      • 類加載器
        • 怎麼唯一确定一個類
        • 雙親委派模型

類加載的時機

類的生命周期

class 位元組碼檔案的整個生命周期會經曆:加載、連接配接、初始化、使用 和 解除安裝 五個階段,在連接配接中包括驗證、準備 和 解析 三個階段:

【JVM】執行子系統 —— 類加載

其中:加載、驗證、準備、初始化和解除安裝會按部就班的開始。在一個階段執行的過程中會觸發(或者說激活)下一個階段的開始。它們的執行可能是交叉的。

加載階段的開始時機

  1. 類加載過程的第一個階段
  2. 什麼時候開始由具體的 JVM 自由把握,Java規範沒有強制要求。

解析階段的開始時機

  1. 可能在初始化階段之後才開始解析階段(為了支撐動态綁定)

初始化階段的開始時機(有且隻有)

  1. 遇到new、getstatic、putstatic或invokestatic這四條位元組碼指令時,需要先初始化對應的類
    位元組碼指令補充:new指令時建立類指令,getstatic、putstatic是通路類指令,invokestatic是調用類指令
  2. 使用java.lang.reflect包的方法對類型進行反射調用的時候 ,需要先初始化對應的類
    本質也是要建立對象,與 new 的差別在于反射調用利用類加載器動态加載,而 new 建立一個編譯時已知的類的執行個體,也就是靜态的建立執行個體
  3. 初始化子類時發現父類沒有初始化時,需要先觸發父類的初始化
  4. JVM 啟動時初始化使用者指定的主類(即:啟動時指定的有 main 方法的啟動類)
  5. 定義了預設方法(被default關鍵字修飾)的接口,在它實作類發生初始化時,接口要在之它之前初始化。
  6. 當使用JDK 7新加入的動态語言支援時,如果一個java.lang.invoke.MethodHandle執行個體最後的解析結果為REF_getStaticREF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種類型的方法句柄,并且這個方法句柄對應的類沒有進行過初始化,則需要先觸發其初始化。
    不太了解這個,感覺類似反射

驗證上面前五種情況的代碼:

package test.init;
public class Demo {
    static {
        System.out.println( "Demo init ......." );
    }
    public static final String T = "t";
    public static String M;
    public Demo(){
        System.out.println("Demo() 無參構造器被調用。。。。");
    }
    public Demo(String m){
        M = m;
        System.out.println("Demo(String m) 有參構造器被調用。。。。");
    }
    public static void setM(String m) {
        M = m;
    }
    public static String getT() {
        return T;
    }
    public static String getM() {
        return M;
    }
}
           
package test.init;
public interface DemoChild {
	/**
	 * 接口中不能使用靜态代碼塊,是以用了一個線程類型的成員變量,并給它初始化值(新的線程),
	 * 這樣接口初始化時就會建立這個線程
	 * 以便判斷接口的初始化情況
	 */
    Thread thead = new Thread(){
        {
            System.err.println("DemoChild is init ..... ");
        }
    };
    default String defaultMethod(){
        System.out.println("defaultMethod");
        return "defaultMethod";
    }
}
           
package test.init;
public class DemoChildImpl extends Demo implements DemoChild{
    static {
        System.out.println("DemoChildImpl init ..... ");
    }
}

           
package test.init;
import org.junit.Test;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;

public class MainTest {

    static {
        System.out.println("MainTest init .....");
    }

    public static void main(String[] args) {

    }
    @Test
    public void testNew(){
        Demo demo = new Demo();
    }
    @Test
    public void testGetStatic(){
        System.out.println(Demo.T);
    }
    @Test
    public void testSetStatic(){
        Demo.M = "kkkksks";
    }
    @Test
    public void testInvokestaticStatic(){
        Demo.setM("kkk");
    }
    @Test
    public void testReflect() throws Exception {
//        String className = "test.init.Demo";
//        Class<?> demo = Class.forName(className);
//        System.out.println(demo);

        //擷取 Demo 的有參構造方法
        Constructor<?> constructor = Demo.class.getConstructor(String.class);
        System.out.println(constructor);
        //利用反射包中的 Constructor 調用構造方法
        Demo demo = (Demo) constructor.newInstance("llll");

    }
    @Test
    public void testSuperAndImpl(){
        DemoChild demoChild = new DemoChildImpl();
    }
}

           

所有引用類型的方式都不會觸發初始化,稱為被動引用

以下隻是幾個被動引用的場景

  • 通過子類引用父類的靜态字段,不會導緻子類初始化
  • 通過數組定義來引用類,不會觸發此類的初始化
    不會觸發引用類的初始化,但是會初始會一個 JVM 提供的直接繼承 Object 的類

    Lorg.fenixsoft.classloading.SuperClass

    類,數字的 length 屬性就封裝在這個類裡面,這樣可以防止非法記憶體的通路。
  • 常量在編譯階段會存入調用類的常量池中,本質上沒有直接引用到定義常量的類,是以不會觸發定義常量的類的初始化

類加載的過程

類加載的全過程包括:加載、連接配接、初始化(加載、驗證、準備、解析和初始化這五個階段所)

【JVM】執行子系統 —— 類加載

在前面簡單了解了這幾個階段的開始時機,下面主要總結以下類加載過程中各個階段所負責的工作。

加載

JVM 在加載階段需要完成的任務:

  1. 通過一個類的全限定名來擷取定義此類的二進制位元組流。
  2. 将這個位元組流所代表的靜态存儲結構轉化為方法區的運作時資料結構。
  3. 在記憶體中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種資料的通路入口。

通過下圖可以更清晰的看出 JVM 再這個階段所作的工作:

【JVM】執行子系統 —— 類加載

加載階段結束後,Java虛拟機外部的二進制位元組流就按照虛拟機所設定的格式存儲在方法區之中

了,方法區中的資料存儲格式完全由虛拟機實作自行定義,《Java虛拟機規範》未規定此區域的具體資料結構。類型資料妥善安置在方法區之後,會在Java堆記憶體中執行個體化一個java.lang.Class類的對象,這個對象将作為程式通路方法區中的類型資料的外部接口。

加載是類加載過程的第一個階段,主要内容可以概況為:通過一個類的全限定名查找此類位元組碼檔案,并利用位元組碼檔案建立一個Class對象。

Java規範沒有嚴格要求加載階段的實作,這各階段的工作由 JVM 的類加載器完成,可以通過自定義的類加載器類根據具體的需求完成加載階段。

對于數組來說有一些特别,數組類本身不通過類加載器建立,它是由Java虛拟機直接在記憶體中動态構造出來的(這一點在被動引用的例子中提到過)。但是對應數組中的元素還是要通過類加載器來完加載工作。

驗證

驗證是連接配接階段的第一步,目的在于確定Class檔案的位元組流中包含資訊符合目前虛拟機要求,不會危害虛拟機自身安全。

從代碼量和耗費的執行性能的角度上講,驗證階段的工作量在虛拟機的類加載過程中占了相當大的比重,驗證階段在加載階段還沒有結束就被觸發了。

驗證階段的工作主要包括四種驗證:

  1. 檔案格式驗證

    驗證位元組流是否符合Class檔案格式的規範,并且能被目前版本的虛拟機處理。

    這階段的驗證是基于二進制位元組流進行的,隻有通過了這個階段的驗證之後,這段位元組流才被允許進入Java虛拟機記憶體的方法區中進行存儲,是以後面的三個驗證階段全部是基于方法區的存儲結構上進行的,不會再直接讀取、操作位元組流了。

  2. 中繼資料驗證

    對位元組碼描述的資訊進行語義分析,目的是對類的中繼資料資訊進行語義校驗,保證不存在與《Java語言規範》定義相悖的中繼資料資訊。

  3. 位元組碼驗證

    主要目的是通過資料流分析和控制流分析,确定程式語義是合法的、符合邏輯的。

    在第二階段對中繼資料資訊中的資料類型校驗完畢以後,這階段就要對類的方法體(Class檔案中的Code屬性)進行校驗分析,保證被校驗類的方法在運作時不會做出危害虛拟機安全的行為。

    為了減輕這個階段驗證過程的複雜性和時間的消耗,這部分與 Java 編譯器做了聯合優化,把盡可能多的校驗輔助措施挪到Javac編譯器裡進行。

  4. 符号引用驗證

    在虛拟機将符号引用轉化為直接引用(解析階段中發生)的時候對轉化過程進行驗證,驗證該類是否缺少或者被禁止通路它依賴的某些外部類、方法、字段等資源。

    符号引用驗證的主要目的是確定解析行為能正常執行,如果無法通過符号引用驗證,Java虛拟機将會抛出一個java.lang.IncompatibleClassChangeError的子類異常

驗證階段在 JVM 中的結構隻用兩種,通過或者不通過,如果程式時一個經過驗證且正确的程式,在生産環境的實施階段就可以考慮使用

-Xverify:none

參數來關閉大部分的類驗證措施,以縮短虛拟機類加載的時間。即:驗證加斷重要但不必要。

準備

這個階段的任務:正式為類變量(即靜态變量,被static修飾的變量)配置設定記憶體并設定類變量初始值(零值)。

這裡需要主要的點:

  • 類變量是指類中定義的靜态變量,其他變量是執行個體變量,在執行個體化時才會配置設定到堆中
  • 被 final 修飾的靜态變量除外,因為 final 在編譯的時候就會配置設定了。
  • 類變量會随着Class對象一起存放在Java堆中(邏輯上是在方法區,但是方法區就是堆中隔離出來的一個非堆區域,JVM 記憶體運作時分區可以參考之前的文章)。

設定類變量初始值:指的是”零值“

資料類型 零值
int
logn 0L
short (short) 0
char ‘\u0000’
byte (byte) 0
boolean false
float 0.0f
double 0.0d
reference null

舉例:

public static int value = 123;

類變量 value 在準備階段由 JVM 在堆中給它配置設定 4 個位元組的空間,并為其設定初始零值 0 ,而将 123 指派給它的指令是 putstatic ,該指令在類構造器()方法之中,在初始化階段才會被執行。

解析

主要将常量池中的符号引用替換為直接引用的過程。

  • 符号引用

    一組符号來描述目标,可以是任何字面量

    詳細參考 Class 檔案格式 的文章,這裡簡單回顧以下

    在 class檔案中有一個常量池,裡面主要存放兩大類常量:

    • 字面量(Literal)

      比較接近于Java語言層面的常量概念,如文本字元串、被聲明為final的常量值等

    • 符号引用(Symbolic References)

      符号引用值的是一些無法直接找到目标,需要借助 JVM 運作時才可以确定的常量資訊。

  • 直接引用

    直接指向目标的指針、相對偏移量或一個間接定位到目标的句柄。

Class檔案是靜态的,其中不會儲存各個方法、字段最終在記憶體中的布局資訊,JVM 在進行類加載的加載階段時會獲得這些符号引用,但此時還無法得到它們真正的記憶體入口位址,也就無法直接被虛拟機使用的,直到在類建立時或運作時 JVM 通過解析階段将符号引用解析到具體的記憶體位址之中,這個過程就是将常量池中的符号引用替換為直接引用的過程。

解析階段需要解析的内容可分為:

  1. 類或接口的解析
  2. 字段解析
  3. 類方法解析
  4. 接口方法解析

初始化

在之前的階段中,隻有加載階段可以通過自定義類加載器來控制,其他階段都有 JVM 自己完成。

初始化階段是Java虛拟機真正開始執行類中編寫的Java程式代碼,将主導權移交給應用程式的階段。也就意味這從這個階段開始,應用程式是由程式員控制的。

初始化階段就是執行類構造器<clinit>()方法的過程。

<clinit>()

方法并不是程式員在Java代碼中直接編寫的方法,我們代碼中寫的構造函數時建立執行個體時用到的構造方法,而這個

<clinit>()

方法是Javac編譯器的自動生成物,在類初始化階段會執行這個方法。

<clinit>()

方法是編譯器自動收集類中的所有類變量的指派動作和靜态語句塊(static{}塊)中的語句合并産生的。收集的順序是由語句在源檔案中出現的順序決定的,是以靜态語句塊隻能通路到它之前定義的靜态變量。

在準備階段 JVM 對一些靜态變量賦過一次系統要求的初始零值,而在初始化階段,則會根據程式員通過程式編碼制定的主觀計劃去初始化類變量和其他資源。

例如:

static int i = 1;

,在初始化階段會為 i 指派為 1。而準備階段隻是給 i 配置設定空間并設定一個系統零值。

類加載器

怎麼唯一确定一個類

對于任意一個類,都必須由加載它的類加載器和這個類本身一起共同确立其在Java虛拟機中的唯一性,每一個類加載器,都擁有一個獨立的類名稱空間。

即:同一個類加載器加載的源于同一個Class檔案的類才被視作同一個類

測試代碼:

package test.classloader;

import org.junit.Test;

import java.io.IOException;
import java.io.InputStream;

public class ClassLoaderTest {

    static {
        System.out.println("ClassLoaderTest init......");
    }

    //自定義一個類加載器 設定成靜态的  友善使用
    static ClassLoader myClassLoader = new ClassLoader() {
        @Override
        public Class<?> loadClass(String name) throws ClassNotFoundException {
            try {
                String fileName = name.substring(name.lastIndexOf(".") + 1)+".class";
                InputStream is = getClass().getResourceAsStream(fileName);
                if (is == null) {
                    return super.loadClass(name);
                }
                byte[] b = new byte[is.available()];
                is.read(b);
                return defineClass(name, b, 0, b.length);
            } catch (IOException e) {
                throw new ClassNotFoundException(name);
            }
        }
    };

    public static void main(String[] args) throws Exception {
        Object obj = myClassLoader.loadClass("test.classloader.ClassLoaderTest").newInstance();
        System.out.println(obj.getClass());
        // 這裡用 JVM 的類加載器加載 test.classloader.ClassLoaderTest
        System.out.println(obj instanceof test.classloader.ClassLoaderTest);
    }
    @Test
    public void testSameLoader() throws Exception {
        Object obj = test.classloader.ClassLoaderTest.class.newInstance();
        System.out.println(obj instanceof test.classloader.ClassLoaderTest);
    }
}

           

main方法執行結果:

【JVM】執行子系統 —— 類加載

testSameLoader()方法執行結果:

【JVM】執行子系統 —— 類加載

可以看出,用不同的類加載加載同一個類檔案時,會初始化兩次,說明 JVM 記憶體方法區應該存了兩個 ClassLoaderTest 類的運作時資料結構,它們與各種的類加載器關聯,屬于不同的命名空間。

雙親委派模型

前面的例子說明了怎麼在 JVM 中唯一地确定一個類,那麼這裡将要總結的是 JVM 保證類加載器隻加載到一個類,防止沖突的模型:雙親委派模型。

【JVM】執行子系統 —— 類加載
需要注意的是,這三層類加載器之間不是繼承關系,而是一種組合的關系。詳細内容可以看 java.lang.ClassLoader 和 sun.misc.Launcher 的源碼。
【JVM】執行子系統 —— 類加載
【JVM】執行子系統 —— 類加載

以開發者視角看,雙親委派機制中,類加載器分為三類(三層結構、雙親委派):

  1. 啟動類加載器

    C++實作,是JVM中的一部分

    它負責将

    <JAVA_HOME>/lib

    路徑下或

    -Xbootclasspath

    參數指定路徑下的 且能被JVM按照檔案名稱識别的類庫(如rt.jar、tools.jar) 加載到記憶體,如果檔案名不被虛拟機識别,即使把jar包丢到lib目錄下也是沒有作用的。
    出于安全考慮,Bootstrap啟動類加載器隻加載包名為 java、javax、sun 等開頭的類。
  2. 擴充類加載器

    Java實作,是sun.misc.Launcher 的一個靜态内部類 ExtClassLoader

    它負責加載

    <JAVA_HOME>/lib/ext

    目錄下或者由系統變量

    -Djava.ext.dir

    指定位路徑中的類庫。開發者可以直接使用标準擴充類加載器。
    【JVM】執行子系統 —— 類加載
    在前面提到過這個類繼承了 URLClassLoader 類,本質是通過 URL 加載。應用程式類加載器也類似。
  3. 應用程式類加載器

    Java實作,由 sun.misc.Launcher 的一個靜态内部類 AppClassLoader 實作

    它負責加載使用者類路徑(ClassPath,

    -D java.class.path

    可以指定)上所有的類庫,開發者同樣可以直接在代碼中使用這個類加載器。如果沒有自定義類加載器,應用程式類加載器将是程式中預設的類加載器。

雙親委派模型的工作過程:

如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,是以所有的加載請求最終都應該傳送到最頂層的啟動類加載器中,隻有當父加載器回報自己無法完成這個加載請求(它的搜尋範圍中沒有找到所需的類)時,子加載器才會嘗試自己去完成加載。

優勢

使得類加載過程有一個隐形的優先級關系,保證了類加載的不沖突、不重複。

代碼實作(ClassLoader的部分源碼)

應為除了啟動類加載器,其他的類加載器都需要直接或間接繼承這個類。
源碼注釋
【JVM】執行子系統 —— 類加載
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // First, check if the class has already been loaded
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                	//如果父加載器不為空,直接托給父類加載器去加載
                    c = parent.loadClass(name, false);
                } else {
                	//父加載器為null 則找 啟動類加載器,并委托啟動類加載器加載
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
                // 如果父加載器沒有加載到,會抛出異常
            }
			// 如果父加載器沒有加載到 即 c 還是 null
            if (c == null) {
                // If still not found, then invoke findClass in order
                // to find the class.
                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()

方法,若父加載器為空則預設使用啟動類加載器作為父加載器。假如父類加載器加載失敗,抛出

ClassNotFoundException

異常的話,才調用自己的findClass()方法嘗試進行加載。

parent 為 null 表示父加載器是 啟動類加載器。