JavaAgent 技術(shù)原理及簡單實現(xiàn)

注:本文定義-在函數(shù)執(zhí)行前后增加對應(yīng)的邏輯的操作統(tǒng)稱為MOCK

1骇笔、引子

在某天與QA同學(xué)進行溝通時,發(fā)現(xiàn)QA同學(xué)有針對某個方法調(diào)用時抡爹,有讓該方法停止一段時間的需求挪圾,我對這部分的功能實現(xiàn)非常好奇,因此決定對原理進行一些深入的了解降淮,力爭找到一種使用者盡可能少的對原有代碼進行修改的方式超埋,以達(dá)到對應(yīng)的MOCK要求。

整體的感知程度可以分為三個級別:

  • 硬編碼

  • 增加配置

  • 無需任何修改

2佳鳖、思路

在對方法進行mock霍殴,暫停以及異常模擬,在不知道其原理的情況下系吩,進行猜想来庭,思考其具體的實現(xiàn)原理,整體來說穿挨,最簡單的實現(xiàn)模型無外乎兩種:

2.1 樸素思路

假設(shè)存在如下的函數(shù)

public Object targetMethod(){
        System.out.println("運行");
}

若想要在函數(shù)執(zhí)行后暫停一段時間月弛、返回特定mock值或拋出特定異常肴盏,那么可以考慮修改對應(yīng)的函數(shù)內(nèi)容:

public Object targetMethod(){
    //在此處加入Sleep return 或 throw邏輯
        System.out.println("運行");
}

或使用類似代理的方法把對應(yīng)的函數(shù)進行代理:

public Object proxy(){
        //執(zhí)行Sleep return 或 throw邏輯
    return targetMethod();
}
public Object targetMethod(){
        System.out.println("運行");
}

2.2 略成熟思路

在樸素思路的基礎(chǔ)上,我們可以看出帽衙,實現(xiàn)類似的暫停菜皂、mock和異常功能整體實現(xiàn)方案無外乎兩種:

  • 代理模式

  • 深入修改內(nèi)部函數(shù)

在這兩種思路的基礎(chǔ)上,我們從代理模式開始考慮(主要是代理使用的比較多厉萝,更熟悉)

2.2.1 動態(tài)代理

說起代理恍飘,最常想到的兩個詞語就是靜態(tài)代理和動態(tài)代理,二者卻別不進行詳述冀泻,對于靜態(tài)代理模式由于需要大量硬編碼常侣,所以完全可以不用考慮。

針對動態(tài)代理來看弹渔,開始考慮最具代表性的CGLIB進行調(diào)研顶吮。

下面的代碼為一個典型的使用CGLIB進行動態(tài)代理的樣例(代理的函數(shù)為PersonService.setPerson):

public class CGlibDynamicProxy implements MethodInterceptor {
    public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
        System.out.println("執(zhí)行前...");
        Object object = methodProxy.invokeSuper(o, objects);
        System.out.println("執(zhí)行后...");
        return object;
    }

    static class PersonService {
        public PersonService() {
            System.out.println("PersonService構(gòu)造");
        }

        public void setPerson() {
            System.out.println("PersonService:setPerson");
        }
    }

    public static void main(String[] args) {
        Enhancer enhancer = new Enhancer();
        enhancer.setSuperclass(PersonService.class);
        enhancer.setCallback(new CGlibDynamicProxy());
        PersonService proxy= (PersonService)  enhancer.create();
        proxy.setPerson();
    }
}

從上面代碼可以看出棋傍,對于CGLIB的動態(tài)代理而言,需要在原有代碼中進行硬編碼,且需要在對象初始化的時候唱歧,使用特定的方式進行初始化磷脯。因此若使用CGLIB完成MOCK柠衅,需要對應(yīng)代碼的的感知程度最高氛赐,達(dá)到了硬編碼的程度。

2.2.2 AspectJ

由于使用代理方式無法在不對代碼進行修改的情況下完成MOCK剃根,因此我們拋棄代理方式哩盲,考慮使用修改方法內(nèi)部代碼的方式進行MOCK。

基于這種思路狈醉,將目光轉(zhuǎn)向了AspectJ廉油。

在使用AspectJ時,需要定義方法執(zhí)行前的函數(shù)以及方法執(zhí)行后的函數(shù):

