阿里P7面試官:請你簡單說一下類載入機制的實現原理?

跟著Mic學架構發表於2021-10-31

面試題:類載入機制的原理

面試官考察點

考察目標: 瞭解面試者對JVM的理解,屬於面試八股文系列。

考察範圍: 工作3年以上。

技術背景知識

在回答這個問題之前,我們需要先了解一下什麼是類載入機制?

類載入機制簡述

什麼是類載入機制?

簡單來說:類的載入指的是將類的.class檔案中的二進位制資料讀入到記憶體中,將其放在執行時資料區的方法區內,然後在堆區建立一個java.lang.Class物件,用來封裝類在方法區內的資料結構。

經過類載入這個過程後,我們才能在程式中構建這個類的例項物件,並完成物件的方法呼叫和操作。

基本的工作原理下圖所示。

image-20211030235925336

我們編寫的.java字尾的原始程式碼,通過JVM編譯之後得到.class檔案。

類載入機制,就是把.class檔案載入到JVM中,我們知道JVM的執行時資料區又分為堆記憶體、虛擬機器棧、元空間、本地方法棧、程式計數器等空間,當類被載入後,會根據JVM記憶體規則,把資料儲存到對應區域內。

瞭解類載入器

大家想想,在實際開發中,執行一個程式,有哪些地方的類需要被載入?

  • 從本地系統直接載入,如JRE、CLASSPATH。

  • 通過網路下載.class檔案

  • 從zip,jar等歸檔檔案中載入.class檔案

  • 從專有資料庫中提取.class檔案

  • 將Java原始檔動態編譯為.class檔案(伺服器)

由於類載入器是負責這些和系統執行有關的所有類的載入行為,而針對不同位置的類,JVM提供了三種類載入器:

  1. 啟動類載入器,BootStrapClassLoader,最頂層的載入類,主要載入核心類庫,也就是我們環境變數下面%JRE_HOME%\lib下的rt.jar、resources.jar、charsets.jar和class等,還可以通過啟動jvm時指定-Xbootclasspath和路徑來改變Bootstrap ClassLoader的載入目錄。
  2. 擴充套件類載入器,ExtClassLoader,載入目錄%JRE_HOME%\lib\ext目錄下的jar包和class檔案。還可以載入-D java.ext.dirs選項指定的目錄
  3. 應用類載入器,AppClassLoader,也稱為SystemAppClass。 載入當前應用的classpath的所有類和jar包

從上述三個類載入器的描述來看,不同的載入器代表了不同的載入職能。當我們自己定義的一個類,要被載入到記憶體中時,類載入器的工作原理如下圖所示。

image-20211031095113427

從Java2開始,類載入過程採取了雙親委派模型(Parents Delegation Model【PDM】),PDM 更好的保證了 Java 平臺的安全性。在該機制中,JVM 自帶的 BootStrapClassLoader 是根載入器,其他的載入器都有且僅有一個父類載入器。類的載入首先請求父類載入器載入,父類載入器無能為力時才由其子類載入器自行載入。

PDM 只是 Java 推薦的機制,並不是強制的。可以繼承java.lang.ClassLoader類,實現自己的類載入器。如果想保持 PDM,就重寫 findClass(name);如果想破壞 PDM,就重寫 loadClass(name)。JDBC使用執行緒上下文載入器打破了 PDM,原因是 JDBC 只提供了介面,並沒有提供實現。

類載入器的演示

通過下面這段程式碼演示一下類所使用的載入器。

public class ClassLoaderExample {

    public static void main(String[] args) {
        ClassLoader loader=ClassLoaderExample.class.getClassLoader();
        System.out.println(loader);  //case1
        System.out.println(loader.getParent()); //case2
        System.out.println(loader.getParent().getParent()); //case3
    }
}
  • Case1 所示的程式碼,表示ClassLoaderExample這個類是被那個類載入器載入的。
  • Case2 所示的程式碼,表示ClassLoaderExample的父載入器
  • Case2 所示的程式碼,表示ClassLoaderExample的祖父載入器

