關於發燒那點事兒:熱交換,熱部署,熱升級

在java的世界裏,如果想做熱升級,無外乎兩種方案,一種是基於自定義的ClassLoader來做,比如SPI插件機制等等;而另一種則是基於java agent技術方案來做,比如全鏈路跟蹤方案。由於在這些實現過程中,或多或少都摻雜着對字節碼的運用,所以基於字節碼的插樁技術,在這裏也是大行其道。

說道熱升級,其實包含的意思挺多的,不僅可以指類的熱交換,比如類A的實現被修改了,然後想在運行時對類A的邏輯進行熱替換。也可以指jar包的熱部署,比如用戶打了個jar包,然後部署完畢並啓動,之後業務方變動了jar包中的一些邏輯,然後想在運行時對jar包進行熱部署,等等諸如在運行時對邏輯進行修改的場景,我這裏統一稱爲熱升級吧。

由於熱升級方案有兩種且方式還不太一樣,所以這裏我們就一一道來。

雙親委派模型

開篇之前,需要特別說道的就是類加載器的雙親委派模型,相信很多人聽過這個模型,也看過這個模型,具體圖示如下:

c42aa99e-9a19-4e9c-ba57-2b460d195f32

1. findClass(loadClass)被調用

2. 進入App ClassLoader中,先檢查緩存中是否存在,如果存在,則直接返回

3. 緩存中不存在,則被代理到父加載器,即Extension ClassLoader

4. 檢查Extension ClassLoader緩存中是否存在

5. 緩存中不存在,則被代理到父加載器,即Bootstrap ClassLoader

6. 檢查Bootstrap ClassLoader緩存中是否存在

7. 緩存中不存在,則從Bootstrap ClassLoader的類搜索路徑下的文件中尋找,一般爲rt.jar等,如果找不到,則拋出ClassNotFound Exception

8. Extension ClassLoader會捕捉ClassNotFound錯誤,然後從Extension ClassLoader的類搜索路徑下的文件中尋找,一般爲環境變量$JRE_HOME/lib/ext路徑下,如果也找不到,則拋出ClassNotFound Exception

9. App ClassLoader會捕捉ClassNotFound錯誤,然後從App ClassLoader的類搜索路徑下的文件中尋找,一般爲環境變量$CLASSPATH路徑下,如果找到,則將其讀入字節數組

10. App ClassLoader調用defineClass()方法

通過上面的整體流程描述,是不是感覺雙親委派機制也不是那麼難理解。本質就是先查緩存,緩存中沒有就委託給父加載器查詢緩存,直至查到Bootstrap加載器,如果Bottstrap加載器再緩存中也找不到,就拋錯,然後這個錯誤再被一層層的捕捉,捕捉到錯誤後就查自己的類搜索路徑,僅此而已。

自定義ClassLoader

基於自定義的ClassLoader的方案,比如類的熱交換或者jar包的熱部署等,其實質上是利用自定義的ClassLoader的這種雙親委派機制來進行操作的。遵循上面的流程,我們很容易的來實現利用自定義的ClassLoader來實現類的熱交換功能:

public class CustomClassLoader extends ClassLoader {
    //需要該類加載器直接加載的類文件的基目錄
    private String baseDir;
    //需要由該類加載器直接加載的類名
    private HashSet classSet;
    public CustomClassLoader(String baseDir, String[] classes) throws IOException {
        super();
        this.baseDir = baseDir;
        this.classSet = new HashSet();
        loadClassByMe(classes);
    }
    private void loadClassByMe(String[] classes) throws IOException {
        for (int i = 0; i < classes.length; i++) {
            findClass(classes[i]);
            classSet.add(classes[i]);
        }
    }
    /**
     * 重寫findclass方法
     *
     * 在ClassLoader中,loadClass方法先從緩存中找,緩存中沒有,會代理給父類查找,如果父類中也找不到,就會調用此用戶實現的findClass方法
     *
     * @param name
     * @return
     */
    @Override
    protected Class findClass(String name) {
        Class clazz = null;
        StringBuffer stringBuffer = new StringBuffer(baseDir);
        String className = name.replace('.', File.separatorChar) + ".class";
        stringBuffer.append(File.separator + className);
        File classF = new File(stringBuffer.toString());
        try {
            clazz = instantiateClass(name, new FileInputStream(classF), classF.length());
        } catch (IOException e) {
            e.printStackTrace();
        }
        return clazz;
    }
    private Class instantiateClass(String name, InputStream fin, long len) throws IOException {
        byte[] raw = new byte[(int) len];
        fin.read(raw);
        fin.close();
        return defineClass(name, raw, 0, raw.length);
    }
}

