偷天換日,用JavaAgent欺騙你的JVM

碼農參上發表於2021-11-17

原創:微信公眾號 碼農參上(ID:CODER_SANJYOU),歡迎分享,轉載請保留出處。

熟悉Spring的小夥伴們應該都對aop比較瞭解,面向切面程式設計允許我們在目標方法的前後織入想要執行的邏輯,而今天要給大家介紹的Java Agent技術,在思想上與aop比較類似,翻譯過來可以被稱為Java代理Java探針技術。

Java Agent出現在JDK1.5版本以後,它允許程式設計師利用agent技術構建一個獨立於應用程式的代理程式,用途也非常廣泛,可以協助監測、執行、甚至替換其他JVM上的程式,先從下面這張圖直觀的看一下它都被應用在哪些場景:

看到這裡你是不是也很好奇,究竟是什麼神仙技術,能夠應用在這麼多場景下,那今天我們就來挖掘一下,看看神奇的Java Agent是如何工作在底層,默默支撐了這麼多優秀的應用。

回到文章開頭的類比,我們還是用和aop比較的方式,來先對Java Agent有一個大致的瞭解:

  • 作用級別:aop執行於應用程式內的方法級別,而agent能夠作用於虛擬機器級別
  • 組成部分:aop的實現需要目標方法和邏輯增強部分的方法,而Java Agent要生效需要兩個工程,一個是agent代理,另一個是需要被代理的主程式
  • 執行場合:aop可以執行在切面的前後或環繞等場合,而Java Agent的執行只有兩種方式,jdk1.5提供的preMain模式在主程式執行前執行,jdk1.6提供的agentMain在主程式執行後執行

下面我們就分別看一下在兩種模式下,如何動手實現一個agent代理程式。

Premain模式

Premain模式允許在主程式執行前執行一個agent代理,實現起來非常簡單,下面我們分別實現兩個組成部分。

agent

先寫一個簡單的功能,在主程式執行前列印一句話,並列印傳遞給代理的引數:

public class MyPreMainAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
        System.out.println("premain start");
        System.out.println("args:"+agentArgs);
    }
}

在寫完了agent的邏輯後,需要把它打包成jar檔案,這裡我們直接使用maven外掛打包的方式,在打包前進行一些配置。

<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-jar-plugin</artifactId>
            <version>3.1.0</version>
            <configuration>
                <archive>
                    <manifest>
                        <addClasspath>true</addClasspath>
                    </manifest>
                    <manifestEntries>
                        <Premain-Class>com.cn.agent.MyPreMainAgent</Premain-Class>                            
                        <Can-Redefine-Classes>true</Can-Redefine-Classes>
                        <Can-Retransform-Classes>true</Can-Retransform-Classes>
                        <Can-Set-Native-Method-Prefix>true</Can-Set-Native-Method-Prefix>
                    </manifestEntries>
                </archive>
            </configuration>
        </plugin>
    </plugins>
</build>

配置的打包引數中,通過manifestEntries的方式新增屬性到MANIFEST.MF檔案中,解釋一下里面的幾個引數:

  • Premain-Class:包含premain方法的類,需要配置為類的全路徑
  • Can-Redefine-Classes:為true時表示能夠重新定義class
  • Can-Retransform-Classes:為true時表示能夠重新轉換class,實現位元組碼替換
  • Can-Set-Native-Method-Prefix: 為true時表示能夠設定native方法的字首

其中Premain-Class為必須配置,其餘幾項是非必須選項,預設情況下都為false,通常也建議加入,這幾個功能我們會在後面具體介紹。在配置完成後,使用mvn命令打包:

mvn clean package

打包完成後生成myAgent-1.0.jar檔案,我們可以解壓jar檔案,看一下生成的MANIFEST.MF檔案:

可以看到,新增的屬性已經被加入到了檔案中。到這裡,agent代理部分就完成了,因為代理不能夠直接執行,需要附著於其他程式,所以下面新建一個工程來實現主程式。

主程式

在主程式的工程中,只需要一個能夠執行的main方法的入口就可以了。

public class AgentTest {
    public static void main(String[] args) {
        System.out.println("main project start");
    }
}

