JVM效能最佳化 —— 類載入器,手動實現類的熱載入

软件测试潇潇發表於2024-04-15

一、類載入的機制的層次結構

每個編寫的”.java”擴充名類檔案都儲存著需要執行的程式邏輯,這些”.java”檔案經過Java編譯器編譯成擴充名為”.class”的檔案,”.class”檔案中儲存著Java程式碼經轉換後的虛擬機器指令,當需要使用某個類時,虛擬機器將會載入它的”.class”檔案,並建立對應的class物件,將class檔案載入到虛擬機器的記憶體,這個過程稱為類載入,這裡我們需要了解一下類載入的過程,如下:

Jvm執行class檔案

JVM效能最佳化 —— 類載入器,手動實現類的熱載入

步驟一、類載入機制

將class檔案位元組碼內容載入到記憶體中,並將這些靜態資料轉換成方法區中的執行時資料結構,在堆中生成一個代表這個類的java.lang.Class物件,作為方法區類資料的訪問入口,這個過程需要類載入器參與。

當系統執行時,類載入器將.class檔案的二進位制資料從外部儲存器(如光碟,硬碟)調入記憶體中,CPU再從記憶體中讀取指令和資料進行運算,並將運算結果存入記憶體中。記憶體在該過程中充當著"二傳手"的作用,通俗的講,如果沒有記憶體,類載入器從外部儲存裝置調入.class檔案二進位制資料直接給CPU處理,而由於CPU的處理速度遠遠大於調入資料的速度,容易造成資料的脫節,所以需要記憶體起緩衝作用。

類將.class檔案載入至執行時的方法區後,會在堆中建立一個Java.lang.Class物件,用來封裝類位於方法區內的資料結構,該Class物件是在載入類的過程中建立的,每個類都對應有一個Class型別的物件,Class類的構造方法是私有的,只有JVM能夠建立。因此Class物件是反射的入口,使用該物件就可以獲得目標類所關聯的.class檔案中具體的資料結構。

JVM效能最佳化 —— 類載入器,手動實現類的熱載入


類載入的最終產物就是位於堆中的Class物件(注意不是目標類物件),該物件封裝了類在方法區中的資料結構,並且向使用者提供了訪問方法區資料結構的介面,即Java反射的介面。

步驟二、連線過程

將java類的二進位制程式碼合併到JVM的執行狀態之中的過程

驗證:確保載入的類資訊符合JVM規範,沒有安全方面的問題

準備:正式為類變數(static變數)分配記憶體並設定類變數初始值的階段,這些記憶體都將在方法區中進行分配

解析:虛擬機器常量池的符號引用替換為位元組引用過程

步驟三、初始化

初始化階段是執行類構造器<clinit>()方法的過程。類構造器<clinit>()方法是由編譯器自動收藏類中的所有類變數的賦值動作和靜態語句塊(static塊)中的語句合併產生,程式碼從上往下執行。

當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化

虛擬機器會保證一個類的<clinit>()方法在多執行緒環境中被正確加鎖和同步
當範圍一個Java類的靜態域時,只有真正聲名這個域的類才會被初始化

二、類載入器的層次結構

啟動(Bootstrap)類載入器

擴充套件(Extension)類載入器

系統(-)類載入器

JVM效能最佳化 —— 類載入器,手動實現類的熱載入

1、啟動(Bootstrap)類載入器

啟動類載入器主要載入的是JVM自身需要的類,這個類載入使用C++語言實現的,是虛擬機器自身的一部分,它負責將<JAVA_HOME>/lib路徑下的核心類庫或-Xbootclasspath引數指定的路徑下的jar包載入到記憶體中,注意必由於虛擬機器是按照檔名識別載入jar包的,如rt.jar,如果檔名不被虛擬機器識別,即使把jar包丟到lib目錄下也是沒有作用的(出於安全考慮,Bootstrap啟動類載入器只載入包名為java、javax、sun等開頭的類)。

2、擴充套件(Extension)類載入器

擴充套件類載入器是指Sun公司(已被Oracle收購)實現的sun.misc.Launcher$ExtClassLoader類,由Java語言實現的,是Launcher的靜態內部類,它負責載入<JAVA_HOME>/lib/ext目錄下或者由系統變數-Djava.ext.dir指定位路徑中的類庫,開發者可以直接使用標準擴充套件類載入器。