上面這段代碼,我們就實現了一個最簡單的自定義類加載器,但是能映射出雙親委派模型呢?

首先點開ClassLoader類,在裏面翻到這個方法:

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) {
                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();
                    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;
        }
    }

這個就是雙親委派模型,對應之前提到的1-8步驟。

而自定義類加載器中的findClass方法如下代碼,則對應步驟9:

clazz = instantiateClass(name, new FileInputStream(classF), classF.length());

而自定義類加載器中的instantiateClass方法如下代碼,則對應步驟10:

 return defineClass(name, raw, 0, raw.length);

看看,整體是不是很清晰?

基於自定義類加載器實現類的熱交換

寫完自定義類加載器,來看看具體的用法吧,我們創建一個類,擁有如下內容:

package com.tw.client;
public class Foo {
    public Foo() {
    }
    public void sayHello() {
        System.out.println("hello world22222! (version 11)");
    }
}

顧名思義,此類只要調用sayHello方法,便會打印出hello world22222! (version 11)出來。

熱交換處理過程如下:

public static void main(String[] args) throws Exception {
        while (true) {
            runBy2();
            Thread.sleep(1000);
        }
    }
    /**
     * ClassLoader用來加載class類文件的,實現類的熱替換
     * 注意,需要在swap目錄下,一層層建立目錄com/tw/client/,然後將Foo.class放進去
     * @throws Exception
     */
    public static void runBy1() throws Exception {
        CustomClassLoader customClassLoader = new CustomClassLoader("swap", new String[]{"com.tw.client.Foo"});
        Class clazz = customClassLoader.loadClass("com.tw.client.Foo");
        Object foo = clazz.newInstance();
        Method method = foo.getClass().getMethod("sayHello", new Class[]{});
        method.invoke(foo, new Object[]{});
    }

當我們運行起來後,我們會將提前準備好的另一個Foo.class來替換當前這個,來看看結果吧(直接將新的Foo.class類拷貝過去覆蓋即可):

hello world22222! (version 11)
hello world22222! (version 11)
hello world22222! (version 11)
hello world22222! (version 11)
hello world22222! (version 11)
hello world2222! (version 2)
hello world2222! (version 2)
hello world2222! (version 2)
hello world2222! (version 2)

可以看到,當我們替換掉原來運行的類的時候,輸出也就變了,變成了新類的輸出結果。整體類的熱交換成功。

不知道我們注意到一個細節沒有,在上述代碼中,我們先創建出Object的類對象,然後利用Method.invoke方法來調用類:

Object foo = clazz.newInstance();
 Method method = foo.getClass().getMethod("sayHello", new Class[]{});
 method.invoke(foo, new Object[]{});

有人在這裏會疑惑,爲啥不直接轉換爲Foo類,然後調用類的Foo.sayHello方法呢?像下面這種方式:

 Foo foo2 = (Foo) clazz.newInstance();
  foo2.sayHello();

這種方式是不行的,但是大家知道爲啥不行嗎?

我們知道,我們寫的類,一般都是被AppClassloader加載的,也就是說,你寫在main啓動類中的所有類,只要你寫出來,那麼就會被AppClassloader加載,所以,如果這裏我們強轉爲Foo類型,那鐵定是會被AppClassloader加載的,但是由於我們的clazz對象是由CustomerClassloader加載的,所以這裏就會出現這樣的錯誤:

java.lang.ClassCastException: com.tw.client.Foo cannot be cast to com.tw.client.Foo

那有什麼方法可以解決這個問題嗎?其實是有的,就是對Foo對象抽象出一個Interface,比如說IFoo,然後轉換的時候,轉換成接口,就不會有這種問題了:

IFoo foo2 = (IFoo) clazz.newInstance();
foo2.sayHello();

通過接口這種方式,我們就很容易對運行中的組件進行類的熱交換了,屬實方便。

需要注意的是,主線程的類加載器,一般都是AppClassLoader,但是當我們創建出子線程後,其類加載器都會繼承自其創建者的類加載器,但是在某些業務中,我想在子線程中使用自己的類加載器,有什麼辦法嗎?