在主程式完成後,要考慮的就是應該如何將主程式與agent工程連線起來。這裡可以通過-javaagent引數來指定執行的代理,命令格式如下:

java -javaagent:myAgent.jar -jar AgentTest.jar

並且,可以指定的代理的數量是沒有限制的,會根據指定的順序先後依次執行各個代理,如果要同時執行兩個代理,就可以按照下面的命令執行:

java -javaagent:myAgent1.jar -javaagent:myAgent2.jar  -jar AgentTest.jar

以我們在idea中執行程式為例,在VM options中加入新增啟動引數:

-javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Hydra
-javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Trunks

執行main方法,檢視輸出結果:

根據執行結果的列印語句可以看出,在執行主程式前,依次執行了兩次我們的agent代理。可以通過下面的圖來表示執行代理與主程式的執行順序。

缺陷

在提供便利的同時,premain模式也有一些缺陷,例如如果agent在執行過程中出現異常,那麼也會導致主程式的啟動失敗。我們對上面例子中agent的程式碼進行一下改造,手動丟擲一個異常。

public static void premain(String agentArgs, Instrumentation inst) {
    System.out.println("premain start");
    System.out.println("args:"+agentArgs);
    throw new RuntimeException("error");
}

再次執行主程式:

可以看到,在agent丟擲異常後主程式也沒有啟動。針對premain模式的一些缺陷,在jdk1.6之後引入了agentmain模式。

Agentmain模式

agentmain模式可以說是premain的升級版本,它允許代理的目標主程式的jvm先行啟動,再通過attach機制連線兩個jvm,下面我們分3個部分實現。

agent

agent部分和上面一樣,實現簡單的列印功能:

public class MyAgentMain {
    public static void agentmain(String agentArgs, Instrumentation instrumentation) {
        System.out.println("agent main start");
        System.out.println("args:"+agentArgs);
    }
}

修改maven外掛配置,指定Agent-Class

<plugin>
    <groupId>org.apache.maven.plugins</groupId>
    <artifactId>maven-jar-plugin</artifactId>
    <version>3.1.0</version>
    <configuration>
        <archive>
            <manifest>
                <addClasspath>true</addClasspath>
            </manifest>
            <manifestEntries>
                <Agent-Class>com.cn.agent.MyAgentMain</Agent-Class>
                <Can-Redefine-Classes>true</Can-Redefine-Classes>
                <Can-Retransform-Classes>true</Can-Retransform-Classes>
            </manifestEntries>
        </archive>
    </configuration>
</plugin>

主程式

這裡我們直接啟動主程式等待代理被載入,在主程式中使用了System.in進行阻塞,防止主程式提前結束。

public class AgentmainTest {
    public static void main(String[] args) throws IOException {
        System.in.read();
    }
}

attach機制

和premain模式不同,我們不能再通過新增啟動引數的方式來連線agent和主程式了,這裡需要藉助com.sun.tools.attach包下的VirtualMachine工具類,需要注意該類不是jvm標準規範,是由Sun公司自己實現的,使用前需要引入依賴:

<dependency>
    <groupId>com.sun</groupId>
    <artifactId>tools</artifactId>
    <version>1.8</version>
    <scope>system</scope>
    <systemPath>${JAVA_HOME}\lib\tools.jar</systemPath>
</dependency>

VirtualMachine代表了一個要被附著的java虛擬機器,也就是程式中需要監控的目標虛擬機器,外部程式可以使用VirtualMachine的例項將agent載入到目標虛擬機器中。先看一下它的靜態方法attach

public static VirtualMachine attach(String var0);

通過attach方法可以獲取一個jvm的物件例項,這裡傳入的引數是目標虛擬機器執行時的程式號pid。也就是說,我們在使用attach前,需要先獲取剛才啟動的主程式的pid,使用jps命令檢視執行緒pid

11140
16372 RemoteMavenServer36
16392 AgentmainTest
20204 Jps
2460 Launcher

獲取到主程式AgentmainTest執行時pid是16392,將它應用於虛擬機器的連線。