3、系統(System)類載入器

也稱應用程式載入器是指 Sun公司實現的sun.misc.Launcher$AppClassLoader。它負責載入系統類路徑java -classpath或-D java.class.path 指定路徑下的類庫,也就是我們經常用到的classpath路徑,開發者可以直接使用系統類載入器,一般情況下該類載入是程式中預設的類載入器,透過ClassLoader#getSystemClassLoader()方法可以獲取到該類載入器。

在Java的日常應用程式開發中,類的載入幾乎是由上述3種類載入器相互配合執行的,在必要時,我們還可以自定義類載入器,需要注意的是,Java虛擬機器對class檔案採用的是按需載入的方式,也就是說當需要使用該類時才會將它的class檔案載入到記憶體生成class物件,而且載入某個類的class檔案時,Java虛擬機器採用的是雙親委派模式即把請求交由父類處理,它一種任務委派模式,下面我們進一步瞭解它。

3.1、理解雙親委派模式

下面我們從程式碼層面瞭解幾個Java中定義的類載入器及其雙親委派模式的實現,它們類圖關係如下

JVM效能最佳化 —— 類載入器,手動實現類的熱載入

雙親委派模式是在Java 1.2後引入的,其工作原理的是,如果一個類載入器收到了類載入請求,它並不會自己先去載入,而是把這個請求委託給父類的載入器去執行,如果父類載入器還存在其父類載入器,則進一步向上委託,依次遞迴,請求最終將到達頂層的啟動類載入器,如果父類載入器可以完成類載入任務,就成功返回,倘若父類載入器無法完成此載入任務,子載入器才會嘗試自己去載入,這就是雙親委派模式,即每個兒子都很懶,每次有活就丟給父親去幹,直到父親說這件事我也幹不了時,兒子自己想辦法去完成,這不就是傳說中的實力坑爹啊?那麼採用這種模式有啥用呢?

3.1、雙親委派模式優勢

採用雙親委派模式的是好處是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

所以無論如何都無法載入成功的。

三、類載入器間的關係

我們進一步瞭解類載入器間的關係(並非指繼承關係),主要可以分為以下4點

  • 啟動類載入器,由C++實現,沒有父類。
  • 擴充類載入器(ExtClassLoader),由Java語言實現,父類載入器為null
  • 系統類載入器(AppClassLoader),由Java語言實現,父類載入器為ExtClassLoader
  • 自定義類載入器,父類載入器肯定為AppClassLoader。

1、類載入器常用方法

loadClass(String)

該方法載入指定名稱(包括包名)的二進位制型別,該方法在JDK1.2之後不再建議使用者重寫但使用者可以直接呼叫該方法,loadClass()方法是ClassLoader類自己實現的,該方法中的邏輯就是雙親委派模式的實現,其原始碼如下,loadClass(String name, boolean resolve)是一個過載方法,resolve引數代表是否生成class物件的同時進行解析相關操作。

正如loadClass方法所展示的,當類載入請求到來時,先從快取中查詢該類物件,如果存在直接返回,如果不存在則交給該類載入去的父載入器去載入,倘若沒有父載入則交給頂級啟動類載入器去載入,最後倘若仍沒有找到,則使用findClass()方法去載入(關於findClass()稍後會進一步介紹)。從loadClass實現也可以知道如果不想重新定義載入類的規則,也沒有複雜的邏輯,只想在執行時載入自己指定的類,那麼我們可以直接使用this.getClass().getClassLoder.loadClass("className"),這樣就可以直接呼叫ClassLoader的loadClass方法獲取到class物件。

findClass(String)

在JDK1.2之前,在自定義類載入時,總會去繼承ClassLoader類並重寫loadClass方法,從而實現自定義的類載入類,但是在JDK1.2之後已不再建議使用者去覆蓋loadClass()方法,而是建議把自定義的類載入邏輯寫在findClass()方法中,從前面的分析可知,findClass()方法是在loadClass()方法中被呼叫的,當loadClass()方法中父載入器載入失敗後,則會呼叫自己的findClass()方法來完成類載入,這樣就可以保證自定義的類載入器也符合雙親委託模式。需要注意的是ClassLoader類中並沒有實現findClass()方法的具體程式碼邏輯,取而代之的是丟擲ClassNotFoundException異常,同時應該知道的是findClass方法通常是和defineClass方法一起使用的(稍後會分析)

