【java虛擬機系列】JVM類加載器與ClassNotFoundException和NoClassDefFoundError

在我們日常的項目開發中,會經常碰到ClassNotFoundException和NoClassDefFoundError這兩種異常,對於經驗足夠的工程師而言,可能很輕鬆的就可以解決,但是卻不一定明白爲何要去這麼做,本博客將從java虛擬機類加載的角度讓大家徹底理解ClassNotFoundException和NoClassDefFoundError這兩種異常及一些重用的解決方案。


在博客中我們已經講解過,程序員定義的java類要被java虛擬機運行首先要做的就是類加載過程,而類加載過程的第一步就是“加載”過程(注意加載只是類加載的一個一個階段而已,不要混淆這兩個不同的概念)。在”加載“階段,虛擬機需要完成以下三件事:

1.通過一個類的全限定名來獲取此類的二進制字節流。

2.將字節流代表的靜態存儲結構轉換爲方法區的運行時數據結構/

3.在內存中創建一個代表此類的java.lang.Class對象,作爲方法區此類的'各種數據的訪問入口。


其中完成第一步動作的代碼模塊就是java類加載器,顧名思義:類加載器(class loader)用來加載 Java 類到 Java 虛擬機中。下面對類加載器進行詳細講解。


一java.lang.ClassLoader類

基本上java中的類加載器都是繼承自java.lang.ClassLoader類的,所以首先我們來介紹一下java.lang.ClassLoader類,然後再介紹java中的幾種類加載器。

java.lang.ClassLoader類的作用就是根據一個指定的類的名稱,找到或者生成其對應的字節代碼,然後從這些字節代碼中定義出一個 Java 類,即 java.lang.Class類的一個實例。除此之外,ClassLoader還負責加載 Java 應用所需的資源,如圖像文件和配置文件等。我們在此只關心和類加載相關的功能,首先我們來看一下java.lang.ClassLoader類中和類加載相關的重要方法:


注意defineClass方法存在多個重載版本,其中一個會拋出NoClassDefFoundError異常,而loadClass()拋出的是 java.lang.ClassNotFoundException異常。


二類加載器的樹狀組織結構


Java 中的類加載器大致可以分成兩類,一類是系統提供的,另外一類則是用戶自定義的。系統提供的類加載器主要包括以下三個:

引導類加載器(bootstrap class loader):它用來加載 Java 的核心庫,是用原生代碼來實現的,不是繼承自 java.lang.ClassLoader。
擴展類加載器(extensions class loader):即ExtClassLoader,它用來加載 Java 的擴展庫。Java 虛擬機的實現會提供一個擴展庫目錄。該類加載器在此目錄裏面查找加載 Java 類,繼承自 java.lang.ClassLoader。
系統類加載器(system class loader):即AppClassLoader,它根據 Java 應用的類路徑(CLASSPATH)來加載 Java 類。一般來說,Java 應用的類都是由它來完成加載的。可以通過 ClassLoader.getSystemClassLoader()來獲取它,該類繼承自 java.lang.ClassLoader。


除了引導類加載器之外,所有的類加載器都有一個父類加載器。通過  getParent()方法可以得到。對於系統提供的類加載器來說,系統類加載器的父類加載器是擴展類加載器,而擴展類加載器的父類加載器是引導類加載器;對於用戶自定義的類加載器來說,其父類加載器是加載此類加載器 Java 類的類加載器。因爲類加載器 Java 類如同其它的 Java 類一樣,也是要由類加載器來加載的。一般來說,用戶自定義的類加載器的父類加載器是系統類加載器。類加載器通過這種方式組織起來,形成樹狀結構。樹的根節點就是引導類加載器。用圖表示如下:


在上述圖示中,給人的直觀感覺是系統類加載器的父類加載器是標準擴展類加載器,標準擴展類加載器的父類加載器是啓動類加載器,真的是這樣嗎?我們通過代碼來測試一下:

public class LoaderTest {

