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

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

面試官考察點

考察目標: 了解面試者對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學架構]公眾號,獲取更多精品原創