一點一滴探究JVM之類加載機制

@date 2017/11/14
@author stormma


生命不息,奮鬥不止


前言

一點一滴探究JVM系列,主要深入探究JVM運行機制。俗話說,知其然知其所以然。如果不懂JVM的運行機制,那麼無法瞭解Java這門語言最核心的東西,
也就談不上編程之美了,因爲你根本不懂得如何使你的代碼更優雅。廢話不多說,今天的主題就是JVM的類加載機制!

開始之前

在正式開始之前,我們先來看一段小程序!

class Singleton {
    private static Singleton intsance = new Singleton();

    public static int counter1;

    public static int counter2 = 0;

    private Singleton() {
        counter1++;
        counter2++;
    }

    public static Singleton getInstance() {
        return instance;
    }
}

public class Test {
    Singleton instance = Singleton.getInstance();
    System.out.println("counter1 = " + Singleton.counter1 + ", counter2 = " + Singleton.counter2);
}

問題
上面的小程序輸出counter1counter2的值是多少?

現在我不會告訴你正確的答案,除非你自己在你的電腦上運行了這段小程序!下面我們開始進入正題

類的生命週期

要想搞清楚類加載機制,我們必須事先知道類的生命週期

類的生命週期

注:
類從被加載到虛擬機內存中開始,到卸載出內存爲止,它的整個生命週期包括:加載、驗證、準備、解析、初始化、使用和卸載七個階段。其中的驗證、準備、
和解析這三部分稱爲連接。上圖中的七個週期,可以簡稱爲加載、連接、初始化。我想你會很好奇這三步究竟發生了什麼?

  1. 加載: 查找並加載類的二進制數據
  2. 連接:
    • 驗證: 確保被加載類的正確性
    • 準備: 爲類的靜態變量分配內存,並將其初始化爲默認值
    • 解析: 把類的符號引用轉換爲直接引用
  3. 初始化: 爲類的靜態變量賦予正確的初始值

可能你還不是很理解��的某些術語,稍安勿躁,這才只是個開始!

關於類加載

什麼情況下,會觸發類的加載過程,這確實是一個很難回答的問題,因爲Java虛擬機規範中並沒有進行強制性的約束,而是交給虛擬機具體實現來把握。Java虛擬機規範允許,類不需要等到被主動使用時候纔去加載它,類加載器在預料到某個類將要被使用時,就預先加載它,如果在加載的過程中遇到了.class文件缺失或者莫名其妙的錯誤,類加載器必須在程序首次主動使用該類的時候才報告錯誤(LinkagError),如果這個類一直沒有被主動使用,那麼類加載器就不會報告錯誤!

我在上面的表述中有三個加粗的字體來着重突出主動使用這個概念,或許你會很好奇什麼是主動使用,既然有主動使用,那麼是不是也有被動使用呢?不得不說你很聰明,主動使用和被動使用的概念,我不打算在這講,因爲這兩個概念和初始化階段關係密切!

類加載需要完成的事情:

  1. 通過一個類的全限定名來獲取其定義的二進制字節流。(全限定名即報名+類名)
  2. 將這個字節流所代表的靜態存儲結構轉化爲方法區的運行時數據結構。
  3. 在Java堆中生成一個代表這個類的 java.lang.Class對象,作爲對方法區中這些數據的訪問入口。

注: 獲得類的二進制字節流還可以從ZIP包讀取、網絡中獲取、JAR包獲取、或者其他文件獲取(JSP應用)!

現在你大概清楚了類加載過程完成了哪些操作,那麼不得不說說類加載器了

類加載器

類加載器雖然只用於實現類的加載動作,但它在Java程序中起到的作用卻遠遠不限於類的加載階段。對於任意一個類,都需要由它的類加載器和這個類本身一同確定其在Java虛擬機中的唯一性,也就是說,即使兩個類來源於同一個Class文件,只要加載它們的類加載器不同,那這兩個類就必定不相等。這裏的“相等”包括了代表類的Class對象的equals()isAssignableFrom()isInstance()等方法的返回結果,也包括了使用instanceof關鍵字對對象所屬關係的判定結果。

