目前我們的業(yè)務(wù)遇到線上問題時經(jīng)常需要加log調(diào)試。從加一行l(wèi)og到push代碼再到j(luò)ekens編譯、打包、最后再部署,這個過程時間消耗非常長,而且各個環(huán)節(jié)都可能出現(xiàn)其他因素干擾(如如多人同時提交導(dǎo)致代碼沖突編譯不過等),造成的時間消耗就更長了。甚至有時候需要逐步加日志排查問題,重復(fù)很多次改代碼再打包部署的操作,實在是費(fèi)心費(fèi)力。。。
可不可以在服務(wù)器上直接改代碼使之實時生效?結(jié)論是可以的。
一、java Instrumentation
從java5開始,jdk中新增了一個java.lang.instrument.Instrumentation 類,它提供在運(yùn)行時重新加載某個類的的class文件的api。下面是它的一些主要api
public interface Instrumentation {
/**
* 加入一個轉(zhuǎn)換器Transformer,之后的所有的類加載都會被Transformer攔截。
* ClassFileTransformer類是一個接口,使用時需要實現(xiàn)它,該類只有一個方法,該方法傳遞類的信息,返回值是轉(zhuǎn)換后的類的字節(jié)碼文件。
*/
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);
/**
* 對JVM已經(jīng)加載的類重新觸發(fā)類加載。使用的就是上面注冊的Transformer。
* 該方法可以修改方法體、常量池和屬性值,但不能新增、刪除、重命名屬性或方法,也不能修改方法的簽名
*/
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;
/**
*此方法用于替換類的定義,而不引用現(xiàn)有的類文件字節(jié),就像從源代碼重新編譯以進(jìn)行修復(fù)和繼續(xù)調(diào)試時所做的那樣。
*在要轉(zhuǎn)換現(xiàn)有類文件字節(jié)的地方(例如在字節(jié)碼插裝中),應(yīng)該使用retransformClasses。
*該方法可以修改方法體、常量池和屬性值,但不能新增、刪除、重命名屬性或方法,也不能修改方法的簽名
*/
void redefineClasses(ClassDefinition... definitions)throws ClassNotFoundException, UnmodifiableClassException;
/**
* 獲取一個對象的大小
*/
long getObjectSize(Object objectToSize);
/**
* 將一個jar加入到bootstrap classloader的 classpath里
*/
void appendToBootstrapClassLoaderSearch(JarFile jarfile);
/**
* 獲取當(dāng)前被JVM加載的所有類對象
*/
Class[] getAllLoadedClasses();
}
通過addTransformer可以加入一個轉(zhuǎn)換器,轉(zhuǎn)換器可以實現(xiàn)對類加載的事件進(jìn)行攔截并返回轉(zhuǎn)換后新的字節(jié)碼,通過redefineClasses或retransformClasses都可以觸發(fā)類的重新加載事件。通過這幾個方法的組合,就可以實現(xiàn)文章開頭提到的不修改代碼使之實時生效的目的了。
二、JAVA Agent
通過操作Instrumentation的api就可以實現(xiàn)不重啟服務(wù)對單個類進(jìn)行簡單的修改。Instrumentation是一個interface,它的實現(xiàn)類InstrumentationImpl只有一個private的構(gòu)造方法。
怎么拿到這個對象呢?下面是Instrumentation類的一段注釋說明:

