被問到JVM類加載機制中雙親委派模型是什么,三次被破壞指什么?
本文就通篇來講解下jvm類加載機制究竟有哪些內容需要我們掌握。
假設有這樣一個類
package com.manong.jvm;
public class Math {
public static final int initData = 666;
public static User user = new User();
public int compute() {
//一個方法對應一塊棧幀內存區域
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系統下java.exe調用底層的jvm.dll文件創建虛擬機.
3.虛擬機首先創建一個引導類加載器.
4.由引導類加載器加載sun.misc.Launcher類(啟動器類),先看下這個類的源碼:
//此源碼只有相關的部分,并且省略了異常捕獲相關的代碼
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;
}
}
- 其中的靜態變量保證加載完就生成了Launcher實例;
- 其中的構造方法中最終生成了ExtClassLoader(擴展類加載器)和AppClassLoader(應用類加載器),并且把AppClassLoader賦值給Launcher實例的成員變量loader;
- 提供getClassLoader成員方法,可以獲取到成員變量loader;
- 提供一個靜態getLauncher方法獲取當前生成的這個Launcher實例
5.調用Launcher實例的getClassLoader方法,獲取應用類加載器開 始加載類,請注意,不管是加載什么類,這里都是用獲取到應用類加載器去加載,利用內部的委派機制向上委派;
6.調用應用類加載器的loadClass方法,進入選擇類加載器階段;
7.調用上層的findClass方法,進入類加載環節;
8.加載完畢,開始執行代碼;
注意:應用類加載器AppClassLoader和擴展類加載器ExtClassLoader其實都是Launcher類的一個內部類,可以自己去源碼中看下
上面就是類加載到jvm并運行的過程,接下來我們重點了解下上面的第6、7 點
1.第6點調用的方法主要是選擇由哪個類加載器進行加載,講到這里我們呢就先了解下jvm中的類加載器
Java中類加載器分類
- 引導類加載器:負責加載支撐JVM運行的位于JRE的lib目錄下的核心類庫,比如rt.jar、charsets.jar等
- 擴展類加載器:負責加載支撐JVM運行的位于JRE的lib目錄下的ext擴展目錄中的JAR類包
- 應用程序類加載器:負責加載ClassPath路徑下的類包,主要就是加載你自己寫的那些類
- 自定義加載器:負責加載用戶自定義路徑下的類包
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"));
}
}
運行結果:
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
上面的代碼足以讓你看清,每種類加載器加載的是哪個路徑下的類。
雙親委派機制
上面的步驟中提過JVM默認使用Launcher的getClassLoader()方法返回的類加載器AppClassLoader的實例加載我們的應用程序,那么我們知道我們自己寫的類是由AppClassLoader加載,這個沒有問題,那一些類庫中類怎么加載呢,這就要依托于雙親委派機制了。
圖片
加載某個類時會先委托父加載器尋找目標類,找不到再委托上層父加載器加載,如果所有父加載器在自己的加載類路徑下都找不到目標類,則在自己的類加載路徑中查找并載入目標類。比如我們的Math類,最先會找應用程序類加載器加載,應用程序類加載器會先委托擴展類加載器加載,擴展類加載器再委托引導類加載器,頂層引導類加載器在自己的類加載路徑里找了半天沒找到Math類,則向下退回加載Math類的請求,擴展類加載器收到回復就自己加載,在自己的類加載路徑里找了半天也沒找到Math類,又向下退回Math類的加載請求給應用程序類加載器,應用程序類加載器于是在自己的類加載路徑里找Math類,結果找到了就自己加載了。。雙親委派機制說簡單點就是,先找父親加載,不行再由兒子自己加載
我們來看下應用程序類加載器AppClassLoader加載類的雙親委派機制源碼,AppClassLoader的loadClass方法最終會調用其父類ClassLoader的loadClass方法,該方法的大體邏輯如下:首先,檢查一下指定名稱的類是否已經加載過,如果加載過了,就不需要再加載,直接返回。如果此類沒有加載過,那么,再判斷一下是否有父加載器;如果有父加載器,則由父加載器加載(即調用parent.loadClass(name, false);).或者是調用bootstrap類加載器來加載。如果父加載器及bootstrap類加載器都沒有找到指定的類,那么調用當前類加載器的findClass方法來完成類加載。
//ClassLoader的loadClass方法,里面實現了雙親委派機制
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 檢查當前類加載器是否已經加載了該類
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();
//都會調用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) { //不會執行
resolveClass(c);
}
return c;
}
為什么要設計雙親委派機制?
- 沙箱安全機制:自己寫的java.lang.String.class類不會被加載,這樣便可以防止核心API庫被隨意篡改
- 避免類的重復加載:當父親已經加載了該類時,就沒有必要在ClassLoader再加載一次,保證被加載類的唯一性
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("**************My String Class**************");
}
}
運行結果:
錯誤: 在類 java.lang.String 中找不到 main 方法, 請將 main 方法定義為......
全盤負責委托機制 “全盤負責”是指當一個ClassLoder裝載一個類時,除非顯示的使用另外一個ClassLoder,該類所依賴及引用的類也由這個ClassLoder載入。
破壞雙親委派模型
雙親委派模型并不是一個具有強制性約束的模型,而是Java設計者推薦給開發者們的類加載器實現方式。在Java的世界中大部分的類加載器都遵循這個模型,但也有例外的情況,直到Java模塊化出現為止,雙親委派模型主要出現過3次較大規模“被破壞”的情況。
雙親委派模型的第一次“被破壞”其實發生在雙親委派模型出現之前——即JDK 1.2面世以前的“遠古”時代。由于雙親委派模型在JDK 1.2之后才被引入,但是類加載器的概念和抽象類java.lang.ClassLoader則在Java的第一個版本中就已經存在,面對已經存在的用戶自定義類加載器的代碼,Java設計者們引入雙親委派模型時不得不做出一些妥協,為了兼容這些已有代碼,無法再以技術手段避免loadClass()被子類覆蓋的可能性,只能在JDK 1.2之后的java.lang.ClassLoader中添加一個新的protected方法findClass(),并引導用戶編寫的類加載邏輯時盡可能去重寫這個方法,而不是在loadClass()中編寫代碼。上節我們已經分析過loadClass()方法,雙親委派的具體邏輯就實現在這里面,按照loadClass()方法的邏輯,如果父類加載失敗,會自動調用自己的findClass()方法來完成加載,這樣既不影響用戶按照自己的意愿去加載類,又可以保證新寫出來的類加載器是符合雙親委派規則的。
雙親委派模型的第二次“被破壞”是由這個模型自身的缺陷導致的,雙親委派很好地解決了各個類加載器協作時基礎類型的一致性問題(越基礎的類由越上層的加載器進行加載),基礎類型之所以被稱為“基礎”,是因為它們總是作為被用戶代碼繼承、調用的API存在,但程序設計往往沒有絕對不變的完美規則,如果有基礎類型又要調用回用戶的代碼,那該怎么辦呢?這并非是不可能出現的事情,一個典型的例子便是JNDI服務,JNDI現在已經是Java的標準服務,它的代碼由啟動類加載器來完成加載(在JDK 1.3時加入到rt.jar的),肯定屬于Java中很基礎的類型了。但JNDI存在的目的就是對資源進行查找和集中管理,它需要調用由其他廠商實現并部署在應用程序的ClassPath下的JNDI服務提供者接口(Service Provider Interface,SPI)的代碼,現在問題來了,啟動類加載器是絕不可能認識、加載這些代碼的,那該怎么辦?為了解決這個困境,Java的設計團隊只好引入了一個不太優雅的設計:線程上下文類加載器(Thread Context ClassLoader)。這個類加載器可以通過java.lang.Thread類的setContext-ClassLoader()方法進行設置,如果創建線程時還未設置,它將會從父線程中繼承一個,如果在應用程序的全局范圍內都沒有設置過的話,那這個類加載器默認就是應用程序類加載器。有了線程上下文類加載器,程序就可以做一些“舞弊”的事情了。JNDI服務使用這個線程上下文類加載器去加載所需的SPI服務代碼,這是一種父類加載器去請求子類加載器完成類加載的行為,這種行為實際上是打通了雙親委派模型的層次結構來逆向使用類加載器,已經違背了雙親委派模型的一般性原則,但也是無可奈何的事情。Java中涉及SPI的加載基本上都采用這種方式來完成,例如JNDI、JDBC、JCE、JAXB和JBI等。不過,當SPI的服務提供者多于一個的時候,代碼就只能根據具體提供者的類型來硬編碼判斷,為了消除這種極不優雅的實現方式,在JDK 6時,JDK提供了java.util.ServiceLoader類,以META-INF/services中的配置信息,輔以責任鏈模式,這才算是給SPI的加載提供了一種相對合理的解決方案。
雙親委派模型的第三次“被破壞”是由于用戶對程序動態性的追求而導致的,這里所說的“動態性”指的是一些非常“熱”門的名詞:代碼熱替換(Hot Swap)、模塊熱部署(Hot Deployment)等。說白了就是希望Java應用程序能像我們的電腦外設那樣,接上鼠標、U盤,不用重啟機器就能立即使用,鼠標有問題或要升級就換個鼠標,不用關機也不用重啟。對于個人電腦來說,重啟一次其實沒有什么大不了的,但對于一些生產系統來說,關機重啟一次可能就要被列為生產事故,這種情況下熱部署就對軟件開發者,尤其是大型系統或企業級軟件開發者具有很大的吸引力。
雖然這里使用了“被破壞”這個詞來形容上述不符合雙親委派模型原則的行為,但這里“被破壞”并不一定是帶有貶義的。只要有明確的目的和充分的理由,突破舊有原則無疑是一種創新。正如OSGi中的類加載器的設計不符合傳統的雙親委派的類加載器架構,且業界對其為了實現熱部署而帶來的額外的高復雜度還存在不少爭議,但對這方面有了解的技術人員基本還是能達成一個共識,認為OSGi中對類加載器的運用是值得學習的,完全弄懂了OSGi的實現,就算是掌握了類加載器的精粹。
自定義類加載器示例:自定義類加載器只需要繼承 java.lang.ClassLoader 類,該類有兩個核心方法:
- 一個是loadClass(String, boolean),實現了雙親委派機制;
- 一個方法是findClass,默認實現是空方法。
所以我們自定義類加載器主要是重寫findClass方法,但是如果我們不想用雙親委派機制,其實可以通過重寫loadClass實現。
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將一個字節數組轉為Class對象,這個字節數組是class文件讀取后最終的字節數組。
return defineClass(name, data, 0, data.length);
} catch (Exception e) {
e.printStackTrace();
throw new ClassNotFoundException();
}
}
}
public static void main(String args[]) throws Exception {
//初始化自定義類加載器,會先初始化父類ClassLoader,其中會把自定義類加載器的父加載器設置為應用程序類加載器AppClassLoader
MyClassLoader classLoader = new MyClassLoader("D:/test");
//D盤創建 test/com/tuling/jvm 幾級目錄,將User類的復制類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());
}
}
運行結果:
=======自己的加載器加載類調用方法=======
打破雙親委派機制 再來一個沙箱安全機制示例,嘗試打破雙親委派機制,用自定義類加載器加載我們自己實現的 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();
}
}
/**
* 重寫類加載方法,實現自己的加載邏輯,不委派給雙親加載
* @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());
}
}
運行結果:
java.lang.SecurityException: Prohibited package name: java.lang
at java.lang.ClassLoader.preDefineClass(ClassLoader.java:659)
2.第7點調用上層的findClass方法,進入類加載環節做了什么事情
在第六步中jvm只是為了找到將要加載類的類加載器,之后便要開始真正的加載邏輯,整個加載的過程如下圖:
圖片
加載 >> 驗證 >> 準備 >> 解析 >> 初始化 >> 使用 >> 卸載
加載
加載是整個類加載過程的一個階段,本階段Java虛擬機規定需要完成以下三件事情。
- 通過一個類的全限定名來獲取定義此類的二進制字節流。
- 將這個字節流所代表的靜態存儲結構轉化為方法區的運行時數據結構。
- 在內存中生成一個代表這個類的java.lang.Class對象,作為方法區這個類的各種數據的訪問入 口。
《Java虛擬機規范》對這三點要求其實并不是特別具體,留給虛擬機實現與Java應用的靈活度都是 相當大的。例如“通過一個類的全限定名來獲取定義此類的二進制字節流”這條規則,它并沒有指明二進制字節流必須得從某個Class文件中獲取,確切地說是根本沒有指明要從哪里獲取、如何獲取。僅僅這一點空隙,Java虛擬機的使用者們就可以在加載階段搭構建出一個相當開放廣闊的舞臺,例如:
- 從ZIP壓縮包中讀取,這很常見,最終成為日后JAR、EAR、WAR格式的基礎。
- 從網絡中獲取,這種場景最典型的應用就是Web Applet。
- 運行時計算生成,這種場景使用得最多的就是動態代理技術,在java.lang.reflect.Proxy中,就是用 了ProxyGenerator.generateProxyClass()來為特定接口生成形式為“*$Proxy”的代理類的二進制字節流。
- 由其他文件生成,典型場景是JSP應用,由JSP文件生成對應的Class文件。
- 從數據庫中讀取,這種場景相對少見些,例如有些中間件服務器(如SAP Netweaver)可以選擇 把程序安裝到數據庫中來完成程序代碼在集群間的分發。
- 可以從加密文件中獲取,這是典型的防Class文件被反編譯的保護措施,通過加載時解密Class文件來保障程序運行邏輯不被窺探。
相對于類加載過程的其他階段,非數組類型的加載階段(準確地說,是加載階段中獲取類的二進 制字節流的動作)是開發人員可控性最強的階段。加載階段既可以使用Java虛擬機里內置的引導類加 載器來完成,也可以由用戶自定義的類加載器去完成,開發人員通過定義自己的類加載器去控制字節 流的獲取方式(重寫一個類加載器的findClass()或loadClass()方法),實現根據自己的想法來賦予應用 程序獲取運行代碼的動態性。
對于數組類而言,情況就有所不同,數組類本身不通過類加載器創建,它是由Java虛擬機直接在 內存中動態構造出來的。但數組類與類加載器仍然有很密切的關系,因為數組類的元素類型(Element Type,指的是數組去掉所有維度的類型)最終還是要靠類加載器來完成加載,一個數組類(下面簡稱 為C)創建過程遵循以下規則:
·如果數組的組件類型(Component Type,指的是數組去掉一個維度的類型,注意和前面的元素類 型區分開來)是引用類型,那就遞歸采用本節中定義的加載過程去加載這個組件類型,數組C將被標 識在加載該組件類型的類加載器的類名稱空間上(這點很重要,在7.4節會介紹,一個類型必須與類加 載器一起確定唯一性)。·如果數組的組件類型不是引用類型(例如int[]數組的組件類型為int),Java虛擬機將會把數組C 標記為與引導類加載器關聯。·數組類的可訪問性與它的組件類型的可訪問性一致,如果組件類型不是引用類型,它的數組類的 可訪問性將默認為public,可被所有的類和接口訪問到。
加載階段結束后,Java虛擬機外部的二進制字節流就按照虛擬機所設定的格式存儲在方法區之中 了,方法區中的數據存儲格式完全由虛擬機實現自行定義,《Java虛擬機規范》未規定此區域的具體 數據結構。類型數據妥善安置在方法區之后,會在Java堆內存中實例化一個java.lang.Class類的對象, 這個對象將作為程序訪問方法區中的類型數據的外部接口。加載階段與連接階段的部分動作(如一部分字節碼文件格式驗證動作)是交叉進行的,加載階段 尚未完成,連接階段可能已經開始,但這些夾在加載階段之中進行的動作,仍然屬于連接階段的一部 分,這兩個階段的開始時間仍然保持著固定的先后順序。
驗證
驗證是連接階段的第一步,這一階段的目的是確保Class文件的字節流中包含的信息符合《Java虛 擬機規范》的全部約束要求,保證這些信息被當作代碼運行后不會危害虛擬機自身的安全。
java本身是一個安全語言,使用java編寫的代碼不會出現像數組越界這樣的錯誤。因為javac在編譯的時候就會做很多相關的校驗,諸如上面說的錯誤,在編譯環節就已經被暴露出來,對于java語言編譯的字節碼文件對于虛擬機來說是安全的,但是我們知道java虛擬機是一個可以運行多種語言的平臺,它接受任何語言編譯成的字節碼文件,上面加載階段也講了,字節碼文件的來源比較多,字節碼的合法性等也無法保證,Java虛擬機如果不檢查輸入的字節流,對其完全信任的話,很可能會因為載入了有錯誤或有惡意企圖的字節碼流而導致整個系統受攻擊甚至崩潰,所以驗證字節碼是Java虛擬機保護自身的一項必要措施。
驗證階段是非常重要的,這個階段是否嚴謹,直接決定了Java虛擬機是否能承受惡意代碼的攻擊,從代碼量和耗費的執行性能的角度上講,驗證階段的工作量在虛擬機的類加載過程中占了相當大的比重。但是早期版本的java虛擬機對這個階段檢查比較模糊和籠統,并未確切說明。直到第7版的java虛擬機規范才變的具體起來。規范中大體把此階段分為4個動作進行校驗:文件格式驗證、元數據驗證、字節碼驗證和符號引用驗證。
1.文件格式驗證驗證字節流是否符合Class文件格式的規范,并且能被當前版本的虛擬機處理。這一階段可能包括下面這些驗證點:
- 是否以魔數0xCAFEBABE開頭。
- 主、次版本號是否在當前Java虛擬機接受范圍之內。
- 常量池的常量中是否有不被支持的常量類型(檢查常量tag標志)。
- 指向常量的各種索引值中是否有指向不存在的常量或不符合類型的常量。
- CONSTANT_Utf8_info型的常量中是否有不符合UTF-8編碼的數據。
- Class文件中各個部分及文件本身是否有被刪除的或附加的其他信息。
實際上第一階段的驗證點還遠不止這些,上面所列的只是從HotSpot虛擬機源碼[1]中摘抄的一小部分內容,該驗證階段的主要目的是保證輸入的字節流能正確地解析并存儲于方法區之內,格式上符合描述一個Java類型信息的要求。這階段的驗證是基于二進制字節流進行的,只有通過了這個階段的驗證之后,這段字節流才被允許進入Java虛擬機內存的方法區中進行存儲,所以后面的三個驗證階段全部是基于方法區的存儲結構上進行的,不會再直接讀取、操作字節流了。
2.元數據驗證對字節碼描述的信息進行語義分析,以保證其描述的信息符合《Java語言規范》的要求,這個階段可能包括的驗證點如下:
- 這個類是否有父類(除了java.lang.Object之外,所有的類都應當有父類)。
- 這個類的父類是否繼承了不允許被繼承的類(被final修飾的類)。
- 如果這個類不是抽象類,是否實現了其父類或接口之中要求實現的所有方法。
- 類中的字段、方法是否與父類產生矛盾(例如覆蓋了父類的final字段,或者出現不符合規則的方法重載,例如方法參數都一致,但返回值類型卻不同等)。
這個過程主要是對類的元數據信息進行語義校驗,保證不存在與《Java語言規范》定義相悖的元數據信息。
3.字節碼驗證第三階段是整個驗證過程中最復雜的一個階段,主要目的是通過數據流分析和控制流分析,確定程序語義是合法的、符合邏輯的。在第二階段對元數據信息中的數據類型校驗完畢以后,這階段就要對類的方法體(Class文件中的Code屬性)進行校驗分析,保證被校驗類的方法在運行時不會做出危害虛擬機安全的行為,例如:·保證任意時刻操作數棧的數據類型與指令代碼序列都能配合工作,例如不會出現類似于“在操作棧放置了一個int類型的數據,使用時卻按long類型來加載入本地變量表中”這樣的情況。·保證任何跳轉指令都不會跳轉到方法體以外的字節碼指令上。·保證方法體中的類型轉換總是有效的,例如可以把一個子類對象賦值給父類數據類型,這是安全的,但是把父類對象賦值給子類數據類型,甚至把對象賦值給與它毫無繼承關系、完全不相干的一個數據類型,則是危險和不合法的。
由于數據流分析和控制流分析的高度復雜性,Java虛擬機的設計團隊為了避免過多的執行時間消耗在字節碼驗證階段中,在JDK 6之后的Javac編譯器和Java虛擬機里進行了一項聯合優化,把盡可能多的校驗輔助措施挪到Javac編譯器里進行。具體做法是給方法體Code屬性的屬性表中新增加了一項名為“StackMapTable”的新屬性,這項屬性描述了方法體所有的基本塊(Basic Block,指按照控制流拆分的代碼塊)開始時本地變量表和操作棧應有的狀態,在字節碼驗證期間,Java虛擬機就不需要根據程序推導這些狀態的合法性,只需要檢查StackMapTable屬性中的記錄是否合法即可。這樣就將字節碼驗證的類型推導轉變為類型檢查,從而節省了大量校驗時間。理論上StackMapTable屬性也存在錯誤或被篡改的可能,所以是否有可能在惡意篡改了Code屬性的同時,也生成相應的StackMapTable屬性來騙過虛擬機的類型校驗,則是虛擬機設計者們需要仔細思考的問題。
JDK 6的HotSpot虛擬機中提供了-XX:-UseSplitVerifier選項來關閉掉這項優化,或者使用參數XX:+FailOverToOldVerifier要求在類型校驗失敗的時候退回到舊的類型推導方式進行校驗。而到了JDK 7之后,盡管虛擬機中仍然保留著類型推導驗證器的代碼,但是對于主版本號大于50(對應JDK6)的Class文件,使用類型檢查來完成數據流分析校驗則是唯一的選擇,不允許再退回到原來的類型推導的校驗方式。
4.符號引用驗證最后一個階段的校驗行為發生在虛擬機將符號引用轉化為直接引用[3]的時候,這個轉化動作將在連接的第三階段——解析階段中發生。符號引用驗證可以看作是對類自身以外(常量池中的各種符號引用)的各類信息進行匹配性校驗,通俗來說就是,該類是否缺少或者被禁止訪問它依賴的某些外部類、方法、字段等資源。本階段通常需要校驗下列內容:
·符號引用中通過字符串描述的全限定名是否能找到對應的類。·在指定類中是否存在符合方法的字段描述符及簡單名稱所描述的方法和字段。·符號引用中的類、字段、方法的可訪問性是否可被當前類訪問。
符號引用驗證的主要目的是確保解析行為能正常執行,如果無法通過符號引用驗證,Java虛擬機將會拋出一個java.lang.IncompatibleClassChangeError的子類異常,典型的如:java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。
驗證階段對于虛擬機的類加載機制來說,是一個非常重要的、但卻不是必須要執行的階段,因為驗證階段只有通過或者不通過的差別,只要通過了驗證,其后就對程序運行期沒有任何影響了。如果程序運行的全部代碼(包括自己編寫的、第三方包中的、從外部加載的、動態生成的等所有代碼)都已經被反復使用和驗證過,在生產環境的實施階段就可以考慮使用-Xverify:none參數來關閉大部分的類驗證措施,以縮短虛擬機類加載的時間。
準備
準備階段是正式為類中定義的變量(即靜態變量,被static修飾的變量)分配內存并設置類變量初 始值的階段,從概念上講,這些變量所使用的內存都應當在方法區中進行分配,但必須注意到方法區 本身是一個邏輯上的區域,在JDK 7及之前,HotSpot使用永久代來實現方法區時,實現是完全符合這 種邏輯概念的;而在JDK 8及之后,類變量則會隨著Class對象一起存放在Java堆中,這時候“類變量在 方法區”就完全是一種對邏輯概念的表述了,關于這部分內容,筆者已在4.3.1節介紹并且驗證過。關于準備階段,還有兩個容易產生混淆的概念筆者需要著重強調,首先是這時候進行內存分配的 僅包括類變量,而不包括實例變量,實例變量將會在對象實例化時隨著對象一起分配在Java堆中。其 次是這里所說的初始值“通常情況”下是數據類型的零值,假設一個類變量的定義為:
public static int value = 123;
那變量value在準備階段過后的初始值為0而不是123,因為這時尚未開始執行任何Java方法,而把 value賦值為123的putstatic指令是程序被編譯后,存放于類構造器()方法之中,所以把value賦值 為123的動作要到類的初始化階段才會被執行。
Java中所有基本數據類型的零值如下:
圖片
上面提到在“通常情況”下初始值是零值,那言外之意是相對的會有某些“特殊情況”:如果類字段 的字段屬性表中存在ConstantValue屬性,那在準備階段變量值就會被初始化為ConstantValue屬性所指定 的初始值,假設上面類變量value的定義修改為:
public static final int value = 123;
編譯時Javac將會為value生成ConstantValue屬性,在準備階段虛擬機就會根據Con-stantValue的設置 將value賦值為123。
解析
解析階段做的事情其實就是虛擬機將常量池內的符號引用替換為直接引用的過程。
- 符號引用:
符號引用以一組符號來描述所引用的目標,符號可以是任何形式的字面量,只要使用時能無歧義地定位到目標即可。符號引用與虛擬機實現的內存布局無關,引用的目標并不一定是已經加載到虛擬機內存當中的內容。各種虛擬機實現的內存布局可以各不相同,但是它們能接受的符號引用必須都是一致的,因為符號引用的字面量形式明確定義在《Java虛擬機規范》的Class文件格式中。 - 直接引用:
直接引用是可以直接指向目標的指針、相對偏移量或者是一個能間接定位到目標的句柄。直接引用是和虛擬機實現的內存布局直接相關的,同一個符號引用在不同虛擬機實例上翻譯出來的直接引用一般不會相同。如果有了直接引用,那引用的目標必定已經在虛擬機的內存中存在。
java虛擬機什么時候開始解析?
《Java虛擬機規范》中只要求了在執行ane-warray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invoke-special、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield和putstatic這17個用于操作符號引用的字節碼指令之前,先對它們所使用的符號引用進行解析。
我們知道類加載過程中的這幾個步驟,只要求發生的先后順序,并未要求具體的放生時間,因此解析階段可以發生在類加載器加載的階段,也可以發生在字節碼被使用的時候,而且jvm中類的加載本身就是懶加載。
java虛擬機對什么內容進行解析?
解析動作主要針對類或接口、字段、類方法、接口方法、方法類型、方法句柄和調用點限定符這7類符號引用進行,分別對應于常量池的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、)進行檢查。
java虛擬機解析內容可否復用?
對同一個符號引用進行多次解析請求是很常見的事情,除invokedynamic指令以外,虛擬機實現可以對第一次解析的結果進行緩存,譬如在運行時直接引用常量池中的記錄,并把常量標識為已解析狀態,從而避免解析動作重復進行。無論是否真正執行了多次解析動作,Java虛擬機都需要保證的是在同一個實體中,如果一個符號引用之前已經被成功解析過,那么后續的引用解析請求就應當一直能夠成功;同樣地,如果第一次解析失敗了,其他指令對這個符號的解析請求也應該收到相同的異常,哪怕這個請求的符號在后來已成功加載進Java虛擬機內存之中。不過對于invokedynamic指令,上面的規則就不成立了。當碰到某個前面已經由invokedynamic指令觸發過解析的符號引用時,并不意味著這個解析結果對于其他invokedynamic指令也同樣生效。因為invokedynamic指令的目的本來就是用于動態語言支持,它對應的引用稱為“動態調用點限定符”,這里“動態”的含義是指必須等到程序實際運行到這條指令時,解析動作才能進行。相對地,其余可觸發解析的指令都是“靜態”的,可以在剛剛完成加載階段,還沒有開始執行代碼時就提前進行解析。
初始化
類的初始化階段是類加載過程的最后一個步驟,之前介紹的幾個類加載的動作里,除了在加載階段用戶應用程序可以通過自定義類加載器的方式局部參與外,其余動作都完全由Java虛擬機來主導控制。直到初始化階段,Java虛擬機才真正開始執行類中編寫的Java程序代碼,將主導權移交給應用程序。
進行準備階段時,變量已經賦過一次系統要求的初始零值,而在初始化階段,則會根據程序員通過程序編碼制定的主觀計劃去初始化類變量和其他資源。我們也可以從另外一種更直接的形式來表達:初始化階段就是執行類構造器clinit方法的過程。clinit并不是程序員在Java代碼中直接編寫的方法,它是Javac編譯器的自動生成物,但我們非常有必要了解這個方法具體是如何產生的,以及 clinit方法執行過程中各種可能會影響程序運行行為的細節,這部分比起其他類加載過程更貼近于普通的程序開發人員的實際工作。
clinit方法是由編譯器自動收集類中的所有類變量的賦值動作和靜態語句塊(static{}塊)中的語句合并產生的,編譯器收集的順序是由語句在源文件中出現的順序決定的,靜態語句塊中只能訪問到定義在靜態語句塊之前的變量,定義在它之后的變量,在前面的靜態語句塊可以賦值,但是不能訪問。
public class Test {
static {
i = 0;
// 給變量復制可以正常編譯通過
System.out.print(i);
// 這句編譯器會提示“非法向前引用”
}
static int i = 1;
}
clinit方法與類的構造函數(即在虛擬機視角中的實例構造器init方法)不同,它不需要顯式地調用父類構造器,Java虛擬機會保證在子類的clinit方法執行前,父類的clinit方法已經執行完畢。因此在Java虛擬機中第一個被執行的clinit方法的類型肯定是java.lang.Object。由于父類的clinit方法先執行,也就意味著父類中定義的靜態語句塊要優先于子類的變量賦值 操作,下面代碼中字段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方法對于類或接口來說并不是必需的,如果一個類中沒有靜態語句塊,也沒有對變量的賦值操作,那么編譯器可以不為這個類生成clinit方法。
接口中不能使用靜態語句塊,但仍然有變量初始化的賦值操作,因此接口與類一樣都會生成clinit方法。但接口與類不同的是,執行接口的clinit方法不需要先執行父接口的clinit方法, 因為只有當父接口中定義的變量被使用時,父接口才會被初始化。此外,接口的實現類在初始化時也一樣不會執行接口的clinit方法。
Java虛擬機必須保證一個類的clinit方法在多線程環境中被正確地加鎖同步,如果多個線程同時去初始化一個類,那么只會有其中一個線程去執行這個類的clinit方法,其他線程都需要阻塞等待,直到活動線程執行完畢clinit方法。如果在一個類的clinit方法中有耗時很長的操作,那就可能造成多個進程阻塞,在實際應用中這種阻塞往往是很隱蔽的,如下面代碼
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();
}
運行結果如下,一條線程在死循環以模擬長時間操作,另外一條線程在阻塞等待:
Thread[Thread-0,5,main]start
Thread[Thread-1,5,main]start
Thread[Thread-0,5,main]init DeadLoopClass
3.總結
上面是從整體上闡述了類加載的整個過程,重點是加載的每個階段都不能少,存在先后順序要求,但是具體執行時間不確定,需要注意的是類加載器在整個類加載過程中做的事情僅僅是“通過一個類的全限定名來獲取描述該類的二進制字節流”。