JVM類加載器就做了這么點事?
本篇講解jvm模塊的類加載機制,學(xué)習(xí)jvm,就必須要知道類是怎么加載的。
假設(shè)有這樣一個類:
package com.manong.jvm;
public class Math {
public static final int initData = 666;
public static User user = new User();
public int compute() {
//一個方法對應(yīng)一塊棧幀內(nèi)存區(qū)域
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
}
以上面的類為例,直接來看這個類是怎么進入jvm,并運行的。
運行流程如下:
(1) javac編譯階段,javac會把我們寫的java文件編譯成class類型文件.
(2) Windows系統(tǒng)下java.exe調(diào)用底層的jvm.dll文件創(chuàng)建虛擬機.
(3) 虛擬機首先創(chuàng)建一個引導(dǎo)類加載器.
(4) 由引導(dǎo)類加載器加載sun.misc.Launcher類(啟動器類),先看下這個類的源碼:
//此源碼只有相關(guān)的部分,并且省略了異常捕獲相關(guān)的代碼
public class Launcher {
private static Launcher launcher = new Launcher();
private ClassLoader loader;
public static Launcher getLauncher() {
return launcher;
}
public Launcher() {
Launcher.ExtClassLoader var1;
var1=Launcher.ExtClassLoader.getExtClassLoader();
this.loader=Launcher.AppClassLoader.getAppClassLoader(var1);
Thread.currentThread().setContextClassLoader(this.loader);
}
public ClassLoader getClassLoader() {
return this.loader;
}
}
- 其中的靜態(tài)變量保證加載完就生成了Launcher實例;
- 其中的構(gòu)造方法中最終生成了ExtClassLoader(擴展類加載器)和AppClassLoader(應(yīng)用類加載器),并且把AppClassLoader賦值給Launcher實例的成員變量loader;
- 提供getClassLoader成員方法,可以獲取到成員變量loader;
- 提供一個靜態(tài)getLauncher方法獲取當(dāng)前生成的這個Launcher實例
(5) 調(diào)用Launcher實例的getClassLoader方法,獲取應(yīng)用類加載器開 始加載類,請注意,不管是加載什么類,這里都是用獲取到應(yīng)用類加載器去加載,利用內(nèi)部的委派機制向上委派;
(6) 調(diào)用應(yīng)用類加載器的loadClass方法,進入選擇類加載器階段;
(7) 調(diào)用上層的findClass方法,進入類加載環(huán)節(jié);
(8) 加載完畢,開始執(zhí)行代碼;
注意:應(yīng)用類加載器AppClassLoader和擴展類加載器ExtClassLoader其實都是Launcher類的一個內(nèi)部類,可以自己去源碼中看下
上面就是類加載到j(luò)vm并運行的過程,接下來我們重點了解下上面的第6、7 點。
一、第6點調(diào)用的方法主要是選擇由哪個類加載器進行加載,講到這里我們呢就先了解下jvm中的類加載器
1.Java中類加載器分類
- 引導(dǎo)類加載器:負責(zé)加載支撐JVM運行的位于JRE的lib目錄下的核心類庫,比如rt.jar、charsets.jar等
- 擴展類加載器:負責(zé)加載支撐JVM運行的位于JRE的lib目錄下的ext擴展目錄中的JAR類包
- 應(yīng)用程序類加載器:負責(zé)加載ClassPath路徑下的類包,主要就是加載你自己寫的那些類
- 自定義加載器:負責(zé)加載用戶自定義路徑下的類包
public class TestJDKClassLoader {
public static void main(String[] args) {
System.out.println(String.class.getClassLoader());
System.out.println(com.sun.crypto.provider.DESKeyFactory.class.getClassLoader().getClass().getName());
System.out.println(TestJDKClassLoader.class.getClassLoader().getClass().getName());
System.out.println();
ClassLoader appClassLoader = ClassLoader.getSystemClassLoader();
ClassLoader extClassloader = appClassLoader.getParent();
ClassLoader bootstrapLoader = extClassloader.getParent();
System.out.println("the bootstrapLoader : " + bootstrapLoader);
System.out.println("the extClassloader : " + extClassloader);
System.out.println("the appClassLoader : " + appClassLoader);
System.out.println();
System.out.println("bootstrapLoader加載以下文件:");
URL[] urls = Launcher.getBootstrapClassPath().getURLs();
for (int i = 0; i < urls.length; i++) {
System.out.println(urls[i]);
}
System.out.println();
System.out.println("extClassloader加載以下文件:");
System.out.println(System.getProperty("java.ext.dirs"));
System.out.println();
System.out.println("appClassLoader加載以下文件:");
System.out.println(System.getProperty("java.class.path"));
}
}
運行結(jié)果:
null
sun.misc.Launcher$ExtClassLoader
sun.misc.Launcher$AppClassLoader
the bootstrapLoader : null
the extClassloader : sun.misc.Launcher$ExtClassLoader@3764951d
the appClassLoader : sun.misc.Launcher$AppClassLoader@14dad5dc
bootstrapLoader加載以下文件:
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/resources.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/rt.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/sunrsasign.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/jsse.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/jce.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/charsets.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/lib/jfr.jar
file:/D:/dev/Java/jdk1.8.0_45/jre/classes
extClassloader加載以下文件:
D:\dev\Java\jdk1.8.0_45\jre\lib\ext;C:\Windows\Sun\Java\lib\ext
appClassLoader加載以下文件:
D:\dev\Java\jdk1.8.0_45\jre\lib\charsets.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\deploy.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\access-bridge-64.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\cldrdata.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\dnsns.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\jaccess.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\jfxrt.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\localedata.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\nashorn.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\sunec.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\sunjce_provider.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\sunmscapi.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\sunpkcs11.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\ext\zipfs.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\javaws.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\jce.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\jfr.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\jfxswt.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\jsse.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\management-agent.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\plugin.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\resources.jar;D:\dev\Java\jdk1.8.0_45\jre\lib\rt.jar;D:\ideaProjects\project-all\target\classes;C:\Users\zhuge\.m2\repository\org\apache\zookeeper\zookeeper\3.4.12\zookeeper-3.4.12.jar;C:\Users\zhuge\.m2\repository\org\slf4j\slf4j-api\1.7.25\slf4j-api-1.7.25.jar;C:\Users\zhuge\.m2\repository\org\slf4j\slf4j-log4j12\1.7.25\slf4j-log4j12-1.7.25.jar;C:\Users\zhuge\.m2\repository\log4j\log4j\1.2.17\log4j-1.2.17.jar;C:\Users\zhuge\.m2\repository\jline\jline\0.9.94\jline-0.9.94.jar;C:\Users\zhuge\.m2\repository\org\apache\yetus\audience-annotations\0.5.0\audience-annotations-0.5.0.jar;C:\Users\zhuge\.m2\repository\io\netty\netty\3.10.6.Final\netty-3.10.6.Final.jar;C:\Users\zhuge\.m2\repository\com\google\guava\guava\22.0\guava-22.0.jar;C:\Users\zhuge\.m2\repository\com\google\code\findbugs\jsr305\1.3.9\jsr305-1.3.9.jar;C:\Users\zhuge\.m2\repository\com\google\errorprone\error_prone_annotations\2.0.18\error_prone_annotations-2.0.18.jar;C:\Users\zhuge\.m2\repository\com\google\j2objc\j2objc-annotations\1.1\j2objc-annotations-1.1.jar;C:\Users\zhuge\.m2\repository\org\codehaus\mojo\animal-sniffer-annotations\1.14\animal-sniffer-annotations-1.14.jar;D:\dev\IntelliJ IDEA 2018.3.2\lib\idea_rt.jar
上面的代碼足以讓你看清,每種類加載器加載的是哪個路徑下的類。
2.雙親委派機制
上面的步驟中提過JVM默認使用Launcher的getClassLoader()方法返回的類加載器AppClassLoader的實例加載我們的應(yīng)用程序,那么我們知道我們自己寫的類是由AppClassLoader加載,這個沒有問題,那一些類庫中類怎么加載呢,這就要依托于雙親委派機制了。
加載某個類時會先委托父加載器尋找目標類,找不到再委托上層父加載器加載,如果所有父加載器在自己的加載類路徑下都找不到目標類,則在自己的類加載路徑中查找并載入目標類。比如我們的Math類,最先會找應(yīng)用程序類加載器加載,應(yīng)用程序類加載器會先委托擴展類加載器加載,擴展類加載器再委托引導(dǎo)類加載器,頂層引導(dǎo)類加載器在自己的類加載路徑里找了半天沒找到Math類,則向下退回加載Math類的請求,擴展類加載器收到回復(fù)就自己加載,在自己的類加載路徑里找了半天也沒找到Math類,又向下退回Math類的加載請求給應(yīng)用程序類加載器,應(yīng)用程序類加載器于是在自己的類加載路徑里找Math類,結(jié)果找到了就自己加載了。。雙親委派機制說簡單點就是,先找父親加載,不行再由兒子自己加載
我們來看下應(yīng)用程序類加載器AppClassLoader加載類的雙親委派機制源碼,AppClassLoader的loadClass方法最終會調(diào)用其父類ClassLoader的loadClass方法,該方法的大體邏輯如下:首先,檢查一下指定名稱的類是否已經(jīng)加載過,如果加載過了,就不需要再加載,直接返回。如果此類沒有加載過,那么,再判斷一下是否有父加載器;如果有父加載器,則由父加載器加載(即調(diào)用parent.loadClass(name, false);).或者是調(diào)用bootstrap類加載器來加載。如果父加載器及bootstrap類加載器都沒有找到指定的類,那么調(diào)用當(dāng)前類加載器的findClass方法來完成類加載。
//ClassLoader的loadClass方法,里面實現(xiàn)了雙親委派機制
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 檢查當(dāng)前類加載器是否已經(jīng)加載了該類
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) { //如果當(dāng)前加載器父加載器不為空則委托父加載器加載該類
c = parent.loadClass(name, false);
} else { //如果當(dāng)前加載器父加載器為空則委托引導(dǎo)類加載器加載該類
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();
//都會調(diào)用URLClassLoader的findClass方法在加載器的類路徑里查找并加載該類
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) { //不會執(zhí)行
resolveClass(c);
}
return c;
}
為什么要設(shè)計雙親委派機制?
- 沙箱安全機制:自己寫的java.lang.String.class類不會被加載,這樣便可以防止核心API庫被隨意篡改
- 避免類的重復(fù)加載:當(dāng)父親已經(jīng)加載了該類時,就沒有必要在ClassLoader再加載一次,保證被加載類的唯一性
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("**************My String Class**************");
}
}
運行結(jié)果:
錯誤: 在類 java.lang.String 中找不到 main 方法, 請將 main 方法定義為......
全盤負責(zé)委托機制 “全盤負責(zé)”是指當(dāng)一個ClassLoder裝載一個類時,除非顯示的使用另外一個ClassLoder,該類所依賴及引用的類也由這個ClassLoder載入。
3.破壞雙親委派模型
雙親委派模型并不是一個具有強制性約束的模型,而是Java設(shè)計者推薦給開發(fā)者們的類加載器實現(xiàn)方式。在Java的世界中大部分的類加載器都遵循這個模型,但也有例外的情況,直到Java模塊化出現(xiàn)為止,雙親委派模型主要出現(xiàn)過3次較大規(guī)模“被破壞”的情況。
雙親委派模型的第一次“被破壞”其實發(fā)生在雙親委派模型出現(xiàn)之前——即JDK 1.2面世以前的“遠古”時代。由于雙親委派模型在JDK 1.2之后才被引入,但是類加載器的概念和抽象類java.lang.ClassLoader則在Java的第一個版本中就已經(jīng)存在,面對已經(jīng)存在的用戶自定義類加載器的代碼,Java設(shè)計者們引入雙親委派模型時不得不做出一些妥協(xié),為了兼容這些已有代碼,無法再以技術(shù)手段避免loadClass()被子類覆蓋的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一個新的protected方法findClass(),并引導(dǎo)用戶編寫的類加載邏輯時盡可能去重寫這個方法,而不是在loadClass()中編寫代碼。
上節(jié)我們已經(jīng)分析過loadClass()方法,雙親委派的具體邏輯就實現(xiàn)在這里面,按照loadClass()方法的邏輯,如果父類加載失敗,會自動調(diào)用自己的findClass()方法來完成加載,這樣既不影響用戶按照自己的意愿去加載類,又可以保證新寫出來的類加載器是符合雙親委派規(guī)則的。
雙親委派模型的第二次“被破壞”是由這個模型自身的缺陷導(dǎo)致的,雙親委派很好地解決了各個類加載器協(xié)作時基礎(chǔ)類型的一致性問題(越基礎(chǔ)的類由越上層的加載器進行加載),基礎(chǔ)類型之所以被稱為“基礎(chǔ)”,是因為它們總是作為被用戶代碼繼承、調(diào)用的API存在,但程序設(shè)計往往沒有絕對不變的完美規(guī)則,如果有基礎(chǔ)類型又要調(diào)用回用戶的代碼,那該怎么辦呢?
這并非是不可能出現(xiàn)的事情,一個典型的例子便是JNDI服務(wù),JNDI現(xiàn)在已經(jīng)是Java的標準服務(wù),它的代碼由啟動類加載器來完成加載(在JDK 1.3時加入到rt.jar的),肯定屬于Java中很基礎(chǔ)的類型了。但JNDI存在的目的就是對資源進行查找和集中管理,它需要調(diào)用由其他廠商實現(xiàn)并部署在應(yīng)用程序的ClassPath下的JNDI服務(wù)提供者接口(Service Provider Interface,SPI)的代碼,現(xiàn)在問題來了,啟動類加載器是絕不可能認識、加載這些代碼的,那該怎么辦?
為了解決這個困境,Java的設(shè)計團隊只好引入了一個不太優(yōu)雅的設(shè)計:線程上下文類加載器(Thread Context ClassLoader)。這個類加載器可以通過java.lang.Thread類的setContext-ClassLoader()方法進行設(shè)置,如果創(chuàng)建線程時還未設(shè)置,它將會從父線程中繼承一個,如果在應(yīng)用程序的全局范圍內(nèi)都沒有設(shè)置過的話,那這個類加載器默認就是應(yīng)用程序類加載器。有了線程上下文類加載器,程序就可以做一些“舞弊”的事情了。JNDI服務(wù)使用這個線程上下文類加載器去加載所需的SPI服務(wù)代碼,這是一種父類加載器去請求子類加載器完成類加載的行為,這種行為實際上是打通了雙親委派模型的層次結(jié)構(gòu)來逆向使用類加載器,已經(jīng)違背了雙親委派模型的一般性原則,但也是無可奈何的事情。Java中涉及SPI的加載基本上都采用這種方式來完成,例如JNDI、JDBC、JCE、JAXB和JBI等。
不過,當(dāng)SPI的服務(wù)提供者多于一個的時候,代碼就只能根據(jù)具體提供者的類型來硬編碼判斷,為了消除這種極不優(yōu)雅的實現(xiàn)方式,在JDK 6時,JDK提供了java.util.ServiceLoader類,以META-INF/services中的配置信息,輔以責(zé)任鏈模式,這才算是給SPI的加載提供了一種相對合理的解決方案。
雙親委派模型的第三次“被破壞”是由于用戶對程序動態(tài)性的追求而導(dǎo)致的,這里所說的“動態(tài)性”指的是一些非常“熱”門的名詞:代碼熱替換(Hot Swap)、模塊熱部署(Hot Deployment)等。說白了就是希望Java應(yīng)用程序能像我們的電腦外設(shè)那樣,接上鼠標、U盤,不用重啟機器就能立即使用,鼠標有問題或要升級就換個鼠標,不用關(guān)機也不用重啟。對于個人電腦來說,重啟一次其實沒有什么大不了的,但對于一些生產(chǎn)系統(tǒng)來說,關(guān)機重啟一次可能就要被列為生產(chǎn)事故,這種情況下熱部署就對軟件開發(fā)者,尤其是大型系統(tǒng)或企業(yè)級軟件開發(fā)者具有很大的吸引力。
雖然這里使用了“被破壞”這個詞來形容上述不符合雙親委派模型原則的行為,但這里“被破壞”并不一定是帶有貶義的。只要有明確的目的和充分的理由,突破舊有原則無疑是一種創(chuàng)新。正如OSGi中的類加載器的設(shè)計不符合傳統(tǒng)的雙親委派的類加載器架構(gòu),且業(yè)界對其為了實現(xiàn)熱部署而帶來的額外的高復(fù)雜度還存在不少爭議,但對這方面有了解的技術(shù)人員基本還是能達成一個共識,認為OSGi中對類加載器的運用是值得學(xué)習(xí)的,完全弄懂了OSGi的實現(xiàn),就算是掌握了類加載器的精粹。
自定義類加載器示例:自定義類加載器只需要繼承 java.lang.ClassLoader 類,該類有兩個核心方法:
- 一個是loadClass(String, boolean),實現(xiàn)了雙親委派機制;
- 一個方法是findClass,默認實現(xiàn)是空方法。
所以我們自定義類加載器主要是重寫findClass方法,但是如果我們不想用雙親委派機制,其實可以通過重寫loadClass實現(xiàn)。
public class MyClassLoaderTest {
static class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
private byte[] loadByte(String name) throws Exception {
name = name.replaceAll("\\.", "/");
FileInputStream fis = new FileInputStream(classPath + "/" + name
+ ".class");
int len = fis.available();
byte[] data = new byte[len];
fis.read(data);
fis.close();
return data;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] data = loadByte(name);
//defineClass將一個字節(jié)數(shù)組轉(zhuǎn)為Class對象,這個字節(jié)數(shù)組是class文件讀取后最終的字節(jié)數(shù)組。
return defineClass(name, data, 0, data.length);
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException();
}
}
}
public static void main(String args[]) throws Exception {
//初始化自定義類加載器,會先初始化父類ClassLoader,其中會把自定義類加載器的父加載器設(shè)置為應(yīng)用程序類加載器AppClassLoader
MyClassLoader classLoader = new MyClassLoader("D:/test");
//D盤創(chuàng)建 test/com/tuling/jvm 幾級目錄,將User類的復(fù)制類User1.class丟入該目錄
Class clazz = classLoader.loadClass("com.tuling.jvm.User1");
Object obj = clazz.newInstance();
Method method = clazz.getDeclaredMethod("sout", null);
method.invoke(obj, null);
System.out.println(clazz.getClassLoader().getClass().getName());
}
}
運行結(jié)果:
=======自己的加載器加載類調(diào)用方法=======
打破雙親委派機制 再來一個沙箱安全機制示例,嘗試打破雙親委派機制,用自定義類加載器加載我們自己實現(xiàn)的 java.lang.String.class
public class MyClassLoaderTest {
static class MyClassLoader extends ClassLoader {
private String classPath;
public MyClassLoader(String classPath) {
this.classPath = classPath;
}
private byte[] loadByte(String name) throws Exception {
name = name.replaceAll("\\.", "/");
FileInputStream fis = new FileInputStream(classPath + "/" + name
+ ".class");
int len = fis.available();
byte[] data = new byte[len];
fis.read(data);
fis.close();
return data;
}
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] data = loadByte(name);
return defineClass(name, data, 0, data.length);
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException();
}
}
/**
* 重寫類加載方法,實現(xiàn)自己的加載邏輯,不委派給雙親加載
* @param name
* @param resolve
* @return
* @throws ClassNotFoundException
*/
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) {
// 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.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
}
public static void main(String args[]) throws Exception {
MyClassLoader classLoader = new MyClassLoader("D:/test");
//嘗試用自己改寫類加載機制去加載自己寫的java.lang.String.class
Class clazz = classLoader.loadClass("java.lang.String");
Object obj = clazz.newInstance();
Method method= clazz.getDeclaredMethod("sout", null);
method.invoke(obj, null);
System.out.println(clazz.getClassLoader().getClass().getName());
}
}
運行結(jié)果:
java.lang.SecurityException: Prohibited package name: java.lang
at java.lang.ClassLoader.preDefineClass(ClassLoader.java:659)
二、第7點調(diào)用上層的findClass方法,進入類加載環(huán)節(jié)做了什么事情
在第六步中jvm只是為了找到將要加載類的類加載器,之后便要開始真正的加載邏輯,整個加載的過程如下圖:
加載 >> 驗證 >> 準備 >> 解析 >> 初始化 >> 使用 >> 卸載
1.加載
加載是整個類加載過程的一個階段,本階段Java虛擬機規(guī)定需要完成以下三件事情。
- 通過一個類的全限定名來獲取定義此類的二進制字節(jié)流。
- 將這個字節(jié)流所代表的靜態(tài)存儲結(jié)構(gòu)轉(zhuǎn)化為方法區(qū)的運行時數(shù)據(jù)結(jié)構(gòu)。
- 在內(nèi)存中生成一個代表這個類的java.lang.Class對象,作為方法區(qū)這個類的各種數(shù)據(jù)的訪問入 口。
《Java虛擬機規(guī)范》對這三點要求其實并不是特別具體,留給虛擬機實現(xiàn)與Java應(yīng)用的靈活度都是 相當(dāng)大的。例如“通過一個類的全限定名來獲取定義此類的二進制字節(jié)流”這條規(guī)則,它并沒有指明二進制字節(jié)流必須得從某個Class文件中獲取,確切地說是根本沒有指明要從哪里獲取、如何獲取。僅僅這一點空隙,Java虛擬機的使用者們就可以在加載階段搭構(gòu)建出一個相當(dāng)開放廣闊的舞臺,例如:
- 從ZIP壓縮包中讀取,這很常見,最終成為日后JAR、EAR、WAR格式的基礎(chǔ)。
- 從網(wǎng)絡(luò)中獲取,這種場景最典型的應(yīng)用就是Web Applet。
- 運行時計算生成,這種場景使用得最多的就是動態(tài)代理技術(shù),在java.lang.reflect.Proxy中,就是用 了ProxyGenerator.generateProxyClass()來為特定接口生成形式為“*$Proxy”的代理類的二進制字節(jié)流。
- 由其他文件生成,典型場景是JSP應(yīng)用,由JSP文件生成對應(yīng)的Class文件。
- 從數(shù)據(jù)庫中讀取,這種場景相對少見些,例如有些中間件服務(wù)器(如SAP Netweaver)可以選擇 把程序安裝到數(shù)據(jù)庫中來完成程序代碼在集群間的分發(fā)。
- 可以從加密文件中獲取,這是典型的防Class文件被反編譯的保護措施,通過加載時解密Class文件來保障程序運行邏輯不被窺探。
相對于類加載過程的其他階段,非數(shù)組類型的加載階段(準確地說,是加載階段中獲取類的二進 制字節(jié)流的動作)是開發(fā)人員可控性最強的階段。加載階段既可以使用Java虛擬機里內(nèi)置的引導(dǎo)類加 載器來完成,也可以由用戶自定義的類加載器去完成,開發(fā)人員通過定義自己的類加載器去控制字節(jié) 流的獲取方式(重寫一個類加載器的findClass()或loadClass()方法),實現(xiàn)根據(jù)自己的想法來賦予應(yīng)用 程序獲取運行代碼的動態(tài)性。
對于數(shù)組類而言,情況就有所不同,數(shù)組類本身不通過類加載器創(chuàng)建,它是由Java虛擬機直接在 內(nèi)存中動態(tài)構(gòu)造出來的。但數(shù)組類與類加載器仍然有很密切的關(guān)系,因為數(shù)組類的元素類型(Element Type,指的是數(shù)組去掉所有維度的類型)最終還是要靠類加載器來完成加載,一個數(shù)組類(下面簡稱 為C)創(chuàng)建過程遵循以下規(guī)則:
·如果數(shù)組的組件類型(Component Type,指的是數(shù)組去掉一個維度的類型,注意和前面的元素類 型區(qū)分開來)是引用類型,那就遞歸采用本節(jié)中定義的加載過程去加載這個組件類型,數(shù)組C將被標 識在加載該組件類型的類加載器的類名稱空間上(這點很重要,在7.4節(jié)會介紹,一個類型必須與類加 載器一起確定唯一性)。·如果數(shù)組的組件類型不是引用類型(例如int[]數(shù)組的組件類型為int),Java虛擬機將會把數(shù)組C 標記為與引導(dǎo)類加載器關(guān)聯(lián)。·數(shù)組類的可訪問性與它的組件類型的可訪問性一致,如果組件類型不是引用類型,它的數(shù)組類的 可訪問性將默認為public,可被所有的類和接口訪問到。
加載階段結(jié)束后,Java虛擬機外部的二進制字節(jié)流就按照虛擬機所設(shè)定的格式存儲在方法區(qū)之中 了,方法區(qū)中的數(shù)據(jù)存儲格式完全由虛擬機實現(xiàn)自行定義,《Java虛擬機規(guī)范》未規(guī)定此區(qū)域的具體 數(shù)據(jù)結(jié)構(gòu)。類型數(shù)據(jù)妥善安置在方法區(qū)之后,會在Java堆內(nèi)存中實例化一個java.lang.Class類的對象, 這個對象將作為程序訪問方法區(qū)中的類型數(shù)據(jù)的外部接口。加載階段與連接階段的部分動作(如一部分字節(jié)碼文件格式驗證動作)是交叉進行的,加載階段 尚未完成,連接階段可能已經(jīng)開始,但這些夾在加載階段之中進行的動作,仍然屬于連接階段的一部 分,這兩個階段的開始時間仍然保持著固定的先后順序。
2.驗證
驗證是連接階段的第一步,這一階段的目的是確保Class文件的字節(jié)流中包含的信息符合《Java虛 擬機規(guī)范》的全部約束要求,保證這些信息被當(dāng)作代碼運行后不會危害虛擬機自身的安全。
java本身是一個安全語言,使用java編寫的代碼不會出現(xiàn)像數(shù)組越界這樣的錯誤。因為javac在編譯的時候就會做很多相關(guān)的校驗,諸如上面說的錯誤,在編譯環(huán)節(jié)就已經(jīng)被暴露出來,對于java語言編譯的字節(jié)碼文件對于虛擬機來說是安全的,但是我們知道java虛擬機是一個可以運行多種語言的平臺,它接受任何語言編譯成的字節(jié)碼文件,上面加載階段也講了,字節(jié)碼文件的來源比較多,字節(jié)碼的合法性等也無法保證,Java虛擬機如果不檢查輸入的字節(jié)流,對其完全信任的話,很可能會因為載入了有錯誤或有惡意企圖的字節(jié)碼流而導(dǎo)致整個系統(tǒng)受攻擊甚至崩潰,所以驗證字節(jié)碼是Java虛擬機保護自身的一項必要措施。
驗證階段是非常重要的,這個階段是否嚴謹,直接決定了Java虛擬機是否能承受惡意代碼的攻擊,從代碼量和耗費的執(zhí)行性能的角度上講,驗證階段的工作量在虛擬機的類加載過程中占了相當(dāng)大的比重。但是早期版本的java虛擬機對這個階段檢查比較模糊和籠統(tǒng),并未確切說明。直到第7版的java虛擬機規(guī)范才變的具體起來。規(guī)范中大體把此階段分為4個動作進行校驗:文件格式驗證、元數(shù)據(jù)驗證、字節(jié)碼驗證和符號引用驗證。
(1) 文件格式驗證驗證字節(jié)流是否符合Class文件格式的規(guī)范,并且能被當(dāng)前版本的虛擬機處理。這一階段可能包括下面這些驗證點:
- 是否以魔數(shù)0xCAFEBABE開頭。
- 主、次版本號是否在當(dāng)前Java虛擬機接受范圍之內(nèi)。
- 常量池的常量中是否有不被支持的常量類型(檢查常量tag標志)。
- 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF-8編碼的數(shù)據(jù)。
- Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。
實際上第一階段的驗證點還遠不止這些,上面所列的只是從HotSpot虛擬機源碼[1]中摘抄的一小部分內(nèi)容,該驗證階段的主要目的是保證輸入的字節(jié)流能正確地解析并存儲于方法區(qū)之內(nèi),格式上符合描述一個Java類型信息的要求。這階段的驗證是基于二進制字節(jié)流進行的,只有通過了這個階段的驗證之后,這段字節(jié)流才被允許進入Java虛擬機內(nèi)存的方法區(qū)中進行存儲,所以后面的三個驗證階段全部是基于方法區(qū)的存儲結(jié)構(gòu)上進行的,不會再直接讀取、操作字節(jié)流了。
(2)元數(shù)據(jù)驗證對字節(jié)碼描述的信息進行語義分析,以保證其描述的信息符合《Java語言規(guī)范》的要求,這個階段可能包括的驗證點如下:
- 這個類是否有父類(除了java.lang.Object之外,所有的類都應(yīng)當(dāng)有父類)。
- 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
- 如果這個類不是抽象類,是否實現(xiàn)了其父類或接口之中要求實現(xiàn)的所有方法。
- 類中的字段、方法是否與父類產(chǎn)生矛盾(例如覆蓋了父類的final字段,或者出現(xiàn)不符合規(guī)則的方法重載,例如方法參數(shù)都一致,但返回值類型卻不同等)。
這個過程主要是對類的元數(shù)據(jù)信息進行語義校驗,保證不存在與《Java語言規(guī)范》定義相悖的元數(shù)據(jù)信息。
(3) 字節(jié)碼驗證第三階段是整個驗證過程中最復(fù)雜的一個階段,主要目的是通過數(shù)據(jù)流分析和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數(shù)據(jù)信息中的數(shù)據(jù)類型校驗完畢以后,這階段就要對類的方法體(Class文件中的Code屬性)進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的行為,例如:
- 保證任意時刻操作數(shù)棧的數(shù)據(jù)類型與指令代碼序列都能配合工作,例如不會出現(xiàn)類似于“在操作棧放置了一個int類型的數(shù)據(jù),使用時卻按long類型來加載入本地變量表中”這樣的情況。
- 保證任何跳轉(zhuǎn)指令都不會跳轉(zhuǎn)到方法體以外的字節(jié)碼指令上。
- 保證方法體中的類型轉(zhuǎn)換總是有效的,例如可以把一個子類對象賦值給父類數(shù)據(jù)類型,這是安全的,但是把父類對象賦值給子類數(shù)據(jù)類型,甚至把對象賦值給與它毫無繼承關(guān)系、完全不相干的一個數(shù)據(jù)類型,則是危險和不合法的。
由于數(shù)據(jù)流分析和控制流分析的高度復(fù)雜性,Java虛擬機的設(shè)計團隊為了避免過多的執(zhí)行時間消耗在字節(jié)碼驗證階段中,在JDK 6之后的Javac編譯器和Java虛擬機里進行了一項聯(lián)合優(yōu)化,把盡可能多的校驗輔助措施挪到Javac編譯器里進行。
具體做法是給方法體Code屬性的屬性表中新增加了一項名為“StackMapTable”的新屬性,這項屬性描述了方法體所有的基本塊(Basic Block,指按照控制流拆分的代碼塊)開始時本地變量表和操作棧應(yīng)有的狀態(tài),在字節(jié)碼驗證期間,Java虛擬機就不需要根據(jù)程序推導(dǎo)這些狀態(tài)的合法性,只需要檢查StackMapTable屬性中的記錄是否合法即可。這樣就將字節(jié)碼驗證的類型推導(dǎo)轉(zhuǎn)變?yōu)轭愋蜋z查,從而節(jié)省了大量校驗時間。
理論上StackMapTable屬性也存在錯誤或被篡改的可能,所以是否有可能在惡意篡改了Code屬性的同時,也生成相應(yīng)的StackMapTable屬性來騙過虛擬機的類型校驗,則是虛擬機設(shè)計者們需要仔細思考的問題。
JDK 6的HotSpot虛擬機中提供了-XX:-UseSplitVerifier選項來關(guān)閉掉這項優(yōu)化,或者使用參數(shù)XX:+FailOverToOldVerifier要求在類型校驗失敗的時候退回到舊的類型推導(dǎo)方式進行校驗。而到了JDK 7之后,盡管虛擬機中仍然保留著類型推導(dǎo)驗證器的代碼,但是對于主版本號大于50(對應(yīng)JDK6)的Class文件,使用類型檢查來完成數(shù)據(jù)流分析校驗則是唯一的選擇,不允許再退回到原來的類型推導(dǎo)的校驗方式。
(4) 符號引用驗證最后一個階段的校驗行為發(fā)生在虛擬機將符號引用轉(zhuǎn)化為直接引用[3]的時候,這個轉(zhuǎn)化動作將在連接的第三階段——解析階段中發(fā)生。
符號引用驗證可以看作是對類自身以外(常量池中的各種符號引用)的各類信息進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某些外部類、方法、字段等資源。本階段通常需要校驗下列內(nèi)容:
- 符號引用中通過字符串描述的全限定名是否能找到對應(yīng)的類。
- 在指定類中是否存在符合方法的字段描述符及簡單名稱所描述的方法和字段。
- 符號引用中的類、字段、方法的可訪問性是否可被當(dāng)前類訪問。
符號引用驗證的主要目的是確保解析行為能正常執(zhí)行,如果無法通過符號引用驗證,Java虛擬機將會拋出一個java.lang.IncompatibleClassChangeError的子類異常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
驗證階段對于虛擬機的類加載機制來說,是一個非常重要的、但卻不是必須要執(zhí)行的階段,因為驗證階段只有通過或者不通過的差別,只要通過了驗證,其后就對程序運行期沒有任何影響了。如果程序運行的全部代碼(包括自己編寫的、第三方包中的、從外部加載的、動態(tài)生成的等所有代碼)都已經(jīng)被反復(fù)使用和驗證過,在生產(chǎn)環(huán)境的實施階段就可以考慮使用-Xverify:none參數(shù)來關(guān)閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
3.準備
準備階段是正式為類中定義的變量(即靜態(tài)變量,被static修飾的變量)分配內(nèi)存并設(shè)置類變量初 始值的階段,從概念上講,這些變量所使用的內(nèi)存都應(yīng)當(dāng)在方法區(qū)中進行分配,但必須注意到方法區(qū) 本身是一個邏輯上的區(qū)域,在JDK 7及之前,HotSpot使用永久代來實現(xiàn)方法區(qū)時,實現(xiàn)是完全符合這 種邏輯概念的;而在JDK 8及之后,類變量則會隨著Class對象一起存放在Java堆中,這時候“類變量在 方法區(qū)”就完全是一種對邏輯概念的表述了,關(guān)于這部分內(nèi)容,筆者已在4.3.1節(jié)介紹并且驗證過。
關(guān)于準備階段,還有兩個容易產(chǎn)生混淆的概念筆者需要著重強調(diào),首先是這時候進行內(nèi)存分配的 僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中。其 次是這里所說的初始值“通常情況”下是數(shù)據(jù)類型的零值,假設(shè)一個類變量的定義為:
public static int value = 123;
那變量value在準備階段過后的初始值為0而不是123,因為這時尚未開始執(zhí)行任何Java方法,而把 value賦值為123的putstatic指令是程序被編譯后,存放于類構(gòu)造器()方法之中,所以把value賦值 為123的動作要到類的初始化階段才會被執(zhí)行。
Java中所有基本數(shù)據(jù)類型的零值如下:
上面提到在“通常情況”下初始值是零值,那言外之意是相對的會有某些“特殊情況”:如果類字段 的字段屬性表中存在ConstantValue屬性,那在準備階段變量值就會被初始化為ConstantValue屬性所指定 的初始值,假設(shè)上面類變量value的定義修改為:
public static final int value = 123;
編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機就會根據(jù)Con-stantValue的設(shè)置 將value賦值為123。
4.解析
解析階段做的事情其實就是虛擬機將常量池內(nèi)的符號引用替換為直接引用的過程。
- 符號引用:符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現(xiàn)的內(nèi)存布局無關(guān),引用的目標并不一定是已經(jīng)加載到虛擬機內(nèi)存當(dāng)中的內(nèi)容。各種虛擬機實現(xiàn)的內(nèi)存布局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在《Java虛擬機規(guī)范》的Class文件格式中。
- 直接引用:直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用是和虛擬機實現(xiàn)的內(nèi)存布局直接相關(guān)的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經(jīng)在虛擬機的內(nèi)存中存在。
(1) java虛擬機什么時候開始解析?
《Java虛擬機規(guī)范》中只要求了在執(zhí)行ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic這17個用于操作符號引用的字節(jié)碼指令之前,先對它們所使用的符號引用進行解析。
我們知道類加載過程中的這幾個步驟,只要求發(fā)生的先后順序,并未要求具體的放生時間,因此解析階段可以發(fā)生在類加載器加載的階段,也可以發(fā)生在字節(jié)碼被使用的時候,而且jvm中類的加載本身就是懶加載。
(2) java虛擬機對什么內(nèi)容進行解析?
解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調(diào)用點限定符這7類符號引用進行,分別對應(yīng)于常量池的CONSTANT_Class_info、CON-STANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info、CONSTANT_Dyna-mic_info和CONSTANT_InvokeDynamic_info 8種常量類型,類似地,對方法或者字段的訪問,也會在解析階段中對它們的可訪問性(public、protected、private、)進行檢查。
(3) java虛擬機解析內(nèi)容可否復(fù)用?
對同一個符號引用進行多次解析請求是很常見的事情,除invokedynamic指令以外,虛擬機實現(xiàn)可以對第一次解析的結(jié)果進行緩存,譬如在運行時直接引用常量池中的記錄,并把常量標識為已解析狀態(tài),從而避免解析動作重復(fù)進行。無論是否真正執(zhí)行了多次解析動作,Java虛擬機都需要保證的是在同一個實體中,如果一個符號引用之前已經(jīng)被成功解析過,那么后續(xù)的引用解析請求就應(yīng)當(dāng)一直能夠成功;同樣地,如果第一次解析失敗了,其他指令對這個符號的解析請求也應(yīng)該收到相同的異常,哪怕這個請求的符號在后來已成功加載進Java虛擬機內(nèi)存之中。不過對于invokedynamic指令,上面的規(guī)則就不成立了。當(dāng)碰到某個前面已經(jīng)由invokedynamic指令觸發(fā)過解析的符號引用時,并不意味著這個解析結(jié)果對于其他invokedynamic指令也同樣生效。因為invokedynamic指令的目的本來就是用于動態(tài)語言支持,它對應(yīng)的引用稱為“動態(tài)調(diào)用點限定符”,這里“動態(tài)”的含義是指必須等到程序?qū)嶋H運行到這條指令時,解析動作才能進行。相對地,其余可觸發(fā)解析的指令都是“靜態(tài)”的,可以在剛剛完成加載階段,還沒有開始執(zhí)行代碼時就提前進行解析。
5.初始化
類的初始化階段是類加載過程的最后一個步驟,之前介紹的幾個類加載的動作里,除了在加載階段用戶應(yīng)用程序可以通過自定義類加載器的方式局部參與外,其余動作都完全由Java虛擬機來主導(dǎo)控制。直到初始化階段,Java虛擬機才真正開始執(zhí)行類中編寫的Java程序代碼,將主導(dǎo)權(quán)移交給應(yīng)用程序。
進行準備階段時,變量已經(jīng)賦過一次系統(tǒng)要求的初始零值,而在初始化階段,則會根據(jù)程序員通過程序編碼制定的主觀計劃去初始化類變量和其他資源。我們也可以從另外一種更直接的形式來表達:初始化階段就是執(zhí)行類構(gòu)造器clinit方法的過程。clinit并不是程序員在Java代碼中直接編寫的方法,它是Javac編譯器的自動生成物,但我們非常有必要了解這個方法具體是如何產(chǎn)生的,以及 clinit方法執(zhí)行過程中各種可能會影響程序運行行為的細節(jié),這部分比起其他類加載過程更貼近于普通的程序開發(fā)人員的實際工作。
clinit方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態(tài)語句塊(static{}塊)中的語句合并產(chǎn)生的,編譯器收集的順序是由語句在源文件中出現(xiàn)的順序決定的,靜態(tài)語句塊中只能訪問到定義在靜態(tài)語句塊之前的變量,定義在它之后的變量,在前面的靜態(tài)語句塊可以賦值,但是不能訪問。
public class Test {
static {
i = 0;
// 給變量復(fù)制可以正常編譯通過
System.out.print(i);
// 這句編譯器會提示“非法向前引用”
}
static int i = 1;
}
clinit方法與類的構(gòu)造函數(shù)(即在虛擬機視角中的實例構(gòu)造器init方法)不同,它不需要顯式地調(diào)用父類構(gòu)造器,Java虛擬機會保證在子類的clinit方法執(zhí)行前,父類的clinit方法已經(jīng)執(zhí)行完畢。因此在Java虛擬機中第一個被執(zhí)行的clinit方法的類型肯定是java.lang.Object。由于父類的clinit方法先執(zhí)行,也就意味著父類中定義的靜態(tài)語句塊要優(yōu)先于子類的變量賦值 操作,下面代碼中字段B的值將會是2而不是1。
static class Parent {
public static int A = 1;
static {
A = 2;
}
}
static class Sub extends Parent {
public static int B = A;
}
public static void main(String[] args) {
System.out.println(Sub.B);
}
clinit方法對于類或接口來說并不是必需的,如果一個類中沒有靜態(tài)語句塊,也沒有對變量的賦值操作,那么編譯器可以不為這個類生成clinit方法。
接口中不能使用靜態(tài)語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成clinit方法。但接口與類不同的是,執(zhí)行接口的clinit方法不需要先執(zhí)行父接口的clinit方法, 因為只有當(dāng)父接口中定義的變量被使用時,父接口才會被初始化。此外,接口的實現(xiàn)類在初始化時也一樣不會執(zhí)行接口的clinit方法。
Java虛擬機必須保證一個類的clinit方法在多線程環(huán)境中被正確地加鎖同步,如果多個線程同時去初始化一個類,那么只會有其中一個線程去執(zhí)行這個類的clinit方法,其他線程都需要阻塞等待,直到活動線程執(zhí)行完畢clinit方法。如果在一個類的clinit方法中有耗時很長的操作,那就可能造成多個進程阻塞,在實際應(yīng)用中這種阻塞往往是很隱蔽的,如下面代碼
static class DeadLoopClass {
static {
// 如果不加上這個if語句,編譯器將提示“Initializer does not complete normally” 并拒絕編譯
if (true) {
System.out.println(Thread.currentThread() + "init DeadLoopClass");
while (true) {
}
}
}
}
public static void main(String[] args) {
Runnable script = new Runnable() {
public void run() {
System.out.println(Thread.currentThread() + "start");
DeadLoopClass dlc = new DeadLoopClass();
System.out.println(Thread.currentThread() + " run over");
}
};
Thread thread1 = new Thread(script);
Thread thread2 = new Thread(script);
thread1.start();
thread2.start();
}
運行結(jié)果如下,一條線程在死循環(huán)以模擬長時間操作,另外一條線程在阻塞等待:
Thread[Thread-0,5,main]start
Thread[Thread-1,5,main]start
Thread[Thread-0,5,main]init DeadLoopClass
三、總結(jié)
上面是從整體上闡述了類加載的整個過程,重點是加載的每個階段都不能少,存在先后順序要求,但是具體執(zhí)行時間不確定,需要注意的是類加載器在整個類加載過程中做的事情僅僅是“通過一個類的全限定名來獲取描述該類的二進制字節(jié)流”。