這是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)初始化。Java虛擬機規范沒有明確規定字節碼“加載”的時機,但卻明確規定了初始化的時機,觸發初始化則肯定先觸發“加載”操作。
觸發初始化的時機有5個:
- 遇到new關鍵字、讀取或設置類的static變量、調用一個類的static方法時;
- 反射調用時;
- 初始化一個類,發現其父類未被初始化,則初始化其父類;
- 虛擬機啟動時,包含main方法的類會先初始化;
- 動態語言支持(略)。
(6)最后詳細說說卸載。類什么時候被卸載呢?當類對應的Class對象不再被引用時,類會被卸載,類在方法區中的數據也會被刪除。問題就變成Class對象什么時候被卸載了。我們知道,Class對象始終會被其類加載器引用,那么也就是說,如果類是被啟動類加載器、引導類加載器以及應用類加載器加載的,那么它始終不會被卸載。
嗯,這篇就先寫到這里。其實很早就寫完了,中間隔了一個月的時間去做畢設寫論文,下周答完辯就算是碩士畢業了。
[1] 如果我們單是下載了Sun的JDK,那么是看不到AppClassLoader的源碼的。這里需要去下載OpenJDK的源碼,通過這個開源的項目,我們可以看到更多關於Java的源碼,甚至還有JVM的源碼。https://download.java.net/openjdk/jdk6
[2] 與傳統的IO不同,NIO使用了臨時存儲區來緩沖數據,它基於塊。ByteBuffer是NIO里用得最多的Buffer,它包含兩個實現方式:HeapByteBuffer是基於Java堆的實現,而(direct)ByteBuffer則使用了sun.misc.Unsafe的API進行了堆外的實現。