java agent技術原理及簡單實現
注:本文定義-在函式執行前後增加對應的邏輯的操作統稱為MOCK
1、引子
在某天與QA同學進行溝通時,發現QA同學有針對某個方法呼叫時,有讓該方法停止一段時間的需求,我對這部分的功能實現非常好奇,因此決定對原理進行一些深入的瞭解,力爭找到一種使用者儘可能少的對原有程式碼進行修改的方式,以達到對應的MOCK要求。
整體的感知程度可以分為三個級別:
-
硬編碼
-
增加配置
-
無需任何修改
2、思路
在對方法進行mock,暫停以及異常模擬,在不知道其原理的情況下,進行猜想,思考其具體的實現原理,整體來說,最簡單的實現模型無外乎兩種:
2.1 樸素思路
假設存在如下的函式
public Object targetMethod(){ System.out.println("執行"); }
若想要在函式執行後暫停一段時間、返回特定mock值或丟擲特定異常,那麼可以考慮修改對應的函式內容:
public Object targetMethod(){ //在此處加入Sleep return 或 throw邏輯 System.out.println("執行"); }
或使用類似代理的方法把對應的函式進行代理:
public Object proxy(){ //執行Sleep return 或 throw邏輯 return targetMethod(); } public Object targetMethod(){ System.out.println("執行"); }
2.2 略成熟思路
在樸素思路的基礎上,我們可以看出,實現類似的暫停、mock和異常功能整體實現方案無外乎兩種:
-
代理模式
-
深入修改內部函式
在這兩種思路的基礎上,我們從代理模式開始考慮(主要是代理使用的比較多,更熟悉)
2.2.1 動態代理
說起代理,最常想到的兩個詞語就是靜態代理和動態代理,二者卻別不進行詳述,對於靜態代理模式由於需要大量硬編碼,所以完全可以不用考慮。
針對動態代理來看,開始考慮最具代表性的CGLIB進行調研。
下面的程式碼為一個典型的使用CGLIB進行動態代理的樣例(代理的函式為HelloInterface.sayHelllo):
public class DynamicProxy implements InvocationHandler { private Object target; public DynamicProxy(Object object) { this.target = object; } private void before() { System.out.println("before"); } private void after() { System.out.println("after"); } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { Object res = null; before(); try { res = method.invoke(target, args); } catch (Throwable e) { throw e.getCause(); } after(); return res; } public static void main(String[] args) throws IOException { try { SayHello sayHello = new SayHello(); DynamicProxy dynamicProxy = new DynamicProxy(sayHello); HelloInterface helloInterface = (HelloInterface) Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(), sayHello.getClass().getInterfaces(), dynamicProxy); helloInterface.sayHello(); } catch (Throwable e) { e.printStackTrace(); } } }
從上面程式碼可以看出,對於CGLIB的動態代理而言,需要在原有程式碼中進行硬編碼,且需要在物件初始化的時候,使用特定的方式進行初始化。因此若使用CGLIB完成MOCK,需要對應程式碼的的感知程度最高,達到了硬編碼的程度。
2.2.2 AspectJ
由於使用代理方式無法在不對程式碼進行修改的情況下完成MOCK,因此我們拋棄代理方式,考慮使用修改方法內部程式碼的方式進行MOCK。
基於這種思路,將目光轉向了AspectJ。
在使用AspectJ時,需要定義方法執行前的函式以及方法執行後的函式:
@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指定對應的切點以及對應的環繞函式
<aspectj> <aspects> <aspect name="com.test.framework.AspectJFrame"> <before method="" pointcut=""/> </aspect> </aspects> </aspectj>
但是基於以上的實現方式,需要對原有專案進行一定侵入,主要包含兩部分內容:
-
在META-INF路徑下增加aop.xml
-
引入對應的切面定義的jar包
通過aspectj可以完成在硬編碼的情況下實現MOCK,但是這種實現方式受限於Aspectj自身侷限,MOCK的功能程式碼在編譯期就已經新增到對應的函式中了,最晚可在執行時完成MOCK功能程式碼的新增。這種方式主要有兩個缺點:
-
對於執行中的java進行無法在不重啟的條件下執行新增MOCK
-
MOCK功能程式碼嵌入到目標函式中,無法對MOCK功能程式碼進行解除安裝,可能帶來穩定性風險
3、 java agent介紹
由於在上述提到的各種技術都難以很好的支援在對原有專案無任何修改下完成MOCK功能的需求,在查閱資料後,將目光放至了java agent技術。
3.1 什麼是java agent?
java agent本質上可以理解為一個外掛,該外掛就是一個精心提供的jar包,這個jar包通過JVMTI(JVM Tool Interface)完成載入,最終藉助JPLISAgent(Java Programming Language Instrumentation Services Agent)完成對目的碼的修改。
java agent技術的主要功能如下:
-
可以在載入java檔案之前做攔截把位元組碼做修改
-
可以在執行期將已經載入的類的位元組碼做變更
-
還有其他的一些小眾的功能
-
獲取所有已經被載入過的類
-
獲取所有已經被初始化過了的類
-
獲取某個物件的大小
-
將某個jar加入到bootstrapclasspath裡作為高優先順序被bootstrapClassloader載入
-
將某個jar加入到classpath裡供AppClassloard去載入
-
設定某些native方法的字首,主要在查詢native方法的時候做規則匹配
-
3.2 java Instrumentation API
通過java agent技術進行類的位元組碼修改最主要使用的就是Java Instrumentation API。下面將介紹如何使用Java Instrumentation API進行位元組碼修改。
3.2.1 實現agent啟動方法
Java Agent支援目標JVM啟動時載入,也支援在目標JVM執行時載入,這兩種不同的載入模式會使用不同的入口函式,如果需要在目標JVM啟動的同時載入Agent,那麼可以選擇實現下面的方法:
[1] public static void premain(String agentArgs, Instrumentation inst);
[2] public static void premain(String agentArgs);
JVM將首先尋找[1],如果沒有發現[1],再尋找[2]。如果希望在目標JVM執行時載入Agent,則需要實現下面的方法:
[1] public static void agentmain(String agentArgs, Instrumentation inst);
[2] public static void agentmain(String agentArgs);
這兩組方法的第一個引數AgentArgs是隨同 “–javaagent”一起傳入的程式引數,如果這個字串代表了多個引數,就需要自己解析這些引數。inst是Instrumentation型別的物件,是JVM自動傳入的,我們可以拿這個引數進行類增強等操作。
3.2.2 指定Main-Class
Agent需要打包成一個jar包,在ManiFest屬性中指定“Premain-Class”或者“Agent-Class”,且需根據需求定義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載入
-
啟動時載入
-
啟動引數增加-javaagent:[path],其中path為對應的agent的jar包路徑
-
-
執行中載入
-
使用com.sun.tools.attach.VirtualMachine載入
-
try { String jvmPid = 目標進行的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語言編寫的插樁服務提供支援。它需要依賴JVMTI的Attach API機制實現。在JDK 1.6以前,instrument只能在JVM剛啟動開始載入類時生效,而在JDK 1.6之後,instrument支援了在執行時對類定義的修改。要使用instrument的類修改功能,我們需要實現它提供的ClassFileTransformer介面,定義一個類檔案轉換器。介面中的transform()方法會在類檔案被載入時呼叫,而在transform方法裡,我們可以利用上文中的ASM或Javassist對傳入的位元組碼進行改寫或替換,生成新的位元組碼陣列後返回。
首先可以定義如下的類轉換器:
public class TestTransformer implements ClassFileTransformer { //目標類名稱, .分隔 private String targetClassName; //目標類名稱, /分隔 private String targetVMClassName; private String targetMethodName; public TestTransformer(String className,String methodName){ this.targetVMClassName = new String(className).replaceAll("\\.","\\/"); this.targetMethodName = methodName; this.targetClassName=className; } //類載入時會執行該函式,其中引數 classfileBuffer為類原始位元組碼,返回值為目標位元組碼,className為/分隔 public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { //判斷類名是否為目標類名 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; } }
類轉換器定義完畢後,需要將定義好的類轉換器新增到對應的instrmentation中,對於已經載入過的類使用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)); } }
從上圖的程式碼可以看出,主方法實現了兩個,分別為agentmain和premain,其中
-
premain
-
用於在啟動時,類載入前定義類的TransFormer,在類載入的時候更新對應的類的位元組碼
-
-
agentmain
-
用於在執行時進行類的位元組碼的修改,步驟整體分為兩步
-
註冊類的TransFormer
-
呼叫retransformClasses函式進行類的重載入
-
-
4、java agent原理簡述
4.1 啟動時修改
啟動時修改主要是在jvm啟動時,執行native函式的Agent_OnLoad方法,在方法執行時,執行如下步驟:
-
建立InstrumentationImpl物件
-
監聽ClassFileLoadHook事件
-
呼叫InstrumentationImpl的loadClassAndCallPremain方法,在這個方法裡會去呼叫javaagent裡MANIFEST.MF裡指定的Premain-Class類的premain方法
4.2 執行時修改
執行時修改主要是通過jvm的attach機制來請求目標jvm載入對應的agent,執行native函式的Agent_OnAttach方法,在方法執行時,執行如下步驟:
-
建立InstrumentationImpl物件
-
監聽ClassFileLoadHook事件
-
呼叫InstrumentationImpl的loadClassAndCallAgentmain方法,在這個方法裡會去呼叫javaagent裡MANIFEST.MF裡指定的Agentmain-Class類的agentmain方法
4.3 ClassFileLoadHook和TransFormClassFile
在4.1和4.2節中,可以看出整體流程中有兩個部分是具有共性的,分別為:
-
ClassFileLoadHook
-
TranFormClassFile
ClassFileLoadHook是一個jvmti事件,該事件是instrument agent的一個核心事件,主要是在讀取位元組碼檔案回撥時呼叫,內部呼叫了TransFormClassFile函式。
TransFormClassFile的主要作用是呼叫java.lang.instrument.ClassFileTransformer的tranform方法,該方法由開發者實現,通過instrument的addTransformer方法進行註冊。
通過以上描述可以看出在位元組碼檔案載入的時候,會觸發ClassFileLoadHook事件,該事件呼叫TransFormClassFile,通過經由instrument的addTransformer註冊的方法完成整體的位元組碼修改。
對於已載入的類,需要呼叫retransformClass函式,然後經由redefineClasses函式,在讀取已載入的位元組碼檔案後,若該位元組碼檔案對應的類關注了ClassFileLoadHook事件,則呼叫ClassFileLoadHook事件。後續流程與類載入時位元組碼替換一致。
4.4 何時進行執行時替換?
在類載入完畢後,對應的想要替換函式可能正在執行,那麼何時進行類位元組碼的替換呢?
由於執行時類位元組碼替換依賴於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 */
其中整體的執行依賴於VMThread,VMThread是一個在虛擬機器建立時生成的單例原生執行緒,這個執行緒能派生出其他執行緒。同時,這個執行緒的主要的作用是維護一個vm操作佇列(VMOperationQueue),用於處理其他執行緒提交的vm operation,比如執行GC等。
VmThread在執行一個vm操作時,先判斷這個操作是否需要在safepoint下執行。若需要safepoint下執行且當前系統不在safepoint下,則呼叫SafepointSynchronize的方法驅使所有執行緒進入safepoint中,再執行vm操作。執行完後再喚醒所有執行緒。若此操作不需要在safepoint下,或者當前系統已經在safepoint下,則可以直接執行該操作了。所以,在safepoint的vm操作下,只有vm執行緒可以執行具體的邏輯,其他執行緒都要進入safepoint下並被掛起,直到完成此次操作。
因此,在執行位元組碼替換的時候需要在safepoint下執行,因此整體會觸發stop-the-world。
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-fi