我發誓我不會騙你,因爲有代碼爲證:

Example.java

public class Example {
    public static final String NAME = "stormma";
}

InstanceOfTest.java

/**
 * @author stormma
 * @date 2017/11/14
 */
public class InstanceOfTest {

    @Test
    public void testDifferentLoaderLoadClass() throws ClassNotFoundException, IllegalAccessException, InstantiationException {
        ClassLoader classLoader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                String filaneName = name.substring(name.lastIndexOf('.') + 1) + ".class";
                InputStream inputStream = getClass().getResourceAsStream(filaneName);
                if (inputStream == null) {
                    return super.loadClass(name);
                }
                try {
                    byte[] b = new byte[inputStream.available()];
                    inputStream.read(b);
                    return defineClass(name, b, 0, b.length);
                } catch (IOException e) {
                    throw new ClassNotFoundException(name);
                }
            }
        };

        Class clazz = classLoader.loadClass("me.stormma.chapter4.Example");
        System.out.println(clazz.newInstance().getClass()); // class me.stormma.chapter4.Example
        System.out.println(clazz.newInstance() instanceof Example); // false
        System.out.println(clazz.getClassLoader()); // me.stormma.chapter4.InstanceOfTest$1@26a7b76d
        System.out.println(InstanceOfTest.class.getClassLoader()); // jdk.internal.loader.ClassLoaders$AppClassLoader@4f8e5cde
        System.out.println(clazz.equals(Example.class)); // false
        System.out.println(clazz.isAssignableFrom(Example.class)); // false
        System.out.println(clazz.isInstance(Example.class)); // false
    }

    @Test
    public void testSameLoaderLoadClass() throws IllegalAccessException, InstantiationException, ClassNotFoundException {
        Class<?> clazz = InstanceOfTest.class.getClassLoader().loadClass("me.stormma.chapter4.Example");
        System.out.println(clazz.newInstance() instanceof Example); //true
        System.out.println(InstanceOfTest.class.getClassLoader() == clazz.getClassLoader()); // true
    }
}

如果從JVM角度來看,所有的類加載器可以分爲:

  • 啓動類加載器(Bootstrap ClassLoader,它負責加載存放在$JAVA_HOME/jre/lib下,或被-Xbootclasspath參數指定的路徑中的,並且能被虛擬機識別的類庫(如 rt.jar)。啓動類加載器是無法被Java程序直接引用的。很容易可以驗證,執行System.out.println(String.class.getClassLoader())打印結果爲null)
  • 擴展類加載器(Extension ClassLoader, 該加載器由sun.misc.Launcher$ExtClassLoader實現,它負責加載$JAVA_HOME/jre/lib/ext目錄中,或者由java.ext.dirs系統變量指定的路徑中的所有類庫(如javax.*開頭的類),開發者可以直接使用擴展類加載器。在jdk1.9中類加載器有所變化!1.9中jdk.internal.loader.ClassLoaders$PlatformClassLoader,稱爲平臺類加載器)
  • 應用程序加載器(Application ClassLoader,該類加載器由sun.misc.Launcher$AppClassLoader來實現,它負責加載用戶類路徑ClassPath所指定的類,開發者可以直接使用該類加載器,如果應用程序中沒有自定義過自己的類加載器,一般情況下這個就是程序中默認的類加載器。注意在jdk1.9中,應用程序加載器由jdk.internal.loader.ClassLoaders$AppClassLoader實現)

你的應用程序,其實就是這幾種類加載器配合使用進行加載的,如果有必要,你可以實現自己的類加載器!比如Tomcat中就有自己的類加載器的實現!

下面,我要介紹一個更重要的概念!雙親委託機制

雙親委託機制

類加載器的層次關係如下:

類加載器的層次關係

這種層次關係稱爲類加載器的雙親委派模型。我們把每一層上面的類加載器叫做當前層類加載器的父加載器,當然,它們之間的父子關係並不是通過繼承關係來實現的,而是使用組合關係來複用父加載器中的代碼。該模型在JDK1.2期間被引入並廣泛應用於之後幾乎所有的Java程序中,但它並不是一個強制性的約束模型,而是Java設計者們推薦給開發者的一種類的加載器實現方式。

