偷天換日,用JavaAgent欺騙你的JVM
熟悉Spring的小伙伴們應(yīng)該都對aop比較了解,面向切面編程允許我們在目標方法的前后織入想要執(zhí)行的邏輯,而今天要給大家介紹的Java Agent技術(shù),在思想上與aop比較類似,翻譯過來可以被稱為Java代理、Java探針技術(shù)。
Java Agent出現(xiàn)在JDK1.5版本以后,它允許程序員利用agent技術(shù)構(gòu)建一個獨立于應(yīng)用程序的代理程序,用途也非常廣泛,可以協(xié)助監(jiān)測、運行、甚至替換其他JVM上的程序,先從下面這張圖直觀的看一下它都被應(yīng)用在哪些場景:

看到這里你是不是也很好奇,究竟是什么神仙技術(shù),能夠應(yīng)用在這么多場景下,那今天我們就來挖掘一下,看看神奇的Java Agent是如何工作在底層,默默支撐了這么多優(yōu)秀的應(yīng)用。
回到文章開頭的類比,我們還是用和aop比較的方式,來先對Java Agent有一個大致的了解:
作用級別:aop運行于應(yīng)用程序內(nèi)的方法級別,而agent能夠作用于虛擬機級別 組成部分:aop的實現(xiàn)需要目標方法和邏輯增強部分的方法,而Java Agent要生效需要兩個工程,一個是agent代理,另一個是需要被代理的主程序 執(zhí)行場合:aop可以運行在切面的前后或環(huán)繞等場合,而Java Agent的執(zhí)行只有兩種方式,jdk1.5提供的 preMain模式在主程序運行前執(zhí)行,jdk1.6提供的agentMain在主程序運行后執(zhí)行
下面我們就分別看一下在兩種模式下,如何動手實現(xiàn)一個agent代理程序。
Premain模式
Premain模式允許在主程序執(zhí)行前執(zhí)行一個agent代理,實現(xiàn)起來非常簡單,下面我們分別實現(xiàn)兩個組成部分。
agent
先寫一個簡單的功能,在主程序執(zhí)行前打印一句話,并打印傳遞給代理的參數(shù):
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.pluginsgroupId>
????????????<artifactId>maven-jar-pluginartifactId>
????????????<version>3.1.0version>
????????????<configuration>
????????????????<archive>
????????????????????<manifest>
????????????????????????<addClasspath>trueaddClasspath>
????????????????????manifest>
????????????????????<manifestEntries>
????????????????????????<Premain-Class>com.cn.agent.MyPreMainAgentPremain-Class>????????????????????????????
????????????????????????<Can-Redefine-Classes>trueCan-Redefine-Classes>
????????????????????????<Can-Retransform-Classes>trueCan-Retransform-Classes>
????????????????????????<Can-Set-Native-Method-Prefix>trueCan-Set-Native-Method-Prefix>
????????????????????manifestEntries>
????????????????archive>
????????????configuration>
????????plugin>
????plugins>
build>
配置的打包參數(shù)中,通過manifestEntries的方式添加屬性到MANIFEST.MF文件中,解釋一下里面的幾個參數(shù):
Premain-Class:包含premain方法的類,需要配置為類的全路徑Can-Redefine-Classes:為true時表示能夠重新定義classCan-Retransform-Classes:為true時表示能夠重新轉(zhuǎn)換class,實現(xiàn)字節(jié)碼替換Can-Set-Native-Method-Prefix:為true時表示能夠設(shè)置native方法的前綴
其中Premain-Class為必須配置,其余幾項是非必須選項,默認情況下都為false,通常也建議加入,這幾個功能我們會在后面具體介紹。在配置完成后,使用mvn命令打包:
mvn?clean?package
打包完成后生成myAgent-1.0.jar文件,我們可以解壓jar文件,看一下生成的MANIFEST.MF文件:

可以看到,添加的屬性已經(jīng)被加入到了文件中。到這里,agent代理部分就完成了,因為代理不能夠直接運行,需要附著于其他程序,所以下面新建一個工程來實現(xiàn)主程序。
主程序
在主程序的工程中,只需要一個能夠執(zhí)行的main方法的入口就可以了。
public?class?AgentTest?{
????public?static?void?main(String[]?args)?{
????????System.out.println("main?project?start");
????}
}
在主程序完成后,要考慮的就是應(yīng)該如何將主程序與agent工程連接起來。這里可以通過-javaagent參數(shù)來指定運行的代理,命令格式如下:
java?-javaagent:myAgent.jar?-jar?AgentTest.jar
并且,可以指定的代理的數(shù)量是沒有限制的,會根據(jù)指定的順序先后依次執(zhí)行各個代理,如果要同時運行兩個代理,就可以按照下面的命令執(zhí)行:
java?-javaagent:myAgent1.jar?-javaagent:myAgent2.jar??-jar?AgentTest.jar
以我們在idea中執(zhí)行程序為例,在VM options中加入添加啟動參數(shù):
-javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Hydra
-javaagent:F:\Workspace\MyAgent\target\myAgent-1.0.jar=Trunks
執(zhí)行main方法,查看輸出結(jié)果:

根據(jù)執(zhí)行結(jié)果的打印語句可以看出,在執(zhí)行主程序前,依次執(zhí)行了兩次我們的agent代理??梢酝ㄟ^下面的圖來表示執(zhí)行代理與主程序的執(zhí)行順序。

缺陷
在提供便利的同時,premain模式也有一些缺陷,例如如果agent在運行過程中出現(xiàn)異常,那么也會導(dǎo)致主程序的啟動失敗。我們對上面例子中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個部分實現(xiàn)。
agent
agent部分和上面一樣,實現(xiàn)簡單的打印功能:
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.pluginsgroupId>
????<artifactId>maven-jar-pluginartifactId>
????<version>3.1.0version>
????<configuration>
????????<archive>
????????????<manifest>
????????????????<addClasspath>trueaddClasspath>
????????????manifest>
????????????<manifestEntries>
????????????????<Agent-Class>com.cn.agent.MyAgentMainAgent-Class>
????????????????<Can-Redefine-Classes>trueCan-Redefine-Classes>
????????????????<Can-Retransform-Classes>trueCan-Retransform-Classes>
????????????manifestEntries>
????????archive>
????configuration>
plugin>
主程序
這里我們直接啟動主程序等待代理被載入,在主程序中使用了System.in進行阻塞,防止主進程提前結(jié)束。
public?class?AgentmainTest?{
????public?static?void?main(String[]?args)?throws?IOException?{
????????System.in.read();
????}
}
attach機制
和premain模式不同,我們不能再通過添加啟動參數(shù)的方式來連接agent和主程序了,這里需要借助com.sun.tools.attach包下的VirtualMachine工具類,需要注意該類不是jvm標準規(guī)范,是由Sun公司自己實現(xiàn)的,使用前需要引入依賴:
<dependency>
????<groupId>com.sungroupId>
????<artifactId>toolsartifactId>
????<version>1.8version>
????<scope>systemscope>
????<systemPath>${JAVA_HOME}\lib\tools.jarsystemPath>
dependency>
VirtualMachine代表了一個要被附著的java虛擬機,也就是程序中需要監(jiān)控的目標虛擬機,外部進程可以使用VirtualMachine的實例將agent加載到目標虛擬機中。先看一下它的靜態(tài)方法attach:
public?static?VirtualMachine?attach(String?var0);
通過attach方法可以獲取一個jvm的對象實例,這里傳入的參數(shù)是目標虛擬機運行時的進程號pid。也就是說,我們在使用attach前,需要先獲取剛才啟動的主程序的pid,使用jps命令查看線程pid:
11140
16372?RemoteMavenServer36
16392?AgentmainTest
20204?Jps
2460?Launcher
獲取到主程序AgentmainTest運行時pid是16392,將它應(yīng)用于虛擬機的連接。
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方法可以實現(xiàn)注入agent代理類的操作,方法的第一個參數(shù)是代理的本地路徑,第二個參數(shù)是傳給代理的參數(shù)。執(zhí)行AttachTest,再回到主程序AgentmainTest的控制臺,可以看到執(zhí)行了了agent中的代碼:

這樣,一個簡單的agentMain模式代理就實現(xiàn)完成了,可以通過下面這張圖再梳理一下三個模塊之間的關(guān)系。

