JVM系列(四) – JVM類載入機制詳解

零壹技術棧發表於2019-03-03

前言

本文將由淺及深,介紹Java類載入的過程和原理,進一步對類載入器的進行原始碼分析,完成一個自定義的類載入器。

JVM系列(四) – JVM類載入機制詳解

正文

(一). 類載入器是什麼

類載入器簡言之,就是用於把.class檔案中的位元組碼資訊轉化為具體的java.lang.Class物件的過程的工具。

具體過程:

  1. 在實際類載入過程中,JVM會將所有的.class位元組碼檔案中的二進位制資料讀入記憶體中,匯入執行時資料區的方法區中。
  2. 當一個類首次被主動載入被動載入時,類載入器會對此類執行類載入的流程 – 載入連線驗證準備解析)、初始化
  3. 如果類載入成功,堆記憶體中會產生一個新的Class物件,Class物件封裝了類在方法區內的資料結構

Class物件的建立過程描述:

JVM系列(四) – JVM類載入機制詳解

(二). 類載入的過程

類載入的過程分為三個步驟(五個階段) :載入 -> 連線驗證準備解析)-> 初始化

載入驗證準備初始化這四個階段發生的順序是確定的,而解析階段可以在初始化階段之後發生,也稱為動態繫結晚期繫結

類載入的過程描述:

JVM系列(四) – JVM類載入機制詳解

1. 載入

載入:查詢並載入類的二進位制資料的過程。

載入的過程描述:

  1. 通過類的全限定名定位.class檔案,並獲取其二進位制位元組流
  2. 把位元組流所代表的靜態儲存結構轉換為方法區執行時資料結構
  3. Java中生成一個此類的java.lang.Class物件,作為方法區中這些資料的訪問入口

2. 連線

連線:包括驗證準備解析三步。

a). 驗證

驗證:確保被載入的類的正確性。驗證是連線階段的第一步,用於確保Class位元組流中的資訊是否符合虛擬機器的要求。

具體驗證形式:

  1. 檔案格式驗證:驗證位元組流是否符合Class檔案格式的規範;例如:是否以0xCAFEBABE開頭、主次版本號是否在當前虛擬機器的處理範圍之內、常量池中的常量是否有不被支援的型別。
  2. 後設資料驗證:對位元組碼描述的資訊進行語義分析(注意:對比javac編譯階段的語義分析),以保證其描述的資訊符合Java語言規範的要求;例如:這個類是否有父類,除了java.lang.Object之外。
  3. 位元組碼驗證:通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。
  4. 符號引用驗證:確保解析動作能正確執行。

b). 準備

準備:為類的靜態變數分配記憶體,並將其初始化為預設值。準備過程通常分配一個結構用來儲存類資訊,這個結構中包含了類中定義的成員變數方法介面資訊等。

具體行為:

  1. 這時候進行記憶體分配的僅包括類變數(static),而不包括例項變數例項變數會在物件例項化時隨著物件一塊分配在Java中。
  2. 這裡所設定的初始值通常情況下是資料型別預設的零值(如00Lnullfalse等),而不是被在Java程式碼中被顯式賦值

c). 解析

解析:把類中對常量池內的符號引用轉換為直接引用

解析動作主要針對類或介面欄位類方法介面方法方法型別方法控制程式碼呼叫點限定符等7類符號引用進行。

3. 初始化

初始化:對類靜態變數賦予正確的初始值 (注意和連線時的解析過程區分開)。

初始化的目標

  1. 實現對宣告類靜態變數時指定的初始值的初始化;
  2. 實現對使用靜態程式碼塊設定的初始值的初始化。

初始化的步驟

  1. 如果此類沒被載入連線,則先載入連線此類;
  2. 如果此類的直接父類還未被初始化,則先初始化其直接父類;
  3. 如果類中有初始化語句,則按照順序依次執行初始化語句。

初始化的時機

  1. 建立類的例項(new關鍵字);
  2. java.lang.reflect包中的方法(如:Class.forName(“xxx”));
  3. 對類的靜態變數進行訪問或賦值;
  4. 訪問呼叫類的靜態方法
  5. 初始化一個類的子類父類本身也會被初始化;
  6. 作為程式的啟動入口,包含main方法(如:SpringBoot入口類)。