    public static void main(String[] args) {
        try {
            System.out.println(ClassLoader.getSystemClassLoader());
            System.out.println(ClassLoader.getSystemClassLoader().getParent());
            System.out.println(ClassLoader.getSystemClassLoader().getParent().getParent());
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
程序的運行結果如下:

sun.misc.Launcher$AppClassLoader@6d06d69c
sun.misc.Launcher$ExtClassLoader@70dea4e
null

我們知道通過java.lang.ClassLoader.getSystemClassLoader()可以直接獲取到系統類加載器,那麼從運行結果來看,系統類加載器的父加載器是標準擴展類加載器,但是我們試圖獲取標準擴展類加載器的父類加載器時確得到了null,不是預期的啓動類加載器,這是爲何呢?這就需要我們從java.lang.ClassLoader中的loadClass(String name)方法的源代碼就找答案了:

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    protected synchronized Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {

        // 首先判斷該類型是否已經被加載
        Class c = findLoadedClass(name);
        if (c == null) {
            //如果沒被加載,就委託給父類加載或者委派給啓動類加載器加載
            try {
                if (parent != null) {
                    //如果存在父類加載器,就委派給父類加載器加載
                    c = parent.loadClass(name, false);
                } else {
                    //如果不存在父類加載器,就通過啓動類加載器加載該類,
                    //通過調用本地方法native findBootstrapClass0(String name)
                    c = findBootstrapClass0(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父類加載器和啓動類加載器都不能完成加載任務,才調用自身的加載功能
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }

從上述代碼可以很清楚的看到如果該加載器的父類爲null則會調用啓動類加載器類加載類,除非啓動類加載器不能完成加載任務i,纔會調用自定義的加載器,因此可知將擴展類的父加載器設置爲null與將其父加載器設置爲啓動類加載器效果是一樣的。


三類加載器的雙親委派規則(代理模式)

雙親委派規則指的是如果一個類加載器收到了類加載的請求,它首先不會自己嘗試去加載這個類,而是把這個請求委派給父類加載器去完成,每一個層次的類加載器都是如此,因此所有的加載請求最終都會傳遞到頂層的啓動類加載器去加載,如果父加載器不能完成這個加載請求(在它的搜索範圍內找不到所需的類),子加載器纔會自己嘗試去加載。


那麼java中爲何會採取雙親委派原則呢?(其實這種原則在各種程序設計中很常見,如安卓中的事件分派機制),要知道這個原因,首先我們需要知道在java中虛擬機是如何判斷兩個類爲同一個類的,Java 虛擬機不僅要看類的全名是否相同,還要看加載此類的類加載器是否一樣。當且僅當兩者都相同的情況,才認爲兩個類是相同的。即便是同樣的字節代碼,被不同的類加載器加載之後所得到的類,也是不同的。比如一個 Java 類 com.example.Sample,編譯之後生成了字節代碼文件 Sample.class。兩個不同的類加載器 ClassLoaderX和 ClassLoaderY分別讀取了這個 Sample.class文件,然後定義出兩個 java.lang.Class類的實例來表示這個類。這兩個實例是不相同的。對於 Java 虛擬機來說,它們是不同的類。如果試圖對這兩個類的對象進行相互賦值,會拋出運行時異常 ClassCastException。


瞭解了這一點之後,就可以理解採用雙親委派規則的原因了。採用雙親委派規則是爲了保證 Java 核心庫的類型安全。所有 Java 應用都至少需要引用 java.lang.Object類,也就是說在運行的時候,java.lang.Object這個類需要被加載到 Java 虛擬機中。如果這個加載過程由 Java 應用自己的類加載器來完成的話,很可能就存在多個版本的 java.lang.Object類,而且這些類之間是不兼容的。通過採用雙親委派規則,對於 Java 核心庫的類的加載工作由引導類加載器來統一完成,保證了 Java 應用所使用的都是同一個版本的 Java 核心庫的類,是互相兼容的。


四類加載器與ClassNotFoundException和NoClassDefFoundError

通過前面的講解我們已經知道類加載器採用的是雙親委派原則,類加載器會首先代理給其它類加載器來嘗試加載某個類。這就意味着真正完成類的加載工作的類加載器和啓動這個加載過程的類加載器,可能不是同一個。真正完成類的加載工作是通過調用 defineClass來實現的;而啓動類的加載過程是通過調用 loadClass來實現的。前者稱爲一個類的定義加載器(defining loader),後者稱爲初始加載器(initiating loader)。在 Java 虛擬機判斷兩個類是否相同的時候,使用的是類的定義加載器。也就是說,哪個類加載器啓動類的加載過程並不重要,重要的是最終定義這個類的加載器。兩種類加載器的關聯之處在於:一個類的定義加載器是它引用的其它類的初始加載器。如類 com.example.Outer引用了類 com.example.Inner,則由類 com.example.Outer的定義加載器負責啓動類 com.example.Inner的加載過程。 java.lang.ClassNotFoundException是被loadClass()拋出的, java.lang.NoClassDefFoundError是被defineClass()拋出。

類加載器在成功加載某個類之後,會把得到的 java.lang.Class類的實例緩存起來。下次再請求加載該類的時候,類加載器會直接使用緩存的類的實例,而不會嘗試再次加載。也就是說,對於一個類加載器實例來說,相同全名的類只加載一次,即 loadClass方法不會被重複調用。


通過前面的講解我們知道loadClass()是來啓動類的加載過程的,其源代碼在前面我們已經分析過,即當父加載器不爲null同時不能完成加載請求的情況下會拋出ClassNotFoundException異常,而導致父加載器無法完成加載的一個原因很簡單就是找不到這個類,通常這種情況是傳入的類的字符串名稱書寫錯誤,如調用class.forName(String name)或者loadClass(String name)時傳入了一個錯誤的類的名稱導致類加載器無法找到這個類。


defineClass是用來完成類的加載工作的,此時已經表明類加載的啓動已經完成了,即當前執行的類已經編譯,但運行時找不到它的定義時(class definition existed when the currently executing class was compiled, but the definition can no longer be found.)就會拋出NoClassDefFoundError異常,這種情況通常出現在創建一個對象實例的時候(creating a new instance),如在類X中定義了一個類Y,如在類X中定義如下語句ClassY y=new ClassY;程序運行成功之後(此時X與Y的字節碼文件已經存在),如果將類Y的字節碼文件刪除了重新運行上述代碼,則會在運行時候拋出NoClassDefFoundError異常。當然這只是爲了說明拋出這種異常的原因,一般不會出現刪除該類字節碼情況,實際上是其它原因導致類似刪除的效果導致的,如JAR重複引入,版本不一致導至。因爲jar中都是一些已經編譯好的Class文件,如果存在多個版本那麼在加載的時候就不知道應該調用哪一個版本(相當於刪除字節碼的效果),此種情況一般出現在引入第三方SDK的時候。

再如類X引用類Y時,類Y初始化失敗時也會導致以上的錯誤出現。其實上面說的重複引入第三方SDK就屬於這種情況的一種,一般在我們自己的java文件中import第三方的java類,如果存在多個版本那麼在加載的時候就不知道應該調用哪一個版本(相當與引入的類Y初始化失敗),當然除了第三方SDK外,自己定義的java文件可能也會出現這種情況。



以上就是本博客的主要內容,如果讀者覺得不錯,記得小手一抖,點個贊哦!另外歡迎大家關注我的博客賬號哦,將會不定期的爲大家分享技術乾貨,福利多多哦!



發佈了98 篇原創文章 · 獲贊 330 · 訪問量 118萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章