@Aspect 
public class AspectJFrame { private Object before() {
        System.out.println("before"); 
        return new Object();
    } 
private Object after() {
        System.out.println("after"); 
        return new Object();
    }

@Around("aroundPoint()") 
public Object doMock(ProceedingJoinPoint joinPoint) {
        Object object=null;
        before(); 
        try {
            object = joinPoint.proceed();
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
        after(); 
        return object;
    }
}

并通過aop.xml指定對應(yīng)的切點以及對應(yīng)的環(huán)繞函數(shù)

<aspectj>
    <aspects>
        <aspect name="com.test.framework.AspectJFrame">
            <before method="" pointcut=""/>
        </aspect>
    </aspects>
</aspectj>

但是基于以上的實現(xiàn)方式苗傅,需要對原有項目進行一定侵入抒线,主要包含兩部分內(nèi)容:

  • 在META-INF路徑下增加aop.xml

  • 引入對應(yīng)的切面定義的jar包

通過aspectj可以完成在硬編碼的情況下實現(xiàn)MOCK,但是這種實現(xiàn)方式受限于Aspectj自身局限渣慕,MOCK的功能代碼在編譯期就已經(jīng)添加到對應(yīng)的函數(shù)中了嘶炭,最晚可在運行時完成MOCK功能代碼的添加。這種方式主要有兩個缺點:

  • 對于運行中的java進行無法在不重啟的條件下執(zhí)行新增MOCK

  • MOCK功能代碼嵌入到目標(biāo)函數(shù)中逊桦,無法對MOCK功能代碼進行卸載眨猎,可能帶來穩(wěn)定性風(fēng)險

3、 java agent介紹

由于在上述提到的各種技術(shù)都難以很好的支持在對原有項目無任何修改下完成MOCK功能的需求强经,在查閱資料后宵呛,將目光放至了java agent技術(shù)。

3.1 什么是java agent夕凝?

java agent本質(zhì)上可以理解為一個插件宝穗,該插件就是一個精心提供的jar包,這個jar包通過JVMTI(JVM Tool Interface)完成加載码秉,最終借助JPLISAgent(Java Programming Language Instrumentation Services Agent)完成對目標(biāo)代碼的修改逮矛。

java agent技術(shù)的主要功能如下:

  • 可以在加載java文件之前做攔截把字節(jié)碼做修改
  • 可以在運行期將已經(jīng)加載的類的字節(jié)碼做變更
  • 還有其他的一些小眾的功能
    • 獲取所有已經(jīng)被加載過的類
    • 獲取所有已經(jīng)被初始化過了的類
    • 獲取某個對象的大小
    • 將某個jar加入到bootstrapclasspath里作為高優(yōu)先級被bootstrapClassloader加載
    • 將某個jar加入到classpath里供AppClassloard去加載
    • 設(shè)置某些native方法的前綴,主要在查找native方法的時候做規(guī)則匹配

3.2 java Instrumentation API

通過java agent技術(shù)進行類的字節(jié)碼修改最主要使用的就是Java Instrumentation API转砖。下面將介紹如何使用Java Instrumentation API進行字節(jié)碼修改须鼎。

3.2.1 實現(xiàn)agent啟動方法

Java Agent支持目標(biāo)JVM啟動時加載,也支持在目標(biāo)JVM運行時加載府蔗,這兩種不同的加載模式會使用不同的入口函數(shù)晋控,如果需要在目標(biāo)JVM啟動的同時加載Agent,那么可以選擇實現(xiàn)下面的方法:

[1] public static void premain(String agentArgs, Instrumentation inst); 
[2] public static void premain(String agentArgs);

JVM將首先尋找[1]姓赤,如果沒有發(fā)現(xiàn)[1]赡译,再尋找[2]。如果希望在目標(biāo)JVM運行時加載Agent不铆,則需要實現(xiàn)下面的方法:

[1] public static void agentmain(String agentArgs, Instrumentation inst); 
[2] public static void agentmain(String agentArgs);

這兩組方法的第一個參數(shù)AgentArgs是隨同 “–javaagent”一起傳入的程序參數(shù)蝌焚,如果這個字符串代表了多個參數(shù),就需要自己解析這些參數(shù)誓斥。inst是Instrumentation類型的對象只洒,是JVM自動傳入的,我們可以拿這個參數(shù)進行類增強等操作劳坑。

3.2.2 指定Main-Class

Agent需要打包成一個jar包毕谴,在ManiFest屬性中指定“Premain-Class”或者“Agent-Class”,且需根據(jù)需求定義Can-Redefine-Classes和Can-Retransform-Classes:

Manifest-Version: 1.0
preMain-Class: com.test.AgentClass
Archiver-Version: Plexus Archiver
Agent-Class: com.test.AgentClass
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Created-By: Apache Maven 3.3.9
Build-Jdk: 1.8.0_112

3.2.3 agent加載