應(yīng)用
到這里,我們就已經(jīng)簡單地了解了兩種模式的實現(xiàn)方法,但是作為高質(zhì)量程序員,我們肯定不能滿足于只用代理單純地打印語句,下面我們再來看看能怎么利用Java Agent搞點實用的東西。
在上面的兩種模式中,agent部分的邏輯分別是在premain方法和agentmain方法中實現(xiàn)的,并且,這兩個方法在簽名上對參數(shù)有嚴格的要求,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參數(shù)的方法優(yōu)先級更高,會被jvm優(yōu)先加載,它的實例inst會由jvm自動注入,下面我們就看看能通過Instrumentation實現(xiàn)什么功能。
Instrumentation
先大體介紹一下Instrumentation接口,其中的方法允許在運行時操作java程序,提供了諸如改變字節(jié)碼,新增jar包,替換class等功能,而通過這些功能使Java具有了更強的動態(tài)控制和解釋能力。在我們編寫agent代理的過程中,Instrumentation中下面3個方法比較重要和常用,我們來著重看一下。
addTransformer
addTransformer方法允許我們在類加載之前,重新定義Class,先看一下方法的定義:
void?addTransformer(ClassFileTransformer?transformer);
ClassFileTransformer是一個接口,只有一個transform方法,它在主程序的main方法執(zhí)行前,裝載的每個類都要經(jīng)過transform執(zhí)行一次,可以將它稱為轉(zhuǎn)換器。我們可以實現(xiàn)這個方法來重新定義Class,下面就通過一個例子看看具體如何使用。
首先,在主程序工程創(chuàng)建一個Fruit類:
public?class?Fruit?{
????public?void?getFruit(){
????????System.out.println("banana");
????}
}
編譯完成后復(fù)制一份class文件,并將其重命名為Fruit2.class,再修改Fruit中的方法為:
public?void?getFruit(){
????System.out.println("apple");
}
創(chuàng)建主程序,在主程序中創(chuàng)建了一個Fruit對象并調(diào)用了其getFruit方法:
public?class?TransformMain?{
????public?static?void?main(String[]?args)?{
????????new?Fruit().getFruit();
????}
}
這時執(zhí)行結(jié)果會打印apple,接下來開始實現(xiàn)premain代理部分。
在代理的premain方法中,使用Instrumentation的addTransformer方法攔截類的加載:
public?class?TransformAgent?{
????public?static?void?premain(String?agentArgs,?Instrumentation?inst)?{
????????inst.addTransformer(new?FruitTransformer());
????}
}
FruitTransformer類實現(xiàn)了ClassFileTransformer接口,轉(zhuǎn)換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方法不能指明需要轉(zhuǎn)換的類,所以需要通過className判斷當前加載的class是否我們要攔截的目標class,對于非目標class直接返回原字節(jié)數(shù)組,注意className的格式,需要將類全限定名中的.替換為/讀取我們之前復(fù)制出來的class文件,讀入二進制字符流,替換原有 classfileBuffer字節(jié)數(shù)組并返回,完成class定義的替換
將agent部分打包完成后,在主程序添加啟動參數(shù):
-javaagent:F:\Workspace\MyAgent\target\transformAgent-1.0.jar
再次執(zhí)行主程序,結(jié)果打?。?/p>
banana
這樣,就實現(xiàn)了在main方法執(zhí)行前class的替換。
redefineClasses
我們可以直觀地從方法的名字上來理解它的作用,重定義class,通俗點來講的話就是實現(xiàn)指定類的替換。方法定義如下:
void?redefineClasses(ClassDefinition...?definitions)?throws??ClassNotFoundException,?UnmodifiableClassException;
它的參數(shù)是可變長的ClassDefinition數(shù)組,再看一下ClassDefinition的構(gòu)造方法:
public?ClassDefinition(Class>?theClass,byte[]?theClassFile)?{...}
ClassDefinition中指定了的Class對象和修改后的字節(jié)碼數(shù)組,簡單來說,就是使用提供的類文件字節(jié),替換了原有的類。并且,在redefineClasses方法重定義的過程中,傳入的是ClassDefinition的數(shù)組,它會按照這個數(shù)組順序進行加載,以便滿足在類之間相互依賴的情況下進行更改。
下面通過一個例子來看一下它的生效過程,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});
????}
}
主程序可以直接復(fù)用上面的,執(zhí)行后打?。?/p>
banana
可以看到,用我們指定的class文件的字節(jié)替換了原有類,即實現(xiàn)了指定類的替換。
retransformClasses
retransformClasses應(yīng)用于agentmain模式,可以在類加載之后重新定義Class,即觸發(fā)類的重新加載。首先看一下該方法的定義:
void?retransformClasses(Class>...?classes)?throws?UnmodifiableClassException;
它的參數(shù)classes是需要轉(zhuǎn)換的類數(shù)組,可變長參數(shù)也說明了它和redefineClasses方法一樣,也可以批量轉(zhuǎn)換類的定義。
下面,我們通過例子來看看如何使用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");
????}
}
看一下這里調(diào)用的addTransformer方法的定義,與上面略有不同:
void?addTransformer(ClassFileTransformer?transformer,?boolean?canRetransform);
ClassFileTransformer轉(zhuǎn)換器依舊復(fù)用了上面的FruitTransformer,重點看一下新加的第二個參數(shù),當canRetransform為true時,表示允許重新定義class。這時,相當于調(diào)用了轉(zhuǎn)換器ClassFileTransformer中的transform方法,會將轉(zhuǎn)換后class的字節(jié)作為新類定義進行加載。
主程序部分代碼,我們在死循環(huán)中不斷的執(zhí)行打印語句,來監(jiān)控類是否發(fā)生了改變:
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");
????}
}
回到主程序控制臺,查看運行結(jié)果:

可以看到在注入代理后,打印語句發(fā)生變化,說明類的定義已經(jīng)被改變并進行了重新加載。
其他
除了這幾個主要的方法外,Instrumentation中還有一些其他方法,這里僅簡單列舉一下常用方法的功能:
removeTransformer:刪除一個ClassFileTransformer類轉(zhuǎn)換器getAllLoadedClasses:獲取當前已經(jīng)被加載的ClassgetInitiatedClasses:獲取由指定的ClassLoader加載的ClassgetObjectSize:獲取一個對象占用空間的大小appendToBootstrapClassLoaderSearch:添加jar包到啟動類加載器appendToSystemClassLoaderSearch:添加jar包到系統(tǒng)類加載器isNativeMethodPrefixSupported:判斷是否能給native方法添加前綴,即是否能夠攔截native方法setNativeMethodPrefix:設(shè)置native方法的前綴
Javassist
在上面的幾個例子中,我們都是直接讀取的class文件中的字節(jié)來進行class的重定義或轉(zhuǎn)換,但是在實際的工作環(huán)境中,可能更多的是去動態(tài)的修改class文件的字節(jié)碼,這時候就可以借助javassist來更簡單的修改字節(jié)碼文件。
簡單來說,javassist是一個分析、編輯和創(chuàng)建java字節(jié)碼的類庫,在使用時我們可以直接調(diào)用它提供的api,以編碼的形式動態(tài)改變或生成class的結(jié)構(gòu)。相對于ASM等其他要求了解底層虛擬機指令的字節(jié)碼框架,javassist真的是非常簡單和快捷。
下面,我們就通過一個簡單的例子,看看如何將Java agent和Javassist結(jié)合在一起使用。首前先引入javassist的依賴:
<dependency>
????<groupId>org.javassistgroupId>
????<artifactId>javassistartifactId>
????<version>3.20.0-GAversion>
dependency>
我們要實現(xiàn)的功能是通過代理,來計算方法執(zhí)行的時間。premain代理部分和之前基本一致,先添加一個轉(zhuǎn)換器:
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動態(tài)的改變了方法的定義:
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();
}
在上面的代碼中,主要實現(xiàn)了這些功能:
利用全限定名獲取類 CtClass根據(jù)方法名獲取方法 CtMethod,并通過CtNewMethod.copy方法復(fù)制一個新的方法修改舊方法的方法名為 getFruit$agent通過 setBody方法修改復(fù)制出來方法的內(nèi)容,在新方法中進行了邏輯增強并調(diào)用了舊方法,最后將新方法添加到類中
主程序仍然復(fù)用之前的代碼,執(zhí)行查看結(jié)果,完成了代理中的執(zhí)行時間統(tǒng)計功能:

這時候我們可以再通過反射看一下:
for?(Method?method?:?Fruit.class.getDeclaredMethods())?{
????System.out.println(method.getName());
????method.invoke(new?Fruit());
????System.out.println("-------");
}
查看結(jié)果,可以看到類中確實已經(jīng)新增了一個方法:

除此之外,javassist還有很多其他的功能,例如新建Class、設(shè)置父類、讀取和寫入字節(jié)碼等等,大家可以在具體的場景中學習它的用法。
總結(jié)
雖然我們在平常的工作中,直接用到Java Agent的場景可能并不是很多,但是在熱部署、監(jiān)控、性能分析等工具中,它們可能隱藏在業(yè)務(wù)系統(tǒng)的角落里,一直在默默發(fā)揮著巨大的作用。
本文從Java Agent的兩種模式入手,手動實現(xiàn)并簡要分析了它們的工作流程,雖然在這里只利用它們完成了一些簡單的功能,但是不得不說,正是Java Agent的出現(xiàn),讓程序的運行不再循規(guī)蹈矩,也為我們的代碼提供了無限的可能性。
我是 Guide哥,一個工作2年有余,接觸編程已經(jīng)6年有余的程序員。大三開源 JavaGuide,目前已經(jīng) 100k+ Star。未來幾年,希望持續(xù)完善 JavaGuide,爭取能夠幫助更多學習 Java 的小伙伴!共勉!凎!點擊即可了解我的個人經(jīng)歷。
簡歷指導(dǎo)/Java 學習/面試指導(dǎo)/面試小冊,歡迎加入我的知識星球(公眾號后臺回復(fù)“星球”即可)。

