深入探究JVM之類載入與雙親委派機制

夜勿語發表於2020-08-01

@

前言

前面學習了虛擬機器的記憶體結構、物件的分配和建立,但物件所對應的類是怎麼載入到虛擬機器中來的呢?載入過程中需要做些什麼?什麼是雙親委派機制以及為什麼要打破雙親委派機制?

類的生命週期

在這裡插入圖片描述
類的生命週期包含了如上的7個階段,其中驗證準備解析統稱為連線 ,類的載入主要是前五個階段,每個階段基本上保持如上順序開始(僅僅是開始,實際上執行是交叉混合的),只有解析階段不一定,在初始化後也有可能才開始執行解析,這是為了支援動態語言。

載入

載入就是將位元組碼的二進位制流轉化為方法區的執行時資料結構,並生成類所物件的Class物件,位元組碼二進位制流可以是我們編譯後的class檔案,也可以從網路中獲取,或者執行時動態生成(動態代理)等等。
那什麼時候會觸發類載入呢?這個在虛擬機器規範中沒有明確定義,只是規定了何時需要執行初始化(稍後詳細分析)。

驗證

這個階段很好理解,就是進行必要的校驗,確保載入到記憶體中的位元組碼是符合要求的,主要包含以下四個校驗步驟(瞭解即可):

  • 檔案格式校驗:這個階段要校驗的東西非常多,主要的有下面這些(實際上遠遠不止)
    • 是否以魔數0xCAFEBABE開頭。
    • 主、次版本號是否在當前Java虛擬機器接受範圍之內。
    • 常量池的常量中是否有不被支援的常量型別(檢查常量tag標誌)。
    • 指向常量的各種索引值中是否有指向不存在的常量或不符合型別的常量。
    • CONSTANT_Utf8_info型的常量中是否有不符合UTF-8編碼的資料。
    • Class檔案中各個部分及檔案本身是否有被刪除的或附加的其他資訊。
    • 。。。。。。
  • 後設資料校驗:對位元組碼描述資訊進行語義分析。
    • 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
    • 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
    • 如果這個類不是抽象類,是否實現了其父類或介面之中要求實現的所有方法。
    • 類中的欄位、方法是否與父類產生矛盾(例如覆蓋了父類的final欄位,或者出現不符合規則的方法過載,例如方法引數都一致,但返回值型別卻不同等)。
    • 。。。。。。
  • 位元組碼校驗:確保程式沒有語法和邏輯錯誤,這是整個驗證階段最複雜的一個步驟。
    • 保證任意時刻運算元棧的資料型別與指令程式碼序列都能配合工作,例如不會出現類似於“在操作棧放置了一個 int 型別的資料,使用時卻按 long 型別來載入入本地變數表中”這樣的情況。
    • 保證任何跳轉指令都不會跳轉到方法體以外的位元組碼指令上。
    • 保證方法體中的型別轉換總是有效的,例如可以把-個子類物件賦值給父類資料型別,這是安全的,但是把父類物件賦值給子類資料型別,甚至把物件賦值給與它毫無繼承關係、完全不相干的一個資料型別,則是危險和不合法的。
    • 。。。。。。
  • 符號引用驗證:這個階段發生在符號引用轉為直接引用的時候,即實際上是在解析階段中進行的。
    • 符號引用中通過字串描述的全限定名是否能找到對應的類。
    • 在指定類中是否存在符合方法的欄位描述符及簡單名稱所描述的方法和欄位。
    • 符號引用中的類、欄位、方法的可訪問性( private、 protected. public、 )。
    • 是否可被當前類訪問。
    • 。。。。。。

準備

該階段是為類變數(static)分配記憶體並設定零值,即類只要經過準備階段其中的靜態變數就是可使用的了,但此時類變數的值還不是我們想要的值,需要經過初始化階段才會將我們希望的值賦值給對應的靜態變數。

解析

解析就是將常量池中的符號引用替換為直接引用的過程。符號引用就是一個代號,比如我們的名字,而這裡可以理解為就是類的完全限定名直接引用則是對應的具體的人、物,這裡就是指目標的記憶體地址。為什麼需要符號引用呢?因為類在載入到記憶體之前還沒有分配記憶體地址,因此必然需要一個東西指代它。這個階段包含了類或介面的解析欄位解析類方法解析介面方法解析,在解析的過程中可能會丟擲以下異常:

  • java.lang.NoSuchFieldError:找不到欄位
  • java.lang.IllegalAccessError:不具有訪問許可權
  • java.lang.NoSuchMethodError:找不到方法

初始化