由於Thread對象中已經附帶了ContextClassLoader屬性,所以這裏我們可以很方便的進行設置和獲取:

//設置操作
Thread t = Thread.currentThread();
t.setContextClassLoader(loader);
//獲取操作
Thread t = Thread.currentThread();
ClassLoader loader = t.getContextClassLoader();
Class<?> cl = loader.loadClass(className);

基於SPI實現類的熱交換

SPI相信大家都聽過,因爲這種模型是集成在java中的,其內部機制也是利用了自定義的類加載器,然後進行了良好的封裝暴露給用戶。

這裏我們寫個簡單的例子:

public interface HelloService {
    void sayHello(String name);
}
public class HelloServiceProvider implements HelloService {
    @Override
    public void sayHello(String name) {
        System.out.println("Hello " + name);
    }
}
public class NameServiceProvider implements HelloService{
    @Override
    public void sayHello(String name) {
        System.out.println("Hi, your name is " + name);
    }
}

然後我們基於接口的包名+類名作爲路徑,創建出com.tinywhale.deploy.spi.HelloService文件到resources中的META-INF.services文件夾,裏面放入如下內容:

com.tinywhale.deploy.spi.HelloServiceProvider
com.tinywhale.deploy.spi.NameServiceProvider

然後在啓動類中運行:

public static void main(String...args) throws Exception {
        while(true) {
            runBy1();
            Thread.sleep(1000);
        }
    }
    private static void runBy1(){
        ServiceLoader<HelloService> serviceLoader = ServiceLoader.load(HelloService.class);
        for (HelloService helloWorldService : serviceLoader) {
            helloWorldService.sayHello("myname");
        }
    }

可以看到,在啓動類中,我們利用ServiceLoader類來遍歷META-INF.services文件夾下面的provider,然後執行,則輸出結果爲兩個類的輸出結果。之後在執行過程中,我們去target文件夾中,將com.tinywhale.deploy.spi.HelloService文件中的NameServiceProvider拿掉,然後保存,就可以看到只有一個類的輸出結果了。

Hello myname
Hi, your name is myname
Hello myname
Hi, your name is myname
Hello myname
Hi, your name is myname
Hello myname
Hello myname
Hello myname
Hello myname

這種基於SPI類的熱交換,比自己自定義加載器更加簡便,非常推薦使用。

Jar包的熱部署

上面講解的內容,一般是類的熱交換,但是如果我們需要對整個jar包進行熱部署,該怎麼做呢?雖然現在有很成熟的技術,比如OSGI等,但是這裏我將從原理層面來講解如何對Jar包進行熱部署操作。

由於內置的URLClassLoader本身可以對jar進行操作,所以我們只需要自定義一個基於URLClassLoader的類加載器即可:

public class BizClassLoader extends URLClassLoader {
    public BizClassLoader(URL[] urls) {
        super(urls);
    }
}

注意,我們打的jar包,最好打成fat jar,這樣處理起來方便,不至於少打東西:

         <plugin>
                  <groupId>org.apache.maven.plugins</groupId>
                  <artifactId>maven-shade-plugin</artifactId>
                  <version>2.4.3</version>
                  <configuration>
                      <!-- 自動將所有不使用的類排除-->
                      <minimizeJar>true</minimizeJar>
                  </configuration>
                  <executions>
                      <execution>
                          <phase>package</phase>
                          <goals>
                              <goal>shade</goal>
                          </goals>
                          <configuration>
                              <shadedArtifactAttached>true</shadedArtifactAttached>
                              <shadedClassifierName>biz</shadedClassifierName>
                          </configuration>
                      </execution>
                  </executions>
              </plugin>

之後,我們就可以使用了:

 public static void main(String... args) throws Exception {
        while (true) {
            loadJarFile();
            Thread.sleep(1000);
        }
    }
    /**
     * URLClassLoader 用來加載Jar文件, 直接放在swap目錄下即可
     *
     * 動態改變jar中類,可以實現熱加載
     *
     * @throws Exception
     */
    public static void loadJarFile() throws Exception {
        File moduleFile = new File("swap\\tinywhale-client-0.0.1-SNAPSHOT-biz.jar");
        URL moduleURL = moduleFile.toURI().toURL();
        URL[] urls = new URL[] { moduleURL };
        BizClassLoader bizClassLoader = new BizClassLoader(urls);
        Class clazz = bizClassLoader.loadClass("com.tw.client.Bar");
        Object foo = clazz.newInstance();
        Method method = foo.getClass().getMethod("sayBar", new Class[]{});
        method.invoke(foo, new Object[]{});
        bizClassLoader.close();
    }