public class AttachTest {
    public static void main(String[] args) {
        try {
            VirtualMachine  vm= VirtualMachine.attach("16392");
            vm.loadAgent("F:\\Workspace\\MyAgent\\target\\myAgent-1.0.jar","param");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

在獲取到VirtualMachine例項後,就可以通過loadAgent方法可以實現注入agent代理類的操作,方法的第一個引數是代理的本地路徑,第二個引數是傳給代理的引數。執行AttachTest,再回到主程式AgentmainTest的控制檯,可以看到執行了了agent中的程式碼:

這樣,一個簡單的agentMain模式代理就實現完成了,可以通過下面這張圖再梳理一下三個模組之間的關係。

應用

到這裡,我們就已經簡單地瞭解了兩種模式的實現方法,但是作為高質量程式設計師,我們肯定不能滿足於只用代理單純地列印語句,下面我們再來看看能怎麼利用Java Agent搞點實用的東西。

在上面的兩種模式中,agent部分的邏輯分別是在premain方法和agentmain方法中實現的,並且,這兩個方法在簽名上對引數有嚴格的要求,premain方法允許以下面兩種方式定義:

public static void premain(String agentArgs)
public static void premain(String agentArgs, Instrumentation inst)

agentmain方法允許以下面兩種方式定義:

public static void agentmain(String agentArgs)
public static void agentmain(String agentArgs, Instrumentation inst)

如果在agent中同時存在兩種簽名的方法,帶有Instrumentation引數的方法優先順序更高,會被jvm優先載入,它的例項inst會由jvm自動注入,下面我們就看看能通過Instrumentation實現什麼功能。

Instrumentation

先大體介紹一下Instrumentation介面,其中的方法允許在執行時操作java程式,提供了諸如改變位元組碼,新增jar包,替換class等功能,而通過這些功能使Java具有了更強的動態控制和解釋能力。在我們編寫agent代理的過程中,Instrumentation中下面3個方法比較重要和常用,我們來著重看一下。

addTransformer

addTransformer方法允許我們在類載入之前,重新定義Class,先看一下方法的定義:

void addTransformer(ClassFileTransformer transformer);

ClassFileTransformer是一個介面,只有一個transform方法,它在主程式的main方法執行前,裝載的每個類都要經過transform執行一次,可以將它稱為轉換器。我們可以實現這個方法來重新定義Class,下面就通過一個例子看看具體如何使用。

首先,在主程式工程建立一個Fruit類:

public class Fruit {
    public void getFruit(){
        System.out.println("banana");
    }
}

編譯完成後複製一份class檔案,並將其重新命名為Fruit2.class,再修改Fruit中的方法為:

public void getFruit(){
    System.out.println("apple");
}

建立主程式,在主程式中建立了一個Fruit物件並呼叫了其getFruit方法:

public class TransformMain {
    public static void main(String[] args) {
        new Fruit().getFruit();
    }
}

這時執行結果會列印apple,接下來開始實現premain代理部分。

在代理的premain方法中,使用InstrumentationaddTransformer方法攔截類的載入:

public class TransformAgent {
    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new FruitTransformer());
    }
}

FruitTransformer類實現了ClassFileTransformer介面,轉換class部分的邏輯都在transform方法中:

public class FruitTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain, byte[] classfileBuffer){
        if (!className.equals("com/cn/hydra/test/Fruit"))
            return classfileBuffer;

        String fileName="F:\\Workspace\\agent-test\\target\\classes\\com\\cn\\hydra\\test\\Fruit2.class";
        return getClassBytes(fileName);
    }

    public static byte[] getClassBytes(String fileName){
        File file = new File(fileName);
        try(InputStream is = new FileInputStream(file);
            ByteArrayOutputStream bs = new ByteArrayOutputStream()){
            long length = file.length();
            byte[] bytes = new byte[(int) length];

            int n;
            while ((n = is.read(bytes)) != -1) {
                bs.write(bytes, 0, n);
            }
            return bytes;
        }catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }
}

transform方法中,主要做了兩件事:

  • 因為addTransformer方法不能指明需要轉換的類,所以需要通過className判斷當前載入的class是否我們要攔截的目標class,對於非目標class直接返回原位元組陣列,注意className的格式,需要將類全限定名中的.替換為/
  • 讀取我們之前複製出來的class檔案,讀入二進位制字元流,替換原有classfileBuffer位元組陣列並返回,完成class定義的替換