執行結果如下:

sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@29453f44
null

證明了,ClassLoaderExample是被AppClassLoader載入。

最後一個應該是Bootstrap類載入器,但是這裡輸出為null,原因是BootStrapClassLoader是一個使用 C/C++ 編寫的類載入器,它已經嵌入到了 JVM 的核心之中。當 JVM 啟動時,BootStrapClassLoader 也會隨之啟動並載入核心類庫。當核心類庫載入完成後,BootStrapClassLoader 會建立 ExtClassLoader 和 AppClassLoader 的例項,兩個 Java 實現的類載入器將會載入自己負責路徑下的類庫,這個過程可以在sun.misc.Launcher中看到。

為什麼要設計PDM

Java中為什麼要採用PDM方式來實現類載入呢?有幾個目的

  1. 防止記憶體中出現多份同樣的位元組碼。如果沒有 PDM 而是由各個類載入器自行載入的話,使用者編寫了一個java.lang.Object的同名類並放在ClassPath中,多個類載入器都能載入這個類到記憶體中,系統中將會出現多個不同的Object類,那麼類之間的比較結果及類的唯一性將無法保證,同時,也會給虛擬機器的安全帶來隱患。
  2. 雙親委派機制能夠保證多載入器載入某個類時,最終都是由一個載入器載入,確保最終載入結果相同。
  3. 這樣可以保證系統庫優先載入,即便是自己重寫,也總是使用Java系統提供的System,自己寫的System類根本沒有機會得到載入,從而保證安全性。

類的載入原理

一個類在載入過程中,到底做了什麼?它的實現原理是什麼呢?

類從被載入到虛擬機器記憶體中開始,到解除安裝出記憶體為止,它的整個生命週期包括:載入、驗證、準備、解析、初始化、使用和解除安裝七個階段。它們的順序如下圖所示:

image-20211031101315995

其中類載入的過程包括了載入、驗證、準備、解析、初始化五個階段。在這五個階段中,載入、驗證、準備和初始化這四個階段發生的順序是確定的,而解析階段則不一定,它在某些情況下可以在初始化階段之後開始。另外注意這裡的幾個階段是按順序開始,而不是按順序進行或完成,因為這些階段通常都是互相交叉地混合進行的,通常在一個階段執行的過程中呼叫或啟用另一個階段。

每個階段的所執行的工作,如下圖所示。

image-20211031102021404

下面詳細分析一下類載入器在每個階段的詳細工作流程。

載入

”載入“是”類加機制”的第一個過程,在載入階段,虛擬機器主要完成三件事:

(1)通過一個類的全限定名來獲取其定義的二進位制位元組流

(2)將這個位元組流所代表的的靜態儲存結構轉化為方法區的執行時資料結構

(3)在堆中生成一個代表這個類的Class物件,作為方法區中這些資料的訪問入口。

驗證

驗證的主要作用就是確保被載入的類的正確性。也是連線階段的第一步。說白了也就是我們載入好的.class檔案不能對我們的虛擬機器有危害,所以先檢測驗證一下。他主要是完成四個階段的驗證:

(1)檔案格式的驗證:驗證.class檔案位元組流是否符合class檔案的格式的規範,並且能夠被當前版本的虛擬機器處理。這裡面主要對魔數、主版本號、常量池等等的校驗(魔數、主版本號都是.class檔案裡面包含的資料資訊、在這裡可以不用理解)。

(2)後設資料驗證:主要是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合java語言規範的要求,比如說驗證這個類是不是有父類,類中的欄位方法是不是和父類衝突等等。

(3)位元組碼驗證:這是整個驗證過程最複雜的階段,主要是通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。在後設資料驗證階段對資料型別做出驗證後,這個階段主要對類的方法做出分析,保證類的方法在執行時不會做出危害虛擬機器安全的事。