雙親委派模型的工作流程是:如果一個類加載器收到了類加載的請求,它首先不會自己去嘗試加載這個類,而是把請求委託給父加載器去完成,依次向上,因此,所有的類加載請求最終都應該被傳遞到頂層的啓動類加載器中,只有當父加載器在它的搜索範圍中沒有找到所需的類時,即無法完成該加載,子加載器纔會嘗試自己去加載該類。

使用雙親委派模型來組織類加載器之間的關係,有一個很明顯的好處,就是Java類隨着它的類加載器(說白了,就是它所在的目錄)一起具備了一種帶有優先級的層次關係,這對於保證Java程序的穩定運作很重要。例如,類java.lang.Object類存放在$JAVA_HOME/jre/lib下的rt.jar之中,因此無論是哪個類加載器要加載此類,最終都會委派給啓動類加載器進行加載,這邊保證了Object類在程序中的各種類加載器中都是同一個類。但是試想一下,如果自定義的加載器去加載的話,那麼程序中會出現不同的Object類(詳細前面測試代碼)!那樣將是一片混亂。

到這,我想我們應該去看一下ClassLoader這個抽象類的雙親委託機制的實現了!

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) { // 調用自身的findClass來進行類的加載
                    // 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
                    PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

話說,jdk的註釋真的好詳細!

這段代碼是不是很簡單很簡單!對照我們上面的測試代碼自定義的那個類加載器,如果是實現findClass()而沒有實現loadClass()方法,那麼加載時候先開始判斷它的父類加載器(自定義類加載器的上一級是應用程序類加載器,然後根據雙親委託機制一步一步進行判斷加載。最後加載都不成功就會調用findClass()方法來加載,jdk1.2之後官方不提倡實現loadClass()!上面的例子,爲了測試兩個Class對象不相等,強制實現了loadClass(),因爲如果只實現findClass(), 就會被應用類加載器所加載)

關於驗證

驗證的目的是爲了確保Class文件中的字節流包含的信息符合當前虛擬機的要求(你可能會有疑問,Java編譯之後的class文件,JVM爲啥還不相信呢?其實,你也可以僞造一個class文件,讓JVM去加載執行,如果這有害,那麼肯定會損害JVM,所以說
JVM很”狡猾”),而且不會危害虛擬機自身的安全。不同的虛擬機對類驗證的實現可能會有所不同,但大致都會完成以下四個階段的驗證:文件格式的驗證、元數據的驗證、字節碼驗證和符號引用驗證。

  • 文件格式的驗證:驗證字節流是否符合Class文件格式的規範,並且能被當前版本的虛擬機處理,該驗證的主要目的是保證輸入的字節流能正確地解析並存儲於方法區之內。經過該階段的驗證後,字節流纔會進入內存的方法區中進行存儲,後面的三個驗證都是基於方法區的存儲結構進行的。
  • 元數據驗證:對類的元數據信息進行語義校驗(其實就是對類中的各數據類型進行語法校驗),保證不存在不符合Java語法規範的元數據信息。
  • 字節碼驗證:該階段驗證的主要工作是進行數據流和控制流分析,對類的方法體進行校驗分析,以保證被校驗的類的方法在運行時不會做出危害虛擬機安全的行爲。
  • 符號引用驗證:這是最後一個階段的驗證,它發生在虛擬機將符號引用轉化爲直接引用的時候(解析階段中發生該轉化,後面會有講解),主要是對類自身以外的信息(常量池中的各種符號引用)進行匹配性的校驗。

關於驗證階段,很多東西都是和class文件字節碼相關的(哦,對,這是句廢話),深入探究驗證階段的前提是讀懂Class文件字節碼,後面的文章,我會專門對Class文件字節碼進行總結,力爭讓看完文章的每個人都可以看懂Class文件字節碼, Come On! CafeBabe,什麼,CafeBabe是啥?這其實就是Class字節碼的魔數,每個Class文件字節碼都是以CafeBabe開頭的,不,不對,是每個符合JVM標準的字節碼,程序員是不是很浪漫,哈哈~

關於準備

準備階段是正式爲類變量分配內存並設置初始值的階段,這些變量所使用的內存都將在方法區中進行分配。這個階段有兩個容易混淆的概念,首先,進行內存分配的僅包括類變量(被static修飾的變量),不包括實例變量,實例變量是在對象實例化的時候分配在
heap區的!看到這,你是不是想回去看看我們開頭的那道題目了,別急,還有一些東西你沒看到呢!

我剛纔說了分配內存之後要設置初始值,對,你沒看錯,但是這個初始值是初值,默認值,而不是你代碼的初始值,這其實就是開頭那道題目答案不如你所想的原因!接着看吧!

假如我們定義了一個類變量public static String NAME = "stormma";

那麼,在當前所處的準備階段,給這個變量分配內存之後,初始值是null而不是stormma,而最後的賦值是發生在初始化階段,關於各種類型的初始值

類型初始值

關於解析

解析階段是虛擬機將常量池中的符號引用轉化爲直接引用的過程。在Class類文件結構一文中已經比較過了符號引用和直接引用的區別和關聯,這裏不再贅述。前面說解析階段可能開始於初始化之前,也可能在初始化之後開始,虛擬機會根據需要來判斷,到底是在類被加載器加載時就對常量池中的符號引用進行解析(初始化之前),還是等到一個符號引用將要被使用前纔去解析它(初始化之後)。

  • 類或接口的解析:判斷所要轉化成的直接引用是對數組類型,還是普通的對象類型的引用,從而進行不同的解析。
  • 字段解析:對字段進行解析時,會先在本類中查找是否包含有簡單名稱和字段描述符都與目標相匹配的字段,如果有,則查找結束;如果沒有,則會按照繼承關係從上往下遞歸搜索該類所實現的各個接口和它們的父接口,還沒有,則按照繼承關係從上往下遞歸搜索其父類,直至查找結束。

對於解析和驗證這一塊,和讀懂Class文件有着密不可分的關係,所以這一塊的補充知識會在讀懂Class文件字節碼之後進行講解!

初始化

初始化是類加載過程的最後一步,到了此階段,才真正開始執行類中定義的Java程序代碼。在準備階段,類變量已經被賦過一次系統要求的初始值,而在初始化階段,則是根據程序員通過程序指定的主觀計劃去初始化類變量和其他資源。
或者可以從另一個角度來表達:初始化階段是執行類構造器<clinit>()方法的過程。

  1. <clinit>()方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊中(static{})的語句合併產生的,編譯器收集的順序是由語句在源文件中出現的順序所決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之後的變量,在前面的靜態語句中可以賦值,但是不能訪問。

  2. <clinit>()方法與實例構造器<clinit>()方法(類的構造函數)不同,它不需要顯式地調用父類構造器,虛擬機會保證在子類的<clinit>()方法執行之前,父類的<clinit>()方法已經執行完畢。因此,在虛擬機中第一個被執行的<clinit>()方法的類肯定是java.lang.Object

  3. <clinit>()方法對於類或接口來說並不是必須的,如果一個類中沒有靜態語句塊,也沒有對類變量的賦值操作,那麼編譯器可以不爲這個類生成()方法。

  4. 接口中不能使用靜態語句塊,但仍然有類變量(final static)初始化的賦值操作,因此接口與類一樣會生成<clinit>()方法。但是接口魚類不同的是:執行接口的()方法不需要先執行父接口的<clinit>()方法,只有當父接口中定義的變量被使用時,父接口才會被初始化。另外,接口的實現類在初始化時也一樣不會執行接口的<clinit>()方法。

  5. 虛擬機會保證一個類的<clinit>()方法在多線程環境中被正確地加鎖和同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()方法,其他線程都需要阻塞等待,直到活動線程執行<clinit>()方法完畢。如果在一個類的<clinit>()方法中有耗時很長的操作,那就可能造成多個線程阻塞,在實際應用中這種阻塞往往是很隱蔽的。

看到這,我相信你現在回頭看看我們開始的那道題目,你已經可以解釋那道題目的答案了!

是的,剛開始觸發類加載之後的一系列操作完成之後,開始進行初始化,賦初值, counter1 = counter2 = 0,instance = null,然後開始執行用戶的初始化,instance = new Singleton(),然後執行構造器,counter1 = counter2 = 1
然後初始化counter1,因爲counter1無用戶初始化的值,然後執行counter2 = 0,所有counter2從0變化1再變化到0。

再看個例子

public class FinalTest {

    public static void main(String[] args) {
        System.out.println(FinalT.NAME);
    }
}


class FinalT {
    public static final java.lang.String NAME = "stormma";

    static {
        System.out.println("FinalT初始化");
    }
}

答案是”stormma”

爲啥沒有”FinalT初始化”呢,我們先來看一下這個class文件的字節碼吧!

stormma@stormma:~/coding/java-project/concurrency/target/classes/me/stormma/chapter4$ javap -verbose FinalTest.class 
Classfile /Users/stormma/coding/java-project/concurrency/target/classes/me/stormma/chapter4/FinalTest.class
  Last modified 2017-11-14; size 598 bytes
  MD5 checksum f6a69bfc19f0e693fdc57a9af831cfb8
  Compiled from "FinalTest.java"
public class me.stormma.chapter4.FinalTest
  minor version: 0
  major version: 50
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #7.#21         // java/lang/Object."<init>":()V
   #2 = Fieldref           #22.#23        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = Class              #24            // me/stormma/chapter4/FinalT
   #4 = String             #25            // stormma
   #5 = Methodref          #26.#27        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #6 = Class              #28            // me/stormma/chapter4/FinalTest
   #7 = Class              #29            // java/lang/Object
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               LocalVariableTable
  #13 = Utf8               this
  #14 = Utf8               Lme/stormma/chapter4/FinalTest;
  #15 = Utf8               main
  #16 = Utf8               ([Ljava/lang/String;)V
  #17 = Utf8               args
  #18 = Utf8               [Ljava/lang/String;
  #19 = Utf8               SourceFile
  #20 = Utf8               FinalTest.java
  #21 = NameAndType        #8:#9          // "<init>":()V
  #22 = Class              #30            // java/lang/System
  #23 = NameAndType        #31:#32        // out:Ljava/io/PrintStream;
  #24 = Utf8               me/stormma/chapter4/FinalT
  #25 = Utf8               stormma
  #26 = Class              #33            // java/io/PrintStream
  #27 = NameAndType        #34:#35        // println:(Ljava/lang/String;)V
  #28 = Utf8               me/stormma/chapter4/FinalTest
  #29 = Utf8               java/lang/Object
  #30 = Utf8               java/lang/System
  #31 = Utf8               out
  #32 = Utf8               Ljava/io/PrintStream;
  #33 = Utf8               java/io/PrintStream
  #34 = Utf8               println
  #35 = Utf8               (Ljava/lang/String;)V
{
  public me.stormma.chapter4.FinalTest();
    descriptor: ()V
    flags: 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 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lme/stormma/chapter4/FinalTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #4                  // String stormma
         5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 11: 0
        line 12: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}
SourceFile: "FinalTest.java"

其中#4 = String #25 // stormma這個地方,是個常量,所以沒有觸發類的初始化,所以也不會執行static塊中的初始化

對了,差點忘了,有個重要的東西沒介紹,前面我們粗體表示的主動使用,以及被動使用

主動使用

主動使用 (這幾種第一次發生的情況下,進行類的初始化)
- 創建類的實例
- 訪問某個類或者接口的靜態變量,或者對該靜態變量賦值
- 訪問類的靜態方法
- 反射Class.forName();
- 初始化一個類的子類
- Jvm啓動時被標記爲啓動類的類

結尾

JVM的類加載機制,深究一篇博文難以概括全,由於本人表達能力欠缺,如果有些東西不懂實屬我的疏漏,如果你有疑問,或者有建議,請聯繫我,github

本文來自我的個人博客轉載請註明出處!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章