(三). 類的主動引用和被動引用

主動引用

主動引用:在類載入階段,只執行載入連線操作,不執行初始化操作。

主動引用的幾種形式

  1. 建立類的例項(new關鍵字);
  2. java.lang.reflect包中的方法(如:Class.forName(“xxx”));
  3. 對類的靜態變數進行訪問或賦值;
  4. 訪問呼叫類的靜態方法
  5. 初始化一個類的子類父類本身也會被初始化;
  6. 作為程式的啟動入口,包含main方法(如:SpringBoot入口類)。

主動引用1 – main方法在初始類中

程式碼示例:

public class OptimisticReference0 {
    static {
        System.out.println(OptimisticReference0.class.getSimpleName() + " is referred!");
    }

    public static void main(String[] args) {
        System.out.println();
    }
}
複製程式碼

執行結果:

OptimisticReference0 is referred!

主動引用2 – 建立子類會觸發父類的初始化

程式碼示例:

public class OptimisticReference1 {
    public static class Parent {
        static {
            System.out.println(Parent.class.getSimpleName() + " is referred!");
        }
    }

    public static class Child extends Parent {
        static {
            System.out.println(Child.class.getSimpleName() + " is referred!");
        }
    }

    public static void main(String[] args) {
        new Child();
    }
}
複製程式碼

執行結果:

Parent is referred!
Child is referred!

主動引用3 – 訪問一個類靜態變數

程式碼示例:

public class OptimisticReference2 {
    public static class Child {
        protected static String name;
        static {
            System.out.println(Child.class.getSimpleName() + " is referred!");
            name = "Child";
        }
    }

    public static void main(String[] args) {
        System.out.println(Child.name);
    }
}
複製程式碼

執行結果:

Child is referred!
Child

主動引用4 – 對類的靜態變數進行賦值

程式碼示例:

public class OptimisticReference3 {
    public static class Child {
        protected static String name;
        static {
            System.out.println(Child.class.getSimpleName() + " is referred!");
        }
    }

    public static void main(String[] args) {
        Child.name = "Child";
    }
}
複製程式碼

執行結果:

Child is referred!

主動引用5 – 使用java.lang.reflect包提供的反射機制

程式碼示例:

public class OptimisticReference4 {
    public static void main(String[] args) throws ClassNotFoundException {
        Class.forName("org.ostenant.jdk8.learning.examples.reference.optimistic.Child");
    }
}
複製程式碼

執行結果:

Child is referred!

被動引用

被動引用: 在類載入階段,會執行載入連線初始化操作。

被動引用的幾種形式:

  1. 通過子類引用父類的的靜態欄位,不會導致子類初始化;
  2. 定義類的陣列引用不賦值,不會觸發此類的初始化;
  3. 訪問類定義的常量,不會觸發此類的初始化。

被動引用1 – 子類引用父類的的靜態欄位,不會導致子類初始化

程式碼示例:

public class NegativeReference0 {
    public static class Parent {
        public static String name = "Parent";
        static {
            System.out.println(Parent.class.getSimpleName() + " is referred!");
        }
    }

    public static class Child extends Parent {
        static {
            System.out.println(Child.class.getSimpleName() + " is referred!");
        }
    }

    public static void main(String[] args) {
        System.out.println(Child.name);
    }
}
複製程式碼

執行結果:

Parent is referred!
Parent

被動引用2 – 定義類的陣列引用而不賦值,不會觸發此類的初始化

程式碼示例:

public class NegativeReference1 {
    public static class Child {
        static {
            System.out.println(Child.class.getSimpleName() + " is referred!");
        }
    }

    public static void main(String[] args) {
        Child[] childs = new Child[10];
    }
}
複製程式碼

執行結果:

無輸出

被動引用3 – 訪問類定義的常量,不會觸發此類的初始化

示例程式碼:

public class NegativeReference2 {
    public static class Child {
        public static final String name = "Child";
        static {
            System.out.println(Child.class.getSimpleName() + " is referred!");
        }
    }

