徹底剖析JVM類載入機制

Awecoder發表於2022-01-09

本文仍然基於JDK8版本,從JDK9模組化器,類載入器有一些變動。

0 javac編譯

java程式碼

public class Math {
    public static final int initData = 666;

    public static User user = new User();

    public int compute() {
        int a = 1;
        int b = 2;
        int c = (a + b) * 10;
        return c;
    }

    public static void main(String[] args) {
        Math math = new Math();
        math.compute();
        System.out.println("end");
    }
}

javac 編譯,javap -v -p 檢視class檔案

Classfile /F:/workspace/advanced-java/target/classes/com/lzp/java/jvm/classloader/Math.class

// 第1部分,描述資訊:大小、修改時間、md5值等
  Last modified 2022年1月8日; size 1006 bytes
  MD5 checksum 4cece4543963b23a98cd219a59c1887c
  Compiled from "Math.java"

// 第2部分,描述資訊:編譯版本
public class com.lzp.java.jvm.classloader.Math
  minor version: 0
  major version: 52
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #2                          // com/lzp/java/jvm/classloader/Math
  super_class: #11                        // java/lang/Object
  interfaces: 0, fields: 2, methods: 4, attributes: 1
  
// 第3部分,常量池資訊
Constant pool:
   #1 = Methodref          #11.#39        // java/lang/Object."<init>":()V
   #2 = Class              #40            // com/lzp/java/jvm/classloader/Math
   #3 = Methodref          #2.#39         // com/lzp/java/jvm/classloader/Math."<init>":()V
   #4 = Methodref          #2.#41         // com/lzp/java/jvm/classloader/Math.compute:()I
   #5 = Fieldref           #42.#43        // java/lang/System.out:Ljava/io/PrintStream;
   #6 = String             #44            // end
   #7 = Methodref          #45.#46        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #8 = Class              #47            // com/lzp/java/jvm/classloader/User
   #9 = Methodref          #8.#39         // com/lzp/java/jvm/classloader/User."<init>":()V
  #10 = Fieldref           #2.#48         // com/lzp/java/jvm/classloader/Math.user:Lcom/lzp/java/jvm/classloader/User;
  #11 = Class              #49            // java/lang/Object
  #12 = Utf8               initData
  #13 = Utf8               I
  #14 = Utf8               ConstantValue
  #15 = Integer            666
  #16 = Utf8               user
  #17 = Utf8               Lcom/lzp/java/jvm/classloader/User;
  #18 = Utf8               <init>
  #19 = Utf8               ()V
  #20 = Utf8               Code
  #21 = Utf8               LineNumberTable
  #22 = Utf8               LocalVariableTable
  #23 = Utf8               this
  #24 = Utf8               Lcom/lzp/java/jvm/classloader/Math;
  #25 = Utf8               compute
  #26 = Utf8               ()I
  #27 = Utf8               a
  #28 = Utf8               b
  #29 = Utf8               c
  #30 = Utf8               main
  #31 = Utf8               ([Ljava/lang/String;)V
  #32 = Utf8               args
  #33 = Utf8               [Ljava/lang/String;
  #34 = Utf8               math
  #35 = Utf8               MethodParameters
  #36 = Utf8               <clinit>
  #37 = Utf8               SourceFile
  #38 = Utf8               Math.java
  #39 = NameAndType        #18:#19        // "<init>":()V
  #40 = Utf8               com/lzp/java/jvm/classloader/Math
  #41 = NameAndType        #25:#26        // compute:()I
  #42 = Class              #50            // java/lang/System
  #43 = NameAndType        #51:#52        // out:Ljava/io/PrintStream;
  #44 = Utf8               end
  #45 = Class              #53            // java/io/PrintStream
  #46 = NameAndType        #54:#55        // println:(Ljava/lang/String;)V
  #47 = Utf8               com/lzp/java/jvm/classloader/User
  #48 = NameAndType        #16:#17        // user:Lcom/lzp/java/jvm/classloader/User;
  #49 = Utf8               java/lang/Object
  #50 = Utf8               java/lang/System
  #51 = Utf8               out
  #52 = Utf8               Ljava/io/PrintStream;
  #53 = Utf8               java/io/PrintStream
  #54 = Utf8               println
  #55 = Utf8               (Ljava/lang/String;)V
{
// 第四部分,變數資訊
  public static final int initData;
    descriptor: I
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: int 666

  public static com.lzp.java.jvm.classloader.User user;
    descriptor: Lcom/lzp/java/jvm/classloader/User;
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC

  public com.lzp.java.jvm.classloader.Math();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/lzp/java/jvm/classloader/Math;
// 第五部分,方法資訊
  public int compute();
    descriptor: ()I
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=2, locals=4, args_size=1
         0: iconst_1
         1: istore_1
         2: iconst_2
         3: istore_2
         4: iload_1
         5: iload_2
         6: iadd
         7: bipush        10
         9: imul
        10: istore_3
        11: iload_3
        12: ireturn
      LineNumberTable:
        line 9: 0
        line 10: 2
        line 11: 4
        line 12: 11
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      13     0  this   Lcom/lzp/java/jvm/classloader/Math;
            2      11     1     a   I
            4       9     2     b   I
           11       2     3     c   I

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class com/lzp/java/jvm/classloader/Math
         3: dup
         4: invokespecial #3                  // Method "<init>":()V
         7: astore_1
         8: aload_1
         9: invokevirtual #4                  // Method compute:()I
        12: pop
        13: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
        16: ldc           #6                  // String end
        18: invokevirtual #7                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        21: return
      LineNumberTable:
        line 16: 0
        line 17: 8
        line 18: 13
        line 19: 21
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      22     0  args   [Ljava/lang/String;
            8      14     1  math   Lcom/lzp/java/jvm/classloader/Math;
    MethodParameters:
      Name                           Flags
      args

  static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=2, locals=0, args_size=0
         0: new           #8                  // class com/lzp/java/jvm/classloader/User
         3: dup
         4: invokespecial #9                  // Method com/lzp/java/jvm/classloader/User."<init>":()V
         7: putstatic     #10                 // Field user:Lcom/lzp/java/jvm/classloader/User;
        10: return
      LineNumberTable:
        line 6: 0
}

方法中的#1/2,可以到ConstantPool找到對應符號。

參考位元組碼指令表:https://docs.oracle.com/javase/specs/jvms/se8/html/index.html。

1 類載入過程

j經典的類載入過程如下圖,包括載入、連結、初始化三部分。

image

1.1 載入class檔案

位元組碼檔案位於磁碟,當使用到某個類(例如,呼叫main()方法,new新物件),在磁碟中查詢並通過IO讀取檔案的二進位制流,轉為方法區資料結構,並存放到方法區,在Java堆中產生 java.lang.Class物件。Class物件是可以方法區的訪問入口,用於Java反射機制,獲取類的各種資訊。

1.2 連結過程

驗證:驗證class檔案是不是符合規範

  1. 檔案格式的驗證。驗證是否以0XCAFEBABE開頭,版本號是否合理

  2. 後設資料驗證。是否有父類,是否繼承了final類(final類不能被繼承),非抽象類實現了所有抽象方法。

  3. 位元組碼驗證。(略)

  4. 符號引用驗證。常量池中描述類是否存在,訪問的方法或欄位是否存在且有足夠的許可權。

-Xverify:none  // 取消驗證

準備:為類的靜態變數分配記憶體,初始化為系統的初始值

final static修飾的變數:直接賦值為使用者定義的值,比如 private final static int value=123,直接賦值123。

private static int value=123,該階段的值依然是0。

解析:符號引用轉換成直接引用(靜態連結)

Java程式碼中每個方法、方法引數都是符號,類載入放入方法區的常量池Constant pool中。

符號引用:應該可以理解成常量池中的這些字面量。【可能沒理解對】

直接引用:符號對應程式碼被載入到JVM記憶體中的位置(指標、控制程式碼)。

靜態連結過程在類載入時完成,主要轉換一些靜態方法。動態連結是在程式執行期間完成的將符號引用替換為直接引用。

1.3 初始化(類初始化clinit-->初始化init)

執行< clinit>方法, clinit方法由編譯器自動收集類裡面的所有靜態變數的賦值動作及靜態語句塊合併而成,也叫類構造器方法

  • 初始化的順序和原始檔中的順序一致

  • 子類的< clinit>被呼叫前,會先呼叫父類的< clinit>

  • JVM會保證clinit方法的執行緒安全性

初始化時,如果例項化一個新物件,會呼叫<init>方法對例項變數進行初始化,並執行對應的構造方法內的程式碼。

類載入過程是懶載入的,用到才會載入。

初始化示例

public class JVMTest2 {
    static {
        System.out.println("JVMTest2靜態塊");
    }

    {
        System.out.println("JVMTest2構造塊");
    }

    public JVMTest2() {
        System.out.println("JVMTest2構造方法");
    }

    public static void main(String[] args) {
        System.out.println("main方法");
        new Sub();
    }
}

class Super {
    static {
        System.out.println("Super靜態程式碼塊");
    }

    public Super() {
        System.out.println("Super構造方法");
    }

    {
        System.out.println("Super普通程式碼塊");
    }
}

class Sub extends Super {
    static {
        System.out.println("Sub靜態程式碼塊");
    }

    public Sub() {
        System.out.println("Sub構造方法");
    }

    {
        System.out.println("Sub普通程式碼塊");
    }
}

JVMTest2靜態塊
main方法
Super靜態程式碼塊
Sub靜態程式碼塊
Super普通程式碼塊
Super構造方法
Sub普通程式碼塊
Sub構造方法

執行main方法,並不需要建立JVMTest2例項。

對於普通程式碼塊,以前認為是和clinit一樣順序載入。其實是不一樣的,普通程式碼塊編譯時對賦值語句和其他語句分別做了優化,如下賦值語句優化為int i = 1; 列印語句優化為構造方法的第一句。

原始碼

public class JVMTest1 {
    int i;
    {
        i = 1;
        System.out.println("JVMTest1構造塊");
    }
    public JVMTest1(){
        System.out.println("JVMTest1構造方法");
    }
}

反編譯後的程式碼

public class JVMTest1 {
    int i = 1;

    public JVMTest1() {
        System.out.println("JVMTest1構造塊");
        System.out.println("JVMTest1構造方法");
    }
}

2 類載入器

檢視當前JDK類載入器

public class PrintJDKClassLoader {
    public static void main(String[] args) {
        ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
        System.out.println(systemClassLoader);
        ClassLoader parent = systemClassLoader.getParent();
        System.out.println(parent);
        ClassLoader parentParent = parent.getParent();
        System.out.println(parentParent);
    }
}

// JDK8
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@28a418fc
null
// JDK11
jdk.internal.loader.ClassLoaders$AppClassLoader@2437c6dc
jdk.internal.loader.ClassLoaders$PlatformClassLoader@1324409e
null

2.1 類載入器(JDK8)

類載入器初始化過程:Java通過呼叫jvm.dll檔案建立JVM,建立一個引導類載入器(由C++實現),通過JVM啟動器(sun.misc.Launcher)載入擴充套件類載入器和應用類載入器。

  • 啟動類載入器:負責載入lib目錄下的核心類庫。作為JVM的一部分,由C++實現。

  • 擴充套件類/平臺類載入器:負責載入lib目錄下的ext擴充套件目錄中的JAR 類包。

  • 應用程式類載入器:負責載入使用者類路徑ClassPath路徑下的類包,主要就是載入使用者自己寫的類。

  • 自定義類載入器:負責載入使用者自定義路徑下的類包。

JVM預設使用Launcher的getClassLoader()方法返回的類載入器AppClassLoader的例項載入我們的應用程式。

// Launcher構造方法
public Launcher() {
    Launcher.ExtClassLoader var1;
    // 構造擴充套件類載入器,設定類載入器parent屬性設為null。
    var1 = Launcher.ExtClassLoader.getExtClassLoader();
    // 構造應用類載入器,設定類載入器parent屬性為擴充套件類載入器。
    this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
    Thread.currentThread().setContextClassLoader(this.loader);
    // 許可權校驗程式碼..
    }
}