(4)符號引用驗證:它是驗證的最後一個階段,發生在虛擬機器將符號引用轉化為直接引用的時候。主要是對類自身以外的資訊進行校驗。目的是確保解析動作能夠完成。

對整個類載入機制而言,驗證階段是一個很重要但是非必需的階段,如果我們的程式碼能夠確保沒有問題,那麼我們就沒有必要去驗證,畢竟驗證需要花費一定的的時間。當然我們可以使用-Xverfity:none來關閉大部分的驗證。

準備

準備階段主要為類變數分配記憶體並設定初始值。這些記憶體都在方法區分配。在這個階段我們只需要注意兩點就好了,也就是類變數和初始值兩個關鍵詞:

(1)類變數(static)會分配記憶體,但是例項變數不會,例項變數主要隨著物件的例項化一塊分配到java堆中,

(2)這裡的初始值指的是資料型別預設值,而不是程式碼中被顯示賦予的值。比如public static int value = 1;,在這裡準備階段過後的value值為0,而不是1。賦值為1的動作在初始化階段。

在上面value是被static所修飾的準備階段之後是0,但是如果同時被final和static修飾準備階段之後就是1了。我們可以理解為static final在編譯器就將結果放入呼叫它的類的常量池中了。

解析

解析階段主要是虛擬機器將常量池中的符號引用轉化為直接引用的過程。什麼是符號應用和直接引用呢?

符號引用:以一組符號來描述所引用的目標,可以是任何形式的字面量,只要是能無歧義的定位到目標就好,就好比在班級中,老師可以用張三來代表你,也可以用你的學號來代表你,但無論任何方式這些都只是一個代號(符號),這個代號指向你(符號引用)直接引用:直接引用是可以指向目標的指標、相對偏移量或者是一個能直接或間接定位到目標的控制程式碼。和虛擬機器實現的記憶體有關,不同的虛擬機器直接引用一般不同。解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制程式碼和呼叫點限定符7類符號引用進行。

初始化

一個類在以下情況下,會被初始化。

  1. 建立類的例項,也就是new一個物件

  2. 訪問某個類或介面的靜態變數,或者對該靜態變數賦值

  3. 呼叫類的靜態方法

  4. 反射(Class.forName("com.gupao.Example"))

  5. 初始化一個類的子類(會首先初始化子類的父類)

  6. JVM啟動時標明的啟動類,即檔名和類名相同的那個類

類的初始化步驟:

  • 如果這個類還沒有被載入和連結,那先進行載入和連結

  • 假如這個類存在直接父類,並且這個類還沒有被初始化(注意:在一個類載入器中,類只能初始化一次),那就初始化直接的父類(不適用於介面)

  • 加入類中存在初始化語句(如static變數和static塊),那就依次執行這些初始化語句。

類載入的擴充套件知識點

在類載入機制中,還有很多可以擴充套件的知識,我們通過三個擴充套件變體來進行鞏固分析

  1. 為什麼靜態方法不能呼叫非靜態方法和變數
  2. 靜態類和非靜態類程式的初始化順序

為什麼靜態方法不能呼叫非靜態方法和變數

我想大家應該都知道,在靜態方法中時無法直接呼叫非靜態方法和變數的,為什麼呢?

理解了類類的載入原理之後,不難發現,靜態方法的記憶體分配時間與例項方法不同。

  1. 靜態方法屬於類,在類載入的時候就會分配記憶體,有了入口地址,可以通過“類名.方法名”直接呼叫。
  2. 非靜態成員(變數和方法)屬於類的物件,所以只有該物件初始化之後才會分配記憶體,然後通過類的物件去訪問。

意味著,也就是說在靜態方法中呼叫非靜態成員變數,該變數可能還未初始化。因此編譯器會報錯。

另外,除此之外,還有其他的變體。比如靜態塊.

public class ClassLoaderExample {
    
    static {
        //dosomething()
    }
}

靜態塊是在什麼時候執行呢?

類中的靜態塊會在整個類載入過程中的初始化階段執行,而不是在類載入過程中的載入階段執行。