    public static void main(String[] args) {
        System.out.println(Child.name);
    }
}
複製程式碼

執行結果:

Child

(四). 三種類載入器

類載入器:類載入器負責載入程式中的型別(類和介面),並賦予唯一的名字予以標識。

類載入器的組織結構

JVM系列(四) – JVM類載入機制詳解

類載入器的關係

  1. Bootstrap Classloader 是在Java虛擬機器啟動後初始化的。
  2. Bootstrap Classloader 負責載入 ExtClassLoader,並且將 ExtClassLoader的父載入器設定為 Bootstrap Classloader
  3. Bootstrap Classloader 載入完 ExtClassLoader 後,就會載入 AppClassLoader,並且將 AppClassLoader 的父載入器指定為 ExtClassLoader

類載入器的作用

Class Loader 實現方式 具體實現類 負責載入的目標
Bootstrap Loader C++ 由C++實現 %JAVA_HOME%/jre/lib/rt.jar以及-Xbootclasspath引數指定的路徑以及中的類庫
Extension ClassLoader Java sun.misc.Launcher$ExtClassLoader %JAVA_HOME%/jre/lib/ext路徑下以及java.ext.dirs系統變數指定的路徑中類庫
Application ClassLoader Java sun.misc.Launcher$AppClassLoader Classpath以及-classpath-cp指定目錄所指定的位置的類或者是jar文件,它也是Java程式預設的類載入器

類載入器的特點

  • 層級結構:Java裡的類裝載器被組織成了有父子關係的層級結構。Bootstrap類裝載器是所有裝載器的父親。
  • 代理模式: 基於層級結構,類的代理可以在裝載器之間進行代理。當裝載器裝載一個類時,首先會檢查它在父裝載器中是否進行了裝載。如果上層裝載器已經裝載了這個類,這個類會被直接使用。反之,類裝載器會請求裝載這個類
  • 可見性限制:一個子裝載器可以查詢父裝載器中的類,但是一個父裝載器不能查詢子裝載器裡的類。
  • 不允許解除安裝:類裝載器可以裝載一個類但是不可以解除安裝它,不過可以刪除當前的類裝載器,然後建立一個新的類裝載器裝載。

類載入器的隔離問題

每個類裝載器都有一個自己的名稱空間用來儲存已裝載的類。當一個類裝載器裝載一個類時,它會通過儲存在名稱空間裡的類全侷限定名(Fully Qualified Class Name) 進行搜尋來檢測這個類是否已經被載入了。

JVMDalvik 對類唯一的識別是 ClassLoader id + PackageName + ClassName,所以一個執行程式中是有可能存在兩個包名類名完全一致的類的。並且如果這兩個不是由一個 ClassLoader 載入,是無法將一個類的例項強轉為另外一個類的,這就是 ClassLoader 隔離性。

為了解決類載入器的隔離問題JVM引入了雙親委託機制

(五). 雙親委託機制

核心思想:其一,自底向上檢查類是否已載入;其二,自頂向下嘗試載入類

具體載入過程

  1. AppClassLoader載入一個class時,它首先不會自己去嘗試載入這個類,而是把類載入請求委派父類載入器ExtClassLoader去完成。
  2. ExtClassLoader載入一個class時,它首先也不會自己去嘗試載入這個類,而是把類載入請求委派BootStrapClassLoader去完成。
  3. 如果BootStrapClassLoader載入失敗(例如在%JAVA_HOME%/jre/lib裡未查詢到該class),會使用ExtClassLoader來嘗試載入;
  4. 如果ExtClassLoader也載入失敗,則會使用AppClassLoader來載入,如果AppClassLoader也載入失敗,則會報出異常ClassNotFoundException

原始碼分析

ClassLoader.class

  1. loadClass():通過指定類的全限定名稱,由類載入器檢測裝載建立並返回該類的java.lang.Class物件。
    JVM系列(四) – JVM類載入機制詳解

ClassLoader通過loadClass()方法實現了雙親委託機制,用於類的動態載入

