1. 程式人生 > >Java 類的熱替換 —— 概念、設計與實現

Java 類的熱替換 —— 概念、設計與實現

轉自:https://www.ibm.com/developerworks/cn/java/j-lo-hotswapcls/index.html

構建基於 Java 的線上升級系統

孫 鳴 和 鄧 輝
2010 年 1 月 14 日釋出

WeiboGoogle+用電子郵件傳送本頁面

Comments

 

8

Java ClassLoader 技術剖析

在本文中,我們將不對 Java ClassLoader 的細節進行過於詳細的講解,而是關注於和構建線上升級系統相關的基礎概念。關於 ClassLoader 的詳細細節許多資料可以參考,有興趣的讀者可以自行研讀。

要構建線上升級系統,一個重要的技術就是能夠實現 Java 類的熱替換 —— 也就是在不停止正在執行的系統的情況下進行類(物件)的升級替換。而 Java 的 ClassLoader 正是實現這項技術的基礎。

在 Java 中,類的例項化流程分為兩個部分:類的載入和類的例項化。類的載入又分為顯式載入和隱式載入。大家使用 new 關鍵字建立類例項時,其實就隱式地包含了類的載入過程。對於類的顯式載入來說,比較常用的是 Class.forName。其實,它們都是通過呼叫 ClassLoader 類的 loadClass 方法來完成類的實際載入工作的。直接呼叫 ClassLoader 的 loadClass 方法是另外一種不常用的顯式載入類的技術。

圖 1. Java 類載入器層次結構圖

Java 類載入器層次結構圖

ClassLoader 在載入類時有一定的層次關係和規則。在 Java 中,有四種類型的類載入器,分別為:BootStrapClassLoader、ExtClassLoader、AppClassLoader 以及使用者自定義的 ClassLoader。這四種類載入器分別負責不同路徑的類的載入,並形成了一個類載入的層次結構。

BootStrapClassLoader 處於類載入器層次結構的最高層,負責 sun.boot.class.path 路徑下類的載入,預設為 jre/lib 目錄下的核心 API 或 -Xbootclasspath 選項指定的 jar 包。ExtClassLoader 的載入路徑為 java.ext.dirs,預設為 jre/lib/ext 目錄或者 -Djava.ext.dirs 指定目錄下的 jar 包載入。AppClassLoader 的載入路徑為 java.class.path,預設為環境變數 CLASSPATH 中設定的值。也可以通過 -classpath 選型進行指定。使用者自定義 ClassLoader 可以根據使用者的需要定製自己的類載入過程,在執行期進行指定類的動態實時載入。

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

圖 2. Java 類的載入過程

圖 2. Java 類的載入過程

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

我們編寫的應用類預設情況下都是通過 AppClassLoader 進行載入的。當我們使用 new 關鍵字或者 Class.forName 來載入類時,所要載入的類都是由呼叫 new 或者 Class.forName 的類的類載入器(也是 AppClassLoader)進行載入的。要想實現 Java 類的熱替換,首先必須要實現系統中同名類的不同版本例項的共存,通過上面的介紹我們知道,要想實現同一個類的不同版本的共存,我們必須要通過不同的類載入器來載入該類的不同版本。另外,為了能夠繞過 Java 類的既定載入過程,我們需要實現自己的類載入器,並在其中對類的載入過程進行完全的控制和管理。

編寫自定義的 ClassLoader