  • 啟動時加載

    • 啟動參數(shù)增加-javaagent:[path],其中path為對應(yīng)的agent的jar包路徑
  • 運行中加載

    • 使用com.sun.tools.attach.VirtualMachine加載
try {
  String jvmPid = 目標(biāo)進行的pid;
  logger.info("Attaching to target JVM with PID: " + jvmPid);
  VirtualMachine jvm = VirtualMachine.attach(jvmPid);
  jvm.loadAgent(agentFilePath);//agentFilePath為agent的路徑
  jvm.detach();
  logger.info("Attached to target JVM and loaded Java agent successfully");
} catch (Exception e) {
  throw new RuntimeException(e);
}

3.2.4 Instrument

instrument是JVM提供的一個可以修改已加載類的類庫距芬,專門為Java語言編寫的插樁服務(wù)提供支持涝开。它需要依賴JVMTI的Attach API機制實現(xiàn)。在JDK 1.6以前蔑穴,instrument只能在JVM剛啟動開始加載類時生效忠寻,而在JDK 1.6之后,instrument支持了在運行時對類定義的修改存和。要使用instrument的類修改功能奕剃,我們需要實現(xiàn)它提供的ClassFileTransformer接口,定義一個類文件轉(zhuǎn)換器捐腿。接口中的transform()方法會在類文件被加載時調(diào)用纵朋,而在transform方法里,我們可以利用上文中的ASM或Javassist對傳入的字節(jié)碼進行改寫或替換茄袖,生成新的字節(jié)碼數(shù)組后返回操软。

首先可以定義如下的類轉(zhuǎn)換器:

public class TestTransformer implements ClassFileTransformer {
    //目標(biāo)類名稱,  .分隔
    private String targetClassName;
    //目標(biāo)類名稱宪祥,  /分隔
    private String targetVMClassName;
    private String targetMethodName;
    

    public TestTransformer(String className,String methodName){
        this.targetVMClassName = new String(className).replaceAll("\\.","\\/");
        this.targetMethodName = methodName;
        this.targetClassName=className;
    }
    //類加載時會執(zhí)行該函數(shù)聂薪,其中參數(shù) classfileBuffer為類原始字節(jié)碼家乘,返回值為目標(biāo)字節(jié)碼,className為/分隔
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
        //判斷類名是否為目標(biāo)類名
        if(!className.equals(targetVMClassName)){
            return classfileBuffer;
        }
        try {
            ClassPool classPool = ClassPool.getDefault();
            CtClass cls = classPool.get(this.targetClassName);
            CtMethod ctMethod = cls.getDeclaredMethod(this.targetMethodName);
            ctMethod.insertBefore("{ System.out.println(\"start\"); }");
            ctMethod.insertAfter("{ System.out.println(\"end\"); }");
            return cls.toBytecode();
        } catch (Exception e) {

        }
        return classfileBuffer;
    }
}

類轉(zhuǎn)換器定義完畢后藏澳,需要將定義好的類轉(zhuǎn)換器添加到對應(yīng)的instrmentation中仁锯,對于已經(jīng)加載過的類使用retransformClasses對類進行重新加載:

public class AgentDemo {

    private static String className = "hello.GreetingController";
    private static String methodName = "getDomain";

    public static void agentmain(String args, Instrumentation instrumentation) {

        try {
            List<Class> needRetransFormClasses = new LinkedList<>();
            Class[] loadedClass = instrumentation.getAllLoadedClasses();
            for (int i = 0; i < loadedClass.length; i++) {
                if (loadedClass[i].getName().equals(className)) {
                    needRetransFormClasses.add(loadedClass[i]);
                }
            }

            instrumentation.addTransformer(new TestTransformer(className, methodName));
            instrumentation.retransformClasses(needRetransFormClasses.toArray(new Class[0]));
        } catch (Exception e) {

        }
    }

    public static void premain(String args, Instrumentation instrumentation) {
        instrumentation.addTransformer(new TestTransformer(className, methodName));
    }
}

從上圖的代碼可以看出,主方法實現(xiàn)了兩個翔悠,分別為agentmain和premain业崖,其中

  • premain
    • 用于在啟動時,類加載前定義類的TransFormer蓄愁,在類加載的時候更新對應(yīng)的類的字節(jié)碼
  • agentmain

    • 用于在運行時進行類的字節(jié)碼的修改双炕,步驟整體分為兩步
    • 注冊類的TransFormer
    • 調(diào)用retransformClasses函數(shù)進行類的重加載

4、java agent原理簡述