loadClass()本身是一個遞迴向上呼叫的過程。

  • 自底向上檢查類是否已載入

    1. 先通過findLoadedClass()方法從最底端類載入器開始檢查類是否已經載入。
    2. 如果已經載入,則根據resolve引數決定是否要執行連線過程,並返回Class物件。
    3. 如果沒有載入,則通過parent.loadClass()委託其父類載入器執行相同的檢查操作(預設不做連線處理)。
    4. 直到頂級類載入器,即parent為空時,由findBootstrapClassOrNull()方法嘗試到Bootstrap ClassLoader中檢查目標類。
  • 自頂向下嘗試載入類

    1. 如果仍然沒有找到目標類,則從Bootstrap ClassLoader開始,通過findClass()方法嘗試到對應的類目錄下去載入目標類。
    2. 如果載入成功,則根據resolve引數決定是否要執行連線過程,並返回Class物件。
    3. 如果載入失敗,則由其子類載入器嘗試載入,直到最底端類載入器也載入失敗,最終丟擲ClassNotFoundException
  1. findLoadedClass()
    JVM系列(四) – JVM類載入機制詳解

查詢當前類載入器的快取中是否已經載入目標類。findLoadedClass()實際呼叫了底層的native方法findLoadedClass0()

  1. findBootstrapClassOrNull()
    JVM系列(四) – JVM類載入機制詳解

查詢最頂端Bootstrap類載入器的是否已經載入目標類。同樣,findBootstrapClassOrNull()實際呼叫了底層的native方法findBootstrapClass()

  1. findClass()
    JVM系列(四) – JVM類載入機制詳解

ClassLoaderjava.lang包下的抽象類,也是所有類載入器(除了Bootstrap)的基類,findClass()ClassLoader對子類提供的載入目標類的抽象方法。

注意Bootstrap ClassLoader並不屬於JVM的層次,它不遵守ClassLoader的載入規則,Bootstrap classLoader並沒有子類。

  1. defineClass()
    JVM系列(四) – JVM類載入機制詳解

defineClass()ClassLoader向子類提供的方法,它可以將.class檔案的二進位制資料轉換為合法的java.lang.Class物件。

(六). 類的動態載入

類的幾種載入方式

  • 通過命令列啟動時由JVM初始化載入;
  • 通過Class.forName()方法動態載入;
  • 通過ClassLoader.loadClass()方法動態載入。

Class.forName()和ClassLoader.loadClass()

  • Class.forName():把類的.class檔案載入到JVM中,對類進行解釋的同時執行類中的static靜態程式碼塊
  • ClassLoader.loadClass():只是把.class檔案載入到JVM中,不會執行static程式碼塊中的內容,只有在newInstance才會去執行。

(七). 物件的初始化

物件的初始化順序

靜態變數/靜態程式碼塊 -> 普通程式碼塊 -> 建構函式

  1. 父類靜態變數靜態程式碼塊(先宣告的先執行);
  2. 子類靜態變數靜態程式碼塊(先宣告的先執行);
  3. 父類普通成員變數普通程式碼塊(先宣告的先執行);
  4. 父類的建構函式
  5. 子類普通成員變數普通程式碼塊(先宣告的先執行);
  6. 子類的建構函式

物件的初始化示例

Parent.java

JVM系列(四) – JVM類載入機制詳解

Children.java

JVM系列(四) – JVM類載入機制詳解

Tester.java

JVM系列(四) – JVM類載入機制詳解

測試結果:

JVM系列(四) – JVM類載入機制詳解

測試結果表明:JVM在建立物件時,遵守以上物件的初始化順序。

(八). 自定義類載入器

編寫自己的類載入器

在原始碼分析階段,我們已經解讀了如何實現自定義類載入器,現在我們開始自己的類載入器。

Step 1:定義待載入的目標類Parent.javaChildren.java

Parent.java

package org.ostenant.jdk8.learning.examples.classloader.custom;

public class Parent {
    protected static String CLASS_NAME;
    protected static String CLASS_LOADER_NAME;
    protected String instanceID;