啓動起來,看下輸出,之後用一個新的jar覆蓋掉,來看看結果吧:

I am bar, Foo's sister, can you catch me ?????????????
I am bar, Foo's sister, can you catch me ?????????????
I am bar, Foo's sister, can you catch me !!!!
I am bar, Foo's sister, can you catch me !!!!
I am bar, Foo's sister, can you catch me !!!!
I am bar, Foo's sister, can you catch me !!!!

可以看到,jar包被自動替換了。當然,如果想卸載此包,我們可以調用如下語句進行卸載:

 bizClassLoader.close();

需要注意的是,jar包中不應有長時間運行的任務或者子線程等,因爲調用類加載器的close方法後,會釋放一些資源,但是長時間運行的任務並不會終止。所以這種情況下,如果你卸載了舊包,然後馬上加載新包,且包中有長時間的任務,請確認做好業務防重,否則會引發不可知的業務問題。

由於Spring中已經有對jar包進行操作的類,我們可以配合上自己的annotation實現特定的功能,比如擴展點實現,插件實現,服務檢測等等等等,用途非常廣泛,大家可以自行發掘。

上面講解的基本是原理部分,由於目前市面上有很多成熟的組件,比如OSGI等,已經實現了熱部署熱交換等的功能,所以很推薦大家去用一用。

Java Agent熱

話說在JDK中,一直有一個比較重要的jar包,名稱爲rt.jar,他是java運行時環境中,最核心和最底層的類庫的來源。比如java.lang.String, java.lang.Thread, java.util.ArrayList or java.io.InputStream等均來源於這個類庫。今天我們所要講解的角色是rt.jar中的java.lang.instrument包,此包提供的功能,可以讓我們在運行時環境中動態的修改系統中的類,而Java Agent作爲其中一個重要的組件,極具特色。

現在我們有個場景,比如說,每次請求過來,我都想把jvm數據信息或者調用量上報上來,由於應用已經上線,無法更改代碼了,那麼有什麼辦法來實現嗎?當然有,這也是Java Agent最擅長的場合,當然也不僅僅只有這種場合,諸如大名鼎鼎的熱部署JRebel,阿里的arthas,線上診斷工具btrace,UT覆蓋工具JaCoCo等,不一而足。

在使用Java Agent前,我們需要了解其兩個重要的方法:

/**
 * main方法執行之前執行,manifest需要配置屬性Premain-Class,參數配置方式載入
 */
public static void premain(String agentArgs, Instrumentation inst);
/**
 * 程序啓動後執行,manifest需要配置屬性Agent-Class,Attach附加方式載入
 */
public static void agentmain(String agentArgs, Instrumentation inst);

還有個必不可少的東西是MANIFEST.MF文件,此文件需要放置到resources/META-INF文件夾下,此文件一般包含如下內容:

Premain-class                : main方法執行前執行的agent類.
Agent-class                  : 程序啓動後執行的agent類.
Can-Redefine-Classes         : agent是否具有redifine類能力的開關,true表示可以,false表示不可以.
Can-Retransform-Classes      : agent是否具有retransform類能力的開關,true表示可以,false表示不可以.
Can-Set-Native-Method-Prefix : agent是否具有生成本地方法前綴能力的開關,trie表示可以,false表示不可以.
Boot-Class-Path              : 此路徑會被加入到BootstrapClassLoader的搜索路徑.

在對jar進行打包的時候,最好打成fat jar,可以減少很多不必要的麻煩,maven加入如下打包內容:

 <plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-shade-plugin</artifactId>
        <executions>
            <execution>
                <phase>package</phase>
                <goals>
                    <goal>shade</goal>
                </goals>
            </execution>
        </executions>
    </plugin>

而MF配置文件,可以利用如下的maven內容進行自動生成:

<plugin>
        <groupId>org.apache.maven.plugins</groupId>
        <artifactId>maven-jar-plugin</artifactId>
        <version>3.2.0</version>
        <configuration>
            <archive>
                <manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
            </archive>
        </configuration>
    </plugin>