為了能夠完全掌控類的載入過程,我們的定製類載入器需要直接從 ClassLoader 繼承。首先我們來介紹一下 ClassLoader 類中和熱替換有關的的一些重要方法。

  • findLoadedClass:每個類載入器都維護有自己的一份已載入類名字空間,其中不能出現兩個同名的類。凡是通過該類載入器載入的類,無論是直接的還是間接的,都儲存在自己的名字空間中,該方法就是在該名字空間中尋找指定的類是否已存在,如果存在就返回給類的引用,否則就返回 null。這裡的直接是指,存在於該類載入器的載入路徑上並由該載入器完成載入,間接是指,由該類載入器把類的載入工作委託給其他類載入器完成類的實際載入。
  • getSystemClassLoaderJava2 中新增的方法。該方法返回系統使用的 ClassLoader。可以在自己定製的類載入器中通過該方法把一部分工作轉交給系統類載入器去處理。
  • defineClass:該方法是 ClassLoader 中非常重要的一個方法,它接收以位元組陣列表示的類位元組碼,並把它轉換成 Class 例項,該方法轉換一個類的同時,會先要求裝載該類的父類以及實現的介面類。
  • loadClass:載入類的入口方法,呼叫該方法完成類的顯式載入。通過對該方法的重新實現,我們可以完全控制和管理類的載入過程。
  • resolveClass:連結一個指定的類。這是一個在某些情況下確保類可用的必要方法,詳見 Java 語言規範中“執行”一章對該方法的描述。

瞭解了上面的這些方法,下面我們來實現一個定製的類載入器來完成這樣的載入流程:我們為該類載入器指定一些必須由該類載入器直接載入的類集合,在該類載入器進行類的載入時,如果要載入的類屬於必須由該類載入器載入的集合,那麼就由它直接來完成類的載入,否則就把類載入的工作委託給系統的類載入器完成。

在給出示例程式碼前,有兩點內容需要說明一下:1、要想實現同一個類的不同版本的共存,那麼這些不同版本必須由不同的類載入器進行載入,因此就不能把這些類的載入工作委託給系統載入器來完成,因為它們只有一份。2、為了做到這一點,就不能採用系統預設的類載入器委託規則,也就是說我們定製的類載入器的父載入器必須設定為 null。該定製的類載入器的實現程式碼如下:

清單 1. 定製的類載入器的實現程式碼

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

class CustomCL extends ClassLoader {

 

    private String basedir; // 需要該類載入器直接載入的類檔案的基目錄

    private HashSet dynaclazns; // 需要由該類載入器直接載入的類名

 

    public CustomCL(String basedir, String[] clazns) {

        super(null); // 指定父類載入器為 null

        this.basedir = basedir;

        dynaclazns = new HashSet();

        loadClassByMe(clazns);

    }

 

    private void loadClassByMe(String[] clazns) {

        for (int i = 0; i < clazns.length; i++) {

            loadDirectly(clazns[i]);

            dynaclazns.add(clazns[i]);

        }

    }

 

    private Class loadDirectly(String name) {

        Class cls = null;

        StringBuffer sb = new StringBuffer(basedir);

        String classname = name.replace('.', File.separatorChar) + ".class";

        sb.append(File.separator + classname);

        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){

        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.dynaclazns.contains(name) && cls == null)

            cls = getSystemClassLoader().loadClass(name);

        if (cls == null)

            throw new ClassNotFoundException(name);

        if (resolve)

            resolveClass(cls);

        return cls;

    }

 

}

在該類載入器的實現中,所有指定必須由它直接載入的類都在該載入器例項化時進行了載入,當通過 loadClass 進行類的載入時,如果該類沒有載入過,並且不屬於必須由該類載入器載入之列都委託給系統載入器進行載入。理解了這個實現,距離實現類的熱替換就只有一步之遙了,我們在下一小節對此進行詳細的講解

實現 Java 類的熱替換

在本小節中,我們將結合前面講述的類載入器的特性,並在上小節實現的自定義類載入器的基礎上實現 Java 類的熱替換。首先我們把上小節中實現的類載入器的類名 CustomCL 更改為 HotswapCL,以明確表達我們的意圖。

現在來介紹一下我們的實驗方法,為了簡單起見,我們的包為預設包,沒有層次,並且省去了所有錯誤處理。要替換的類為 Foo,實現很簡單,僅包含一個方法 sayHello:

清單 2. 待替換的示例類

1

2

3

4

5

public class Foo{

    public void sayHello() {

        System.out.println("hello world! (version one)");

    }

}

