1. 程式人生 > >《深入理解Java虛擬機器》個人讀書總結——虛擬機器類載入機制

《深入理解Java虛擬機器》個人讀書總結——虛擬機器類載入機制

我們都知道Java虛擬機器是用來執行我們編譯好的.class檔案的,class檔案中夾帶類的各種資訊,虛擬機器要執行這些檔案,第一件事就是要載入到虛擬機器中,這就引出了這次總結的問題——虛擬機器是如何載入這些class檔案的?載入後虛擬機器是怎麼處理檔案中夾帶的資訊的?

類載入機制

首先什麼事類載入機制,這裡有必要先介紹一下概念:
虛擬機器把描述類的資料從CLass檔案載入到記憶體中,並對資料進行校驗、轉換解析和初始化,最終形成可以被虛擬機器直接使用的java型別,這就是虛擬機器的類載入機制。

類載入的時機

類從被載入到虛擬機器開始,到卸載出記憶體為止,它的整個生命週期包括:載入、驗證、準備、解析、初始化、使用和解除安裝7個階段。它的生命週期如圖所示:
這裡寫圖片描述


其中載入、驗證、準備、初始化和解除安裝這5個階段的順序是確定的,類的載入過程必須按照這種順序按部就班地開始,而解析階段則不一定,為了支援java語言的執行時繫結,解析動作放在了類初始化之後。

類載入的全過程

載入

在載入階段,虛擬機器需要完成以下3件事情:
1)通過一個類的全限定名來獲取定義此類的二進位制位元組流。
2)將這個位元組流所代表的靜態儲存結構轉化為方法區的執行時資料結構。
3)在記憶體中生成一個代表這個類的java.lang.Class物件,作為方法區這個類的各種資料的訪問入口。
這是虛擬機器規範的三點,但是並沒有明確指出具體要怎麼做。
例如第一條:虛擬機器要一個二進位制位元組流,但是沒有明確指明要從哪裡獲取,怎麼樣獲取,這就可以玩出很多花樣來了。最常見的如從壓縮包中讀取,jar包啊、war包之類的。還有jsp檔案直接生成對應的Class類。
載入階段完成之後,虛擬機器外部的二進位制位元組流就按照虛擬機器所需的格式儲存在方法區之中,方法區中的資料儲存格式由虛擬機器實現自行定義,虛擬機器規範未規定此區域的具體資料結構。然後在記憶體中例項化一個java.lang.Class類的物件(並沒有明確規定是在java堆中,在HotSpot虛擬機器中,Class物件是被放在了方法區裡面,雖然它也是物件)

驗證

驗證是連線階段的第一步,這一階段是為了確保Class檔案的位元組流中包含的資訊符合當前虛擬機器的要求,並且不會危害虛擬機器自身的安全。
在驗證階段大致上會完成下面4個階段的檢驗動作:檔案格式校驗、元資料驗證、位元組碼驗證、符號引用驗證。
1)檔案格式校驗
要驗證位元組流是否符合Class檔案格式的規範,並且能被當前版本的虛擬機器處理。這個部分是直接操作位元組流的,通過驗證以後位元組流就進入到記憶體中進行儲存,之後的動作也就基於方法區的儲存結構進行了,不再直接操作位元組流。
2)元資料驗證
這要做的是對位元組碼描述的資訊進行語義分析,以保證其描述的資訊符合Java語言規範的要求。對類的元資料資訊進行語義校驗,這些資料主要描述資料的資料,通俗來說是描述程式碼之間的關係的資料。如子父類之間的關係等等。。。
3)位元組碼驗證
這一步是最複雜的,主要目的是通過資料流和控制流分析,確定程式語義是合法的、符合邏輯的。這裡校驗的是類的方法體。
4)符號引用驗證
最後一階段的校驗發生在虛擬機器將符號引用轉化成直接引用的時候,這個轉化動作將在連線的第三個階段———解析階段中發生。符號引用驗證可以看做是對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗。它的目的是確保解析動作能正常執行,如果無法正常通過符號引用驗證,那麼將會丟擲一個java.lang.IncompatibleClassChangeError異常的子類,如常見的NoSuchMethodError、IllegalAccessError。

準備

準備階段是正式為類變數分配記憶體並設定類變數初始值的階段,這些變數所使用的記憶體都將在方法區中進行分配。要注意的是,在這個階段進行記憶體分配的只有static變數,並不包括例項變數,例項變數將會在物件例項化時隨著物件一起分配在java堆中。設定的初始值通常情況下是資料型別的零值,但是如果類變數被設定成final了,編譯時會在欄位屬性表中生成ConstantValue屬性,在準備階段虛擬機器就會根據ConstantValue設定。

解析