將agent部分打包完成後,在主程式新增啟動引數:

-javaagent:F:\Workspace\MyAgent\target\transformAgent-1.0.jar

再次執行主程式,結果列印:

banana

這樣,就實現了在main方法執行前class的替換。

redefineClasses

我們可以直觀地從方法的名字上來理解它的作用,重定義class,通俗點來講的話就是實現指定類的替換。方法定義如下:

void redefineClasses(ClassDefinition... definitions) throws  ClassNotFoundException, UnmodifiableClassException;

它的引數是可變長的ClassDefinition陣列,再看一下ClassDefinition的構造方法:

public ClassDefinition(Class<?> theClass,byte[] theClassFile) {...}

ClassDefinition中指定了的Class物件和修改後的位元組碼陣列,簡單來說,就是使用提供的類檔案位元組,替換了原有的類。並且,在redefineClasses方法重定義的過程中,傳入的是ClassDefinition的陣列,它會按照這個陣列順序進行載入,以便滿足在類之間相互依賴的情況下進行更改。

下面通過一個例子來看一下它的生效過程,premain代理部分:

public class RedefineAgent {
    public static void premain(String agentArgs, Instrumentation inst) 
            throws UnmodifiableClassException, ClassNotFoundException {
        String fileName="F:\\Workspace\\agent-test\\target\\classes\\com\\cn\\hydra\\test\\Fruit2.class";
        ClassDefinition def=new ClassDefinition(Fruit.class,
                FruitTransformer.getClassBytes(fileName));
        inst.redefineClasses(new ClassDefinition[]{def});
    }
}

主程式可以直接複用上面的,執行後列印:

banana

可以看到,用我們指定的class檔案的位元組替換了原有類,即實現了指定類的替換。

retransformClasses

retransformClasses應用於agentmain模式,可以在類載入之後重新定義Class,即觸發類的重新載入。首先看一下該方法的定義:

void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

它的引數classes是需要轉換的類陣列,可變長引數也說明了它和redefineClasses方法一樣,也可以批量轉換類的定義。

下面,我們通過例子來看看如何使用retransformClasses方法,agent代理部分程式碼如下:

public class RetransformAgent {
    public static void agentmain(String agentArgs, Instrumentation inst)
            throws UnmodifiableClassException {
        inst.addTransformer(new FruitTransformer(),true);
        inst.retransformClasses(Fruit.class);
        System.out.println("retransform success");
    }
}

看一下這裡呼叫的addTransformer方法的定義,與上面略有不同:

void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

ClassFileTransformer轉換器依舊複用了上面的FruitTransformer,重點看一下新加的第二個引數,當canRetransformtrue時,表示允許重新定義class。這時,相當於呼叫了轉換器ClassFileTransformer中的transform方法,會將轉換後class的位元組作為新類定義進行載入。

主程式部分程式碼,我們在死迴圈中不斷的執行列印語句,來監控類是否發生了改變:

public class RetransformMain {
    public static void main(String[] args) throws InterruptedException {
        while(true){
            new Fruit().getFruit();
            TimeUnit.SECONDS.sleep(5);
        }
    }
}

最後,使用attach api注入agent代理到主程式中:

public class AttachRetransform {
    public static void main(String[] args) throws Exception {
        VirtualMachine vm = VirtualMachine.attach("6380");
        vm.loadAgent("F:\\Workspace\\MyAgent\\target\\retransformAgent-1.0.jar");
    }
}

回到主程式控制臺,檢視執行結果:

可以看到在注入代理後,列印語句發生變化,說明類的定義已經被改變並進行了重新載入。

其他

除了這幾個主要的方法外,Instrumentation中還有一些其他方法,這裡僅簡單列舉一下常用方法的功能:

  • removeTransformer:刪除一個ClassFileTransformer類轉換器
  • getAllLoadedClasses:獲取當前已經被載入的Class
  • getInitiatedClasses:獲取由指定的ClassLoader載入的Class
  • getObjectSize:獲取一個物件佔用空間的大小
  • appendToBootstrapClassLoaderSearch:新增jar包到啟動類載入器
  • appendToSystemClassLoaderSearch:新增jar包到系統類載入器
  • isNativeMethodPrefixSupported:判斷是否能給native方法新增字首,即是否能夠攔截native方法
  • setNativeMethodPrefix:設定native方法的字首

