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


原創:微信公眾號 碼農參上(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,歡迎添加好友,進一步交流。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM