1. 程式人生 > >Java類載入之熱替換

Java類載入之熱替換

一、含義

類的熱替換,是指程式在執行的時候,對記憶體方法區中類定義進行替換,因為堆中的Class物件是對方法區物件的封裝,所以可以理解為對Class物件的替換,當一個class被替換後,系統無需重啟,替換的類會立即生效。

二、類的載入

在Java中,類的例項化分為兩部分:類的載入類的例項化。而類的載入又分為顯式載入隱式載入
我們平時使用new建立類例項時,其實就是隱式地包含了類的載入過程。對於類的顯式載入,比較常用的是用Class.forName()方法。其實,它們都是通過呼叫ClassLoader類的loadClass()方法來完成類的實際載入工作。直接呼叫ClassLoader的loadClass()方法是另外一種不常用的顯式載入類的方法。下面來介紹一下ClassLoader類:

 1. ClassLoader是一個抽象類;
 2. ClassLoader的例項把讀入的Java位元組碼類裝載到JVM中;
 3. ClassLoader可以定製,滿足不同的位元組碼流獲取方式;
 4. ClassLoader負責類裝載過程中的載入階段;

三、類載入器ClassLoader

ClassLoader在載入類時有一定的層次關係和規則。在Java中,有四種類型的類載入器,分別為:

 1. BootStrapClassLoader(啟動ClassLoader)
 2. ExtClassLoader(擴充套件ClassLoader)
 3. AppClassLoader(應用ClassLoader)
 4. Custom ClassLoader(自定義ClassLoader)

這四種類載入器分別負責不同路徑的類的載入,並形成了一個類載入的層次結構。見下圖