工欲善其事必先利其器,準備好了之後,先來手寫個Java Agent嚐鮮吧,模擬premain調用,main調用和agentmain調用。

首先是premain調用類 ,agentmain調用類,main調用類:

public class AgentPre {
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("execute premain method");
    }
}
public class App4a {
    public static void main(String... args) throws Exception {
        System.out.println("execute main method ");
    }
}
public class AgentMain {
    public static void agentmain(String agentArgs, Instrumentation inst) {
        System.out.println("execute agentmain method");
    }
}

可以看到,邏輯很簡單,輸出了方法執行體中打印的內容。之後編譯jar包,則會生成fat jar。需要注意的是,MANIFEST.MF文件需要手動創建下,裏面加入如下內容:

Manifest-Version: 1.0
Premain-Class: com.tinywhale.deploy.javaAgent.AgentPre
Agent-Class: com.tinywhale.deploy.javaAgent.AgentMain

由於代碼是在IDEA中啓動,所以想要執行premain,需要在App4a啓動類上右擊:Run App4a.main(),之後IDEA頂部會出現App4a的執行配置:

d359234c-e988-4dea-8aad-6dfb89d8f4df

我們需要點擊Edit Configurations選項,然後在VM options中填入如下命令:

-javaagent:D:\app\tinywhale\tinywhale-deploy\target\tinywhale-deploy-1.0-SNAPSHOT.jar

之後啓動App4a,就可以看到輸出結果了。

execute premain method
execute main method 

但是這裏的話,我們看不到agentmain輸出,是因爲agentmain的運行,是需要進行attach的,這裏我們來操作一下。

首先,需要在premain中列出啓動前的vm列表信息,這裏爲了方便將premain中的數據傳入到main中進行計算,我們使用ThreadLocal類來進行:

public class ThreadHelper {
    public static ThreadLocal threadLocal = new ThreadLocal();
}
public class AgentPre {
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("execute premain method");
        //獲取當前系統中vm數量
        List<VirtualMachineDescriptor> list = VirtualMachine.list();
        ThreadHelper.threadLocal.set(list);
    }
}

可以看到,premain中,我將vm列表信息放到了ThreadLocal中。

之後,在main方法中,我們對agentmain進行attach:

public class App4a {
    public static void main(String... args) throws Exception {
        System.out.println("execute main method ");
        attach();
    }
    private static void attach() {
        File agentFile = Paths.get("D:\\app\\tinywhale\\tinywhale-deploy\\target\\tinywhale-deploy-1.0-SNAPSHOT.jar").toFile();
        try {
            VirtualMachine jvm = VirtualMachine.attach(getPid());
            jvm.loadAgent(agentFile.getAbsolutePath());
            //jvm.detach();
        } catch (Exception e) {
            System.out.println(e);
        }
    }
    private static String getPid() {
        //啓動前vm數量
        List<VirtualMachineDescriptor> originlList = (List<VirtualMachineDescriptor>) ThreadHelper.threadLocal.get();
        //啓動後vm數量
        List<VirtualMachineDescriptor> currentList = VirtualMachine.list();
        //差值即爲當前啓動的vm
        currentList.removeAll(originlList);
        //返回pid
        return currentList.get(0).id()+"";
    }
}

這裏需要說明一下,VirtualMachine.attach方法是attach到指定pid號的進程上。由於我們不知道IDEA啓動後,我們的vm是哪個,所以我這裏就又獲取了一次vm列表,然後和之前的vm列表求差值,則差值就是咱們剛啓動的這個VM,返回其pid號即可。

啓動app4a後,得到的結果爲:

execute premain method
execute main method 
execute agentmain method

可以看到,整個執行都被串起來了。

講到這裏,相信大家基本上理解java agent的執行順序和配置了吧, premain執行需要配置-javaagent啓動參數,而agentmain執行需要attach vm pid。

看到這裏,相信大家對原理部分都有了解了,那麼想實現一個基於調用鏈跟蹤的工具,也不是什麼難事了吧。這裏,我封裝了一個基於java agent的組件,tiny-upgrade,目前正在完善,感興趣的,可以一起完善完善。

 

 

參考文章

參考文章:談談Java Intrumentation和相關應用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章