一、背景介紹
1、需求說明
需求是在程序運行期間,向某個類的某個方法前、后加入某段業務代碼,或者直接替換整個方法的業務邏輯,即業務方法客制化。注意是運行期間動態更改,做到無侵入,而不是事先在代碼中寫死切入點或邏輯。
拿到這個需求,首先想到的是使用 spring aop 技術,但這種方式需要事先在方法上加注解進行攔截,可我們在服務啟動前並不知道要攔截哪些方法。或者直接攔截所有方法,但這樣或多或少都會有一些性能問題,每次方法調用時,都會進入切面,需要判斷是否需要對這個方法做客制化,而判斷的規則以及客制化代碼一般存儲在緩存中,這時還會涉及緩存查詢,性能肯定會有所降低。鑒於以上考慮,選擇 Java 動態字節碼技術 來實現。
2、動態字節碼技術
Java 代碼都是要被編譯成字節碼后才能放到 JVM 里執行的,而字節碼一旦被加載到虛擬機中,就可以被解釋執行。字節碼文件(.class)就是普通的二進制文件,它是通過 Java 編譯器生成的。而只要是文件就可以被改變,如果我們用特定的規則解析了原有的字節碼文件,對它進行修改或者干脆重新定義,這不就可以改變代碼行為了么。動態字節碼技術優勢在於 Java 字節碼生成之后,對其進行修改,增強其功能,這種方式相當於對應用程序的二進制文件進行修改。
Java 生態里有很多可以動態處理字節碼的技術,比較流行的有兩個,一個是 ASM,一個是 Javassist 。
ASM:直接操作字節碼指令,執行效率高,但涉及到JVM的操作和指令,要求使用者掌握Java類字節碼文件格式及指令,對使用者的要求比較高。
Javassist:提供了更高級的API,執行效率相對較差,但無需掌握字節碼指令的知識,簡單、快速,對使用者要求較低。
考慮到簡單易用性,這里選擇 Javassist 工具來實現。
3、技術設計
① 首先需要一個掃描服務類及方法的功能,這樣我們才能選擇某個方法切入。
調用客戶端服務掃描切入點接口,需要掃描出服務中的包名、類名、方法名、以及方法參數列表。
② 維護規則,配置切入的位置、業務代碼。
位置可以是前置、后置、替換。客制化的代碼類需要實現 ICustomizeHandler 接口的 execute 方法,目的是固定結構。
在切入方法時,只需要創建這個 handler 的實例對象,然后執行 execute 方法即可。這種方式比較簡單,但也有一定的局限性。
在 execute 方法中如果要引用 spring 容器中的其它對象,需要通過 ApplicationContext 上下文獲取,不能使用依賴注入,如果要使用依賴注入,還需要處理類的屬性。
③ 維護切入點與規則之間的關系,因為一個切入點可以維護多個規則。
維護好規則和關系之后,就需要應用規則,即調用客戶端客制化接口,動態應用規則。
4、准備工作
① 切入點、客制化代碼、以及關系 已經維護好了,客制化 test-service 服務中 org.test.demo.app.service.impl.DemoServiceImpl 類的 selectOrder 方法,在方法前、后加一段代碼,打印一些東西。
② OrderServiceImpl 的代碼,之后通過觀察控制台打印內容來確認客制化效果。