這裡寫圖片描述

  1. BootStrapClassLoader:處於類載入器層次結構的最高層,預設負責載入jre/lib/rt.jar路徑下的核心類,或-Xbootclasspath選項指定的jar包;
  2. ExtClassLoader:預設載入路徑為%JAVA_HOME%/lib/ext/*.jar;
  3. AppClassLoader:預設載入路徑為環境變數CLASSPATH中設定的值。也可以通過-classpath選項進行指定;
  4. Custom ClassLoader:可以根據使用者的需要定製自己的類載入過程,在執行期進行指定類的動態實時載入;(熱替換也是基於該類,來繞過Java類的既定載入過程)

一般來說,這四種類載入器會形成一種父子關係,高層為低層的父載入器。在類進行載入時,首先會自底向上挨個檢查是否已經載入了指定類,如果已經載入,則直接返回該類的引用。**如果到最高層也沒有找到載入過指定類,那麼會自頂向下挨個嘗試載入,直到使用者自定義類載入器,如果還不能成功,就會丟擲異常。**過程如下圖:

這裡寫圖片描述

每個類載入器有自己的名字空間,對於同一個類載入器例項來說,名字相同的類只能存在一個,並且僅載入一次。不管該類有沒有變化,下次再需要載入時,它只是從自己的快取中直接返回已經載入過的類引用。

我們編寫的應用類預設情況下都是通過AppClassLoader進行載入的。當我們使用new關鍵字或使用Class.forName()來載入類時,所要載入的類都是由呼叫new(Class.forName)類的類載入器(也是AppClassLoader)進行載入的。

要想實現Java類的熱替換,首先必須要讓系統中同名類的不同版本例項的共存要想實現同一個類的不同版本的共存,必須要通過不同的類載入器來載入該類的不同版本。另外,為了能夠繞過Java類的既定載入過程,需要實現自己的類載入器。

四、自定義類載入器CustomLoader

為了能夠完全掌控類的載入過程,需要自定義類載入器,且需要從ClassLoader繼承。下面來介紹一下ClassLoader類中和熱替換有關的一些重要方法。

  1. **findLoadedClass():該方法會在對應載入器的名字空間中尋找指定的類是否已存在,如果存在就返回給類的引用,否則就返回null。**每個類載入器都維護有自己的一份已載入類名字空間,其中不能出現兩個同名的類。凡是通過該類載入器載入的類,無論是直接的還是間接的,都儲存在自己的名字空間中,這裡的直接是指,存在於該類載入器的載入路徑上並由該載入器完成載入,間接是指,由該類載入器把類的載入工作委託給其他類載入器完成類的實際載入。

  2. **getSystemClassLoader():該方法返回系統使用的ClassLoader。**可以在自定義的類載入器中通過該方法把一部分工作轉交給系統類載入器去處理。

  3. defineClass():該方法接收以位元組陣列表示的類位元組碼,並把它轉換成Class例項。該方法轉換一個類的同時,會先要求裝載該類的父類以及實現的介面類。

  4. loadClass():載入類的入口方法,呼叫該方法完成類的顯式載入。通過對該方法的重寫,可以完全控制和管理類的載入過程。執行loadClass方法,只是單純的把類載入到記憶體,並不是對類的主動使用,不會引起類的初始化。

  5. resolveClass():連結一個指定的類。這是一個在某些情況下確保類可用的必要方法。

瞭解了上面的這些方法,接下來實現一個自定義的類載入器來實現熱替換,在給出示例程式碼前,再重申兩點內容:

1. 要想實現同一個類的不同版本的共存,那麼這些不同版本必須由不同的類載入器進行載入,因此就不能把這些類的載入工作委託給系統載入器來完成,因為它們只有一份。
2. 為了做到這一點,就不能採用系統預設的類載入器委託規則,也就是說我們定製的類載入器的父載入器必須設定為null。

該定製的類載入器的實現程式碼如下:

	public class CustomClassLoader extends ClassLoader {
	private String basedir; // 需要該類載入器直接載入的類檔案的基目錄
	private HashSet className; // 需要由該類載入器直接載入的類名

	public CustomClassLoader(String basedir, String[] clazns) throws Exception {
		super(null); // 指定父類載入器為 null
		this.basedir = basedir;
		className = new HashSet();
		loadClassByMe(clazns);
	}

	private void loadClassByMe(String[] clazns) throws Exception {
		for (int i = 0; i < clazns.length; i++) {
			loadDirectly(clazns[i]);
			className.add(clazns[i]);
		}
	}

	private Class loadDirectly(String name) throws Exception, Exception {
		Class cls = null;
		StringBuffer sb = new StringBuffer(basedir);
		String classname = name.replace('.', File.separatorChar) + ".class";
		sb.append(File.separator + classname);
		System.out.println(sb.toString());
		File classF = new File(sb.toString());
		cls = instantiateClass(name, new FileInputStream(classF), classF.length());
		return cls;
	}

	private Class instantiateClass(String name, InputStream fin, long len) throws Exception {
		byte[] raw = new byte[(int) len];
		fin.read(raw);
		fin.close();
		return defineClass(name, raw, 0, raw.length);
	}

	protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException {
		Class cls = null;
		cls = findLoadedClass(name);
		if (!this.className.contains(name) && cls == null)
			cls = getSystemClassLoader().loadClass(name);
		if (cls == null)
			throw new ClassNotFoundException(name);
		if (resolve)
			resolveClass(cls);
		return cls;
	}

	public static void main(String[] args) throws FileNotFoundException, IOException {
		new Timer().schedule(new TimerTask() {

			@Override
			public void run() {
				try {
					// 每次都創建出一個新的類載入器
					CustomClassLoader customClassLoader = new CustomClassLoader(
							CustomClassLoader.class.getResource("").getFile(), new String[] { "Foo" });
					Class<?> cls = customClassLoader.loadClass("Foo");
					Object foo = cls.newInstance();

					Method m = foo.getClass().getMethod("sayHi", new Class[] {});
					m.invoke(foo, new Object[] {});
				} catch (Exception ex) {
					ex.printStackTrace();
				}
			}
		}, 0, 1000L);
	}}

在main方法中:編寫一個定時器任務,每隔1秒鐘執行一次。其中,程式會建立新的類載入器例項載入Foo類,生成例項,並呼叫sayHi()方法。此處第一次載入的事Foo.java檔案,該檔案內容如下:

	public class Foo implements FooInterface {

		@Override
		public void sayHi() {
			// TODO Auto-generated method stub
			System.out.println("hi\tv1");
		}
	}

對應介面檔案為:

	public interface FooInterface {
		public void sayHi();
	}

執行結果如下:
這裡寫圖片描述

接下來,重寫編寫一份 Foo.java 類(連同介面),修改其中的 sayHi() 方法的列印內容,在cmd中用javac重新編譯後並拷貝到專案的對應目錄下,在系統正常執行的情況下,替換掉原來的 Foo.class,會看到系統會打印出更改後的內容。

這裡寫圖片描述

這裡需要分析的是:
如果把main函式中的程式碼改為:Foo foo = (Foo)cls.newInstance(); 會發現會丟擲 ClassCastException 異常。這是因為
在上面的例子中 cls 是由CustomClassLoader 載入的,而 foo 變數型別聲名類卻是由 run 方法所屬的類的載入器(預設為 AppClassLoader)載入的,因此是完全不同的型別。

如果把main函式中的程式碼改為:FooInterface foo = (FooInterface )cls.newInstance(); 會發現還會丟擲 ClassCastException 異常。這是因為外部聲名和轉型部分的 FooInterface 是由 run 方法所屬的類載入器載入的,而 Foo 類定義中 implements FooInterface 中的 FooInterface 是由 CustomClassLoader 載入的,因此屬於不同的型別轉型還是會丟擲異常的,但是由於我們在例項化 CustomClassLoader 時是這樣的:

String path = CustomClassLoader.class.getResource("").getFile();
CustomClassLoader ccl  = new CustomClassLoader (path, new String[]{"Foo"});

其中僅僅指定 Foo 類由 CustomClassLoader 載入(因為在Foo用javac編譯的時候,需要用到它實現的介面,但在拷貝Foo.class檔案的時候,只拷貝了Foo.class一個檔案,並沒有拷貝它的介面檔案),而其實現的 FooInterface 介面檔案會委託給系統類載入器載入,因此轉型成功,採用介面呼叫的程式碼如下:

	Object foo = ccl.newInstance();
	FooInterface foo = (FooInterface )ccl.newInstance(); 
    foo.sayHello(); 

五、總結

上面介紹了類的載入過程和載入的原理,並闡述了Java熱替換。其實上面的程式可以寫的再完美一點,在進行替換後,可以把老的Class給解除安裝掉,但需要注意的是:只有自定義類載入器載入的類(被替換的類)才可以解除安裝。解除安裝的辦法很簡單,把類物件,Class物件,classloader物件的引用設定為null,JVM就會把它們當作是垃圾(此處可以瞭解JVM的垃圾回收機制),會在適當的時候,解除安裝掉記憶體方法區中的二進位制資料。

最後補充一點類載入器的名稱空間

  1. 名稱空間由載入器和所有的父載入器所載入的類構成;
  2. 在同一個名稱空間中,不可能出現類名相同的兩個類;
  3. 在不同的名稱空間中,可能出現類名相同的兩個類(類名指類全稱);
  4. 由子載入器載入的類能看見父載入器載入的類,反之不可以;(比如java.lang.String類,我們自己寫的類肯定能看見,但是父載入器肯定看不見我們自己定義的類)
  5. 如果兩個載入器之間沒有直接或者間接的父子關係,那麼兩個載入器載入的類是相互不可見的;