2.2 雙親委派模型

類載入器採用三層、雙親委派模型,類載入器的父子關係不是繼承關係,而是組合關係。除了啟動類載入器外,其他類載入器都是繼承自ClassLoader類。

image

工作過程:類載入器收到類載入請求,首先判斷類是否已經載入,如果未被載入,嘗試將請求向上委派給父類載入器載入。當父類載入器無法完成載入任務,再由子類載入器嘗試載入。

// ClassLoader
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 檢查類是否已被載入
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) { 
                    // 非啟動類載入器
                    c = parent.loadClass(name, false);
                } else { 
                    // 啟動類載入器
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父類載入器無法載入指定類
            }

            if (c == null) {
                // 呼叫當前類載入器的findClass方法進行類載入
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

為什麼使用雙親委派模型,感覺走了彎路?

雙親委派模型下,類載入請求總會被委派給最上層的啟動類載入器。對於未載入的類來說,需要從底層走到頂層;如果使用者定義的類已經被載入過,則不需要委派過程。

使用雙親委派機制有下面幾個好處:

  • 沙箱安全機制,防止核心類庫程式碼被篡改。
  • 避免類重複載入,父類載入器載入過,子類載入器不需要再次載入。

全盤負責委託機制

全盤負責 :即是當一個classloader載入一個Class的時候,這個Class所依賴的和引用的其它Class 通常 也由這個classloader負責載入。 委託機制 :先讓parent(父)類載入器 尋找,只有在parent找不到的時候才從自己的類路徑中去尋找。

參考Launcher構造方法

Thread.currentThread().setContextClassLoader(this.loader);

自定義類載入器

自定義類載入器操作主要是繼承ClassLoader類,重寫上面原始碼中的findClass(name)方法。

public class CustomClassLoaderTest {
    static class CustomClassLoader extends ClassLoader {
        private String classFilePath;

        public CustomClassLoader(String classFilePath) {
            this.classFilePath = classFilePath;
        }
		// 載入class資料流
        private byte[] loadClassFile(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classFilePath + "/" + name + ".class");
            int len = fis.available();
            byte[] data = new byte[len];
            fis.read(data);
            fis.close();
            return data;
        }

        protected Class<?> findClass(String name) throws ClassNotFoundException{
            try {
                byte[] data = loadClassFile(name);
                // 載入--連結--初始化等邏輯
                return defineClass(name,data,0,data.length);
            } catch (Exception e) {
                throw new ClassNotFoundException();
            }
        }
    }

    public static void main(String[] args) throws Exception {
        CustomClassLoader classLoader = new CustomClassLoader("F:");
        Class<?> clazz = classLoader.loadClass("com.lzp.java.jvm.classloader.JVMTest");
        Object instance = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("add", null);
        System.out.println(method.invoke(instance));
        System.out.println(clazz.getClassLoader().getClass().getName());
    }
}

自定義類載入器的父載入器是應用類載入器。CustomClassLoader是使用AppClassLoader執行的,自然而然是父類載入器。

打破雙親委派機制

在一些場景下,打破雙親委派是必要的。例如Tomcat中可能有多個應用,引用了不同的Spring版本。打破雙親委派,可以實現應用隔離。

JVM使用loadClass方法實現雙親委派機制。重寫loadClass方法,便可以打破雙親委派機制。

直接刪除雙親委派程式碼是不可行的,Java程式碼繼承自Object,總會需要雙親委派來載入核心程式碼。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            // 非自定義的類還是走雙親委派載入
            if (!name.equals("com.lzp.java.jvm.classloader.JVMTest")) {
                c = this.getParent().loadClass(name);
            } else {
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

注:JDK自帶的核心庫程式碼,是不允許自行配置修改的。例如,不可以將Object.class拷出來執行。沙箱隔離。

image

相關文章