1 package org.test.demo.app.service.impl; 2 3 import java.util.List; 4 5 import org.slf4j.Logger; 6 import org.slf4j.LoggerFactory; 7 import org.springframework.stereotype.Service; 8 import org.test.demo.app.service.DemoService; 9 import org.test.demo.domain.entity.Order; 10 import org.test.demo.domain.repository.OrderRepository; 11 12 @Service 13 public class DemoServiceImpl implements DemoService { 14 15 private static final Logger LOGGER = LoggerFactory.getLogger(DemoServiceImpl.class); 16 17 private final OrderRepository orderRepository; 18 19 public DemoServiceImpl(OrderRepository orderRepository) { 20 this.orderRepository = orderRepository; 21 } 22 23 @Override 24 public List<Order> selectOrder(String orderNumber, String status) { 25 26 Order params = new Order(); 27 params.setOrderNumber(orderNumber); 28 params.setStatus(status); 29 List<Order> orders = orderRepository.select(params); 30 LOGGER.info("order size is {}", orders.size()); 31 32 return orders; 33 } 34 35 }
背景及准備工作介紹完了,下面就來看看如何一步步實現動態切面的能力。接下來首先對一些必備知識做簡要介紹,然后對實現過程中一些核心邏輯做介紹。
二、知識准備:Javassist
1、Javassist
Javassist 是一個開源的分析、編輯和創建Java字節碼的類庫。其主要的優點,在於簡單,而且快速。直接使用 java 編碼的形式,而不需要了解虛擬機指令,就能動態改變類的結構,或者動態生成類。
Javassist 中最為重要的是 ClassPool,CtClass ,CtMethod 以及 CtField 這幾個類。
ClassPool:一個基於 Hashtable 實現的 CtClass 對象容器,其中鍵是類名稱,值是表示該類的 CtClass 對象。
CtClass:CtClass 表示類,一個 CtClass (編譯時類)對象可以處理一個 class 文件,這些 CtClass 對象可以從 ClassPool 獲得。
CtMethods:表示類中的方法。
CtFields :表示類中的字段。
2、ClassPool 使用
① 獲取 ClassPool 對象
1 // 獲取 ClassPool 對象,使用系統默認類路徑 2 ClassPool pool = new ClassPool(true); 3 // 效果與 new ClassPool(true) 一致 4 ClassPool pool1 = ClassPool.getDefault();
② 獲取類
1 // 通過類名獲取 CtClass,未找到會拋出異常 2 CtClass ctClass = pool.get("org.test.demo.DemoService"); 3 // 通過類名獲取 CtClass,未找到返回 null,不會拋出異常 4 CtClass ctClass1 = pool.getOrNull("org.test.demo.DemoService");
③ 創建新類
1 // 復制一個類,創建一個新類 2 CtClass ctClass2 = pool.getAndRename("org.test.demo.DemoService", "org.test.demo.DemoCopyService"); 3 // 通過類名,創建一個新類 4 CtClass ctClass3 = pool.makeClass("org.test.demo.NewDemoService"); 5 // 通過文件流,創建一個新類,注意文件必須是編譯后的 class 文件,不是源代碼文件。 6 CtClass ctClass4 = pool.makeClass(new FileInputStream(new File("./customize/DemoBeforeHandler.class")));
④ 添加類搜索路徑
通過 ClassPool.getDefault() 獲取的 ClassPool 使用 JVM 的類搜索路徑。如果程序運行在 JBoss 或者 Tomcat 等 Web 服務器上,ClassPool 可能無法找到用戶的類,因為 Web 服務器使用多個類加載器作為系統類加載器。在這種情況下,ClassPool 必須添加額外的類搜索路徑才能搜索到用戶的類。
1 // 將類搜索路徑插入到搜索路徑之前 2 pool.insertClassPath(new ClassClassPath(this.getClass())); 3 // 將類搜索路徑添加到搜索路徑之后 4 pool.appendClassPath(new ClassClassPath(this.getClass())); 5 // 將一個目錄作為類搜索路徑 6 pool.insertClassPath("/usr/local/javalib");
⑤ 避免內存溢出
如果 CtClass 對象的數量變得非常大(這種情況很少發生,因為 Javassist 試圖以各種方式減少內存消耗),ClassPool 可能會導致巨大的內存消耗。為了避免此問題,可以從 ClassPool 中顯式刪除不必要的 CtClass 對象。或者每次使用新的 ClassPool 對象。
1 // 從 ClassPool 中刪除 CtClass 對象 2 ctClass.detach(); 3 // 也可以每次創建一個新的 ClassPool,而不是 ClassPool.getDefault(),避免內存溢出 4 ClassPool pool2 = new ClassPool(true);
3、CtClass 使用
通過 CtClass 對象可以得到很多關於類的信息以及對類進行修改等操作。
① 獲取類屬性
1 // 類名 2 String simpleName = ctClass.getSimpleName(); 3 // 類全名 4 String name = ctClass.getName(); 5 // 包名 6 String packageName = ctClass.getPackageName(); 7 // 接口 8 CtClass[] interfaces = ctClass.getInterfaces(); 9 // 繼承類 10 CtClass superclass = ctClass.getSuperclass(); 11 // 獲取字節碼文件,可以通過 ClassFile 對象進行字節碼級操作 12 ClassFile classFile = ctClass.getClassFile(); 13 // 獲取帶參數的方法,第二個參數為參數列表數組,類型為 CtClass 14 CtMethod ctMethod = ctClass.getDeclaredMethod("selectOrder", new CtClass[] {pool.get(String.class.getName()), pool.get(String.class.getName())}); 15 // 獲取字段 16 CtField ctField = ctClass.getField("orderRepository");
② 類型判斷
1 // 判斷數組類型 2 ctClass.isArray(); 3 // 判斷原生類型 4 ctClass.isPrimitive(); 5 // 判斷接口類型 6 ctClass.isInterface(); 7 // 判斷枚舉類型 8 ctClass.isEnum(); 9 // 判斷注解類型 10 ctClass.isAnn
③ 添加類屬性
1 // 添加接口 2 ctClass.addInterface(...); 3 // 添加構造器 4 ctClass.addConstructor(...); 5 // 添加字段 6 ctClass.addField(...); 7 // 添加方法 8 ctClass.addMethod(...);
④ 編譯類
1 // 編譯成字節碼文件,使用當前線程上下文類加載器加載類,如果類已存在或者編譯失敗將拋出異常 2 Class clazz = ctClass.toClass(); 3 // 編輯成字節碼文件,返回 byte 數組 4 byte[] bytes = ctClass.toBytecode();
4、CtMethod 使用
① 獲取方法屬性
1 CtClass ctClass5 = pool.get(TestService.class.getName()); 2 CtMethod ctMethod = ctClass5.getDeclaredMethod("selectOrder"); 3 // 方法名 4 String methodName = ctMethod.getName(); 5 // 返回類型 6 CtClass returnType = ctMethod.getReturnType(); 7 // 方法參數,通過此種方式得到方法參數列表 格式:com.test.TestService.selectOrder(java.lang.String,java.util.List,com.test.Order) 8 ctMethod.getLongName(); 9 // 方法簽名 格式:(Ljava/lang/String;Ljava/util/List;Lcom/test/Order;)Ljava/lang/Integer; 10 ctMethod.getSignature(); 11 12 // 獲取方法參數名稱,可以通過這種方式得到方法真實參數名稱 13 List<String> argKeys = new ArrayList<>(); 14 MethodInfo methodInfo = ctMethod.getMethodInfo(); 15 CodeAttribute codeAttribute = methodInfo.getCodeAttribute(); 16 LocalVariableAttribute attr = (LocalVariableAttribute) codeAttribute.getAttribute(LocalVariableAttribute.tag); 17 int len = ctMethod.getParameterTypes().length; 18 // 非靜態的成員函數的第一個參數是this 19 int pos = Modifier.isStatic(ctMethod.getModifiers()) ? 0 : 1; 20 for (int i = pos; i < len; i++) { 21 argKeys.add(attr.variableName(i)); 22 }
② 方法操作
1 // 在方法體前插入代碼塊 2 ctMethod.insertBefore(""); 3 // 在方法體后插入代碼塊 4 ctMethod.insertAfter(""); 5 // 在某行 字節碼 后插入代碼塊 6 ctMethod.insertAt(10, ""); 7 // 添加參數 8 ctMethod.addParameter(CtClass); 9 // 設置方法名 10 ctMethod.setName("newName"); 11 // 設置方法體 12 ctMethod.setBody("");
③ 方法內部引用變量
5、實際應用
① 創建新類
1 public static void main(String[] args) throws Exception { 2 ClassPool pool = new ClassPool(true); 3 4 // 創建 IHello 的實現類 5 CtClass newClass = pool.makeClass("org.test.HelloImpl"); 6 // 添加接口 7 newClass.addInterface(pool.get(IHello.class.getName())); 8 // 返回類型 Void 9 CtClass returnType = pool.get(void.class.getName()); 10 // 參數 11 CtClass[] parameters = new CtClass[]{ pool.get(String.class.getName()) }; 12 // 定義方法 13 CtMethod method = new CtMethod(returnType, "sayHello", parameters, newClass); 14 // 方法體代碼塊,必須用 {} 包裹代碼 15 String source = "{" + 16 "System.out.println(\"hello \" + $1);" 17 + "}" 18 ; 19 // 設置方法體 20 method.setBody(source); 21 // 添加方法 22 newClass.addMethod(method); 23 // 編譯、轉換成 Class 字節碼對象 24 Class helloClass = newClass.toClass(); 25 26 IHello hello = (IHello) helloClass.newInstance(); 27 hello.sayHello("javassist"); 28 }
② 創建代理方法
1 public static void main(String[] args) throws Exception { 2 ClassPool pool = new ClassPool(true); 3 4 CtClass targetClass = pool.get("com.lyyzoo.test.bytecode.javassist.service.HelloServiceImpl"); 5 6 CtMethod method = targetClass.getDeclaredMethod("sayHello"); 7 8 // 復制方法生成一個新的代理方法 9 CtMethod agentMethod = CtNewMethod.copy(method, method.getName()+"$agent", targetClass, null); 10 agentMethod.setModifiers(Modifier.PRIVATE); 11 // 添加方法 12 targetClass.addMethod(agentMethod); 13 // 構建新的方法體,並使用代理方法 14 String source = "{" 15 + "System.out.println(\"before handle > ...\" + $type);" 16 + method.getName() + "$agent($$);" 17 + "System.out.println(\"after handle ...\");" 18 + "}" 19 ; 20 // 設置方法體 21 method.setBody(source); 22 // 編譯,注意:如果類已經加載了,是不能重定義的,會報錯 duplicate class definition.... 23 targetClass.toClass(); 24 25 // 使用 javassist.util.HotSwapAgent 重定義類。這種方式必須 attach 代理程序才能使用:-XXaltjvm=dcevm -javaagent:E:\hotswap-agent-1.3.0.jar 26 //HotSwapAgent.redefine(HelloServiceImpl.class, targetClass); 27 28 IHello hello = new HelloServiceImpl(); 29 hello.sayHello("javassist"); 30 }
6、資料參考
Javassist 有着豐富的API來操作類,其它的特性及使用可以參考如下文章
三、知識准備:Javaagent
對於Java 程序員來說,Java Intrumentation、Java agent 這些技術可能平時接觸的很少。實際上,我們日常應用的各種工具中,有很多都是基於他們實現的,例如常見的熱部署(JRebel, spring-loaded)、IDE debug、各種線上診斷工具(btrace,、Arthas)等等。
1、Instrumentation
使用 java.lang.instrument.Instrumentation,使得開發者可以構建一個獨立於應用程序的代理程序(Agent),用來監測和協助運行在 JVM 上的程序,甚至能夠替換和修改某些類的定義。有了這樣的功能,開發者就可以實現更為靈活的運行時虛擬機監控和 Java 類操作,這樣的特性實際上提供了一種虛擬機級別支持的 AOP 實現方式,使得開發者無需對 JDK 做任何升級和改動,就可以實現某些 AOP 的功能了。Instrumentation 的最大作用,就是類定義動態改變和操作。
Instrumentation的一些主要方法如下:
1 public interface Instrumentation { 2 /** 3 * 注冊一個Transformer,從此之后的類加載都會被 transformer 攔截。 4 * ClassFileTransformer 的 transform 方法可以直接對類的字節碼進行修改,但是只能修改方法體,不能變更方法簽名、增加和刪除方法/類的成員屬性 5 */ 6 void addTransformer(ClassFileTransformer transformer); 7 8 /** 9 * 對JVM已經加載的類重新觸發類加載,使用上面注冊的 ClassFileTransformer 重新對類進行修飾。 10 */ 11 void retransformClasses(Class<?>... classes) throws UnmodifiableClassException; 12 13 /** 14 * 重新定義類,不是使用 transformer 修飾,而是把處理結果(bytecode)直接給JVM。 15 * 調用此方法同樣只能修改方法體,不能變更方法簽名、增加和刪除方法/類的成員屬性 16 */ 17 void redefineClasses(ClassDefinition... definitions) throws ClassNotFoundException, UnmodifiableClassException; 18 19 /** 20 * 獲取一個對象的大小 21 */ 22 long getObjectSize(Object objectToSize); 23 24 /** 25 * 將一個jar加入到bootstrap classloader 的 classpath 里 26 */ 27 void appendToBootstrapClassLoaderSearch(JarFile jarfile); 28 29 /** 30 * 將一個jar加入到 system classloader 的 classpath 里 31 */ 32 void appendToSystemClassLoaderSearch(JarFile jarfile); 33 34 /** 35 * 獲取當前被JVM加載的所有類對象 36 */ 37 Class[] getAllLoadedClasses(); 38 }
2、Javaagent
Java agent 是一種特殊的Java程序(Jar文件),它是 Instrumentation 的客戶端。與普通 Java 程序通過main方法啟動不同,agent 並不是一個可以單獨啟動的程序,而必須依附在一個Java應用程序(JVM)上,與它運行在同一個進程中,通過 Instrumentation API 與虛擬機交互。
Java agent 與 Instrumentation 密不可分,二者也需要在一起使用。因為JVM 會把 Instrumentation 的實例會作為參數注入到 Java agent 的啟動方法中。因此如果想使用 Instrumentation 功能,拿到 Instrumentation 實例,我們必須通過Java agent。
Java agent 有兩個啟動時機,一個是在程序啟動時通過 -javaagent 參數啟動代理程序,一個是在程序運行期間通過 Java Tool API 中的 attach api 動態啟動代理程序。
① JVM啟動時靜態加載
對於VM啟動時加載的 agent,Instrumentation 會通過 premain 方法傳入代理程序,premain 方法會在程序 main 方法執行之前被調用。此時大部分Java類都沒有被加載(“大部分”是因為,agent類本身和它依賴的類還是無法避免的會先加載的),是一個對類加載埋點做手腳(addTransformer)的好機會。但這種方式有很大的局限性,Instrumentation 僅限於 main 函數執行前,此時有很多類還沒有被加載,如果想為其注入 Instrumentation 就無法辦到。
1 /** 2 * agentArgs 是 premain 函數得到的程序參數,通過 -javaagent 傳入。這個參數是個字符串,如果程序參數有多個,需要程序自行解析這個字符串。 3 * inst 是一個 java.lang.instrument.Instrumentation 的實例,由 JVM 自動傳入。 4 */ 5 public static void premain(String agentArgs, Instrumentation inst) { 6 7 } 8 9 /** 10 * 帶有 Instrumentation 參數的 premain 優先級高於不帶此參數的 premain。 11 * 如果存在帶 Instrumentation 參數的 premain,不帶此參數的 premain 將被忽略。 12 */ 13 public static void premain(String agentArgs) { 14 15 }
這種方式的應用比如在 IDEA 啟動 debug 模式時,就是以 -javaagent 的形式啟動 debug 代理程序實現的。
② JVM 啟動后動態加載
對於VM啟動后動態加載的 agent,Instrumentation 會通過 agentmain 方法傳入代理程序,agentmain 在 main 函數開始運行后才被調用。
1 /** 2 * agentArgs 是 agentmain 函數得到的程序參數,在 attach 時傳入。這個參數是個字符串,如果程序參數有多個,需要程序自行解析這個字符串。 3 * inst 是一個 java.lang.instrument.Instrumentation 的實例,由 JVM 自動傳入。 4 */ 5 public static void agentmain(String agentArgs, Instrumentation inst) { 6 7 } 8 9 /** 10 * 帶有 Instrumentation 參數的 agentmain 優先級高於不帶此參數的 agentmain。 11 * 如果存在帶 Instrumentation 參數的 agentmain,不帶此參數的 agentmain 將被忽略。 12 */ 13 public static void agentmain(String agentArgs) { 14 15 }
這種方式的應用比如在啟用 Arthas 來診斷線上問題時,通過 attach api,來動態加載代理程序到目標VM。
3、MANIFEST.MF
寫好的代理類想要運行,在打 jar 包前,還需要要在 MANIFEST.MF 中指定代理程序入口。
①、MANIFEST.MF
大多數 JAR 文件會包含一個 META-INF 目錄,它用於存儲包和擴展的配置數據,如安全性和版本信息。其中會有一個 MANIFEST.MF 文件,該文件包含了該 Jar 包的版本、創建人和類搜索路徑等信息,如果是可執行Jar 包,會包含Main-Class屬性,表明 Main 方法入口。
例如下面是通過 mvn clean package 命令打包后的 Jar 包中的 MANIFEST.MF 文件,從中可以看出 jar 的版本、創建者、SpringBoot 版本、程序入口、類搜索路徑等信息。
② 與 agent 相關的參數
- Premain-Class :JVM 啟動時指定了代理,此屬性指定代理類,即包含 premain 方法的類。
- Agent-Class :JVM動態加載代理,此屬性指定代理類,即包含 agentmain 方法的類。
- Boot-Class-Path :設置引導類加載器搜索的路徑列表,列表中的路徑由一個或多個空格分開。
- Can-Redefine-Classes :布爾值(true 或 false)。是否能重定義此代理所需的類。
- Can-Retransform-Classes :布爾值(true 或 false)。是否能重轉換此代理所需的類。
- Can-Set-Native-Method-Prefix :布爾值(true 或 false)。是否能設置此代理所需的本機方法前綴。
4、Attach API
Java agent 可以在JVM啟動后再加載,就是通過 Attach API 實現的。當然,Attach API 不僅僅是為了實現動態加載 agent,Attach API 其實是跨JVM進程通訊的工具,能夠將某種指令從一個JVM進程發送給另一個JVM進程。
加載 agent 只是 Attach API 發送的各種指令中的一種, 諸如 jstack 打印線程棧、jps 列出Java進程、jmap 做內存dump等功能,都屬於Attach API 可以發送的指令。
Attach API不是Java的標准API,而是Sun公司提供的一套擴展API,用來向目標JVM"附着"(Attach)代理工具程序的。有了它,開發者可以方便的監控一個JVM,運行一個外加的代理程序。
① 引入 Attach API
在使用 Attach API時,需要引入 tools.jar
1 <dependency> 2 <groupId>jdk.tools</groupId> 3 <artifactId>jdk.tools</artifactId> 4 <version>1.8</version> 5 <scope>system</scope> 6 <systemPath>${env.JAVA_HOME}/lib/tools.jar</systemPath> 7 </dependency>
打包運行時,需要將 tools.jar 打包進去
1 <build> 2 <plugins> 3 <plugin> 4 <groupId>org.springframework.boot</groupId> 5 <artifactId>spring-boot-maven-plugin</artifactId> 6 <configuration> 7 <includeSystemScope>true</includeSystemScope> 8 </configuration> 9 </plugin> 10 </plugins> 11 </build>
② attach agent
1 // VirtualMachine等相關Class位於JDK的tools.jar 2 VirtualMachine vm = VirtualMachine.attach("1234"); // 1234表示目標JVM進程pid 3 try { 4 vm.loadAgent(".../agent.jar"); // 指定agent的jar包路徑,發送給目標進程 5 } finally { 6 vm.detach(); 7 }
5、資料參考
其它更詳細的相關知識請參考如下文章
四、知識准備:JVM類加載器
1、類加載器簡介
類加載器(class loader)用來加載 Java 類到 Java 虛擬機中。一般來說,Java 虛擬機使用 Java 類的方式如下:Java 源程序(.java 文件)在經過 Java 編譯器編譯之后就被轉換成 Java 字節代碼(.class 文件)。類加載器負責讀取 Java 字節代碼,並轉換成 java.lang.Class 類的一個實例。每個這樣的實例用來表示一個 Java 類。
基本上所有的類加載器都是 java.lang.ClassLoader 類的一個實例。java.lang.ClassLoader 類的基本職責就是根據一個指定的類的名稱,找到或者生成其對應的字節代碼,然后從這些字節代碼中定義出一個 Java 類,即 java.lang.Class 類的一個實例。
Java 中的類加載器大致可以分成兩類,一類是系統提供的,另外一類則是由 Java 應用開發人員編寫的,開發人員可以通過繼承 java.lang.ClassLoader 類的方式實現自定義類加載器,以滿足一些特殊的需求。
系統提供的類加載器主要有下面三個:
- 引導類加載器(Bootstrap ClassLoader):負責將 $JAVA_HOME/lib 或者 -Xbootclasspath 參數指定路徑下面的文件(按照文件名識別,如 rt.jar) 加載到虛擬機內存中。它用來加載 Java 的核心庫,是用原生代碼實現的,並不繼承自 java.lang.ClassLoader,引導類加載器無法直接被 java 代碼引用。
- 擴展類加載器(Extension ClassLoader):負責加載 $JAVA_HOME/lib/ext 目錄中的文件,或者 java.ext.dirs 系統變量所指定的路徑的類庫,它用來加載 Java 的擴展庫。
- 應用程序類加載器(Application ClassLoader):一般是系統的默認加載器,它根據 Java 應用的類路徑(CLASSPATH)來加載 Java 類。一般 Java 應用的類都是由它來完成加載的,可以通過 ClassLoader.getSystemClassLoader() 來獲取它。
2、類加載過程 — 雙親委派模型
① 類加載器結構
除了引導類加載器之外,所有的類加載器都有一個父類加載器。應用程序類加載器的父類加載器是擴展類加載器,擴展類加載器的父類加載器是引導類加載器。一般來說,開發人員自定義的類加載器的父類加載器是應用程序類加載器。
② 雙親委派模型
類加載器在嘗試去查找某個類的字節代碼並定義它時,會先代理給其父類加載器,由父類加載器先去嘗試加載這個類,如果父類加載器沒有,繼續尋找父類加載器,依次類推,如果到引導類加載器都沒找到才從自身查找。這個類加載過程就是雙親委派模型。
首先要明白,Java 虛擬機判定兩個 Java 類是否相同,不僅要看類的全名是否相同,還要看加載此類的類加載器是否一樣(可以通過 class.getClassLoader() 獲得)。只有兩個類來源於同一個Class文件,並且被同一個類加載器加載,這兩個類才相等。不同類加載器加載的類之間是不兼容的。
雙親委派模型就是為了保證 Java 核心庫的類型安全的。所有 Java 應用都至少需要引用 java.lang.Object 類,也就是說在運行的時候,java.lang.Object 這個類需要被加載到 Java 虛擬機中。如果這個加載過程由 Java 應用自己的類加載器來完成的話,很可能就存在多個版本的 java.lang.Object 類,而這些類之間是不兼容的。通過雙親委派模型,對於 Java 核心庫的類加載工作由引導類加載器來統一完成,保證了 Java 應用所使用的都是同一個版本的 Java 核心庫的類,是互相兼容的。
類加載器在成功加載某個類之后,會把得到的 java.lang.Class 類的實例緩存起來。下次再請求加載該類的時候,類加載器會直接使用緩存的類的實例,而不會嘗試再次加載。
3、線程上下文類加載器
線程上下文類加載器可通過 java.lang.Thread 中的方法 getContextClassLoader() 獲得,可以通過 setContextClassLoader(ClassLoader cl) 來設置線程的上下文類加載器。如果沒有通過 setContextClassLoader(ClassLoader cl) 方法進行設置的話,線程將繼承其父線程的上下文類加載器。Java 應用運行的初始線程的上下文類加載器是應用程序類加載器。在線程中運行的代碼可以通過此類加載器來加載類和資源。
4、SpringBoot 類加載器
由於我是使用 SpringBoot (2.0.x) 開發,且打包成 jar 的形式部署在服務器上,這里有必要了解下 Spring boot 相關的類加載機制,遇到的很多問題就是由於 Spring Boot 的類加載機制導致的。
SpringBoot 的可執行jar包又稱 fat jar ,是包含所有第三方依賴的 jar 包,jar 包中嵌入了除 java 虛擬機以外的所有依賴,是一個 all-in-one jar 包。普通插件 maven-jar-plugin 生成的包和 spring-boot-maven-plugin 生成的包之間的直接區別是, fat jar 中主要增加了兩部分,第一部分是 lib 目錄,存放的是Maven依賴的jar包文件,第二部分是spring boot 類加載器相關的類。
使用 spring-boot-maven-plugin 插件打包出來的結構

├─BOOT-INF │ ├─classes │ │ │ application.yml │ │ │ bootstrap.yml │ │ │ │ │ ├─org │ │ │ └─sunny │ │ │ └─demo │ │ │ │ DemoApplication.class │ │ │ └─lib │ spring-boot-starter-2.0.6.RELEASE.jar │ undertow-core-1.4.26.Final.jar │ ... │ ├─META-INF │ │ MANIFEST.MF │ │ spring-autoconfigure-metadata.properties │ │ │ └─maven │ └─org.sunny │ └─sunny-demo │ pom.properties │ pom.xml │ └─org └─springframework └─boot └─loader │ JarLauncher.class │ LaunchedURLClassLoader.class │ ....... │ ├─archive │ Archive.class │ ExplodedArchive.class │ ....... │ ├─data │ RandomAccessData.class │ ....... │ ├─jar │ JarEntry.class │ JarFile.class │ ..... │ └─util SystemPropertyUtils.class
MANIFEST.MF 的內容
從生成的 MANIFEST.MF 文件中,可以看到兩個關鍵信息 Main-Class 和 Start-Class。說明程序的啟動入口並不是我們 SpringBoot 中定義的啟動類的 main,而是 JarLauncher#main。
為了不解壓就能啟動 SpringBoot 程序,在 JarLauncher 內部,會讀取 /BOOT-INF/lib/ 下的 jar 文件以及 /BOOT-INF/classes/ 構造一個 URL 數組,並用這個數組來構造 SpringBoot 的自定義類加載器 LaunchedURLClassLoader,該類繼承了 java.net.URLClassLoader,其父類加載器是應用程序類加載器。
LaunchedURLClassLoader 創建好之后,會通過反射來啟動我們寫的啟動類中的 main 函數,並設置當前線程上下文類加載器為 LaunchedURLClassLoader。
5、Javaagent 類加載器
javaagent 的代碼永遠都是被應用類加載器( Application ClassLoader)所加載,和應用代碼的真實加載器無關。比如,當前運行在 undertow 中的代碼是 LaunchedURLClassLoader 加載的,如果啟動參數加上 -javaagent,這個 javaagent 還是在 Application ClassLoader 中加載的。
6、資料參考
其它一些深入詳細的資料可以參考下面的一些文章:
深入理解Java ClassLoader及在 JavaAgent 中的應用
五、使用 Javassist 掃描類方法
首先我們來看下如何掃描出服務中指定包下的類及方法信息的。由於源碼不開放,只貼出部分核心代碼邏輯。
1、讀取資源
要在程序運行期間讀取資源文件,可以注入 ResourceLoader 來讀取,MetadataReaderFactory 可以用來從 Resource 中讀取元數據信息。
1 public class DefaultApiScanService implements ResourceLoaderAware, InitializingBean { 2 3 private ResourceLoader resourceLoader; 4 private ResourcePatternResolver resolver; 5 private MetadataReaderFactory metadataReader; 6 7 @Override 8 public void setResourceLoader(@NotNull ResourceLoader resourceLoader) { 9 this.resourceLoader = resourceLoader; 10 } 11 12 @Override 13 public void afterPropertiesSet() throws Exception { 14 Assert.notNull(this.resourceLoader, "resourceLoader should not be null"); 15 this.resolver = ResourcePatternUtils.getResourcePatternResolver(resourceLoader); 16 this.metadataReader = new MethodsMetadataReaderFactory(resourceLoader); 17 } 18 }
讀取類元數據信息
1 String packagePattern = "org.test.demo.app.service.impl"; 2 // 讀取資源文件 3 Resource[] resources = resolver.getResources("classpath*:" + packagePattern + "/**/*.class"); 4 for (Resource resource : resources) { 5 MetadataReader reader = metadataReader.getMetadataReader(resource); 6 // 讀取類元數據信息 7 ClassMetadata classMetadata = reader.getClassMetadata(); 8 }
2、使用 Javassist 解析方法信息
// 創建新的 ClassPool,避免內存溢出 ClassPool classPool = new ClassPool(true); // 將當前類加載路徑加入 ClassPool 的 ClassPath 中,避免找不到類 classPool.insertClassPath(new ClassClassPath(this.getClass())); // 使用 ClassPool 加載類 CtClass ctClass = classPool.get(classMetadata.getClassName()); // 去除接口、注解、枚舉、原生、數組等類型的類,以及代理類不解析 if (ctClass.isInterface() || ctClass.isAnnotation() || ctClass.isEnum() || ctClass.isPrimitive() || ctClass.isArray() || ctClass.getSimpleName().contains("$")) { return; } // 獲取所有聲明的方法 CtMethod[] methods = ctClass.getDeclaredMethods(); for (CtMethod method : methods) { // 代理方法不解析 if (method.getName().contains("$")) { continue; } // 包名 String packageName = ctClass.getPackageName(); // 類名 String className = ctClass.getSimpleName(); // 方法名 String methodName = method.getName(); // 參數:method.getLongName() 返回格式:com.test.TestService.selectOrder(java.lang.String,java.util.List,com.test.Order),所以截取括號中的即可 String methodSignature = StringUtils.defaultIfBlank(StringUtils.substringBetween(method.getLongName(), "(", ")"), null); }
六、動態編譯源碼
我們在最開始的規則中,已經維護好了一個業務處理類的源代碼,但首先需要將其編譯成字節碼才能被使用,所以就涉及到如何動態編譯源碼了。
1、Java Compile API
JavaCompiler:表示java編譯器,run方法執行編譯操作.,還有一種編譯方式是先生成編譯任務(CompilationTask),然后調用 CompilationTask 的 call 方法執行編譯任務
JavaFileObject:表示一個java源文件對象
JavaFileManager:Java源文件管理類, 管理一系列JavaFileObject
Diagnostic:表示一個診斷信息
DiagnosticListener:診斷信息監聽器,編譯過程觸發
動態編譯相關的API在 tools.jar 包里,所以需要在 pom 中引入 tools.jar
1 <dependency> 2 <groupId>jdk.tools</groupId> 3 <artifactId>jdk.tools</artifactId> 4 <version>1.8</version> 5 <scope>system</scope> 6 <systemPath>${env.JAVA_HOME}/lib/tools.jar</systemPath> 7 </dependency>
2、動態編譯
下面這段代碼先從源碼中解析出包名和類名,並將源碼文件寫入到磁盤中,然后使用 JavaCompiler 編譯源碼。注意再次編譯同樣類名時,名稱不能相同,否則編譯不通過,因為 JVM 已經加載過此實例了,類名可以加上個隨機數避免重復。
1 private void createAndCompileJavaFile(String sourceCode) throws Exception { 2 // 從源碼中解析包名 3 String packageName = StringUtils.trim(StringUtils.substringBetween(sourceCode, "package", ";")); 4 // 從源碼中解析類名 5 String className = StringUtils.trim(StringUtils.substringBetween(sourceCode, "class", "implements")); 6 // 類全名 7 String classFullName = packageName + "." + className; 8 9 // 將源碼寫入 java 文件 10 File javaFile = new File(CUSTOMIZE_SRC_DIR + StringUtils.replace(classFullName, ".", File.separator) + ".java"); 11 FileUtils.writeByteArrayToFile(javaFile, sourceCode.getBytes()); 12 13 // 使用 JavaCompiler 編譯java文件 14 JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); 15 // 編譯,實際上底層就是調用 javac 命令執行編譯工作 16 int result = javac.run(null, null, null, javaFile.getAbsolutePath()); 17 18 if (result != 0) { 19 System.out.println("compile failure."); 20 } else { 21 System.out.println("compile success."); 22 } 23 }
在IDEA中啟動服務,這段代碼沒有任何問題,可以正常編譯通過,可以看到 class 文件也編譯出來了。
但是,一旦打成 jar 包運行,就不能正常編譯了,會出現如下錯誤:程序包 xxx 不存在、找不到符號等。
實際上,這個錯誤也很好理解,javac.run(null, null, null, javaFile.getAbsolutePath()) 這行代碼可以看成直接使用 javac 命令編譯源文件一樣,如果不指定 classpath ,肯定無法找到代碼中引用的其它類。
那為何IDEA中可以,jar 包運行就不可以呢?這實際上是因為 springboot jar 的特殊性,springboot jar 是 all-in-one,classes 和 lib 都在 jar 包內,IDEA 中的 classes 都在 target 包下,能夠直接被訪問到。
3、基於 classpath 編譯
如果是這樣,那我們可以將 /BOOT-INF/classes/ 以及 /BOOT-INF/lib/ 下的文件加入到編譯時的 classpath 路徑下就沒問題了。
首先,jar 包中的內容無法直接訪問,比較次的方法就是將 jar 包解壓,然后將路徑拼接好之后再編譯。
① 解壓縮包
1 File file = new File("app.jar"); 2 // 得到 JarFile 3 JarFile jarFile = new JarFile(file); 4 5 // 解壓 jar 包 6 for (Enumeration<JarEntry> e = jarFile.entries(); e.hasMoreElements();) { 7 JarEntry je = e.nextElement(); 8 String outFileName = CUSTOMIZE_LIB_DIR + je.getName(); 9 File f = new File(outFileName); 10 11 if(je.isDirectory()){ 12 if (!f.exists()) { 13 f.mkdirs(); 14 } 15 } else{ 16 File pf = f.getParentFile(); 17 if(!pf.exists()){ 18 pf.mkdirs(); 19 } 20 21 try (InputStream in = jarFile.getInputStream(je); 22 OutputStream out = new BufferedOutputStream(new FileOutputStream(f))) { 23 byte[] buffer = new byte[2048]; 24 int b = 0; 25 while ((b = in.read(buffer)) > 0) { 26 out.write(buffer, 0, b); 27 } 28 out.flush(); 29 } 30 } 31 }
② 拼接 classpath
1 String bootLib = StringUtils.join(CUSTOMIZE_LIB_DIR, "BOOT-INF", File.separator, "lib"); 2 String bootLibPath = StringUtils.join(bootLib, File.separator); 3 String bootClasses = StringUtils.join(CUSTOMIZE_LIB_DIR, "BOOT-INF", File.separator, "classes"); 4 5 File libDir = new File(bootLib); 6 File[] libs = libDir.listFiles(); 7 // 拼接 classpath 8 StringBuilder classpath = new StringBuilder(StringUtils.join(bootClasses, File.pathSeparator)); 9 for (File lib : libs) { 10 classpath.append(bootLibPath).append(lib.getName()).append(File.pathSeparator); 11 } 12 return classpath.toString();
③ 編譯
javac 命令只需通過 -cp 參數指定 classpath 即可,這樣就可以編譯成功了。
1 // 使用 JavaCompiler 編譯java文件 2 JavaCompiler javac = ToolProvider.getSystemJavaCompiler(); 3 // 編譯,實際上底層就是調用 javac 命令執行編譯工作 4 int result = javac.run(null, null, null, "-cp", classpath, javaFile.getAbsolutePath());
4、優雅的動態編譯
① Arthas 內存編譯
上面的方式需要解壓 jar 包得到 classpath,否則無法編譯,很不優雅,只能算是一種備選方案。通過參考 Arthas 的源碼發現,其中有一個內存編譯模塊,可以輕松的實現動態編譯的能力。
通過學習它的源碼發現,底層還是使用 JavaCompiler 相關的API完成編譯工作,不同的是它在獲取源碼中引用類的方式上。
首先繼承 ForwardingJavaFileManager 實現自定義查找 JavaFileObject。然后可以看到它會使用自定義的 PackageInternalsFinder 來查找類,可以看出,它還是會從 jar 包中去查找相關的類。更多的大家可以自行閱讀其源碼。
② 使用
首先在 pom 中引入 arthas-memorycompiler 的依賴。
1 <dependency> 2 <groupId>com.taobao.arthas</groupId> 3 <artifactId>arthas-memorycompiler</artifactId> 4 <version>3.1.1</version> 5 </dependency>
使用方式
1 // 使用 Arthas 動態編譯 2 DynamicCompiler dynamicCompiler = new DynamicCompiler(Thread.currentThread().getContextClassLoader()); 3 dynamicCompiler.addSource(className, sourceCode); 4 Map<String, byte[]> byteCodes = dynamicCompiler.buildByteCodes(); 5 6 File outputDir = new File(CUSTOMIZE_CLZ_DIR); 7 8 for (Map.Entry<String, byte[]> entry : byteCodes.entrySet()) { 9 File byteCodeFile = new File(outputDir, StringUtils.replace(entry.getKey(), ".", File.separator) + ".class"); 10 FileUtils.writeByteArrayToFile(byteCodeFile, entry.getValue()); 11 }
5、資料參考
七、代碼切入方法
源代碼編譯成字節碼已經完成了,接下來就看如何切入到要攔截的方法中。
1、 加載字節碼,定義 Class 實例
首先,需要將字節碼加載到 JVM 中,創建 Class 實例這個類才能被使用。
1 // 讀取字節碼文件 2 CtClass executeClass = classPool.makeClass(new FileInputStream("..../DemoBeforeHandler.class")); 3 // 當前上下文類加載器 4 System.out.println("----> current thread context classLoader : " + Thread.currentThread().getContextClassLoader().toString()); 5 // 當前上下文類加載器的父類加載器 6 System.out.println("----> current thread context classLoader's parent classLoader : " + Thread.currentThread().getContextClassLoader().getParent().toString()); 7 // 應用程序類加載器 8 System.out.println("----> application classLoader : " + ClassLoader.getSystemClassLoader().toString()); 9 // 定義 Class 實例 10 Class clazz = executeClass.toClass();
toClass 不傳參數時,其內部實際是使用當前上下文類加載器來加載字節碼的,也可以自己傳入類加載器。
不同的容器,這個當前上下文類加載器可能不同。我這里使用的是 undertow 容器,上下文類加載器是 LaunchedURLClassLoader;當使用 tomcat 容器時,運行時上下文類加載器是 TomcatEmbeddedWebappClassLoader,其父類加載器是 LaunchedURLClassLoader。在IDEA中運行時,上下文類加載器是 AppClassLoader,即應用程序類加載器。
----> current thread context classLoader : org.springframework.boot.loader.LaunchedURLClassLoader@20ad9418 ----> current thread context classLoader's parent classLoader : sun.misc.Launcher$AppClassLoader@5c647e05 ----> application classLoader : sun.misc.Launcher$AppClassLoader@5c647e05
這里有個坑需要注意,在程序啟動期間調用時,這里的上下文類加載器是 LaunchedURLClassLoader;但是在運行期間調用時,如果使用 tomcat 容器,這里的上下文類加載器是 TomcatEmbeddedWebappClassLoader,是個代理類加載器。
此時如果使用這個類加載器來定義 Class 實例,能定義成功,但是在后面使用的時候,就會發現報錯:NoClassDefFoundError。
這是因為實際請求時,上下文類加載器是 LaunchedURLClassLoader,是 TomcatEmbeddedWebappClassLoader 的父類加載器,類定義在子類加載器中定義,在父類加載器中使用肯定就找不到咯。
----> current thread context classLoader : TomcatEmbeddedWebappClassLoader context: ROOT delegate: true ----------> Parent Classloader: org.springframework.boot.loader.LaunchedURLClassLoader@20ad9418 ----> current thread context classLoader's parent classLoader : org.springframework.boot.loader.LaunchedURLClassLoader@20ad9418 ----> application classLoader : sun.misc.Launcher$AppClassLoader@5c647e05
因此在調用 toClass 時需要傳入 LaunchedURLClassLoader 類加載器,不能使用子類加載器。
1 final String LAUNCHED_CLASS_LOADER = "org.springframework.boot.loader.LaunchedURLClassLoader"; 2 // 上下文類加載器 3 ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); 4 if (!LAUNCHED_CLASS_LOADER.equals(contextClassLoader.getClass().getName())) { 5 if (LAUNCHED_CLASS_LOADER.equals(contextClassLoader.getParent().getClass().getName())) { 6 contextClassLoader = contextClassLoader.getParent(); 7 } else { 8 contextClassLoader = ClassLoader.getSystemClassLoader(); 9 } 10 } 11 // 傳入類加載器 12 executeClass.toClass(contextClassLoader, null);
2、 構建代碼塊
最簡單的方式,就是直接創建 handler 的一個實例對象,然后切入到方法中去使用。
1 private void builderExecuteBody(CtClass executeClass, boolean returnValue) { 2 StringBuilder executeBody = new StringBuilder("{").append("\r\n"); 3 // 效果:org.test.DemoHandler DemoHandler = new org.test.DemoHandler(); 4 executeBody 5 .append(executeClass.getName()) // 類型 6 .append(" ") 7 .append(executeClass.getSimpleName()) // 變量名稱 8 .append(" = ") 9 .append("new ").append(executeClass.getName()).append("();") 10 .append("\r\n"); 11 // 如果有返回值,則使用臨時變量存儲 12 if (returnValue) { 13 executeBody.append("Object result = "); 14 } 15 // 效果:DemoHandler.execute($$); 16 executeBody 17 .append(executeClass.getSimpleName()).append(".execute($args);") 18 .append("\r\n"); 19 if (returnValue) { 20 executeBody.append("return ($r) result;").append("\r\n"); 21 } 22 executeBody.append("}").append("\r\n"); 23 }
生成的效果:
1 { 2 org.test.demo.app.service.impl.DemoBeforeHandler DemoBeforeHandler = new org.test.demo.app.service.impl.DemoBeforeHandler(); 3 DemoBeforeHandler.execute($args); 4 }
3、 插入代碼塊
1 String targetClassName = point.getPackageName() + "." + point.getClassName(); 2 // 目標類 3 CtClass targetClass = classPool.get(targetClassName); 4 // 根據參數類型構建 CtClass 數組 5 CtClass[] params = buildParams(classPool, point); 6 // 目標方法 7 CtMethod targetMethod = targetClass.getDeclaredMethod(point.getMethodName(), params); 8 9 // 前置規則 10 String beforeCode = "{...}"; 11 12 // 替換規則 13 String replaceCode = "{...}"; 14 15 // 后置規則 16 String afterCode = "{...}"; 17 18 // 替換方法 19 targetMethod.setBody(replaceCode); 20 21 // 在方法體前插入代碼塊 22 targetMethod.insertBefore(beforeCode); 23 24 // 在方法體后插入代碼塊 25 targetMethod.insertAfter(afterCode);
八、動態創建代理程序、實現類重載
方法體已經修改好了,剩下的就是怎么讓 JVM 重載這個類,達到動態更改源碼的目的。
1、Javassist HotSwapAgent
javassist 提供了一個 HotSwapAgent 的代理,可以使用它的 redefine 方法重新定義類,但是這個工具基本無法使用。
javassist.util.HotSwapAgent.redefine(Class.forName(targetClassName), targetClass);
首先我們來看下 javassist.util.HotSwapAgent 重載類的原理。
在 redefine 方法里面,首先會調用 startAgent 方法動態加載代理程序,然后通過 instrumentation 來重定義類,instrumentation 即 java.lang.instrument.Instrumentation。
在 startAgent 方法內,首先判斷如果 instrumentation 已經有了,就不再加載動態代理程序。如果沒有,首先動態創建代理程序 jar 包,然后使用 VirtualMachine attach 到當前虛擬機上,然后加載代理程序。
在創建代理程序包內,首先創建 MAINFEST 文件,並指定 Premain-Class、Agent-Class 為 javassist.util.HotSwapAgent,接着將 javassist.util.HotSwapAgent.java 的字節碼文件寫入 javassist.util.HotSwapAgent.class,最終打成 agent.jar 。
HotSwapAgent premain 與 agentmain,代理程序都是為了能夠得到 Instrumentation 的實例,這個實例在加載代理程序時由虛擬機傳入。
默認生成的 agent.jar 是在用戶目錄下一個臨時目錄下,agent.jar 目錄結構如下。
以上就是動態創建代理程序並加載代理程序的過程,這個功能要是能直接用就完美了,可惜不能。
2、IDEA 方式運行
首先我們來看看 IDEA 中使用 javassist.util.HotSwapAgent 的問題,在開始之前,先來看看 IDEA 中啟動服務的幾種方式。
user-local 和 none 是一樣的:這是默認選項,這種方式會把所有依賴的 jar 包拼接后通過 -classpath 參數指定,命令行參數會很長。如果命令行參數長度超出了OS限制,會報錯:Command line is too long。
JAR manifest:將所有依賴的 jar 包拼接好后,創建一個臨時的 jar 文件,寫入 META-INF/MANIFEST.MF 文件的 Class-Path 參數中,然后通過 -classpath 參數指定這個 jar 包,這樣的目的是縮短命令行。
classpath file:將所有依賴的 jar 包拼接好后,將其寫入一個臨時的文件,然后通過 com.intellij.rt.execution.CommandLineWrapper 來啟動。
這三種方式的一個區別就是他們啟動時的上線文類加載器不一樣:
user-local:sun.misc.Launcher$AppClassLoader@18b4aac2,應用程序類加載器
JAR manifest:sun.misc.Launcher$AppClassLoader@18b4aac2,應用程序類加載器
classpath file:java.net.URLClassLoader@7cbd213e,URLClassLoader
之前說過,代理程序在加載時使用的是應用程序類加載器,所以,使用 user-local、JAR manifest 方式啟動時,可以正確加載代理程序,他們的類加載器是一樣的。如果使用 classpath 啟動時,就會報 NoClassDefFoundError。這是因為在 javassist 這個 jar 包的類是由 URLClassLoader 類加載器加載,而應用程序類加載器是加載不到這個lib 的類的。
java.lang.NoClassDefFoundError: javassist/NotFoundException at java.lang.Class.getDeclaredMethods0(Native Method) at java.lang.Class.privateGetDeclaredMethods(Class.java:2701) at java.lang.Class.getDeclaredMethod(Class.java:2128) at sun.instrument.InstrumentationImpl.loadClassAndStartAgent(InstrumentationImpl.java:327) at sun.instrument.InstrumentationImpl.loadClassAndCallAgentmain(InstrumentationImpl.java:411) Caused by: java.lang.ClassNotFoundException: javassist.NotFoundException at java.net.URLClassLoader.findClass(URLClassLoader.java:382) at java.lang.ClassLoader.loadClass(ClassLoader.java:424) at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:349) at java.lang.ClassLoader.loadClass(ClassLoader.java:357) ... 5 more
3、JAR 包方式運行
當我們打成 jar 包運行時,實際上還是會報同樣的錯誤,因為 JAR 包運行時上下文類加載器是 org.springframework.boot.loader.LaunchedURLClassLoader@20ad9418,其父類加載是應用程序類加載器。
總結起來就是,根據雙親委派模型,子類加載器可以加載父類加載器中的類;但父類加載器無法加載子類加載器中的類。
4、自定義代理程序加載
由於 javassist.util.HotSwapAgent 在加載時使用的是應用程序類加載器,所以代理程序在進入 agentmain 方法設置 instrumentation 變量時,實際是在應用程序類加載器中。
而程序啟動時使用的是其子類加載器加載的 HotSwapAgent,所以這里實際上有兩個不同的 HotSwapAgent 類實例,雖然類名一樣,但是使用的類加載器是不一樣的。所以在程序運行期間還是得不到 instrumentation 實例對象。
我這里用了一個比較簡單粗暴的方法解決這個問題:
① 覆蓋 javassist.util.HotSwapAgent 方法(我是重寫的,將 startAgent 和 agentmain 分開)
② 增加靜態的 setInstrumentation 方法
③ 在 agentmain 方法中,得到程序運行時的上下文類加載器(LaunchedURLClassLoader)
④ 通過 LaunchedURLClassLoader 找到程序中加載的 javassist.util.HotSwapAgent 類實例
⑤ 通過反射的方式調用 setInstrumentation 方法將JVM傳進來的 Instrumentation 設置進去。
⑥ 之后我們就可以在程序運行期間調用 HotSwapClient.redefine 來重載類了。
1 private static final String CLASS_LOADER = "org.springframework.boot.loader.LaunchedURLClassLoader"; 2 private static final String SWAP_CLIENT = "javassist.util.HotSwapClient"; 3 private static final String SWAP_CLIENT_SETTER = "setInstrumentation"; 4 5 // 增加一個靜態 setInstrumentation 方法,由 agentmain 設置 Instrumentation 6 public static void setInstrumentation(Instrumentation instrumentation) { 7 HotSwapAgent.instrumentation = instrumentation; 8 } 9 10 public static void agentmain(String agentArgs, Instrumentation inst) throws Throwable { 11 if (!inst.isRedefineClassesSupported()) 12 throw new RuntimeException("this JVM does not support redefinition of classes"); 13 14 instrumentation = inst; 15 16 // 得到程序類加載器 LaunchedURLClassLoader 17 ClassLoader classLoader = getClassLoader(inst); 18 // 得到 LaunchedURLClassLoader 類加載器中的 javassist.util.HotSwapClient 類實例 19 Class<?> clientClass = classLoader.loadClass(SWAP_CLIENT); 20 // 通過反射方式設置 Instrumentation 21 clientClass.getMethod(SWAP_CLIENT_SETTER, Instrumentation.class).invoke(null, inst); 22 } 23 24 private static ClassLoader getClassLoader(Instrumentation inst) { 25 // 獲取所有已加載的類 26 Class[] loadedClasses = inst.getAllLoadedClasses(); 27 // 找出 LaunchedURLClassLoader 28 return Arrays.stream(loadedClasses) 29 .filter(c -> c.getClassLoader() != null && c.getClassLoader().getClass() != null) 30 .filter(c -> CLASS_LOADER.equals(c.getClassLoader().getClass().getName())) 31 .map(Class::getClassLoader) 32 .findFirst() 33 .orElse(Thread.currentThread().getContextClassLoader()); 34 }
九、結果驗證及局限性
1、結果驗證
可以看到已經成功在要攔截的方法前后加入了定制化的代碼邏輯了,也可以動態地再次更新代碼,再重新應用規則。至此,動態切面的功能基本就實現了。
2、局限性
① 客制化代碼時,由於是創建的一個對象,然后通過方法調用的形式插入方法體中的,所以客制化代碼的結構必須固定。
② 客制化代碼中,不能使用 @Autowired 等方式直接注入 Spring 容器對象,目前沒有處理這種情況。
③ 由於 Instrumentation 本身的局限性,我們只能更改方法體,不能更改方法的定義,不能向類中增加方法、字段,否則重載失敗。
十、附:使用 Arthas 診斷Java問題
在開發這個功能的過程中,簡單了解了下 Arthas 的源碼原理,以及如何使用 Arthas 來診斷一些線上問題,這里僅列出官方的一些文檔,看文檔很容易上手。
Arthas 是 阿里巴巴開源出來的一個針對 java 的工具,主要是針對 java 的問題進行診斷!詳細內容可以參考官方文檔。
① IDEA 安裝插件:通過Cloud Toolkit插件使用Arthas一鍵診斷遠程服務器
② 使用入門:Arthas 快速入門
③ 命令列表:Arthas 命令列表
④ Attach 失敗:Attach 時報 ERROR
⑤ Github 源碼:Arthas Github