Javassist

在上面的幾個例子中,我們都是直接讀取的class檔案中的位元組來進行class的重定義或轉換,但是在實際的工作環境中,可能更多的是去動態的修改class檔案的位元組碼,這時候就可以藉助javassist來更簡單的修改位元組碼檔案。

簡單來說,javassist是一個分析、編輯和建立java位元組碼的類庫,在使用時我們可以直接呼叫它提供的api,以編碼的形式動態改變或生成class的結構。相對於ASM等其他要求瞭解底層虛擬機器指令的位元組碼框架,javassist真的是非常簡單和快捷。

下面,我們就通過一個簡單的例子,看看如何將Java agent和Javassist結合在一起使用。首前先引入javassist的依賴:

<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.20.0-GA</version>
</dependency>

我們要實現的功能是通過代理,來計算方法執行的時間。premain代理部分和之前基本一致,先新增一個轉換器:

public class Agent {
    public static void premain(String agentArgs, Instrumentation inst) {
        inst.addTransformer(new LogTransformer());
    }

    static class LogTransformer implements ClassFileTransformer {
        @Override
        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, 
                                ProtectionDomain protectionDomain, byte[] classfileBuffer) 
            throws IllegalClassFormatException {
            if (!className.equals("com/cn/hydra/test/Fruit"))
                return null;

            try {
                return calculate();
            } catch (Exception e) {
                e.printStackTrace();
                return null;
            }
        }
    }
}

calculate方法中,使用javassist動態的改變了方法的定義:

static byte[] calculate() throws Exception {
    ClassPool pool = ClassPool.getDefault();
    CtClass ctClass = pool.get("com.cn.hydra.test.Fruit");
    CtMethod ctMethod = ctClass.getDeclaredMethod("getFruit");
    CtMethod copyMethod = CtNewMethod.copy(ctMethod, ctClass, new ClassMap());
    ctMethod.setName("getFruit$agent");

    StringBuffer body = new StringBuffer("{\n")
            .append("long begin = System.nanoTime();\n")
            .append("getFruit$agent($$);\n")
            .append("System.out.println(\"use \"+(System.nanoTime() - begin) +\" ns\");\n")
            .append("}");
    copyMethod.setBody(body.toString());
    ctClass.addMethod(copyMethod);
    return ctClass.toBytecode();
}

在上面的程式碼中,主要實現了這些功能:

  • 利用全限定名獲取類CtClass
  • 根據方法名獲取方法CtMethod,並通過CtNewMethod.copy方法複製一個新的方法
  • 修改舊方法的方法名為getFruit$agent
  • 通過setBody方法修改複製出來方法的內容,在新方法中進行了邏輯增強並呼叫了舊方法,最後將新方法新增到類中

主程式仍然複用之前的程式碼,執行檢視結果,完成了代理中的執行時間統計功能:

這時候我們可以再通過反射看一下:

for (Method method : Fruit.class.getDeclaredMethods()) {
    System.out.println(method.getName());
    method.invoke(new Fruit());
    System.out.println("-------");
}

檢視結果,可以看到類中確實已經新增了一個方法:

除此之外,javassist還有很多其他的功能,例如新建Class、設定父類、讀取和寫入位元組碼等等,大家可以在具體的場景中學習它的用法。

總結

雖然我們在平常的工作中,直接用到Java Agent的場景可能並不是很多,但是在熱部署、監控、效能分析等工具中,它們可能隱藏在業務系統的角落裡,一直在默默發揮著巨大的作用。

本文從Java Agent的兩種模式入手,手動實現並簡要分析了它們的工作流程,雖然在這裡只利用它們完成了一些簡單的功能,但是不得不說,正是Java Agent的出現,讓程式的執行不再循規蹈矩,也為我們的程式碼提供了無限的可能性。

作者簡介,碼農參上(CODER_SANJYOU),一個熱愛分享的公眾號,有趣、深入、直接,與你聊聊技術。個人微信DrHydra9,歡迎新增好友,進一步交流。

相關文章