這是類載入過程中的最後一個步驟,主要是收集類的靜態變數的賦值動作static塊中的語句合成<cinit>方法,通過該方法根據我們的意願為靜態變數賦值以及執行static塊,該方法會被加鎖,確保多執行緒情況下只有一個執行緒能初始化成功,利用該特性可以實現單例模式。虛擬機器規定了有且只有遇到以下情況時必須先確保對應類的初始化完成(載入、準備必然在此之前):

  • 遇到new、getstatic、putstatic或invokestatic這四條位元組碼指令時。能夠生成這四條指令的典型Java程式碼場景有:
    • 使用new關鍵字例項化物件的時候。
    • 讀取或設定一個型別的靜態欄位(被final修飾、已在編譯期把結果放入常量池的靜態欄位除外)的時候。
    • 呼叫一個型別的靜態方法的時候。
  • 反射呼叫類時。
  • 當初始化類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
  • 當虛擬機器啟動時,使用者需要指定一個要執行的主類(包含main()方法的那個類),虛擬機器會先初始化這個主類。
  • 當使用JDK 7新加入的動態語言支援時,如果一個java.lang.invoke.MethodHandle例項最後的解析結果為REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四種型別的方法控制程式碼,並且這個方法控制程式碼對應的類沒有進行過初始化,則需要先觸發其初始化。
  • 當一個介面中定義了JDK 8新加入的預設方法(被default關鍵字修飾的介面方法)時,如果有這個介面的實現類發生了初始化,那該介面要在其之前被初始化。

下面分析幾個案例程式碼,讀者們可以先思考後再執行程式碼看看和自己想的是否一樣。

案例一

先定義如下兩個類:

public class SuperClazz {
	static 	{
		System.out.println("SuperClass init!");
	}
	public static int value=123;
	public static final String HELLOWORLD="hello world";
	public static final int WHAT = value;
}

public class SubClaszz extends SuperClazz {
	static{
		System.out.println("SubClass init!");
	}

}

然後進行下面的呼叫:

public class Initialization {
	public static void main(String[]args){
		Initialization initialization = new Initialization();
		initialization.M1();
	}
	
	public void M1(){
		System.out.println(SubClaszz.value);
	}
}

第一個案例是通過子類去引用父類中的靜態變數,兩個類都會載入和初始化麼?列印結果看看:

SuperClass init!
123

可以看到只有父類初始化了,那麼父類必然是載入了的,問題就在於子類有沒有被載入呢?可以加上引數:-XX:+TraceClassLoading再執行(該引數的作用就是列印被載入了的類),可以看到子類是被載入了的。所以通過子類引用父類靜態變數,父子類都會被載入,但只有父類會進行初始化
為什麼呢?反編譯後可以看到生成了如下指令:

0: getstatic     #5                  // Field java/lang/System.out:Ljava/io/PrintStream;
3: getstatic     #6                  // Field ex7/init/SubClaszz.value:I
6: invokevirtual #7                  // Method java/io/PrintStream.println:(I)V
9: return

關鍵就是getstatic指令就會觸發類的初始化,但是為什麼子類不會初始化呢?因為這個變數是來自於父類的,為了提高效率,所以虛擬機器進行了優化,這種情況只需要初始化父類就行了。

案例二

呼叫下面的方法:

	public void M2(){
		SubClaszz[]sca = new SubClaszz[10];
	}

執行後可以發現,使用陣列,不會觸發初始化,但父子類都會被載入

案例三

	public void M3(){
		System.out.println(SuperClazz.HELLOWORLD);
	}

引用常量不會觸發類的載入和初始化,因為常量在編譯後就已經存在當前class的常量池。

案例四

	public void M4(){
		System.out.println(SubClaszz.WHAT);
	}

通過常量去引用其它的靜態變數會發生什麼呢?這個和案例一結果是一樣的。

類載入器

類載入器和雙親委派模型