解析階段是虛擬機器將常量池的符號引用替換成直接引用的過程。
符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。
直接引用:直接指向目標的指標、相對偏移量或是一個能間接到目標的控制代碼。
解析動作主要針對類或介面、欄位、類方法、介面方法、方法型別、方法控制代碼和呼叫點限定符7類符號引用進行,分別對應於常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodred_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info7種常量型別。
1)類或介面的解析
假設有類D,如果要解析一個符號引用N成類或介面C的直接引用:
如果C不是一個數組型別,虛擬機器會把代表N的全限定名傳遞給D的類載入器去載入類C。
如果C是一個數組型別,並且陣列的元素型別為物件,N的描述符類似”[Ljava/lang/Integer”,會按照上一條規則載入,如果N的描述符類似”java.lang.Integer”,那就會由虛擬機器生成一個代表此陣列維度和元素的陣列物件。
在完成前還要進行符號引用驗證,確認D是否具備對C的訪問許可權。不具備則丟擲”IllegalAccessError”異常。
2)欄位解析
首先對欄位表class_index項中索引CONSTANT_Class_info符號引用進行解析,也就是欄位所屬的類或介面的符號引用。如果解析成功,那將這個欄位所屬的類或介面用C表示,虛擬機器還會對C進行後續欄位的搜尋。
如果C本身就包含有相配的,或與C的相關的有繼承關係的進行遞迴查詢到有相配的,返回直接引用。返回成功還有許可權驗證。
找不到則丟擲NoSuchFieldError異常。
3)類方法解析
類方法解析和欄位解析差不多,同樣的查不到也會丟擲NoSuchMethodError。
4)介面方法解析
從介面方法表class_index項中索引中找,如果找到的C是個類而不是介面,直接拋異常IncompatibleClassChangeError。
後面的找法和類方法解析基本一樣。找不到拋異常NoSuchMethodError。
此外,介面中的方法因為都是預設public修飾的,所以不存在訪問許可權的問題,自然也不會丟擲IllegalAccessError異常。

初始化

類初始化階段是類載入的最後一步,初始化階段是執行類構造器<clinit>()方法的過程。
<clinit>()方法是有編譯器自動收集類中的所有類變數的賦值動作和靜態語句塊(static{}塊)中的語句合併產生的。
虛擬機器會保證一個類的()方法在多執行緒環境中被正確地加鎖、同步,如果多個執行緒同時去初始化一個類,那麼只會有一個執行緒去執行這個類的<clinit>()方法,其他執行緒都需要阻塞等待,直到活動執行緒執行<clinit>()方法完畢。如果一個類的<clinit>()方法中有耗時很長的操作,就可能造成多個程序阻塞。


既然說到類載入機制,就必不可少地要說到類載入器了。

類載入器

虛擬機器設計團隊把類載入器階段中的”通過一個類的全限定名來獲取描述此類的二進位制位元組流”這個動作放到Java虛擬機器外部去實現,以便讓應用程式自己決定如何取獲取所需要的類。實現這個動作的程式碼模組稱為”類載入器”。對於任意一個類,都需要由載入它的類載入器和這個類本身一同確立其在java虛擬機器中的唯一性,每一個類載入器,都擁有一個獨立的類名稱空間。通俗來說就是兩個類是否相等,先得以這兩個類是不是同一類載入器載入為前提。從java虛擬機器的角度來看,只存在兩種不同的類載入器:一種是啟動類載入器(BootStrap ClassLoader),這個類載入器是使用C++語言實現的,是虛擬機器的一部分;另一種就是所有其他的類載入器,這些是獨立於虛擬機器外部的,都是由java語言實現的,並且都繼承自java.lang.ClassLoader。
從java開發人員角度來看,以下3種是最常使用的。
1)啟動類載入器(BootStrap ClassLoader)
負責將存放在<JAVA_HOME>\lib目錄中的,或者被-Xbootclasspath引數所指定的路徑中的,並且是虛擬機器識別的類庫載入到虛擬機器記憶體中。
2)擴充套件類載入器(Extension ClassLoader)
負責載入<JAVA_HOME>\lib\ext目錄中的,或者被java.ext.dirs系統變數所指定的路徑中的所有類庫。開發者可直接使用。
3)應用程式類載入器
ClassLoader的getSystemClassLoader()的返回值。負責載入使用者類路徑(ClassPath)上所指定的類庫。
我們的應用程式都是由這3種類載入器互相配合載入的,它們之間的關係如圖所示:
這裡寫圖片描述

雙親委派模型

這就是我們老生常談的類載入器的雙親委派模型。要求除了頂層的啟動類載入器外,其餘的類載入器都應當有自己的父類載入器。這裡類載入器之間的父子關係一般不會以繼承關係來實現,而是使用組合的關係來複用父載入器的程式碼。
雙親委派模型的工作過程是這樣的:如果一個類載入器收到了類載入的請求,它首先不會自己去嘗試載入這個類,而是把這個請求委派給父類載入器去完成,每一個層次的類載入器都是如此,因此所有的載入請求最終都應該傳送到頂層的啟動類載入器中,只有當父類載入器反饋自己無法完成這個載入請求(它的搜尋範圍中沒有找到所需的類)時,子載入器才會嘗試自己去載入。
使用雙親委派模型的好處顯而易見的就是java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係。它的實現很簡單,我們來看看原始碼

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

步驟:
先檢查是否已經被載入過,若沒有載入則呼叫父載入器的loadClass()方法,若父載入器為空則預設使用啟動類載入器作為父載入器。如果父類載入器失敗,丟擲異常後再呼叫自己的findClass()方法進行載入。