重温类加载机制
前言
我們在學習 java 基礎的時候,從宏觀上了解一個類到運行大致是:.java 文件通過 javac 編譯器編譯得到 .class 文件,在用到該類時,jvm 會加載該 class 文件,并創建對應的 class 對象,將 class 文件加載到 jvm 的內存當中,這個過程也被稱之為類加載過程。
下面我們將詳細了解這個過程,本篇過長建議先收藏。
1、類加載過程
其實關于類加載過程是分為5個階段的:
加載,驗證,準備,解析,初始化
接下來我們看一下這五個階段:
1.1 加載
JVM 在該階段的主要目的是將字節碼從不同的數據源(可能是 class 文件、也可能是 jar 包,甚至網絡)轉化為二進制字節流加載到內存中,并生成一個代表該類的 java.lang.Class 對象。
1.2 驗證
這一階段的主要目的是為了確保 Class 文件的字節流中包含的信息是否符合當前虛擬機的要求,并且不會危害虛擬機自身的安全,只有符合 JVM 字節碼規范的才能被 JVM 正確執行。該階段是保證 JVM 安全的重要屏障,下面是一些主要的檢查。
-
確保二進制字節流格式符合預期(比如說是否以 cafe bene 咖啡北鼻開頭)。
-
是否所有方法都遵守訪問控制關鍵字的限定。
-
方法調用的參數個數和類型是否正確。
-
確保變量在使用之前被正確初始化了。
-
檢查變量是否被賦予恰當類型的值。
1.3 準備
JVM 會在該階段對類變量(也稱為靜態變量,static 關鍵字修飾的)分配內存并初始化(對應數據類型的默認初始值,如 0、0L、null、false 等)。
準備階段是正式為類變量分配內存并設置類變量的初始值階段,即在方法區中分配這些變量所使用的內存空間。注意這里所說的初始值概念,比如一個類變量定義為:
public static int value1 = 123;實際上變量 value1 在準備階段過后的初始值為 0 而不是 123(如果是String類型,初始值為null),將 value1 賦值為 123 的 putstatic 指令是程序被編譯后,存放于類構造器方法之中。
但是注意如果聲明為:
在編譯階段會為 value2 生成 ConstantValue 屬性,在準備階段虛擬機會根據 ConstantValue 屬性將 value2 賦值為 123。
也就是,static final 修飾的變量被稱作為常量,和類變量不同。常量一旦賦值就不會改變了,所以 value2 在準備階段的值為 123 而不是 0。
1.4 解析
該階段將常量池中的符號引用轉化為直接引用。
what?符號引用,直接引用?
符號引用以一組符號(任何形式的字面量,只要在使用時能夠無歧義的定位到目標即可)來描述所引用的目標。
在編譯時,Java 類并不知道所引用的類的實際地址,因此只能使用符號引用來代替。比如 club.sscai.Test1 類引用了 club.sscai.Test2 類,編譯時 Test1 類并不知道 Test2 類的實際內存地址,因此只能使用符號 club.sscai.Test2。
直接引用通過對符號引用進行解析,找到引用的實際內存地址。
1.5 初始化
該階段是類加載過程的最后一步。在準備階段,類變量已經被賦過默認初始值,而在初始化階段,類變量將被賦值為代碼期望賦的值。換句話說,初始化階段是執行類構造器方法的過程。
上面這段話說得比較抽象,不好理解,我來舉個例子。
String niceyoo = new String("感謝關注");上面這段代碼使用了 new 關鍵字來實例化一個字符串對象,那么這時候,就會調用 String 類的構造方法對 niceyoo 進行實例化,怎么個實例化?就是賦值唄。
本節點總結
其實看完類加載過程,由于大部分偏理論,乏味的同時又很難理解,也不容易記憶。所以將類加載過程結合面試題來進一步擴展,如下:
建議先思考后再看答案
題目一:如下代碼中,執行 main 函數會通過編譯嗎?如果可以通過,打印結果是什么呢?
public class A {public static void fun1(){System.out.println("fun1");}public void fun2(){System.out.println("fun2");}public static void main(String[] args){((A) null).fun1();((A) null).fun2();}}答案: 首先代碼是可以通過編譯的,null 可以強制轉為任意類型,調用其類中的靜態方法 fun1 不報異常,調用其類中的非靜態方法 fun2 會報空指針異常。
分析: 編譯是否正常通過最大的干擾項應該是 null 強轉吧,估計有的小伙伴都不一定見過,null 可以被強制類型轉換成任意類型的對象,知識點,下次要考。
關于打印結果則主要是類加載過程的考察:當加載類對象時,首先初始化靜態屬性,然后靜態代碼塊;當實例化對象時,首先執行構造塊(直接寫在類中的代碼塊{ xxx }),然后執行構造方法。至于各靜態塊和靜態屬性初始化哪個些執行,是按代碼的先后順序。屬性、構造塊、構造方法之間的執行順序(但構造塊一定會在構造方法前執行),也是按代碼的先后順序。
綜上,對象即便被將轉為空時,靜態方法也是可以被調用的,這也是我們平時在使用一些工具類時,直接通過對象.來訪問其方法的原因。
題目二:請指出下面程序的運行結果。
class A {static {System.out.print("1");}public A() {System.out.print("2");} }class B extends A {static {System.out.print("a");}public B() {System.out.print("b");} }public class Hello {public static void main(String[] args) {A ab = new B();ab = new B();} }分析: 通過上一題目的分析中,我們可能機智的得到了靜態代碼塊是優于構造方法的執行的,但是這個題目中出現了A\B類的繼承關系,所以可能帶來困擾,但是沒關系,靜態代碼塊就是優于構造方法的,只是父類優先級相對高一級罷了,比如 new B() 會先調用父類 A 的靜態代碼塊,其次是 B 的靜態代碼塊,然后是 A 的構造方法,最后是 B 的構造方法。
匯總:執行順序是先執行父類的靜態代碼塊,然后執行子類的靜態代碼塊;然后執行父類的非靜態代碼塊,再執行父類的構造方法;之后再執行子類的非靜態代碼塊,再執行子類的構造方法。靜態代碼塊>非靜態代碼塊>構造方法。
再就是對象的創建只會調用一次靜態代碼塊,因為類初始化信息是存在方法區里,當加載類的時候去檢查,第二次的時候它會發現已經初始化過了,就不會再執行,所以再去 new B() 的時候,是不會再去打印 1a 的。
如果覺得比較繞,再舉個例子,就好比你玩王者榮耀的時候,有個趙云的6元首充禮包,你第一次充錢,創建了這個首充禮包的對象,當你第二次充錢時就不會再有首充禮包了。
答案:1a2b2b
2、類加載器
聊完類加載過程的五個階段,我們再來看看加載階段用到的類加載器。
系統運行時,是由類加載器將 .class 文件的二進制數據從外部存儲器(如光盤,硬盤)調入內存中,CPU再從內存中讀取指令和數據進行運算,并將運算結果存入內存中的,顯然類加載器是很重要的第一步。
一般來說,Java 程序員并不需要直接同類加載器進行交互。JVM 默認的行為就已經足夠滿足大多數情況的需求了。不過,如果遇到了需要和類加載器進行交互的情況,而對類加載器的機制又不是很了解的話,就不得不花大量的時間去調試 ClassNotFoundException 和 NoClassDefFoundError 等異常。
對于任意一個類,都需要由它的類加載器和這個類本身一同確定其在 JVM 中的唯一性。也就是說,如果兩個類的加載器不同,即使兩個類來源于同一個字節碼文件,那這兩個類就必定不相等(比如兩個類的 Class 對象不 equals)。
Java 類加載器可以分為三種:
1)啟動類加載器(Bootstrap Class-Loader),加載?jre/lib?包下面的 jar 文件,比如說常見的 rt.jar。
啟動類加載器主要加載的是JVM自身需要的類,這個類加載使用 C++ 語言實現的,是虛擬機自身的一部分,它負責將 <JAVA_HOME>/lib路徑下的核心類庫或-Xbootclasspath參數指定的路徑下的jar包加載到內存中,注意必由于虛擬機是按照文件名識別加載jar包的,如rt.jar,如果文件名不被虛擬機識別,即使把jar包丟到lib目錄下也是沒有作用的(出于安全考慮,Bootstrap啟動類加載器只加載包名為java、javax、sun等開頭的類)。
2)擴展類加載器(Extension or Ext Class-Loader),加載?jre/lib/ext?包下面的 jar 文件。
擴展類加載器是指Sun公司(已被Oracle收購)實現的sun.misc.Launcher$ExtClassLoader類,由Java語言實現的,是Launcher的靜態內部類,它負責加載<JAVA_HOME>/lib/ext目錄下或者由系統變量-Djava.ext.dir指定位路徑中的類庫,開發者可以直接使用標準擴展類加載器。
3)應用類加載器(Application or App Clas-Loader),根據程序的類路徑(classpath)來加載 Java 類。
也稱應用程序加載器是指 Sun公司實現的sun.misc.Launcher$AppClassLoader。它負責加載系統類路徑java -classpath或-D java.class.path 指定路徑下的類庫,也就是我們經常用到的classpath路徑,開發者可以直接使用系統類加載器,一般情況下該類加載是程序中默認的類加載器,通過ClassLoader#getSystemClassLoader()方法可以獲取到該類加載器。
來來來,通過一段簡單的代碼了解下。
public class Test1 {public static void main(String[] args){ClassLoader currentLoader = Test.class.getClassLoader();System.out.println(currentLoader.toString());ClassLoader parentLoader = currentLoader.getParent();System.out.println(parentLoader.toString());ClassLoader parentParentLoader = parentLoader.getParent();System.out.println(parentParentLoader);} }每個 Java 類都維護著一個指向定義它的類加載器的引用,通過?類名.class.getClassLoader()?可以獲取到此引用;然后通過?.getParent()?可以獲取類加載器的上層類加載器。
這段代碼的輸出結果如下:
sun.misc.Launcher$AppClassLoader@18b4aac2 sun.misc.Launcher$ExtClassLoader@4554617c null第一行輸出為 Test 的類加載器,即應用類加載器,它是?sun.misc.LauncherAppClassLoader??類的實例;第二行輸出為擴展類加載器,是??sun.misc.LauncherAppClassLoader**?類的實例;第二行輸出為擴展類加載器,是?**sun.misc.LauncherAppClassLoader???類的實例;第二行輸出為擴展類加載器,是???sun.misc.LauncherExtClassLoader?類的實例。那啟動類加載器呢?按理說,擴展類加載器的上層類加載器是啟動類加載器,但在我這個版本的 JDK 中, 擴展類加載器的?getParent()?返回?null。
在 Java 的日常應用程序開發中,類的加載幾乎是由上述3種類加載器相互配合執行的,在必要時,我們還可以自定義類加載器,需要注意的是,Java 虛擬機對 class 文件采用的是按需加載的方式,也就是說當需要使用該類時才會將它的 class 文件加載到內存生成 class 對象,而且加載某個類的 class 文件時, Java 虛擬機采用的是雙親委派模式即把請求交由父類處理,它一種任務委派模式,下面我們進一步了解它。
別放棄,加油!
3、雙親委派模型
雙親委派模式是在 Java 1.2 后引入的,其工作原理的是,如果一個類加載器收到了類加載請求,它并不會自己先去加載,而是把這個請求委托給父類的加載器去執行,如果父類加載器還存在其父類加載器,則進一步向上委托,依次遞歸,請求最終將到達頂層的啟動類加載器,如果父類加載器可以完成類加載任務,就成功返回,倘若父類加載器無法完成此加載任務,子加載器才會嘗試自己去加載,這就是雙親委派模式,即每個兒子都很懶,每次有活就丟給父親去干,直到父親說這件事我也干不了時,兒子自己想辦法去完成,這不就是傳說中的實力坑爹啊?那么采用這種模式有啥用呢?
雙親委派模式優勢
采用雙親委派模式的是好處是 Java 類隨著它的類加載器一起具備了一種帶有優先級的層次關系,通過這種層級關可以避免類的重復加載,當父親已經加載了該類時,就沒有必要子 ClassLoader 再加載一次。
其次是考慮到安全因素,java 核心 api 中定義類型不會被隨意替換,假設通過網絡傳遞一個名為 java.lang.Integer 的類,通過雙親委托模式傳遞到啟動類加載器,而啟動類加載器在核心 Java API 發現這個名字的類,發現該類已被加載,并不會重新加載網絡傳遞的過來的 java.lang.Integer,而直接返回已加載過的 Integer.class,這樣便可以防止核心API庫被隨意篡改。
可能你會想,如果我們在 classpath 路徑下自定義一個名為 java.lang.SingleInterge 類(該類是胡編的)呢?該類并不存在 java.lang 中,經過雙親委托模式,傳遞到啟動類加載器中,由于父類加載器路徑下并沒有該類,所以不會加載,將反向委托給子類加載器加載,最終會通過系統類加載器加載該類。但是這樣做是不允許,因為 java.lang 是核心 API 包,需要訪問權限,強制加載將會報出如下異常:
java.lang.SecurityException: Prohibited package name: java.lang文字內容太乏味,上個例子吧,我們通過自定義類加載器去證實雙親委派模式。
先簡單了解一下這個類加載器的主要方法:
loadClass:該方法中的邏輯就是雙親委派模式的實現,當類加載請求到來時,先從緩存中查找該類對象,如果存在直接返回,如果不存在則交給該類加載去的父加載器去加載,倘若沒有父加載則交給頂級啟動類加載器去加載,最后倘若仍沒有找到,則使用findClass()方法去加載。
findClass:findClass()方法是在loadClass()方法中被調用的,當loadClass()方法中父加載器加載失敗后,則會調用自己的findClass()方法來完成類加載,這樣就可以保證自定義的類加載器也符合雙親委托模式。
defineClass:通過這個方法不僅能夠通過class文件實例化class對象,也可以通過其他方式實例化class對象,defineClass()方法通常與findClass()方法一起使用,一般情況下,在自定義類加載器時,會直接覆蓋ClassLoader的findClass()方法并編寫加載規則,取得要加載類的字節碼后轉換成流,然后調用defineClass()方法生成類的Class對象。
resolveClass:使用該方法可以使用類的Class對象創建完成也同時被解析。前面我們說鏈接階段主要是對字節碼進行驗證,為類變量分配內存并設置初始值同時將字節碼文件中的符號引用轉換為直接引用。
自定義類加載器 MyClassLoader :
package club.sscai.test7; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream;/** * author:??niceyoo * blog:????https://cnblogs.com/niceyoo * desc:????自定義類加載器 */ public class MyClassLoader extends ClassLoader {private String path;/* 加載器的路徑 */private String name;/* 類加載器名稱 */public MyClassLoader(String path,String name){super();/* 讓起同類加載器成為該類的父加載器 */this.name = name;this.path = path;}/*** 父類加載器構造方法* @param parent* @param path* @param name*/public MyClassLoader(ClassLoader parent,String path,String name){super(parent);/* 顯示指定父類加載器 */this.name = name;this.path = path;}/*** 加載我們自己定義的類,通過我們自定義的這個 ClassLoader* 例如:club.sscai.test7.Demo* @param name* @return* @throws ClassNotFoundException*/@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException {/* 讀取 class 文件,轉換成二進制數組 */byte[] data = readClassFileToByteArray(name);return this.defineClass(name,data,0,data.length);}@Overridepublic String toString() {return this.name;}/*** 獲取 .class 字節數組* 【讀取 class 文件,將類轉換成二進制數組】*??club.sscai.test7.Demo >*??F:/idea_workspace/test/Demo.class* @param name* @return*/private byte[] readClassFileToByteArray(String name) {InputStream is = null;byte[] returnData = null;name = name.replace("\\.","/");String filePath = this.path + name + ".class";File file = new File(filePath);ByteArrayOutputStream os = new ByteArrayOutputStream();try {is = new FileInputStream(file);int tep = 0;while ((tep = is.read()) != -1){os.write(tep);}returnData = os.toByteArray();} catch (Exception e) {e.printStackTrace();} finally {try {is.close();os.close();} catch (IOException e) {e.printStackTrace();}}return returnData;}@Overrideprotected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {return super.loadClass(name, resolve);} }當前項目位于 F:\idea_workspace\Demo
當前項目中的干擾項 Demo.java:
package club.sscai.test7;/** * author:??niceyoo * blog:????https://cnblogs.com/niceyoo * desc:????干擾項Demo */ public class Demo {public Demo() {System.out.println("我是父加載器加載的Demo:"+Demo.class.getClassLoader());} }存放在 F:/idea_workspace/test/ 目錄下的 Demo.java,注意如下代碼需要通過 javac 編譯成 Demo.class
public class Demo {public Demo(){System.out.println("Demo:" + this.getClass().getClassLoader());} }測試代碼 TestDemo.java 如下:
public class TestDemo {public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {/* 參數一為讀取class路徑,參數二為自定義類加載器名稱 */MyClassLoader xwLoader = new MyClassLoader("F:/idea_workspace/test/","xiaowang");Class<?> demo = xwLoader.loadClass("Demo");demo.newInstance();} }執行 main 方法后會打印什么呢?
Demo:xiaowang我們改一下測試代碼:
public class TestDemo {public static void main(String[] args) throws ClassNotFoundException, IllegalAccessException, InstantiationException {MyClassLoader xwLoader = new MyClassLoader("F:/idea_workspace/test/","xiaowang");Class<?> demo = xwLoader.loadClass("club.sscai.test7.Demo");demo.newInstance();} }再次執行會打印什么呢?
我是父加載器加載的Demo:sun.misc.Launcher$AppClassLoader@18b4aac2顯然第二次并沒有加載 F:/idea_workspace/test/ 目錄下的 Demo,而是執行了當前項目中的 Demo,為什么?
這就是雙親委派模式,由于當前啟動類 TestDemo 的父級是 AppClassLoader,顯然該包下已經加載過 Demo 類了,所以不會再去加載目標 Demo
4、熱部署與熱加載(擴展)
上邊算是說了一堆理論吧,熱部署、熱加載則算是實際應用了,相信這兩者應該并不陌生,或多或少的應該也有所了解吧。
熱加載的實現原理主要依賴java的類加載機制,在實現方式可以概括為在容器啟動的時候起一條后臺線程,定時的檢測類文件的時間戳變化,如果類的時間戳變掉了,則將類重新載入。
熱部署原理類似,但它是直接重新加載整個應用,這種方式會釋放內存,比熱加載更加干凈徹底,但同時也更費時間。
簡單總結一下兩者的區別與聯系:
Java熱部署與熱加載的聯系
Java熱部署與熱加載的區別:
- 部署方式
– 熱部署在服務器運行時重新部署項目
– 熱加載在運行時重新加載class - 實現原理
– 熱部署直接重新加載整個應用
– 熱加載在運行時重新加載class - 使用場景
– 熱部署更多的是在生產環境使用
– 熱加載則更多的實在開發環境使用
想要實現熱部署可以分以下三個步驟:
相關代碼:
User沒有被修改類:
public class User {public void add() {System.out.println("addV1,沒有修改過...");}}User更新類
public class User {public void add() {System.out.println("我把之前的user add方法修改啦!");}}自定義類加載器:
public class MyClassLoader extends ClassLoader {@Overrideprotected Class<?> findClass(String name) throws ClassNotFoundException {try {/* 文件名稱 */String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";/* 獲取文件輸入流 */InputStream is = this.getClass().getResourceAsStream(fileName);/* 讀取字節 */byte[] b = new byte[is.available()];is.read(b);/* 將byte字節流解析成jvm能夠識別的Class對象 */return defineClass(name, b, 0, b.length);} catch (Exception e) {throw new ClassNotFoundException();}}}更新代碼:
public class Hotswap {public static void main(String[] args)throws ClassNotFoundException, InstantiationException, IllegalAccessException, NoSuchMethodException,SecurityException, IllegalArgumentException, InvocationTargetException, InterruptedException {loadUser();System.gc();Thread.sleep(1000);/* 等待資源回收 *//* 需要被熱部署的class文件 */File file1 = new File("F:\\test\\User.class");/* 之前編譯好的class文件 */File file2 = new File("F:\\idea_workspace\\target\\classes\\club\\sscai\\User.class");/* 刪除舊版本的class文件 */boolean isDelete = file2.delete();if (!isDelete) {System.out.println("熱部署失敗.");return;}file1.renameTo(file2);System.out.println("update success!");loadUser();}public static void loadUser() throws ClassNotFoundException, InstantiationException, IllegalAccessException,NoSuchMethodException, SecurityException, IllegalArgumentException, InvocationTargetException {MyClassLoader myLoader = new MyClassLoader();Class<?> class1 = myLoader.findClass("club.sscai.User");Object obj1 = class1.newInstance();Method method = class1.getMethod("add");method.invoke(obj1);System.out.println(obj1.getClass());System.out.println(obj1.getClass().getClassLoader());}}5、最后
本篇有點過長了,其實大致看下來,類加載無非也就那么回事。
類加載機制:JVM 將類的信息動態添加到內存并使用的一種機制。
總結
- 上一篇: error LNK2019: 无法解析的
- 下一篇: WCF 服务端+客户端动态调用