原創:微信公眾號
碼農參上
(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
時表示能夠重新定義classCan-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
方法中,使用Instrumentation
的addTransformer
方法攔截類的加載:
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
,重點看一下新加的第二個參數,當canRetransform
為true
時,表示允許重新定義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
:獲取當前已經被加載的ClassgetInitiatedClasses
:獲取由指定的ClassLoader
加載的ClassgetObjectSize
:獲取一個對象占用空間的大小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,歡迎添加好友,進一步交流。