初始化階段是類載入過程中的最後一個階段,該階段就是執行類構造器方法的過程,方法由編譯器自動收集類中所有類變數(靜態變數)的賦值動作和靜態語句塊中的語句合併生成,一個類一旦進入初始化階段,必然會執行靜態語句塊。所以說,靜態塊一定會在類載入過程中被執行,但不會在載入階段被執行。

clinit是類構造器方法,也就是在jvm進行類載入—–驗證—-解析—–初始化,中的初始化階段jvm會呼叫clinit方法。

clinit是class類構造器對靜態變數,靜態程式碼塊進行初始化

class Example {

   static Log log = LogFactory.getLog(); // <clinit>

   private int x = 1;   // <init>

   Example(){
      // <init>
   }

   static {
      // <clinit>
   }

}

Java程式的初始化順序

有以下程式碼,請說出它們的載入順序.

class Base {
    public Base() {
        System.out.println("父類構造方法");
    }
  
    String b = "父類非靜態變數";
  
    {
        System.out.println(b);
        System.out.println("父類非靜態程式碼塊");
    }
    static String a = "父類靜態變數";
    static {
        System.out.println(a);
        System.out.println("父類靜態程式碼塊");
    }
    public static void A() {
        System.out.println("父類普通靜態方法");
    }
}
class Derived extends Base {
    public Derived() {
        System.out.println("子類構造器");
    }
    String b = "子類非靜態變數";
    {
        System.out.println(b);
        System.out.println("子類非靜態程式碼塊");
    }
    static String a = "子類靜態變數";
    static {
        System.out.println(a);
        System.out.println("子類靜態塊");
    }
    public static void A() {
        System.out.println("子類普通靜態方法");
    }
    public static void main(String[] args) {
        Base.A();
        Derived.A();
        new Derived();
    }
}

這個問題,需要理解類的載入順序,初始化規則如下。

  • 父類靜態變數

  • 父類靜態程式碼塊

  • 子類靜態變數

  • 子類靜態程式碼塊

  • 父類非靜態變數

  • 父類非靜態程式碼塊

  • 父類建構函式

  • 子類非靜態變數

  • 子類非靜態程式碼塊

  • 子類建構函式

總的來說,父類需要優先載入,然後在是子類,接著是父類的靜態方法載入優先,其次是子類。

自定義類載入器

除了系統自帶的三種類載入器以外,我們還可以定義自己的類載入器。

需要繼承java.lang.ClassLoader這個類來實現自定義類載入器,並且重寫findClass方法或者loadClass方法。

1、如果不想打破雙親委派模型,那麼只需要重寫findClass方法。

protected Class<?> findClass(String name) throws ClassNotFoundException {
  throw new ClassNotFoundException(name);
}

這個方法並沒有實現,它直接返回ClassNotFoundException。因此,自定義類載入器必須重寫findClass方法。

2、如果想打破雙親委派模型,那麼就重寫loadClass方法。

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 {
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
            }

            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;
    }
}

ClassLoader中的loadClass方法,大致流程如下:

  1. 檢查類是否已載入,如果是則不用再重新載入了;
  2. 如果未載入,則通過父類載入(依次遞迴)或者啟動類載入器(bootstrap)載入;
  3. 如果還未找到,則呼叫本載入器的findClass方法;

不破壞雙親委派自定義類載入器實戰

實現自定義類載入器的實現,主要分三個步驟

  • 建立一個類繼承ClassLoader抽象類

  • 重寫findClass()方法

  • 在findClass()方法中呼叫defineClass()

/tmp目錄下建立一個PrintClass.java類,程式碼如下。

public class PrintClass {
  public PrintClass(){
     System.out.println("PrintClass:"+getClass().getClassLoader());
     System.out.println("PrintClass Parent:"+getClass().getClassLoader().getParent());
  }
  public String print(){
    System.out.println("PrintClass method for print");
    return "PrintClass.print()";
  }
}