在我們平時開發中,確定一個類需要通過完全限定名,而不能簡單的通過名字,因為在不同的路徑下我們是可以定義同名的類的。那麼在虛擬機器中又是怎麼區分類的呢?在虛擬機器中需要類載入器+完全限定名一起來指定一個類的唯一性,即相同限定名的類若由兩個不同的類載入器載入,那虛擬機器就不會把它們當做一個類。從這裡我們可以看出類載入器一定是有多個的,那麼不同的類載入器是怎麼組織的?它們又分別需要載入哪些類呢?
在這裡插入圖片描述
從虛擬角度看,只有兩種型別的類載入器:啟動類載入器(BootstrapClassLoader)非啟動類載入器。前者是C++實現,屬於虛擬機器的一部分,後者則是由Java實現的,獨立於虛擬機器的外部,並且全部繼承自抽象類java.lang.ClassLoader。
但從Java本身來看,一直保持著三層類載入器雙親委派的結構,當然除了Java本身提供的三層類載入器,我們還可以自定義實現類載入器。如上圖,上面三個就是原生的類載入器,每一個都是下一個類載入器的父載入器,注意這裡都是採用組合而非繼承。當開始載入類時,首先交給父載入器載入,父載入器載入了子載入器就不用再載入了,而若是父載入器載入不了,就會交給子載入器載入,這就是雙親委派機制。這就好比工作中遇到了無法處理的事,你會去請示直接領導,直接領導處理不了,再找上層領導,然後上層領導覺得這是個小事,不用他親自動手,就讓你的直接領導去做,接著他又交給你去做等等。下面來看看每個類載入器的具體作用:

  • BootstrapClassLoader:啟動類載入器,顧名思義,這個類載入器主要負責載入JDK lib包,以及-Xbootclasspath引數指定的目錄,並且虛擬機器對檔名進行了限定,也就是說即使我們自己寫個jar放入到上述目錄,也不會被載入。由於該類載入器是C++使用,所以我們的Java程式中無法直接引用,呼叫java.lang.ClassLoader.getClassLoader()方法時預設返回的是null。
  • ExtClassLoader:擴充套件類載入器,主要負責載入JDK lib/ext包,以及被系統變數java.ext.dirs指向的所有類庫,這個類庫可以存放我們自己寫的通用jar。
  • AppClassLoader:應用程式類載入器,負責載入使用者classpath上的所有類。它是java.lang.ClassLoader.getSystemClassLoader()的返回值,也是我們程式的預設類載入器(如果我們沒有自定義類載入器的話)。

通過這三個類載入以及雙親委派機制,一個顯而易見的好處就是,不同的類隨它的類載入器天然具有了載入優先順序,像Object、String等等這些核心類庫自然就會在我們的應用程式類之前被載入,使得程式更安全,不會出現錯誤,Spring的父子容器也是這樣的一個設計。通過下面這段程式碼可以看到每個類所對應的類載入器:

public class ClassLoader {
    public static void main(String[] args) {
        System.out.println(String.class.getClassLoader()); //啟動類載入器
        System.out.println(sun.net.spi.nameservice.dns.DNSNameService.class.getClassLoader());//擴充類載入器
        System.out.println(ClassLoader.class.getClassLoader());//應用程式類載入器
    }
}

輸出:

null
sun.misc.Launcher$ExtClassLoader@4b67cf4d
sun.misc.Launcher$AppClassLoader@14dad5dc

破壞雙親委派模型

剛剛我舉了工作中的一個例子來說明雙親委派機制,但現實中我們不需要事事都去請示領導,同樣類載入器也不是完全遵循雙親委派機制,在必要的時候是可以打破這個規則的。下面列舉四個破壞的情況,在此之前我們需要先了解下雙親 委派的程式碼實現原理,在java.lang.ClassLoader類中有一個loadClass以及findClass方法:

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

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

從上面可以看到首先是呼叫parent去載入類,沒有載入到才呼叫自身的findClass方法去載入。也就是說使用者在實現自定義類載入器的時候需要覆蓋的是fiindClass而不是loadClass,這樣才能滿足雙親委派模型
下面具體來看看破壞雙親委派的幾個場景。

第一次

第一次破壞是在雙親委派模型出現之前, 因為該模型是在JDK1.2之後才引入的,那麼在此之前,抽象類java.lang.ClassLoader就已經存在了,使用者自定義的類載入器都會去覆蓋該類中的loadClass方法,所以雙親委派模型出現後,就無法避免使用者覆蓋該方法,因此新增了findClass引導使用者去覆蓋該方法實現自己的類載入邏輯。

SPI

第二次破壞是由於這個模型本身缺陷導致的,因為該模型保證了類的載入優先順序,但是有些介面是Java定義在核心類庫中,但具體的服務實現是由使用者提供的,這時候就不得不破壞該模型才能實現,典型的就是Java中的SPI機制(對SPI不瞭解的讀者可以翻閱我之前的文章或是其它資料,這裡不進行闡述)。J
DBC的驅動載入就是SPI實現的,所以直接看到java.sql.DriverManager類,該類中有一個靜態初始化塊:

    static {
        loadInitialDrivers();
        println("JDBC DriverManager initialized");
    }

    private static void loadInitialDrivers() {
        String drivers;
        try {
            drivers = AccessController.doPrivileged(new PrivilegedAction<String>() {
                public String run() {
                    return System.getProperty("jdbc.drivers");
                }
            });
        } catch (Exception ex) {
            drivers = null;
        }

        AccessController.doPrivileged(new PrivilegedAction<Void>() {
            public Void run() {

                ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
                Iterator<Driver> driversIterator = loadedDrivers.iterator();
                
                try{
                    while(driversIterator.hasNext()) {
                        driversIterator.next();
                    }
                } catch(Throwable t) {
                // Do nothing
                }
                return null;
            }
        });

        println("DriverManager.initialize: jdbc.drivers = " + drivers);

        if (drivers == null || drivers.equals("")) {
            return;
        }
        String[] driversList = drivers.split(":");
        println("number of Drivers:" + driversList.length);
        for (String aDriver : driversList) {
            try {
                println("DriverManager.Initialize: loading " + aDriver);
                Class.forName(aDriver, true,
                        ClassLoader.getSystemClassLoader());
            } catch (Exception ex) {
                println("DriverManager.Initialize: load failed: " + ex);
            }
        }
    }