4.1 啟動時修改

image

啟動時修改主要是在jvm啟動時撮抓,執(zhí)行native函數(shù)的Agent_OnLoad方法妇斤,在方法執(zhí)行時,執(zhí)行如下步驟:

  • 創(chuàng)建InstrumentationImpl對象

  • 監(jiān)聽ClassFileLoadHook事件

  • 調(diào)用InstrumentationImpl的loadClassAndCallPremain方法胀滚,在這個方法里會去調(diào)用javaagent里MANIFEST.MF里指定的Premain-Class類的premain方法

4.2 運行時修改

image

運行時修改主要是通過jvm的attach機制來請求目標(biāo)jvm加載對應(yīng)的agent趟济,執(zhí)行native函數(shù)的Agent_OnAttach方法,在方法執(zhí)行時咽笼,執(zhí)行如下步驟:

  • 創(chuàng)建InstrumentationImpl對象

  • 監(jiān)聽ClassFileLoadHook事件

  • 調(diào)用InstrumentationImpl的loadClassAndCallAgentmain方法顷编,在這個方法里會去調(diào)用javaagent里MANIFEST.MF里指定的Agentmain-Class類的agentmain方法

4.3 ClassFileLoadHook和TransFormClassFile

在4.1和4.2節(jié)中,可以看出整體流程中有兩個部分是具有共性的剑刑,分別為:

  • ClassFileLoadHook

  • TranFormClassFile

ClassFileLoadHook是一個jvmti事件媳纬,該事件是instrument agent的一個核心事件,主要是在讀取字節(jié)碼文件回調(diào)時調(diào)用施掏,內(nèi)部調(diào)用了TransFormClassFile函數(shù)钮惠。

TransFormClassFile的主要作用是調(diào)用java.lang.instrument.ClassFileTransformer的tranform方法,該方法由開發(fā)者實現(xiàn)七芭,通過instrument的addTransformer方法進行注冊素挽。

通過以上描述可以看出在字節(jié)碼文件加載的時候,會觸發(fā)ClassFileLoadHook事件狸驳,該事件調(diào)用TransFormClassFile预明,通過經(jīng)由instrument的addTransformer注冊的方法完成整體的字節(jié)碼修改。

對于已加載的類耙箍,需要調(diào)用retransformClass函數(shù)撰糠,然后經(jīng)由redefineClasses函數(shù),在讀取已加載的字節(jié)碼文件后辩昆,若該字節(jié)碼文件對應(yīng)的類關(guān)注了ClassFileLoadHook事件阅酪,則調(diào)用ClassFileLoadHook事件。后續(xù)流程與類加載時字節(jié)碼替換一致。

4.4 何時進行運行時替換术辐?

在類加載完畢后砚尽,對應(yīng)的想要替換函數(shù)可能正在執(zhí)行,那么何時進行類字節(jié)碼的替換呢辉词?

由于運行時類字節(jié)碼替換依賴于redefineClasses尉辑,那么可以看一下該方法的定義:

jvmtiError
JvmtiEnv::RedefineClasses(jint class_count, const jvmtiClassDefinition* class_definitions) {
//TODO: add locking
  VM_RedefineClasses op(class_count, class_definitions, jvmti_class_load_kind_redefine);
  VMThread::execute(&op);
  return (op.check_error());
} /* end RedefineClasses */

其中整體的執(zhí)行依賴于VMThread,VMThread是一個在虛擬機創(chuàng)建時生成的單例原生線程较屿,這個線程能派生出其他線程。同時卓练,這個線程的主要的作用是維護一個vm操作隊列(VMOperationQueue)隘蝎,用于處理其他線程提交的vm operation,比如執(zhí)行GC等襟企。

VmThread在執(zhí)行一個vm操作時嘱么,先判斷這個操作是否需要在safepoint下執(zhí)行。若需要safepoint下執(zhí)行且當(dāng)前系統(tǒng)不在safepoint下顽悼,則調(diào)用SafepointSynchronize的方法驅(qū)使所有線程進入safepoint中曼振,再執(zhí)行vm操作。執(zhí)行完后再喚醒所有線程蔚龙。若此操作不需要在safepoint下冰评,或者當(dāng)前系統(tǒng)已經(jīng)在safepoint下,則可以直接執(zhí)行該操作了木羹。所以甲雅,在safepoint的vm操作下,只有vm線程可以執(zhí)行具體的邏輯坑填,其他線程都要進入safepoint下并被掛起抛人,直到完成此次操作。