	// 1.先執行靜態變數和靜態程式碼塊(只在類載入期間執行一次)
    static {
        CLASS_NAME = Parent.class.getName();
        CLASS_LOADER_NAME = Parent.class.getClassLoader().toString();
        System.out.println("Step a: " + CLASS_NAME + " is loaded by " + CLASS_LOADER_NAME);
    }

    // 2.然後執行變數和普通程式碼塊(每次建立例項都會執行)
    {
        instanceID = this.toString();
        System.out.println("Step c: Parent instance is created: " + CLASS_LOADER_NAME + " -> " + instanceID);
    }

    // 3.然後執行構造方法
    public Parent() {
        System.out.println("Step d: Parent instance:" + instanceID + ", constructor is invoked");
    }

    public void say() {
        System.out.println("My first class loader...");
    }
}
複製程式碼

Children.java

package org.ostenant.jdk8.learning.examples.classloader.custom;

public class Children extends Parent {
    static {
        CLASS_NAME = Children.class.getName();
        CLASS_LOADER_NAME = Children.class.getClassLoader().toString();
        System.out.println("Step b: " + CLASS_NAME + " is loaded by " + CLASS_LOADER_NAME);
    }

    {
        instanceID = this.toString();
        System.out.println("Step e: Children instance is created: " + CLASS_LOADER_NAME + " -> " + instanceID);
    }

    public Children() {
        System.out.println("Step f: Children instance:" + instanceID + ", constructor is invoked");
    }

    public void say() {
        System.out.println("My first class loader...");
    }
}
複製程式碼

Step 2:實現自定義類載入器CustomClassLoader

CustomClassLoader.java