有兩種方式拿到Instrumentation對象:
在jvm啟動時指定agent,Instrumentation對象會通過agent的premain方法傳遞。
在jvm啟動后通過jvm提供的機(jī)制加載agent,Instrumentation對象會通過agent的agentmain方法傳遞。
三、實踐java啟動時加載agent 獲取Instrumentation對象
編寫agent類并編譯成.class文件,之后把它打成jar包,然后在jvm啟動參數(shù)中指定jar包位置,具體操作步驟:
1、創(chuàng)建一個agent類,并創(chuàng)建premain方法,premain方法的參數(shù)是固定的。
public class preMainAgentClz {
private static Instrumentation instrumentation;
public static void premain(String agentArgs, Instrumentation inst) {
instrumentation = inst;
System.err.println("com.hexuan.agent.demo1.preMainAgentClz 我在main啟動之前啟動");
}
}
2、指定premain方法的位置(兩種指定方式,設(shè)置一種就行)
方式1)創(chuàng)建并編輯 resources/META-INF/MANIFEST.MF 文件,當(dāng)打jar包時將該文件一并打包
Premain-Class: com.hexuan.agent.demo1.preMainAgentClz #premain方法所在類的位置
方式2)如果是maven項目,在pom.xml加入
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<configuration>
<archive>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>com.hexuan.agent.demo1.preMainAgentClz</Premain-Class>
<Agent-Class>com.hexuan.agent.demo1.agentMainAgentClz</Agent-Class>
</manifestEntries>
</archive>
</configuration>
</plugin>
3、如果是在pom中配置的,直接maven package就好了。如果是MANIFEST.MF文件指定的方式,將包含premain的類編譯成class文件,并和MANIFEST.MF一起文件打包jar。
4、啟動時指定agent位置,在jvm啟動參數(shù)中加入-javaagent參數(shù)并指定jar文件位置。
-javaagent:/Users/hexuan/IdeaProjects/acfun_WorkSpace/java-agent-demo/target/java-agent-demo-1.0-SNAPSHOT.jar
5、啟動java,agent的premain方法會在main方法之前執(zhí)行。
四、在java啟動后以attach的方式加載agent
上文介紹了java進(jìn)程啟動時加載agent的方式和步驟,通過它在啟動之前將指定的類進(jìn)行替換。但如果要實現(xiàn)文章開頭提到的調(diào)試線上代碼,我們需要在修改了class文件后重啟jvm并且設(shè)置-javaagent參數(shù),顯然這種方式不是我們最想要的。上文提到過我們可以在jvm啟動后通過jvm提供的機(jī)制加載agent,也就是說我們能夠在任何時候去加載agent,然后替換類文件。這個機(jī)制就是jdk的attach api。
Attach API是Sun公司提供的一套擴(kuò)展API,用來向目標(biāo)JVM"附著"(Attach)代理工具程序的。有了它,開發(fā)者可以方便的監(jiān)控一個JVM,運(yùn)行一個外加的代理程序,Sun JVM Attach API功能上非常簡單,僅提供了如下幾個功能:
- 列出當(dāng)前所有的JVM實例描述
- Attach到其中一個JVM上,建立通信管道
- 讓目標(biāo)JVM加載Agent
Attach Api 對應(yīng)的代碼位置在 com.sun.tools.attach 包,包里邊有一個類VirtualMachine,它有兩個比較重要方法:
/**
*傳遞一個進(jìn)程號作為參數(shù),返回目標(biāo)jvm進(jìn)程的vm對象。
*該方法其實是JVM進(jìn)程之間指令傳遞的橋梁,底層通過socket進(jìn)行通信。
*JVM A可以發(fā)送一些指令給JVM B,B收到指令之后,可以執(zhí)行對應(yīng)的邏輯
* 比如在命令行中經(jīng)常使用的jstack、jcmd、jps等,很多都是基于這種機(jī)制實現(xiàn)的
**/
public static VirtualMachine attach(String var0) throws AttachNotSupportedException, IOException
/**
*該方法允許我們將agent對應(yīng)的jar文件地址作為參數(shù)傳遞目標(biāo)jvm
*目標(biāo)jvm收到該命令后會加載這個agent
**/
public void loadAgent(String var1) throws AgentLoadException, AgentInitializationException, IOException
顯然,我們可以創(chuàng)建一個java進(jìn)程,用它attach到對應(yīng)的jvm,并加載agent,agent加載后我們的類也就被成功替換了。
五、怎么得到新的類文件
Instrumentation操作的是.class文件,對于我們開發(fā)人員來講,我們看不懂.class文件,更無法直接修改它了。還是考慮文章一開始提到的線上改代碼調(diào)試的場景,我們知道了如何去替換類,但是如何得到新的.class類文件呢?
方式1:線下修改.java文件 -->編譯成.class文件 -->上傳到線上機(jī)器-->instrument
方式2:線上.class舊文件 -->反編譯成.java文件 -->修改java文件 -->編譯成.class文件 -->instrument
方式3:通過ASM或其他操作字節(jié)碼的組件直接修改.class文件-->instrument
...
無論哪種方式,流程太復(fù)雜容易出錯,有成熟的組件嗎?有,Arthas和Btrace
六、Arthas&Btrace
BTrace 是基于動態(tài)字節(jié)碼修改技術(shù)(Instrumentation)來實現(xiàn)運(yùn)行時 java 程序的跟蹤和替換。大體的原理可以用下面的公式描述:Client(Java compile api + attach api) + Agent(腳本解析引擎 + ASM + JDK6 Instumentation) + Socket其實 BTrace 就是使用了 java attach api 附加 agent.jar ,然后使用腳本解析引擎+asm來重寫指定類的字節(jié)碼,再使用 instrument 實現(xiàn)對原有類的替換。
但是BTrace腳本在使用上有一定的學(xué)習(xí)成本,如果能把一些常用的功能封裝起來,對外直接提供簡單的命令即可操作的話,那就再好不過了。2018年9月份阿里開源了自己的Java診斷工具Arthas。Arthas功能非常強(qiáng)大,通過簡單的命令行操作即可完成對應(yīng)功能。究其背后的技術(shù)原理,和本文中提到的大致無二。
Btrace開源地址:https://github.com/btraceio/btrace
Arthas開源地址:https://github.com/alibaba/arthas
七、總結(jié)
java instrument在很多應(yīng)用領(lǐng)域都發(fā)揮著重要的作用,比如:
- apm:(Application Performance Management)應(yīng)用性能管理。pinpoint、cat、skywalking等都基于Instrumentation實現(xiàn)
- idea的HotSwap、Jrebel等熱部署工具
- 應(yīng)用級故障演練
- Java診斷工具Arthas、Btrace等
java agent加載的時序圖:

附1:java Instrumentation的redefineClasses 和retransformClasses 的補(bǔ)充說明:
- 二者的區(qū)別:都是替換已經(jīng)存在的class文件,redefineClasses是自己提供字節(jié)碼文件替換掉已存在的class文件,retransformClasses是在已存在的字節(jié)碼文件上修改后再替換之。
- 相互依賴的類加載: 允許傳類集合,以滿足類之間相互依賴的情況,加載順序為集合順序
- 替換后生效時機(jī):如果一個被修改的方法已經(jīng)在棧楨中存在,則棧楨中的會使用舊字節(jié)碼定義的方法繼續(xù)運(yùn)行,新字節(jié)碼會在新棧楨中執(zhí)行
- 不修改變量值:該方法不會導(dǎo)致類的一些初始化方法執(zhí)行、不會修改靜態(tài)變量的值
- 只改變方法體:該方法可以改變類的方法體、常量池和屬性值,但不能新增、刪除、重命名屬性或方法,也不能修改方法的簽名
- 字節(jié)碼有問題時不加載:在類轉(zhuǎn)化前該方法不會check字節(jié)碼文件,如果結(jié)果字節(jié)碼出錯了,該方法將拋出異常。如果該方法拋出異常,則不會重新定義任何類
附:2、使用Arthas實現(xiàn)加log調(diào)試
#下載arthas agent
wget https://alibaba.github.io/arthas/arthas-boot.jar
#啟動agent
java -jar arthas-boot.jar --target-ip 0.0.0.0
#sc:search class 查找類文件
sc *SelectionController
#jad 反編譯class 并輸出到文件
jad --source-only com.acfun.controller.SelectionController > /tmp/SelectionController.java
#修改源代碼
vi /tmp/SelectionController.java
#sc查找加載UserController的ClassLoader -d參數(shù)可以打印出類加載的具體信息
sc -d *SelectionController |grep classLoaderHash
#編譯源代碼 使用mc(Memory Compiler)命令來編譯,并且通過-c參數(shù)指定ClassLoader
mc -c 3787f831 /tmp/SelectionController.java -d /tmp
#使用redefine命令重新加載新編譯好的class
redefine /tmp/com/acfun/controller/SelectionController.class
#redefine成功之后,訪問controller,觀察代碼是否生效