在當前工作目錄下建立一個新的目錄 swap,把編譯好的 Foo.class 檔案放在該目錄中。接下來要使用我們前面編寫的 HotswapCL 來實現該類的熱替換。具體的做法為:我們編寫一個定時器任務,每隔 2 秒鐘執行一次。其中,我們會建立新的類載入器例項載入 Foo 類,生成例項,並呼叫 sayHello 方法。接下來,我們會修改 Foo 類中 sayHello 方法的列印內容,重新編譯,並在系統執行的情況下替換掉原來的 Foo.class,我們會看到系統會打印出更改後的內容。定時任務的實現如下(其它程式碼省略,請讀者自行補齊):

清單 3. 實現定時任務的部分程式碼

1

2

3

4

5

6

7

8

9

10

11

12

13

14

public void run(){

    try {

        // 每次都創建出一個新的類載入器

        HowswapCL cl = new HowswapCL("../swap", new String[]{"Foo"});

        Class cls = cl.loadClass("Foo");

        Object foo = cls.newInstance();

 

        Method m = foo.getClass().getMethod("sayHello", new Class[]{});

        m.invoke(foo, new Object[]{});

     

    }  catch(Exception ex) {

        ex.printStackTrace();

    }

}

編譯、執行我們的系統,會出現如下的列印:

圖 3. 熱替換前的執行結果

圖 3. 熱替換前的執行結果

好,現在我們把 Foo 類的 sayHello 方法更改為:

1

2

3

public void sayHello() {

    System.out.println("hello world! (version two)");

}

在系統仍在執行的情況下,編譯,並替換掉 swap 目錄下原來的 Foo.class 檔案,我們再看看螢幕的列印,奇妙的事情發生了,新更改的類線上即時生效了,我們已經實現了 Foo 類的熱替換。螢幕列印如下:

圖 4. 熱替換後的執行結果

圖 4. 熱替換後的執行結果

敏銳的讀者可能會問,為何不用把 foo 轉型為 Foo,直接呼叫其 sayHello 方法呢?這樣不是更清晰明瞭嗎?下面我們來解釋一下原因,並給出一種更好的方法。

如果我們採用轉型的方法,程式碼會變成這樣:Foo foo = (Foo)cls.newInstance(); 讀者如果跟隨本文進行試驗的話,會發現這句話會丟擲 ClassCastException 異常,為什麼嗎?因為在 Java 中,即使是同一個類檔案,如果是由不同的類載入器例項載入的,那麼它們的型別是不相同的。在上面的例子中 cls 是由 HowswapCL 載入的,而 foo 變數型別聲名和轉型裡的 Foo 類卻是由 run 方法所屬的類的載入器(預設為 AppClassLoader)載入的,因此是完全不同的型別,所以會丟擲轉型異常。

那麼通過介面呼叫是不是就行了呢?我們可以定義一個 IFoo 介面,其中聲名 sayHello 方法,Foo 實現該介面。也就是這樣:IFoo foo = (IFoo)cls.newInstance(); 本來該方法也會有同樣的問題的,因為外部聲名和轉型部分的 IFoo 是由 run 方法所屬的類載入器載入的,而 Foo 類定義中 implements IFoo 中的 IFoo 是由 HotswapCL 載入的,因此屬於不同的型別轉型還是會丟擲異常的,但是由於我們在例項化 HotswapCL 時是這樣的:

HowswapCL cl = new HowswapCL("../swap", new String[]{"Foo"});

其中僅僅指定 Foo 類由 HotswapCL 載入,而其實現的 IFoo 介面檔案會委託給系統類載入器載入,因此轉型成功,採用介面呼叫的程式碼如下:

清單 4. 採用介面呼叫的程式碼

1

2

3

4

5

6

7

8

9

10

public void run(){

    try {

        HowswapCL cl = new HowswapCL("../swap", new String[]{"Foo"});

        Class cls = cl.loadClass("Foo");

        IFoo foo = (IFoo)cls.newInstance();

        foo.sayHello();

    } catch(Exception ex) {

        ex.printStackTrace();

    }

}