因此脐瑰,在執(zhí)行字節(jié)碼替換的時候需要在safepoint下執(zhí)行妖枚,因此整體會觸發(fā)stop-the-world。

98苍在、歡迎關(guān)注

歡迎關(guān)注公眾號绝页,一起溝通,一起學(xué)習(xí)忌穿,一起成長

博主的公眾號

99抒寂、參考文檔

http://lovestblog.cn/blog/2015/09/14/javaagent/
https://tech.meituan.com/2019/09/05/java-bytecode-enhancement.html
https://tech.meituan.com/2019/11/07/java-dynamic-debugging-technology.html
http://www.throwable.club/2019/06/29/java-understand-instrument-first/

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市掠剑,隨后出現(xiàn)的幾起案子屈芜,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 206,602評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件井佑,死亡現(xiàn)場離奇詭異属铁,居然都是意外死亡,警方通過查閱死者的電腦和手機躬翁,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,442評論 2 382
  • 文/潘曉璐 我一進店門焦蘑,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人盒发,你說我怎么就攤上這事例嘱。” “怎么了宁舰?”我有些...
    開封第一講書人閱讀 152,878評論 0 344
  • 文/不壞的土叔 我叫張陵拼卵,是天一觀的道長。 經(jīng)常有香客問我蛮艰,道長腋腮,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,306評論 1 279
  • 正文 為了忘掉前任壤蚜,我火速辦了婚禮即寡,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘袜刷。我一直安慰自己聪富,他們只是感情好,可當(dāng)我...
    茶點故事閱讀 64,330評論 5 373
  • 文/花漫 我一把揭開白布水泉。 她就那樣靜靜地躺著善涨,像睡著了一般。 火紅的嫁衣襯著肌膚如雪草则。 梳的紋絲不亂的頭發(fā)上钢拧,一...
    開封第一講書人閱讀 49,071評論 1 285
  • 那天,我揣著相機與錄音炕横,去河邊找鬼源内。 笑死,一個胖子當(dāng)著我的面吹牛份殿,可吹牛的內(nèi)容都是我干的膜钓。 我是一名探鬼主播,決...
    沈念sama閱讀 38,382評論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼卿嘲,長吁一口氣:“原來是場噩夢啊……” “哼颂斜!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起拾枣,我...
    開封第一講書人閱讀 37,006評論 0 259
  • 序言:老撾萬榮一對情侶失蹤沃疮,失蹤者是張志新(化名)和其女友劉穎盒让,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體司蔬,經(jīng)...
    沈念sama閱讀 43,512評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡邑茄,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 35,965評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了俊啼。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片肺缕。...
    茶點故事閱讀 38,094評論 1 333
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖授帕,靈堂內(nèi)的尸體忽然破棺而出同木,到底是詐尸還是另有隱情,我是刑警寧澤跛十,帶...
    沈念sama閱讀 33,732評論 4 323
  • 正文 年R本政府宣布泉手,位于F島的核電站,受9級特大地震影響偶器,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜缝裤,卻給世界環(huán)境...
    茶點故事閱讀 39,283評論 3 307
  • 文/蒙蒙 一屏轰、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧憋飞,春花似錦霎苗、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,286評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至检眯,卻和暖如春厘擂,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背锰瘸。 一陣腳步聲響...
    開封第一講書人閱讀 31,512評論 1 262
  • 我被黑心中介騙來泰國打工刽严, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人避凝。 一個月前我還...
    沈念sama閱讀 45,536評論 2 354
  • 正文 我出身青樓舞萄,卻偏偏與公主長得像,于是被迫代替她去往敵國和親管削。 傳聞我的和親對象是個殘疾皇子倒脓,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 42,828評論 2 345

推薦閱讀更多精彩內(nèi)容

  • 晴天下巴上磕了個小口子,剛剛結(jié)痂晴天(指著下巴):媽媽含思,看我這里崎弃!掉了甘晤!媽媽:啊吊履?你自己摳掉的嗎安皱?晴天:嗯,剛剛洗...
    純棉小背背閱讀 258評論 0 0
  • 這是我們所有的死黨艇炎,在分別了二個月之后酌伊,新的一年第一次聚在一起,見面時感覺真的很開心缀踪! 這次會議的議題是國軍提出的...
    龔慧群閱讀 367評論 0 1
  • loan
    軒小豆閱讀 149評論 0 0
  • 人是有性靈的生命居砖,生而為人,是造化的大恩驴娃。為報這個大恩奏候,就要活出你的性靈,擁有自由的頭腦唇敞、豐富的心靈蔗草、高貴的靈魂,...
    大胡子逸舟閱讀 391評論 0 1