位元組碼調試的入口 —— JVM 的寄生插件 javaAgent 那些事

来源:https://www.cnblogs.com/88223100/archive/2023/08/15/The-Entry-Point-for-Bytecode-Debugging---JVMs-Parasitic-Plugin-JavaAgent.html
-Advertisement-
Play Games

Java Instrumentation 包 Java Instrumentation 概述 Java Instrumentation 這個技術看起來非常神秘,很少有書會詳細介紹。但是有很多工具是基於 Instrumentation 來實現的: APM 產品: pinpoint、skywalking ...


Java Instrumentation 包

Java Instrumentation 概述

Java Instrumentation 這個技術看起來非常神秘,很少有書會詳細介紹。但是有很多工具是基於 Instrumentation 來實現的:

  • APM 產品: pinpoint、skywalking、newrelic、聽雲的 APM 產品等都基於 Instrumentation 實現
  • 熱部署工具:Intellij idea 的 HotSwap、Jrebel 等
  • Java 診斷工具:Arthas、Btrace 等

由於對位元組碼修改功能的巨大需求,JDK 從 JDK5 版本開始引入了java.lang.instrument 包。它可以通過 addTransformer 方法設置一個 ClassFileTransformer,可以在這個 ClassFileTransformer 實現類的轉換。

JDK 1.5 支持靜態 Instrumentation,基本的思路是在 JVM 啟動的時候添加一個代理(javaagent),每個代理是一個 jar 包,其 MANIFEST.MF 文件里指定了代理類,這個代理類包含一個 premain 方法。JVM 在類載入時候會先執行代理類的 premain 方法,再執行 Java 程式本身的 main 方法,這就是 premain 名字的來源。在 premain 方法中可以對載入前的 class 文件進行修改。這種機制可以認為是虛擬機級別的 AOP,無需對原有應用做任何修改,就可以實現類的動態修改和增強。

從 JDK 1.6 開始支持更加強大的動態 Instrument,在JVM 啟動後通過 Attach API 遠程載入,後面會詳細介紹。

本文會分為 javaagent 和動態 Attach 兩個部分來介紹

Java Instrumentation 核心方法

Instrumentation 是 java.lang.instrument 包下的一個介面,這個介面的方法提供了註冊類文件轉換器、獲取所有已載入的類等功能,允許我們在對已載入和未載入的類進行修改,實現 AOP、性能監控等功能。

常用的方法如下:

/**
 * 為 Instrumentation 註冊一個類文件轉換器,可以修改讀取類文件位元組碼
 */
void addTransformer(ClassFileTransformer transformer, boolean canRetransform);

/**
 * 對JVM已經載入的類重新觸發類載入
 */
void retransformClasses(Class<?>... classes) throws UnmodifiableClassException;

/**
 * 獲取當前 JVM 載入的所有類對象
 */
Class[] getAllLoadedClasses()

它的 addTransformer 給 Instrumentation 註冊一個 transformer,transformer 是 ClassFileTransformer 介面的實例,這個介面就只有一個 transform 方法,調用 addTransformer 設置 transformer 以後,後續JVM 載入所有類之前都會被這個 transform 方法攔截,這個方法接收原類文件的位元組數組,返迴轉換過的位元組數組,在這個方法中可以做任意的類文件改寫。

下麵是一個空的 ClassFileTransformer 的實現:

public class MyClassTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classBytes) throws IllegalClassFormatException {
        // 在這裡讀取、轉換類文件
        return classBytes;
    }
}

接下來我們來介紹本文的主角之一 javaagent。

Javaagent 介紹

Javaagent 是一個特殊的 jar 包,它並不能單獨啟動的,而必須依附於一個 JVM 進程,可以看作是 JVM 的一個寄生插件,使用 Instrumentation 的 API 用來讀取和改寫當前 JVM 的類文件。

Agent 的兩種使用方式

它有兩種使用方式:

  • 在 JVM 啟動的時候載入,通過 javaagent 啟動參數 java -javaagent:myagent.jar MyMain,這種方式在程式 main 方法執行之前執行 agent 中的 premain 方法
  • 在 JVM 啟動後 Attach,通過 Attach API 進行載入,這種方式會在 agent 載入以後執行 agentmain 方法 premain 和 agentmain 方法簽名如下:
public static void premain(String agentArgument, Instrumentation instrumentation) throws Exception

public static void agentmain(String agentArgument, Instrumentation instrumentation) throws Exception

這兩個方法都有兩個參數

  • 第一個 agentArgument 是 agent 的啟動參數,可以在 JVM 啟動命令行中設置,比如java -javaagent:<jarfile>=appId:agent-demo,agentType:singleJar test.jar的情況下 agentArgument 的值為 "appId:agent-demo,agentType:singleJar"。

  • 第二個 instrumentation 是 java.lang.instrument.Instrumentation 的實例,可以通過 addTransformer 方法設置一個 ClassFileTransformer。

第一種 premain 方式的載入時序如下:

圖片

Agent 打包

為了能夠以 javaagent 的方式運行 premain 和 agentmain 方法,我們需要將其打包成 jar 包,併在其中的 MANIFEST.MF 配置文件中,指定 Premain-class 等信息,一個典型的生成好的 MANIFEST.MF 內容如下

為了能夠以 javaagent 的方式運行 premain 和 agentmain 方法,我們需要將其打包成 jar 包,併在其中的 MANIFEST.MF 配置文件中,指定 Premain-class 等信息,一個典型的生成好的 MANIFEST.MF 內容如下

下麵是一個可以幫助生成上面 MANIFEST.MF 的 maven 配置

<build>
  <finalName>my-javaagent</finalName>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-jar-plugin</artifactId>
      <configuration>
        <archive>
          <manifestEntries>
            <Agent-Class>me.geek01.javaagent.AgentMain</Agent-Class>
            <Premain-Class>me.geek01.javaagent.AgentMain</Premain-Class>
            <Can-Redefine-Classes>true</Can-Redefine-Classes>
            <Can-Retransform-Classes>true</Can-Retransform-Classes>
          </manifestEntries>
        </archive>
      </configuration>
    </plugin>
  </plugins>
</build>

Agent 使用方式一:JVM 啟動參數

下麵使用 javaagent 實現簡單的函數調用棧跟蹤,以下麵的代碼為例:

public class MyTest {
    public static void main(String[] args) {
        new MyTest().foo();
    }
    public void foo() {
        bar1();
        bar2();
    }

    public void bar1() {
    }

    public void bar2() {
    }
}

通過 javaagent 啟動參數的方式在每個函數進入和結束時都列印一行日誌,實現調用過程的追蹤的效果。

核心的方法 instrument 的邏輯如下:

public static class MyMethodVisitor extends AdviceAdapter {

    @Override
    protected void onMethodEnter() {
        // 在方法開始處插入 <<<enter xxx
        mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitLdcInsn("<<<enter " + this.getName());
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
        super.onMethodEnter();
    }

    @Override
    protected void onMethodExit(int opcode) {
        super.onMethodExit(opcode);
        // 在方法結束處插入 <<<exit xxx
        mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
        mv.visitLdcInsn(">>>exit " + this.getName());
        mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
    }
}

把 agent 打包生成 my-trace-agent.jar,添加 agent 啟動 MyTest 類

java -javaagent:/path_to/my-trace-agent.jar MyTest

可以看到輸出結果如下:

<<<enter main
<<<enter foo
<<<enter bar1
>>>exit bar1
<<<enter bar2
>>>exit bar2
>>>exit foo
>>>exit main

通過上面的方式,我們在不修改 MyTest 類源碼的情況下實現了調用鏈跟蹤的效果。更加健壯和完善的調用鏈跟蹤實現會在後面的 APM 章節詳細介紹。

Agent 使用方式二:Attach API 使用

在 JDK5 中,開發者只能 JVM 啟動時指定一個 javaagent 在 premain 中操作位元組碼,Instrumentation 也僅限於 main 函數執行前,這樣的方式存在一定的局限性。從 JDK6 開始引入了動態 Attach Agent 的方案,除了在命令行中指定 javaagent,現在可以通過 Attach API 遠程載入。我們常用的 jstack、arthas 等工具都是通過 Attach 機制實現的。

接下來我們會結合跨進程通信中的信號和 Unix 域套接字來看 JVM Attach API 的實現原理

JVM Attach API 基本使用

下麵以一個實際的例子來演示動態 Attach API 的使用,代碼中有一個 main 方法,每隔 3s 輸出 foo 方法的返回值 100,接下來動態 Attach 上 MyTestMain 進程,修改 foo 的位元組碼,讓 foo 方法返回 50。

public class MyTestMain {
    public static void main(String[] args) throws InterruptedException {
        while (true) {
            System.out.println(foo());
            TimeUnit.SECONDS.sleep(3);
        }
    }

    public static int foo() {
        return 100; // 修改後 return 50;
    }
}

步驟如下:

1、編寫 Attach Agent,對 foo 方法做註入,完整的代碼見:github.com/arthur-zhan…

動態 Attach 的 agent 與通過 JVM 啟動 javaagent 參數指定的 agent jar 包的方式有所不同,動態 Attach 的 agent 會執行 agentmain 方法,而不是 premain 方法。

public class AgentMain {
    public static void agentmain(String agentArgs, Instrumentation inst) throws ClassNotFoundException, UnmodifiableClassException {
        System.out.println("agentmain called");
        inst.addTransformer(new MyClassFileTransformer(), true);
        Class classes[] = inst.getAllLoadedClasses();
        for (int i = 0; i < classes.length; i++) {
            if (classes[i].getName().equals("MyTestMain")) {
                System.out.println("Reloading: " + classes[i].getName());
                inst.retransformClasses(classes[i]);
                break;
            }
        }
    }
}

2、因為是跨進程通信,Attach 的發起端是一個獨立的 java 程式,這個 java 程式會調用 VirtualMachine.attach 方法開始和目標 JVM 進行跨進程通信。

public class MyAttachMain {
    public static void main(String[] args) throws Exception {
        VirtualMachine vm = VirtualMachine.attach(args[0]);
        try {
            vm.loadAgent("/path/to/agent.jar");
        } finally {
            vm.detach();
        }
    }
}

使用 jps 查詢到 MyTestMain 的進程 id,

java -cp /path/to/your/tools.jar:. MyAttachMain pid

可以看到 MyTestMain 的輸出的 foo 方法已經返回了 50。

java -cp . MyTestMain

100
100
100
agentmain called
Reloading: MyTestMain
50
50
50

JVM Attach 過程分析

執行 MyAttachMain,當指定一個不存在的 JVM 進程時,會出現如下的錯誤:

java -cp /path/to/your/tools.jar:. MyAttachMain 1234
Exception in thread "main" java.io.IOException: No such process
	at sun.tools.attach.LinuxVirtualMachine.sendQuitTo(Native Method)
	at sun.tools.attach.LinuxVirtualMachine.<init>(LinuxVirtualMachine.java:91)
	at sun.tools.attach.LinuxAttachProvider.attachVirtualMachine(LinuxAttachProvider.java:63)
	at com.sun.tools.attach.VirtualMachine.attach(VirtualMachine.java:208)
	at MyAttachMain.main(MyAttachMain.java:8)

可以看到 VirtualMachine.attach 最終調用了 sendQuitTo 方法,這是一個 native 的方法,底層就是發送了 SIGQUIT 信號給目標 JVM 進程。

前面信號部分我們介紹過,JVM 對 SIGQUIT 的預設行為是 dump 當前的線程堆棧,那為什麼調用 VirtualMachine.attach 沒有輸出調用棧堆棧呢?

對於 Attach 的發起方,假設目標進程為 12345,這部分的詳細的過程如下:

1、Attach 端檢查臨時文件目錄是否有 .java_pid12345 文件

這個文件是一個 UNIX 域套接字文件,由 Attach 成功以後的目標 JVM 進程生成。如果這個文件存在,說明正在 Attach 中,可以用這個 socket 進行下一步的通信。如果這個文件不存在則創建一個 .attach_pid12345 文件,這部分的偽代碼如下:

String tmpdir = "/tmp";
File socketFile = new File(tmpdir,  ".java_pid" + pid);
if (socketFile.exists()) {
    File attachFile = new File(tmpdir, ".attach_pid" + pid);
    createAttachFile(attachFile.getPath());
}

2、Attach 端檢查如果沒有 .java_pid12345 文件,創建完 .attach_pid12345 文件以後發送 SIGQUIT 信號給目標 JVM。然後每隔 200ms 檢查一次 socket 文件是否已經生成,5s 以後還沒有生成則退出,如果有生成則進行 socket 通信

3、對於目標 JVM 進程而言,它的 Signal Dispatcher 線程收到 SIGQUIT 信號以後,會檢查 .attach_pid12345 文件是否存在。

  • 目標 JVM 如果發現 .attach_pid12345 不存在,則認為這不是一個 attach 操作,執行預設行為,輸出當前所有線程的堆棧
  • 目標 JVM 如果發現 .attach_pid12345 存在,則認為這是一個 attach 操作,會啟動 Attach Listener 線程,負責處理 Attach 請求,同時創建名為 .java_pid12345 的 socket 文件,監聽 socket。源碼中 /hotspot/src/share/vm/runtime/os.cpp 這一部分處理的邏輯如下:
#define SIGBREAK SIGQUIT

static void signal_thread_entry(JavaThread* thread, TRAPS) {
  while (true) {
    int sig;
    {
    switch (sig) {
      case SIGBREAK: { 
        // Check if the signal is a trigger to start the Attach Listener - in that
        // case don't print stack traces.
        if (!DisableAttachMechanism && AttachListener::is_init_trigger()) {
          continue;
        }
        ...
        // Print stack traces
    }
}

AttachListener 的 is_init_trigger 在 .attach_pid12345 文件存在的情況下會新建 .java_pid12345 套接字文件,同時監聽此套接字,準備 Attach 端發送數據。

那 Attach 端和目標進程用 socket 傳遞了什麼信息呢?可以通過 strace 的方式看到 Attach 端究竟往 socket 裡面寫了什麼:

sudo strace -f java -cp /usr/local/jdk/lib/tools.jar:. MyAttachMain 12345  2> strace.out

...
5841 [pid  3869] socket(AF_LOCAL, SOCK_STREAM, 0) = 5
5842 [pid  3869] connect(5, {sa_family=AF_LOCAL, sun_path="/tmp/.java_pid12345"}, 110)      = 0
5843 [pid  3869] write(5, "1", 1)            = 1
5844 [pid  3869] write(5, "\0", 1)           = 1
5845 [pid  3869] write(5, "load", 4)         = 4
5846 [pid  3869] write(5, "\0", 1)           = 1
5847 [pid  3869] write(5, "instrument", 10)  = 10
5848 [pid  3869] write(5, "\0", 1)           = 1
5849 [pid  3869] write(5, "false", 5)        = 5
5850 [pid  3869] write(5, "\0", 1)           = 1
5855 [pid  3869] write(5, "/home/ya/agent.jar"..., 18 <unfinished ...>

可以看到往 socket 寫入的內容如下:

1
\0
load
\0
instrument
\0
false
\0
/home/ya/agent.jar
\0

數據之間用 \0 字元分隔,第一行的 1 表示協議版本,接下來是發送指令 "load instrument false /home/ya/agent.jar" 給目標 JVM,目標 JVM 收到這些數據以後就可以載入相應的 agent jar 包進行位元組碼的改寫。

如果從 socket 的角度來看,VirtualMachine.attach 方法相當於三次握手建連,VirtualMachine.loadAgent 則是握手成功之後發送數據,VirtualMachine.detach 相當於四次揮手斷開連接。

這個過程如下圖所示:

圖片

小結

本文講解了 javaagent,一起來回顧一下要點:

  • 第一,javaagent 是一個使用 instrumentation 的 API 用來改寫類文件的 jar 包,可以看作是 JVM 的一個寄生插件。
  • 第二,javaagent 有兩個重要的入口類:Premain-Class 和 Agent-Class,分別對應入口函數 premain 和 agentmain,其中 agentmain 可以採用遠程 attach API 的方式遠程掛載另一個 JVM 進程。

作者|頂尖架構師棧

本文來自博客園,作者:古道輕風,轉載請註明原文鏈接:https://www.cnblogs.com/88223100/p/The-Entry-Point-for-Bytecode-Debugging---JVMs-Parasitic-Plugin-JavaAgent.html


您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 利用AI幫你讀文章、利用AI幫你分析非結構化數據,這些最為潮流的AI輔助工具,相信很多讀者都在各種媒體上看到過了。但還是有不少人並沒有真正的使用過,這裡有很多原因導致,具體就不細說了,懂的都懂。 今天TJ就給大家推薦一個你可以線上使用,也可以自己搭建的AI輔助工具:[**Quivr**](https ...
  • Sun公司以及其他虛擬機提供商發佈了許多可以運行在各種不同平臺上的虛擬機,這些虛擬機都可以載入和執行同一種平臺無關的的程式存儲格式——位元組碼(ByteCode),從而實現了程式的“一次編寫,到處運行”。“Class文件”這種特定的二進位文件格式所關聯,Class文件中包含了Java虛擬機指令集和符號... ...
  • [TOC] ## 1. 我以為 我以為 [GoPool](https://github.com/devchat-ai/gopool) 這個項目會曇花一現,從此在 GitHub 上封塵。 > 關於 GoPool 項目誕生的故事:[《僅三天,我用 GPT-4 生成了性能全網第一的 Golang Work ...
  • 最近小組在開展讀書角活動,我們小組選的是《深入理解JVM虛擬機》,相信這本書對於各位程式猿們都不陌生,我也是之前在學校準備面試期間大致讀過一遍,emm時隔多日,對裡面的知識也就模糊了。這次開始的時候從前面的JDK發展史和JVM虛擬機家族著手,之前都是粗略讀過,這次通過查閱相關資料並收集在每一個JDK... ...
  • 作者:老鷹湯 \ 鏈接:https://juejin.cn/post/7156439842958606349 ## 線上事故回顧 前段時間新增一個特別簡單的功能,晚上上線前`review`代碼時想到公司拼搏進取的價值觀臨時加一行log日誌,覺得就一行簡單的日誌基本上沒啥問題,結果剛上完線後一堆報警, ...
  • ## 1、問題 在工作中經常需要在內網環境中安裝python第三方庫,使用從pypi上下載的whl文件來安裝又經常遇到該庫也需要依賴包,以至於並不能成功安裝。 ## 2、解決辦法 (1)查看所需第三方庫安裝是否需要依賴庫(以requests為例) ``` pip show requests ``` ...
  • ## 教程簡介 Lucene是apache軟體基金會 jakarta項目組的一個子項目,是一個開放源代碼的全文檢索引擎工具包,但它不是一個完整的全文檢索引擎,而是一個全文檢索引擎的架構,提供了完整的查詢引擎和索引引擎,部分文本分析引擎(英文與德文兩種西方語言)。Lucene的目的是為軟體開發人員提供 ...
  • Redis典型的應用場景就是數據緩存能力,用來解決業務中最容易出現的查詢性能問題,提升系統的響應效率;其次就是分散式鎖機制,用來解決分散式系統中多線程併發處理資源的安全問題; ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...