使用javac PrintClass對原始檔進行編譯,得到PrintClass.class檔案

接在,下Java專案中建立一個自定義類載入器,程式碼如下。

public class MyClassLoader extends ClassLoader {

    private String classPath;

    public MyClassLoader(String classPath) {
        this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            byte[] bytes = getClassBytes(name);
            Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
            return c;
        } catch (Exception e) {
            e.printStackTrace();
        }
        return super.findClass(name);
    }
    private byte[] getClassBytes(String name) throws Exception {
        name = name.replaceAll("\\.", "/");
        FileInputStream fis = new FileInputStream(classPath + "/" + name + ".class");
        int len = fis.available();
        byte[] data = new byte[len];
        fis.read(data);
        fis.close();
        return data;
    }
}

MyClassLoader繼承了ClassLoader並且重寫了findClass方法。該方法中是從指定路徑下載入.class檔案。

編寫測試程式碼.

public class ClassLoaderMain {

    public static void main(String[] args) throws Exception {
        MyClassLoader mc=new MyClassLoader("/tmp");
        Class clazz=mc.loadClass("PrintClass");
        Object o=clazz.newInstance();
        Method print=clazz.getDeclaredMethod("print",null);
        print.invoke(o,null);
    }
}

執行結果如下:

PrintClass:org.example.cl.MyClassLoader@5cad8086
PrintClass Parent:sun.misc.Launcher$AppClassLoader@18b4aac2
PrintClass method for print

可以看到,PrintClass.class這個類,它的類載入器是MyClassLoader

破壞雙親委派自定義類載入器實戰

原本ClassLoader類中的loadClass方法,是基於雙親委派機制來實現。破壞雙親委派,只需要重寫loadClass方法即可。

在MyClassLoader類中,重寫loadClass方法,程式碼如下。

@Override
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) {
      // If still not found, then invoke findClass in order
      // to find the class.
      long t1 = System.nanoTime();

      //非自定義的類還是走雙親委派載入
      if (!name.equals("PrintClass")) { 
        c = this.getParent().loadClass(name);
      } else { //自己寫的類,走自己的類載入器。
        c = findClass(name);
      }
      // this is the defining class loader; record the stats
      sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
      sun.misc.PerfCounter.getFindClasses().increment();
    }
    if (resolve) {
      resolveClass(c);
    }
    return c;
  }
}

PrintClass.java複製到/tmp/cl目錄下,並修改print方法。

public class PrintClass {
  public PrintClass(){
     System.out.println("PrintClass:"+getClass().getClassLoader());
     System.out.println("PrintClass Parent:"+getClass().getClassLoader().getParent());
  }
  public String print(){
    System.out.println("PrintClass method for print NEW");  //修改了列印語句,用來區分被載入的類
    return "PrintClass.print()";
  }
}

編寫測試程式碼

public class ClassLoaderMain {

    public static void main(String[] args) throws Exception {
        MyClassLoader mc=new MyClassLoader("/tmp");
        Class clazz=mc.loadClass("PrintClass");
        System.out.println(clazz.getClassLoader());
        System.out.println();
        //在另外一個目錄下建立相同的PrintClass.class檔案
        MyClassLoader mc1=new MyClassLoader("/tmp/cl");
        Class clazz1=mc1.loadClass("PrintClass");
        System.out.println(clazz1.getClassLoader());
        System.out.println();
    }
}

上述程式碼中,分別載入tmptmp/cl目錄下的PrintClass.class檔案,列印結果如下。

PrintClass:org.example.cl.MyClassLoader@5cad8086
PrintClass Parent:sun.misc.Launcher$AppClassLoader@18b4aac2
PrintClass method for print
PrintClass:org.example.cl.MyClassLoader@610455d6
PrintClass Parent:sun.misc.Launcher$AppClassLoader@18b4aac2
PrintClass method for print NEW

結論:通過重寫loadClass方法,使得自己建立的類,讓第一個載入器直接載入,不委託父載入器尋找,從而實現雙親委派的破壞

Tomcat是如何實現應用jar包的隔離的?

相信不少小夥伴在面試的時候遇到過這個問題。

在思考這個問題之前,我們先來想想Tomcat作為一個JSP/Servlet容器,它應該要解決什麼問題?

  1. 一個web容器可能需要部署兩個應用程式,不同的應用程式可能會依賴同一個第三方類庫的不同版本,不能要求同一個類庫在同一個伺服器只有一份,因此要保證每個應用程式的類庫都是獨立的,保證相互隔離。
  2. 部署在同一個web容器中相同的類庫相同的版本可以共享。否則,如果伺服器有10個應用程式,那麼要有10份相同的類庫載入進虛擬機器,必然會帶來記憶體消耗過高的問題。
  3. web容器也有自己依賴的類庫,不能與應用程式的類庫混淆。基於安全考慮,應該讓容器的類庫和程式的類庫隔離開來。

為了達到這些目的,Tomcat一定不能使用預設的類載入機制。

原因:如果使用預設的類載入器機制,那麼是無法載入兩個相同類庫的不同版本的,預設的類載入器是不管你是什麼版本的,只在乎你的全限定類名,並且只有一份

所以Tomcat實現了自己的類載入器,同樣也打破了雙親委派這一機制,下圖表示Tomcat的類載入機制。

image-20211031140814884

我們看到,前面3個類載入和預設的一致,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader則是Tomcat自己定義的類載入器,它們分別載入${TOMCAT_HOME}/lib/WebApp/WEB-INF/*中的Java類庫。

其中WebApp類載入器和Jsp類載入器通常會存在多個例項,每一個Web應用程式對應一個WebApp類載入器,每一個JSP檔案對應一個Jsp類載入器。

  • commonLoader:Tomcat最基本的類載入器,載入路徑中的class可以被Tomcat容器本身以及各個Webapp(web應用)訪問;
  • catalinaLoader:Tomcat容器私有的類載入器,載入路徑中的class對於Webapp不可見;
  • sharedLoader:各個Webapp共享的類載入器,載入路徑中的class對於所有Webapp可見,但是對於Tomcat容器不可見;
  • WebappClassLoader:各個Webapp私有的類載入器,載入路徑中的class只對當前Webapp可見;

從圖中的委派關係中可以看出:

CommonClassLoader能載入的類都可以被Catalina ClassLoader和SharedClassLoader使用,從而實現了公有類庫的共用,而CatalinaClassLoader和Shared ClassLoader自己能載入的類則與對方相互隔離。

WebAppClassLoader可以使用SharedClassLoader載入到的類,但各個WebAppClassLoader例項之間相互隔離。

而JasperLoader的載入範圍僅僅是這個JSP檔案所編譯出來的那一個.Class檔案,它出現的目的就是為了實現JSP的HotSwap功能。

很顯然,Tomcat為了實現隔離性,打破了雙親委派,每個webappClassLoader載入自己的目錄下的class檔案。

問題解答

面試題:類載入機制的原理

回答: 類的載入指的是將類的.class檔案中的二進位制資料讀入到記憶體中,將其放在執行時資料區的方法區內,然後在堆區建立一個java.lang.Class物件,用來封裝類在方法區內的資料結構。

類的載入機制包括載入、驗證、準備、解析、初始化這5個過程,其中

  • 載入:將.class檔案載入到記憶體中
  • 驗證:確保載入的類符合JVM規範
  • 準備:正式為類變數分配記憶體並設定初始值
  • 解析:JVM常量池的符號引用轉換為直接引用
  • 初始化:執行類的構造方法。

問題總結

一個小小的面試題,涉及到背後的技術知識非常龐大。

在面試的時候,遇到這類問題,如果自己不具備體系化的知識,那麼回答時很容易找不到切入點。特別是這種比較泛的問題,切入點太多時,回答起來會比較混亂。
關注[跟著Mic學架構]公眾號,獲取更多精品原創

相關文章