主要看ServiceLoader.load方法,這個就是通過SPI去載入我們引入java.sql.Driver實現類(比如引入mysql的驅動包就是com.mysql.cj.jdbc.Driver):

    public static <S> ServiceLoader<S> load(Class<S> service) {
        ClassLoader cl = Thread.currentThread().getContextClassLoader();
        return ServiceLoader.load(service, cl);
    }

這個方法主要是從當前執行緒中獲取類載入器,然後通過這個類載入器去載入驅動實現類(這個叫執行緒上下文類載入器,我們也可以使用這個技巧去打破雙親委派),那這裡會獲取到哪一個類載入器呢?具體的設定是在sun.misc.Launcher類的構造器中:

    public Launcher() {
        Launcher.ExtClassLoader var1;
        try {
            var1 = Launcher.ExtClassLoader.getExtClassLoader();
        } catch (IOException var10) {
            throw new InternalError("Could not create extension class loader", var10);
        }

        try {
            this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
        } catch (IOException var9) {
            throw new InternalError("Could not create application class loader", var9);
        }

        Thread.currentThread().setContextClassLoader(this.loader);
        String var2 = System.getProperty("java.security.manager");
        if (var2 != null) {
            SecurityManager var3 = null;
            if (!"".equals(var2) && !"default".equals(var2)) {
                try {
                    var3 = (SecurityManager)this.loader.loadClass(var2).newInstance();
                } catch (IllegalAccessException var5) {
                } catch (InstantiationException var6) {
                } catch (ClassNotFoundException var7) {
                } catch (ClassCastException var8) {
                }
            } else {
                var3 = new SecurityManager();
            }

            if (var3 == null) {
                throw new InternalError("Could not create SecurityManager: " + var2);
            }

            System.setSecurityManager(var3);
        }

    }

可以看到設定的就是AppClassLoader。你可能會有點疑惑,這個類載入器載入類的時候不也是先呼叫父類載入器載入麼,怎麼就打破雙親委派了呢?其實打破雙親委派指的就是類的層次結構,延伸意思就是類的載入優先順序,這裡本應該是在載入核心類庫的時候卻提前將我們應用程式中的類庫給載入到虛擬機器中來了。

Tomcat

在這裡插入圖片描述
上圖是Tomcat類載入的類圖,前面三個不用說,CommonClassLoaderCatalinaClassLoaderSharedClassLoaderWebAppClassLoaderJspClassLoader則是Tomcat自己實現的類載入器,分別載入common包server包shared包WebApp/WEB-INF/lib包以及JSP檔案,前面三個在tomcat 6之後已經合併到根目錄下的lib目錄下。而WebAppClassLoader則是每一個應用程式對應一個,JspClassLoader是每一個JSP檔案都會對應一個,並且這兩個類載入器都沒有父類載入器,這也就違背了雙親委派模型。
為什麼每個應用程式需要單獨的WebAppClassLoader例項?因為每個應用程式需要彼此隔離,假如在兩個應用中定義了一樣的類(完全限定名),如果遵循雙親委派那就只會存在一份了,另外不同的應用還有可能依賴同一個類庫的不同版本,這也需要隔離,所以每一個應用程式都會對應一個WebAppClassLoader,它們共享的類庫可以讓SharedClassLoader載入,另外這些類載入載入的類對Tomcat本身來說也是隔離的(CatalinaClassLoader載入的)。
為什麼每個JSP檔案需要對應單獨的一個JspClassLoader例項?這是由於JSP是支援執行時修改的,修改後會丟棄掉之前編譯生成的class,並重新生成一個JspClassLoader例項去載入新的class。
以上就是Tomcat為什麼要打破雙親委派模型的原因。

OSGI

OSGI是用於實現模組熱部署,像Eclipse的外掛系統就是利用OSGI實現的,這個技術非常複雜同時使用的也越來越少了,感興趣的讀者可自行查閱資料學習,這裡不再進行闡述。

總結

類載入的過程讓我們瞭解到一個類是如何被載入到記憶體中,需要經過哪些階段;而類載入器和雙親委派模型則是告訴我們應該怎麼去載入類、類的載入優先順序是怎樣的,其中的設計思想我們也可以學習借鑑;最後需要深刻理解的是為什麼需要打破雙親委派,在遇到相應的場景時應該怎麼做。

相關文章