defineClass(byte[] b, int off, int len)

defineClass()方法是用來將byte位元組流解析成JVM能夠識別的Class物件(ClassLoader中已實現該方法邏輯),透過這個方法不僅能夠透過class檔案例項化class物件,也可以透過其他方式例項化class物件,如透過網路接收一個類的位元組碼,然後轉換為byte位元組流建立對應的Class物件,defineClass()方法通常與findClass()方法一起使用,一般情況下,在自定義類載入器時,會直接覆蓋ClassLoader的findClass()方法並編寫載入規則,取得要載入類的位元組碼後轉換成流,然後呼叫defineClass()方法生成類的Class物件

resolveClass(Class<?> c)

使用該方法可以使用類的Class物件建立完成也同時被解析。前面我們說連結階段主要是對位元組碼進行驗證,為類變數分配記憶體並設定初始值同時將位元組碼檔案中的符號引用轉換為直接引用。

四、熱部署

對於Java應用程式來說,熱部署就是在執行時更新Java類檔案。

1、熱部署的原理是什麼

想要知道熱部署的原理,必須要了解java類的載入過程。一個java類檔案到虛擬機器裡的物件,要經過如下過程。

首先透過java編譯器,將java檔案編譯成class位元組碼,類載入器讀取class位元組碼,再將類轉化為例項,對例項newInstance就可以生成物件。

類載入器ClassLoader功能,也就是將class位元組碼轉換到類的例項。

在java應用中,所有的例項都是由類載入器,載入而來。

一般在系統中,類的載入都是由系統自帶的類載入器完成,而且對於同一個全限定名的java類(如com.csiar.soc.HelloWorld),只能被載入一次,而且無法被解除安裝。

這個時候問題就來了,如果我們希望將java類解除安裝,並且替換更新版本的java類,該怎麼做呢?

既然在類載入器中,java類只能被載入一次,並且無法解除安裝。那是不是可以直接把類載入器給換了?答案是可以的,我們可以自定義類載入器,並重寫ClassLoader的findClass方法。想要實現熱部署可以分以下三個步驟:

  1. 銷燬該自定義ClassLoader
  2. 更新class類檔案
  3. 建立新的ClassLoader去載入更新後的class類檔案。

2、熱部署與熱載入

2.1、Java熱部署與Java熱載入的聯絡和區別

Java熱部署與熱載入的聯絡

  1. 不重啟伺服器編譯/部署專案
  2. 基於Java的類載入器實現

Java熱部署與熱載入的區別

  1. 部署方式
  • 熱部署在伺服器執行時重新部署專案
  • 熱載入在執行時重新載入class
  • 實現原理
  • 熱部署直接重新載入整個應用
  • 熱載入在執行時重新載入class
  • 使用場景
  • 熱部署更多的是在生產環境使用
  • 熱載入則更多的實在開發環境使用

3、相關程式碼

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 {

	@Override
	protected 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:\\test\\test\\target\\classes\\com\\itmayiedu\\User.class");
		boolean isDelete = file2.delete();// 刪除舊版本的class檔案
		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("com.test.User");
		Object obj1 = class1.newInstance();
		Method method = class1.getMethod("add");
		method.invoke(obj1);
		System.out.println(obj1.getClass());
		System.out.println(obj1.getClass().getClassLoader());
	}
}

行動吧,在路上總比一直觀望的要好,未來的你肯定會感 謝現在拼搏的自己!如果想學習提升找不到資料,沒人答疑解惑時,請及時加入扣群:731789136,裡面有各種軟體測試+開發資料和技術可以一起交流學習哦。

最後感謝每一個認真閱讀我文章的人,禮尚往來總是要有的,這些資料,對於【軟體測試】的朋友來說應該是最全面最完整的備戰倉庫,雖然不是什麼很值錢的東西,如果你用得到的話可以直接拿走:

如果你想學習軟體測試和需要軟體測試資料,歡迎加入扣扣交流群:731789136,裡面可以免費領取軟體測試+自動化測試資料+軟體測試面試寶典+簡歷模版+實戰專案+面試刷題工具和大佬答疑解惑,我們一起交流一起學習!

相關文章