確實,簡潔明瞭了很多。在我們的實驗中,每當定時器排程到 run 方法時,我們都會建立一個新的 HotswapCL 例項,在產品程式碼中,無需如此,僅當需要升級替換時才去建立一個新的類載入器例項。

線上升級系統的設計原則

在上小節中,我們給出了一個 Java 類熱替換的例項,掌握了這項技術,就具備了實現線上升級系統的基礎。但是,對於一個真正的產品系統來說,升級本省就是一項非常複雜的工程,如果要線上升級,就會更加複雜。其中,實現類的熱替換隻是最後一步操作,線上升級的要求會對系統的整體設計帶來深遠的影響。下面我們來談談線上升級系統設計方面的一些原則:

  • 在系統設計一開始,就要考慮系統的哪些部分是需要以後線上升級的,哪些部分是穩定的。

    雖然我們可以把系統設計成任何一部分都是可以線上升級的,但是其成本是非常高昂的,也沒有必要。因此,明確地界定出系統以後需要線上升級的部分是明智之舉。這些部分常常是系統業務邏輯規則、演算法等等。

  • 設計出規範一致的系統狀態轉換方法。

    替換一個類僅僅是線上升級系統所要做的工作中的一個步驟,為了使系統能夠在升級後正常執行,就必須保持升級前後系統狀態的一致性。因此,在設計時要考慮需要線上升級的部分所涉及的系統狀態有哪些,把這些狀態設計成便於獲取、設定和轉換的,並用一致的方式來進行。

  • 明確出系統的升級控制協議。

    這個原則是關於系統線上升級的時機和流程控制的,不考慮系統的當前執行狀態就貿然進行升級是一項非常危險的活動。因此在系統設計中, 就要考慮並預留出系統線上升級的控制點, 並定義清晰、明確的升級協議來協調、控制多個升級實體的升級次序,以確保系統在升級的任何時刻都處在一個確定的狀態下。

  • 考慮到升級失敗時的回退機制。

    即使我們做了非常縝密細緻的設計,還是難以從根本上保證系統升級一定是成功的,對於大型分散式系統來說尤其如此。因此在系統設計時,要考慮升級失敗後的回退機制。

好了,本小節我們簡單介紹了線上升級系統設計時的幾個重要的原則,下一小節我們將給出一個簡單的例項,來演示一下如何來實現一個線上升級系統。

線上升級系統例項

首先,我們來簡單介紹一下這個例項的結構組成和要完成的工作。在我們的例子中,主要有三個實體,一個是升級控制實體,兩個是工作實體,都基於 ActiveObject 實現,通過命令訊息進行通訊(關於 ActiveObject 的詳細資訊,可以參見作者的另外一篇文章“構建 Java 併發模型框架”)。

升級控制實體以 RMI 的方式對外提供了一個管理命令介面,用以接收外部的線上升級命令。工作實體有兩個訊息佇列,一個用以接收分配給它的任務(我們用定時器定時給它傳送任務命令訊息),我們稱其為任務佇列;另一個用於和升級控制實體互動,協作完成升級過程,我們稱其為控制佇列。工作實體中的任務很簡單,就是使用我們前面介紹的 Foo 類簡單地打印出一個字串,不過這次字串作為狀態儲存在工作實體中,動態設定給 Foo 類的例項的。升級的協議流程如下:

當升級控制實體接收到來自 RMI 的線上升級命令時,它會向兩個工作實體的任務佇列中傳送一條準備升級訊息,然後等待迴應。當工作實體在任務佇列中收到準備升級訊息時,會立即給升級控制實體傳送一條準備就緒訊息,然後切換到控制佇列等待進一步的升級指令。升級控制實體收齊這兩個工作實體發來的準備就緒訊息後,就給這兩個工作實體的控制佇列各發送一條開始升級訊息,然後等待結果。工作實體收到開始升級訊息後,進行實際的升級工作,也就是我們前面講述的熱替換類。然後,給升級控制實體傳送升級完畢訊息。升級控制實體收到來自兩個工作實體的升級完畢訊息後,會給這兩個工作實體的控制佇列各發送一條繼續工作訊息,工作實體收到繼續工作訊息後,切換到任務佇列繼續工作。升級過程結束。

