Java基礎篇(JVM)——類載入機制
這是Java基礎篇(JVM)的第二篇文章,緊接著上一篇位元組碼詳解,這篇我們來詳解Java的類載入機制,也就是如何把位元組碼代表的類資訊載入進入記憶體中。
我們知道,不管是根據類新建物件,還是直接使用類變數/方法,都需要在類資訊已經載入進入記憶體的前提下。在Java虛擬機器規範中,類載入過程也就是類的生命週期包括7個部分:載入、驗證、準備、解析、初始化、使用、解除安裝。不過我們先不寫這幾個階段,先講講類載入器的知識,然後再來看具體的類載入過程。
1. 類載入器
關於類載入器,我主要關注兩個方面,一是類載入器的作用,二是類載入器的雙親委託機制。
首先說第一個,類載入器在Java體系中有兩個作用:
(1)在類生命週期的載入階段,通過一個類的全限定名來獲取此類的二進位制位元組流。在JVM規範中,沒有強制規定類載入器為虛擬機器的一部分,也就是說,類載入過程是可以放到JVM外部去實現的。說通俗一點,就是我們可以根據規範自己去實現載入器,如HotSpot實現中,啟動類載入器是C++寫的,是虛擬機器的一部分,但其它類載入器都是Java寫的,繼承自java.lang.ClassLoader類。
這樣規定有兩個好處,一是二進位制位元組流的來源可以不限於Class檔案,可從zip包獲取(jar、war)、從網路獲取(Applet)、執行時計算生成(動態代理)、從其它檔案生成(JSP編譯得到)等;第二個是我們可以自己實現類載入器,如OSGi就充分利用了類載入器的靈活實現(反雙親委託)、Tomcat等伺服器也有自己的類載入器體系。
(2)在類的整個生命週期內,用來判定兩個類是否相等。只有當類的全限定相等,且由同一個類載入器載入時,才認為兩個類完全相等。這會影響到equals()方法、isAssignableFrom()方法、isInstance()方法(instanceOf)的執行結果。
這裡我要問兩個問題,一是普通類是和類載入器類相關聯還是和它的例項相關聯?二是它們是如何關聯起來的?
第一個問題,應該是和類載入器的例項相關聯。從需求出發,我們需要有多個不同的類載入器來載入類,這時候就不能使用靜態的方法,而應用例項來載入;而且從結果來推過程:看ClassLoader等的原始碼,loadClass()方法都不是static的,所以應該是和類載入器的例項相關聯。
第二個問題,我們看到ClassLoader類中維護了一個HashSet,這個集合中儲存的是以該載入器作為初始載入器的類的全限定名,這稱為類載入器的名稱空間,這樣,類和類載入器就聯絡起來了。
對Java程式員來說,類載入器的體系結構如圖:
注意這裡的父類/子類載入器並非繼承關係,而是組合的關係:在ClassLoader類中,定義了一個變數parent。在使用類載入器時,會首先給這個變數賦值,如AppClassLoader類載入器,首先會將這個parent賦值為ExtClassLoader型別的變數。
類載入器真正的繼承關係是之前提到的:啟動類載入器是JVM的一部分,其它類載入器都繼承自ClassLoader抽象類。
各個類載入器的作用是:
(1)啟動類載入器:載入放在\lib中的、JVM能夠識別的類庫。Java程式不能直接引用啟動類載入器。
(2)引導類載入器:載入放在\lib\ext中的所有類庫,開發者可以直接使用擴充套件類載入器。
(3)應用類載入器:載入使用者類路徑(ClassPath)下的指定的類庫,開發者可以直接使用自定義類載入器,通常我們自己編寫的類都是由這個類載入器載入。
比較特殊的是自定義類載入器,通常來說有了上面的類載入器體系就夠用了,但對於一些特殊的場合,還需要編寫自定義載入器,比較常見的有我自己總結有兩個:
1. 在雙親委託機制下,實現特殊的需要。如為了安全考慮,需要先將位元組碼加密,類載入器載入時需要先解密;或者需要從非標準的來源如網路獲取二進位制位元組碼進行載入等;
2. 破壞雙親委託機制,以實現諸如熱部署等功能。
編寫自定義類載入器的方法是:繼承ClassLoader抽象類,並重寫其findClass()方法,如何重寫findClass()方法見下文。
再來看看第二個,類載入器的雙親委託機制:
類的載入採用雙親委託的機制,即:先由本載入器的父類載入器嘗試載入,只有當父類載入器不能完成載入動作時,才由本類載入器進行載入(如果父類載入器為null,則由啟動類載入器嘗試載入)。預設是應用類載入器,逐級往上委託。
另外,類載入器還是全盤委託的,也就是說,與本類相關的(引用或繼承等)類都以本類為初始載入器,並通過雙親委託機制確定其最終的載入器。
採用雙親委託機制的好處是,“Java類隨著它的類載入器一起具備了一種帶有優先順序的層次關係”,可以保證一些基本的Java不會被破壞。如Object、String等。因為標誌一個類除了類本身,還有載入它的類載入器。
這裡我有個問題,我看到ClassLoader中的loadClass()等方法都不是static的,也就是說是類載入器類的例項進行的載入操作,那麼對於我們一個普通程式而言,並沒有顯式地去新建一個類載入器類的物件,這個物件是虛擬機器啟動時就自動建好的嗎?如果是,那載入ClassPath下的類的類載入器例項是同一個嗎?
這個問題我找了很多地方都沒有明白回答,我談談自己的理解:我們知道名稱空間的規則是:同一個名稱空間中類的全限定名不能重複;不同名稱空間中的類不能相互訪問。因為存的是以它作為初始類載入器的類,由全盤委託機制可得到,與之相關的類它都可以訪問。以此看來,虛擬機器啟動時是為每個層次都新建了一個類載入器物件,如果沒有顯式地自己新建類載入器物件,那麼所有的類都是由這幾個預設的載入器例項載入。由同一層級類載入器例項載入的類,也就都在同一名稱空間,可以相互訪問。
前面提到了破壞雙親委託機制,這裡再簡要地說說這個點。破壞雙親委託機制通常有兩種場合:
一是基礎類需要回呼叫戶的程式碼,這時由於基礎類是由更上層的類載入器載入的(如啟動類載入器),它不能載入使用者程式碼中的類,如果還按照雙親委託,則這些類永遠無法載入。如JNDI、JDBC等都是這種場合。這時候的解決方案是,通過引入“執行緒上下文類載入器”來載入使用者程式碼中的類,這個執行緒上下文類載入器不是雙親委託機制體系下的類載入器,自然就不受雙親委託機制的約束了。
二是在要求程式動態性的場合,如需要程式碼熱替換、模組熱部署等。這時候類載入機制就不再是雙親委託機制中的樹狀結構,二是複雜的網狀結構。這屬於模組化這部分的知識,具體不是很清楚,可以先放一放以後再瞭解。
最後,關於類載入器,有個問題我一直沒有搞明白,那就是類載入器到底是如何載入表示類資訊的二進位制位元組流的?前面說到,我們自定義一個類載入器,Java規範推薦我們重寫findClass()方法(而不是重寫loadClass()方法,以避免破壞雙親委託機制),那麼我們該如何重寫findClass()方法呢?
我想,如果可以搞清楚擴充套件類載入器或者應用類載入器的findClass()方法,上面的疑問應該就可以搞清楚了。下面我們就通過AppClassLoader的原始碼,來分析分析應用類載入器的findClass()方法[1]。
首先來看AppClassLoader的繼承結構:
可以看到,URLClassLoader繼承了ClassLoader抽象類,AppClassLoader是sun.misc.Launcher的靜態內部類,它繼承了URLClassLoader類。
下面是AppClassLoader的loadClass()方法:
public synchronized Class loadClass(String name, boolean resolve) throws ClassNotFoundException { int i = name.lastIndexOf('.'); if (i != -1) { SecurityManager sm = System.getSecurityManager(); if (sm != null) { sm.checkPackageAccess(name.substring(0, i)); } } return (super.loadClass(name, resolve)); }
我們看到最後一行是呼叫super的loadClass()方法,由於它的直接父類URLClassLoader()沒有重寫loadClass()方法,最終這裡是呼叫ClassLoader的loadClass()方法,仍然遵循雙親委託原則。下面是ClassLoader的loadClass方法:
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 { // 否則,讓JVM啟動類載入器載入 c = findBootstrapClass0(name); } } catch (ClassNotFoundException e) { // 父類(和啟動類)載入器無法載入,則使用本類載入器載入 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; }
對應用類載入器來說,載入類時還是呼叫它的loadClass()方法,緊接著呼叫ClassLoader的loadClass()方法,在該方法中,呼叫了findClass()方法。這個方法在ClassLoader類中沒有給出具體實現,其具體實現在URLClassLoader中:
protected Class<?> findClass(final String name) throws ClassNotFoundException { try { return (Class) AccessController.doPrivileged(new PrivilegedExceptionAction() { public Object run() throws ClassNotFoundException { String path = name.replace('.', '/').concat(".class"); Resource res = ucp.getResource(path, false); if (res != null) { try { return defineClass(name, res); } catch (IOException e) { throw new ClassNotFoundException(name, e); } } else { throw new ClassNotFoundException(name); } } }, acc); } catch (java.security.PrivilegedActionException pae) { throw (ClassNotFoundException) pae.getException(); } }
可以看到,findClass()方法的核心程式碼在defineClass()處,它是URLClassLoader中的方法。關於這個方法,官方的描述是:使用從特定源獲取的位元組碼來構造一個Class物件(返回的Class物件在使用前必須先解析)。defineClass()的原始碼較長,這裡選取其中比較核心的一段:
java.nio.ByteBuffer bb = res.getByteBuffer(); if (bb != null) { // Use (direct) ByteBuffer: CodeSigner[] signers = res.getCodeSigners(); CodeSource cs = new CodeSource(url, signers); return defineClass(name, bb, cs); } else { byte[] b = res.getBytes(); // must read certificates AFTER reading bytes. CodeSigner[] signers = res.getCodeSigners(); CodeSource cs = new CodeSource(url, signers); return defineClass(name, b, 0, b.length, cs); }
我們看到,這裡使用了NIO的 (direct) ByteBuffer類來緩衝特定源的位元組碼,最終呼叫了ClassLoader類中的defineClass()方法。
本文暫時就分析到這個層次,因為目的是回答先前提出的兩個問題,現在我們可以給出一個較為合適的答案:
問:1. 類載入器是如何載入二進位制位元組碼的?
答:使用NIO的ByteBuffer類來緩衝並讀入,接著呼叫defineClass()方法,只要位元組碼符合規範,這個方法就能夠在記憶體中構造Class物件,並返回對其的引用。
問:2. 編寫自定義類載入器時,如何重寫findClass()方法?
答:首先,要考慮具體的需求,其次,常見的步驟是先用IO或者NIO讀入位元組碼檔案,再呼叫defineClass()方法。
2. 類載入過程
大致講完了類載入器我關注的幾點,現在正式來寫類載入的過程。前面說到,在Java虛擬機器規範中,類載入過程也就是類的生命週期包括7個部分:載入、驗證、準備、解析、初始化、使用、解除安裝:
各個過程的作用簡要介紹如下:
(1)載入。載入過程用到的就是我們前面討論了那麼長的類載入器,這個過程的主要目的是通過一個類的全限定名來獲取這個類的二進位制位元組流,並將這個位元組流代表的靜態儲存結構轉化成方法區中執行時的資料結構,最後,在記憶體中生成這個類的Class物件。
載入階段的結果是,方法區中儲存了該類的資訊,記憶體中也生成了相應的Class物件。
需要注意的是,在HotSpot虛擬機器實現中,Class物件在方法區中,而不是在堆中。另外,陣列類本身不由類載入器建立,而是由虛擬機器直接建立,但是陣列類的元素型別是類載入器建立的。最後,載入階段可能並未完成,後面的連線階段就已經開始。
(2)驗證。Java語言本身相對安全,但是由於位元組碼檔案來源不確定,所以必須驗證其安全性,以免危害整個系統。
驗證階段主要的工作是:檔案格式驗證、元資料驗證、位元組碼驗證以及符號引用驗證。
只有經過檔案格式驗證階段的驗證,位元組流才會進入記憶體的方法區進行儲存,而後面三個驗證階段都是基於方法區的儲存結構進行。
元資料驗證階段是為了保證類資訊符合Java語言規範。
位元組碼驗證是為了確定程式語義合法、符合邏輯,最為複雜。
符號引用驗證發生在虛擬機器將符號引用轉化為直接引用的時候(解析階段),是進行對類自身以外(常量池中的各種符號引用)的資訊進行匹配性校驗。
(3)準備。正式為類變數分配記憶體並賦予初值。這裡有兩個點需要注意,一是這個階段只為類變數賦初值,二是這裡的初值是程式預設的初值(null或0或false)。
(4)解析。將常量池中的符號引用轉換為直接引用。符號是指Class檔案中的各種常量,符號引用僅僅使用相應的符號來表示要引用的目標,並不要求所引用的目標都在記憶體當中。直接引用則不同,直接引用和記憶體佈局相關,直接引用的物件一定是被載入到記憶體當中的。
(5)最後詳細說說解除安裝。類什麼時候被解除安裝呢?當類對應的Class物件不再被引用時,類會被解除安裝,類在方法區中的資料也會被刪除。問題就變成Class物件什麼時候被解除安裝了。我們知道,Class物件始終會被其類載入器引用,那麼也就是說,如果類是被啟動類載入器、引導類載入器以及應用類載入器載入的,那麼它始終不會被解除安裝。
嗯,這篇就先寫到這裡。其實很早就寫完了,中間隔了一個月的時間去做畢設寫論文,下週答完辯就算是碩士畢業了。
[1] 如果我們單是下載了Sun的JDK,那麼是看不到AppClassLoader的原始碼的。這裡需要去下載OpenJDK的原始碼,通過這個開源的專案,我們可以看到更多關於Java的原始碼,甚至還有JVM的原始碼。 ofollow,noindex" target="_blank">https://download.java.net/openjdk/jdk6
[2] 與傳統的IO不同,NIO使用了臨時儲存區來緩衝資料,它基於塊。ByteBuffer是NIO裡用得最多的Buffer,它包含兩個實現方式:HeapByteBuffer是基於Java堆的實現,而(direct)ByteBuffer則使用了sun.misc.Unsafe的API進行了堆外的實現。