小王是一個剛來不久的妹子,啊呸,是一個剛來不久的程式媛,經常垂頭喪氣的~讓我很是不解,終於有一天我怕小王哪天想不開離職了豈不是會增加我的工作量(部門為數不多的妹子 - 1)?於是乎,我主動找小王進行了談心找到了問題所在,原來是小王程式設計經驗不足,不知道如何巧妙的進行日誌列印,那麼因果關係就總結出來了:經驗不足導致編碼經常出錯,編碼出錯由於日誌未列印導致排查困難,排查困難導致開發抑鬱。查到問題的原因,那麼進行對症下藥即可~
其實以上問題我相信很多小夥伴都遇到過,開發過程中未出現的錯誤在上線後就頻頻出現,那麼只能不斷的進行新增日誌列印然後再打包上傳進行問題跟蹤,一天的時間絕大部分都浪費在了打包上傳的上面。那麼能不能直接進行bug跟蹤,然後檢視到問題出錯的所在?這種需求不亞於給奔跑中的汽車更換輪胎,匪夷所思卻又無可奈何~其實有開發經驗的小夥伴已經想出來一箇中間件,那就是 Arthas!但是這篇文章不是介紹如何使用 Archas,而是我們自己能不能實現這種動態除錯的技能?那麼就進入我們今天的整體 --- Java Agent 技術
Java Instrument
這個玩意並不是什麼 Java 的新特性,早在 JDK 1.5 的時候就誕生了,位於 java.lang.instrument.Instrumentation 中,它的作用就是用來在執行的時候重新載入某個類的 calss 檔案的 api。
這種類的實現方式其實是一種 Java Agent 技術,我們這裡可以順帶了解一下什麼是 Java Agent。
一、Java Agent
代理這個詞對於我們開發人員來說並不預設,我們經常用到的 AOP 面向切面程式設計用到的就是代理方式。它可以動態切入某個面,進行程式碼增強 。這種不用重複補充輪子的方式大大增加了我們開發效率,那麼這裡捕獲到了一個關鍵詞 動態。那麼 Java Agent 如何實現?那就可以說到 JVMTI(JVM Tool Interface) ,這是Java 虛擬機器對外提供的 Native 程式設計介面,透過它我們可以獲取執行時JVM的諸多資訊,而 Agent 是一個執行在目標 JVM 的特定程式,它可以從目標 JVM 獲取資料,然後將資料傳遞給外部程序,然後外部程序可以根據獲取到的資料進行動態Enhance。
那麼 Java Agent 什麼時候能夠載入?
- 目標 JVM 啟動時
- 目標 JVM 執行時
那麼我們關注的是 執行時 ,這樣子就能滿足我們動態載入的需求。
而 Java Agent看上去這麼高大上,我們要如何編寫?當然在 JDK 1.5 之前,實現起來是具有困難性的,我們需要編寫 Native 程式碼來實現,那麼 JDK 1.5 之後我們就可以利用上面說到的 Java Instrument 來實現了!
首先我們先了解一下 Instrumentation 這個介面,其中有幾個方法:
- addTransformer(ClassFileTransformer transformer, boolean canRetransform)
加入一個轉換器 Transformer ,之後所有的目標類載入都會被 Transformer 攔截,可自定義實現 ClassFileTransformer 介面,重寫該介面的唯一方法 transform() 方法,返回值是轉換後的類位元組碼檔案
- retransformClasses(Class<?>... classes)
對 JVM 已經載入的類重新觸發類載入,使用上面自定義的轉換器進行處理。該方法可以修改方法體,常量池和屬性值,但不能新增、刪除、重新命名屬性或方法,也不能修改方法的簽名
- redefineClasses(ClassDefinition... definitions)
此方法用於替換類的定義,而不引用現有類檔案位元組。
- getObjectSize(Object objectToSize)
獲取一個物件的大小
- appendToBootstrapClassLoaderSearch(JarFile jarfile)
將一個 jar 檔案新增到 bootstrap classload 的 classPath 中
- getAllLoadedClasses()
獲取當前被 JVM 載入的所有類物件
redefineClasses 和 retransformClasses 補充說明
兩者區別:
redefineClasses 是自己提供位元組碼檔案替換掉已存在的 class 檔案
retransformClasses 是在已存在的位元組碼檔案上修改後再進行替換
替換後生效的時機
如果一個被修改的方法已經在棧幀中存在,則棧幀中的方法會繼續使用舊位元組碼執行,新位元組碼會在新棧幀中執行
注意點
兩個方法都是隻能改變類的方法體、常量池和屬性值,但不能新增、刪除、重新命名屬性或方法,也不能修改方法的簽名
二、實現 Agent
1、編寫方法
上面我們已經說到了有兩處地方可以進行 Java Agent 的載入,分別是 目標JVM啟動時載入 和 目標JVM執行時載入,這兩種不同的載入模式使用不同的入口函式:
1、JVM 啟動時載入
入口函式如下所示:
// 函式1
public static void premain(String agentArgs, Instrumentation inst);
// 函式2
public static void premain(String agentArgs);
JVM 首先尋找函式1,如果沒有發現函式1,則會尋找函式2
2、JVM 執行時載入
入口函式如下所示:
// 函式1
public static void agentmain(String agentArgs, Instrumentation inst);
// 函式2
public static void agentmain(String agentArgs);
與上述一致,JVM 首先尋找函式1,如果沒有發現函式1,則會尋找函式2
這兩組方法的第一個引數 agentArgs 是隨同 “-javaagent” 一起傳入的程式引數,如果這個字串代表了多個引數,就需要自己解析這引數,inst 是 Instrumentation 型別的物件,是 JVM 自己傳入的,我們可以那這個引數進行引數的增強操作。
2、宣告方法
當定義完這兩組方法後,要使之生效還需要手動宣告,宣告方式有兩種:
1、使用 MANIFEST.MF 檔案
我們需要建立resources/META-INF.MANIFEST.MF 檔案,當 jar包打包時將檔案一併打包,檔案內容如下:
Manifest-Version: 1.0
Can-Redefine-Classes: true # true表示能重定義此代理所需的類,預設值為 false(可選)
Can-Retransform-Classes: true # true 表示能重轉換此代理所需的類,預設值為 false (可選)
Premain-Class: cbuc.life.agent.MainAgentDemo #premain方法所在類的位置
Agentmain-Class: cbuc.life.agent.MainAgentDemo #agentmain方法所在類的位置
2、如果是maven專案,在pom.xml加入
3、指定 agent
要讓目標JVM認你這個 Agent ,你就要給目標JVM介紹這個 Agent
1、JVM 啟動時載入
我們直接在 JVM 啟動引數中加入 -javaagent 引數並指定 jar 檔案的位置
# 將該類編譯成 class 檔案
javac TargetJvm.java
# 指定agent程式並執行該類
java -javaagent:./java-agent.jar TargetJvm
2、JVM 執行時載入
要實現動態除錯,我們就不能將目標JVM停機後再重新啟動,這不符合我們的初衷,因此我們可以使用 JDK 的 Attach Api 來實現執行時掛載 Agent。
Attach Api 是 SUN 公司提供的一套擴充套件 API,用來向目標 JVM 附著(attach)在目標程式上,有了它我們可以很方便地監控一個 JVM。Attach Api 對應的程式碼位於 com.sun.tools.attach包下,提供的功能也非常簡單:
- 列出當前所有的 JVM 例項描述
- Attach 到其中一個 JVM 上,建立通訊管道
- 讓目標JVM載入Agent
該包下有一個類 VirtualMachine,它提供了兩個重要的方法:
- VirtualMachine attach(String var0)
傳遞一個程序號,返回目標 JVM 程序的 vm 物件,該方法是 JVM程序之間指令傳遞的橋樑,底層是透過 socket 進行通訊
- void loadAgent(String var1)
該方法允許我們將 agent 對應的 jar 檔案地址作為引數傳遞給目標 JVM,目標 JVM 收到該命令後會載入這個 Agent
有了 Attach Api ,我們就可以建立一個java程序,用它attach到對應的jvm,並載入agent。
以下是簡單的 Attach 程式碼實現:
注意:在mac上安裝了的jdk是能直接找到 VirtualMachine 類的,但是在windows中安裝的jdk無法找到,如果你遇到這種情況,請手動將你jdk安裝目錄下:lib目錄中的tools.jar新增進當前工程的Libraries中。
上面程式碼十分簡易的實現了 Attach 的方式,透過尋找當前系統中所有執行的 JVM 程序,然後透過比對 PID 來篩選出目標JVM,然後讓 Agent 附著在目標 JVM 上。當然這邊已經簡易到直接在程式碼中指定目標JVM的 PID,這種方式在實際生產中是十分不可取的,我們可以透過動態引數的方式傳入 PID~!而 Attach 的執行原理也不復雜,簡單流程如下:
三、案例說明
我們上述簡單聊了下 Java Agent 的實現過程,那我們下面也簡單寫個案例來理解一下 Java Agent 的實現過程~
我們上面說到可以使用 Java Instrumentation 來完成動態類修改的功能,並且在 Instrumentation 介面中我們可以透過 addTransformer() 方法來增加一個類轉換器,類轉換器由類 ClassFileTransformer 介面實現。該介面中有一個唯一的方法 transform() 用於實現類的轉換,也就是我們可以增強類處理的地方!當類被載入的時候就會呼叫 transform()方法,實現對類載入的事件進行攔截並返回轉換後新的位元組碼,透過 redefineClasses()或retransformClasses()都可以觸發類的重新載入事件。
實際操作
1)準備目標JVM
我們這裡直接使用一個 SpringBoot 專案來試驗,方便大家增強改造~ 專案結構如下:
target-jvm
├─src
├─main
├─java
└─cbuc
└─life
└─targetjvm
├─controller
| └─TestController.java
└─service
| └─SimpleService.java
└─TargetJvmApplication.java
其中 TestController 和 SimpleService 兩個類的內容也很簡單,直接貼程式碼
2)準備 Agent
1、編寫方法
然後編寫我們的Agent jar包。因為懶惰,所以我這邊將 premain 和 agentmain 兩個方法寫在同一個 jar 包中,然後分別以 啟動時 和 執行時 來模擬場景~
很簡單,一個類中包含了我們需要的所有功能~ 防止圖片內容過於擁擠,小菜貼心地分別粘貼出核心程式碼:
- premain
- agentmain
- ClassFileTransformer
2)宣告方法
然後將 Agent 打包,打包的時候需要在 pom.xml 檔案中新增以下內容
然後執行mvn assembly:assembly 既可
3)啟動 Agent
當我們已經準備好了兩個 jar 包便可以開始測試了!
1、啟動時載入
nohup java -javaagent:./java-agent-jar-with-dependencies.jar -jar target-jvm.jar &
xxxxxxxxxxbr nohup java -javaagent:./java-agent-jar-with-dependencies.jar -jar target-jvm.jar &
我們直接啟動時新增引數,帶上我們的 Agent jar包
結果並沒有讓小菜太尷尬,成功的實現我們想要的功能,但是這只是啟動時載入,明顯不是我們想要的~ 我們來試下執行時如何載入
2、執行時載入
正常執行下,方法並沒有做耗時統計,我們的需求就來了,我們想要統計該方法的耗時,首先獲取該程序ID
然後透過 Attach 方式(呼叫controller 的 active() 方法)附著 Agent,我們可以實時檢視控制檯
已經可以看到 Agent 似乎已經成功附著了,然後我們繼續請求 test 介面
可以發現 resolve 方法已經被我們增強了!
四、題外話
上面我們已經簡單的實現了動態操作目標類檔案,文章開頭就說明了給奔跑中的汽車更換輪胎是一個匪夷所思卻又無可奈何的需求,但是這個需求能不能讓別人實現,其實是可以的,而這個就是小菜的主要目的,我們瞭解瞭如何實現動態換輪胎的原理後,當我們運用其成熟的中介軟體也能更加應手而不會不知所措,知識不能讓我們只學會臥槽兩個字,而是當別人實現的時候我們能默默思考,思考後再說出牛逼~!感興趣的同學不妨拉取一下原始碼演練一番:Arthas gitee,已經使用過類似 Arthas 或 BTrace 的同學,看完相信會更加了解其工作執行原理,沒使用過的同學下次用到的時候也不會那麼戰戰兢兢!