主要的程式碼片段如下(略去命令訊息的定義和執行細節):

清單 5. 主要的程式碼片段

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

// 升級控制實體關鍵程式碼

class UpgradeController extends ActiveObject{

    int nready  = 0;

    int nfinished = 0;

    Worker[] workers;

    ......

    // 收到外部升級命令訊息時,會觸發該方法被呼叫

    public void askForUpgrade() {

        for(int i=0; i<workers.length; i++)

            workers[i].getTaskQueue().enqueue(new PrepareUpgradeCmd(workers[i]));

    }

 

    // 收到工作實體迴應的準備就緒命令訊息時,會觸發該方法被呼叫

    public void readyForUpgrade(String worker_name) {

        nready++;       

        if(nready == workers.length){

            for(int i=0; i<workers.length; i++)

                workers[i].getControlQueue().enqueue(new

                    StartUpgradeCmd(workers[i]));

        }     

    }

 

    // 收到工作實體迴應的升級完畢命令訊息時,會觸發該方法被呼叫

    public void finishUpgrade(String worker_name) {

        nfinished++;

        if(nfinished == workers.length){

            for(int i=0; i<workers.length; i++)

                workers[i].getControlQueue().enqueue(new

                    ContineWorkCmd(workers[i]));

 

        }

    }

     

    ......

 

}

 

// 工作實體關鍵程式碼

class Worker extends ActiveObject{

    UpgradeController ugc;

    HotswapCL hscl;

    IFoo foo;

    String state = "hello world!";

     

    ......

    

    // 收到升級控制實體的準備升級命令訊息時,會觸發該方法被呼叫

    public void prepareUpgrade() {

        switchToControlQueue();

        ugc.getMsgQueue().enqueue(new ReadyForUpdateCMD(ugc,this));

    }

 

    // 收到升級控制實體的開始升級命令訊息時,會觸發該方法被呼叫

    public void startUpgrade(String worker_name) {

        doUpgrade();

        ugc.getMsgQueue().enqueue(new FinishUpgradeCMD(ugc,this));

    }

 

    // 收到升級控制實體的繼續工作命令訊息時,會觸發該方法被呼叫

    public void continueWork(String worker_name) {

        switchToTaskQueue();

    }

 

    // 收到定時命令訊息時,會觸發該方法被呼叫

    public void doWork() {

        foo.sayHello();   

    }

 

    // 實際升級動作

    private void doUpgrade() {

        hscl = new HowswapCL("../swap", new String[]{"Foo"});

        Class cls = hscl.loadClass("Foo");

        foo = (IFoo)cls.newInstance();

        foo.SetState(state);

    }

}

 

//IFoo 介面定義

interface IFoo {

    void SetState(String);

    void sayHello();

}

在 Foo 類第一個版本的實現中,只是把設定進來的字串直接打印出來。在第二個版本中,會先把設定進來的字串變為大寫,然後打印出來。例子很簡單,旨在表達規則或者演算法方面的升級變化。另外,我們並沒有提及諸如:訊息超時、升級失敗等方面的異常情況,這在實際產品開發中是必須要考慮的。

小結

在本文中,我們對 Java 線上升級系統中設計的基礎技術:類的熱替換,進行了詳細的講解。此外,還給出了線上升級系統設計時的一些主要指導原則。為了使讀者更好地理解這些技術和原則,我們在最後給出了一個線上升級系統的例項。值得注意的是,構建線上升級系統不僅僅是一個技術問題,還牽扯到很多管理方面的因素,比如:如何管理、部署系統中的可線上升級部分和不可線上升級部分以降低系統的管理、維護成本等。希望本文在讀者構建自己的線上升級系統時能夠提供一些幫助。