public class CustomClassLoader extends ClassLoader {
    private String classPath;

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

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class<?> c = findLoadedClass(name); // 可省略
        if (c == null) {
            byte[] data = loadClassData(name);
            if (data == null) {
                throw new ClassNotFoundException();
            }
            return defineClass(name, data, 0, data.length);
        }
        return null;
    }

    protected byte[] loadClassData(String name) {
        try {
            // package -> file folder
            name = name.replace(".", "//");
            FileInputStream fis = new FileInputStream(new File(classPath + "//" + name + ".class"));
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int len = -1;
            byte[] b = new byte[2048];
            while ((len = fis.read(b)) != -1) {
                baos.write(b, 0, len);
            }
            fis.close();
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}
複製程式碼

Step 3:測試類載入器的載入過程

CustomerClassLoaderTester.java

  1. 測試程式啟動時,逐一拷貝載入待載入的目標類原始檔
    private static final String CHILDREN_SOURCE_CODE_NAME = SOURCE_CODE_LOCATION + "Children.java";
    private static final String PARENT_SOURCE_CODE_NAME = SOURCE_CODE_LOCATION + "Parent.java";
    private static final List<String> SOURCE_CODE = Arrays.asList(CHILDREN_SOURCE_CODE_NAME, PARENT_SOURCE_CODE_NAME);

    static {
        SOURCE_CODE.stream().map(path -> new File(path))
            // 路徑轉檔案物件
            .filter(f -> !f.isDirectory())
            // 檔案遍歷
            .forEach(f -> {
            // 拷貝後原始碼
            File targetFile = copySourceFile(f);
            // 編譯原始碼
            compileSourceFile(targetFile);
        });
    }
複製程式碼
  1. 拷貝單一原始檔到自定義類載入器的類載入目錄
    protected static File copySourceFile(File f) {
        BufferedReader reader = null;
        BufferedWriter writer = null;
        try {
            reader = new BufferedReader(new FileReader(f));
            // package ...;
            String firstLine = reader.readLine();

            StringTokenizer tokenizer = new StringTokenizer(firstLine, " ");
            String packageName = "";
            while (tokenizer.hasMoreElements()) {
                String e = tokenizer.nextToken();
                if (e.contains("package")) {
                    continue;
                } else {
                    packageName = e.trim().substring(0, e.trim().length() - 1);
                }
            }

            // package -> path
            String packagePath = packageName.replace(".", "//");
            // java file path
            String targetFileLocation = TARGET_CODE_LOCALTION + "//" + packagePath + "//";

            String sourceFilePath = f.getPath();
            String fileName = sourceFilePath.substring(sourceFilePath.lastIndexOf("\") + 1);

            File targetFile = new File(targetFileLocation, fileName);
            File targetFileLocationDir = new File(targetFileLocation);
            if (!targetFileLocationDir.exists()) {
                targetFileLocationDir.mkdirs();
            }
            // writer
            writer = new BufferedWriter(new FileWriter(targetFile));
            // 寫入第一行
            writer.write(firstLine);
            writer.newLine();
            writer.newLine();

            String input = "";
            while ((input = reader.readLine()) != null) {
            writer.write(input);
                writer.newLine();
            }

            return targetFile;
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            try {
                reader.close();
                writer.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }
複製程式碼
  1. 對拷貝後的.java原始檔執行手動編譯,在同級目錄下生成.class檔案。
    protected static void compileSourceFile(File f) {
        try {
            JavaCompiler javaCompiler = ToolProvider.getSystemJavaCompiler();
            StandardJavaFileManager standardFileManager = javaCompiler.getStandardFileManager(null, null, null);
            Iterable<? extends JavaFileObject> javaFileObjects = standardFileManager.getJavaFileObjects(f);

            // 執行編譯任務
            CompilationTask task = javaCompiler.getTask(null, standardFileManager, null, null, null, javaFileObjects);
            task.call();
            standardFileManager.close();

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
複製程式碼
  1. 通過自定義類載入器載入Childrenjava.lang.Class<?>物件,然後用反射機制建立Children的例項物件。
    @Test
    public void test() throws Exception {
        // 建立自定義類載入器
        CustomClassLoader classLoader = new CustomClassLoader(TARGET_CODE_LOCALTION); // E://myclassloader//classpath
        // 動態載入class檔案到記憶體中(無連線)
        Class<?> c = classLoader.loadClass("org.ostenant.jdk8.learning.examples.classloader.custom.Children");
        // 通過反射拿到所有的方法
        Method[] declaredMethods = c.getDeclaredMethods();
        for (Method method : declaredMethods) {
            if ("say".equals(method.getName())) {
                // 通過反射拿到children物件
                Object children = c.newInstance();
                // 呼叫children的say()方法
                method.invoke(children);
                break;
            }
        }
    }
複製程式碼

測試編寫的類載入器

(一). 測試場景一

  1. 保留static程式碼塊,把目標類Children.javaParent.java拷貝到類載入的目錄,然後進行手動編譯
  2. 保留測試專案目錄中的目標類Children.javaParent.java
    JVM系列(四) – JVM類載入機制詳解

測試結果輸出:

JVM系列(四) – JVM類載入機制詳解

測試結果分析:

我們成功建立了Children物件,並通過反射呼叫了它的say()方法。
然而檢視控制檯日誌,可以發現類載入使用的仍然是AppClassLoaderCustomClassLoader並沒有生效。

檢視CustomClassLoader的類載入目錄:

JVM系列(四) – JVM類載入機制詳解

類目錄下有我們拷貝編譯ParentChidren檔案。

分析原因:

由於專案空間中的Parent.javaChildren.java,在拷貝後並沒有移除。導致AppClassLoader優先在其Classpath下面找到併成功載入了目標類。

(二). 測試場景二

  1. 註釋掉static程式碼塊(類目錄下有已編譯的目標類.class檔案)。
  2. 移除測試專案目錄中的目標類Children.javaParent.java
    JVM系列(四) – JVM類載入機制詳解

測試結果輸出:

JVM系列(四) – JVM類載入機制詳解

測試結果分析:

我們成功通過自定義類載入器載入了目標類。建立了Children物件,並通過反射呼叫了它的say()方法。

至此,我們自己的一個簡單的類載入器就完成了!

參考書籍

周志明,深入理解Java虛擬機器:JVM高階特性與最佳實踐,機械工業出版社


歡迎關注技術公眾號: 零壹技術棧

零壹技術棧

本帳號將持續分享後端技術乾貨,包括虛擬機器基礎,多執行緒程式設計,高效能框架,非同步、快取和訊息中介軟體,分散式和微服務,架構學習和進階等學習資料和文章。

相關文章