擁抱 invokedynamic,在 Java agent 中馴服類載入器

错过發表於2024-04-22

前言

在開發專案的agent 時,找了很多類隔離載入的解決方案,最終參照開源專案實現,採用了 Elastic APM Java agent 的方案。以下為本方案的核心說明文章。

翻譯正文

Byte Buddy 最棒的一點是,它允許您編寫 Java agent,而無需手動處理位元組程式碼。agent作者只需用純 Java 編寫要注入的程式碼,即可對方法進行檢測。這使得編寫 Java agent變得更加容易,並避免了複雜的入門要求。

在第一次成功的實驗之後,agent作者往往會被 JVM 的複雜性所困擾:類載入器(例如OSGi)、類可見性、對內部 API 的依賴性、類路徑掃描器和版本衝突等等。

在本文中,我們將探討一種相對新穎的方法來突破這堵複雜的牆。該架構基於 invokedynamic 位元組碼指令(一種因利用 Java lambda 表示式而聞名的位元組碼),允許在編寫插樁時使用簡單的心智模型。另外,這樣還能在執行時更新到較新版本的agent,而無需重新啟動插樁的應用程式。一年多以前,Elastic APM Java agent開始遷移到invokedynamic為基礎的架構( migration to this invokedynamic-based architecture),並於最近完成了遷移。

傳統 advice 分派方法的問題

讓我們舉一個簡單的例子:一個agent希望測量 Java servlets 的響應時間。在所謂的 advice 方法中,我們可以定義應在實際方法之前或之後執行的程式碼。此外,還可以訪問instrumented方法的引數。

@Advice.OnMethodEnter
public static long enter() {
    return System.nanoTime();
}

@Advice.OnMethodExit
public static void exit(
        @Advice.Argument(0) HttpServletRequest request、
        @Advice.Enter long startTime) {
    System.out.printf(
            "向 %s 請求花費了 %d ns%n"、
            request.getRequestURI()、
            System.nanoTime() - startTime);
}

在 Byte Buddy 中,有兩種主要方法可將 advice 應用於 instrumented method

內嵌 advice

預設情況下,進入和退出 advice 會被複制到目標方法中,就像類的原作者將agent程式碼新增到方法中一樣。如果用純 Java 編寫 插樁的方法一樣,它看起來會如下所示:

protected void service(HttpServletRequest req, HttpServletResponse resp) {
    long startTime = System.nanoTime();
    // 原始方法體
    System.out.printf(
            "Request to %s took %d ns%n"、
            request.getRequestURI()、
            System.nanoTime() - startTime);
}

這樣做的好處是, advice 可以訪問插樁方法通常可以訪問的任何值或型別。在上例中,這就允許訪問 javax.servlet.http.HttpServletRequest,儘管agent本身並不包含該介面。當agent程式碼在目標方法中執行時,它只需獲取方法本身已有的型別定義。

缺點是, advice 程式碼不再在其定義的上下文中執行。因此,你不能在 advice 方法中設定斷點,因為它從未被實際呼叫過。請記住:方法只是用作模板。

但真正的問題在於,將程式碼從 advice 方法中分離出來或呼叫任何通常可從 advice 方法中訪問的方法已不再可能。由於所有程式碼現在都從插樁方法中執行,agent可能會在完全不同的類載入器上執行,與插樁方法沒有任何聯絡,因此即使是公共方法插樁程式碼中也無法呼叫。我們將在下一節進一步討論這個問題。

Delegated advice

對於類似但仍然非常不同的方法,可以指示 Byte Buddy 委託使用 advice 方法。這可以透過 advice 註解屬性 @Advice.OnMethodEnter(inline = false) 進行控制。預設情況下,Byte Buddy 將透過靜態方法呼叫委託給 advice 方法。這樣,插樁方法就會如下所示:

protected void service(HttpServletRequest req, HttpServletResponse resp) {
    long startTime = AdviceClass.enter();
    // 原始方法體
    AdviceClass.exit(req, startTime);
}

與之前類似,需要由agent的開發人員來確保插樁方法中能看到 advice 程式碼。如果插樁方法與agent的程式碼不共享類載入器層次結構,那麼在檢測到上述方法時就會產生 NoClassDefFoundError。即使agent可以訪問委託 advice ,agent的類載入器也可能無法使用 HttpServletRequest 等引數型別。這樣,只有在agent呼叫 advice 時,錯誤才會轉移到agent的程式碼中。

類載入器問題

預設情況下,agent在連線到 JVM 時會被新增到系統類載入器,而 java.lang.instrument.Instrumentation 介面提供了將agent新增到引導類載入器的方法。理論上,將類新增到引導類載入器會使它們在任何地方都可見。但是,有些類載入器(如 OSGi)只允許從系統或引導類載入器載入某些類(如 java.、com.sun.)。常見的解決方案是檢測所有類載入器,並顯式地將某些包中類的載入直接重定向到引導載入器。

但在系統類載入器和引導類載入器中新增類也有其弊端。額外的類可能會減慢類路徑掃描速度,甚至導致應用程式無法啟動。請參見 elastic/apm-agent-java#364,以瞭解示例。此外,無法解除安裝這種持久類載入器的類,這在設計一個希望在執行時自行刪除的agent時是個問題。

從概念上講,只有兩種方法可以克服這些類載入器問題(advice 類想要呼叫通常隨agent一起提供的不同方法,但這些方法可能無法訪問)。要麼,必須將這些程式碼注入到插樁類的類載入器中,以便可以直接從那裡查詢這些方法。或者,必須定義一個新的類載入器,作為前一個類載入器的子類,現在可以透過實現這樣一個自定義類載入器來找到任何其他型別。

對於第一種方法,Byte Buddy 自帶的實用工具允許將類注入到任何類載入器中(net.bytebuddy.dynamic.loading.ClassInjector)。雖然這看起來是一個簡單的解決方案,但卻有很大的缺點。更靈活的注入器建立在內部 API(如 sun.misc.Unsafe / jdk.internal.misc.Unsafe)之上。此外,聽起來更安全的類注入器策略(如 UsingReflection)也使用了巧妙的變通方法來規避最近 Java 版本中引入的保護措施,這些措施通常不允許使用 Unsafe::putBoolean 訪問私有欄位。到目前為止,限制訪問內部 API 和在反射 API 中強制執行可見性的 Oracle 與發現可以規避這些措施的新漏洞之間,就像一場貓捉老鼠的遊戲。同時,使用方法控制代碼查詢的官方閘道器幾乎與agent不相容,其整合也是一個未決問題(https://bugs.openjdk.java.net/browse/JDK-8200559)。因此,使用當前不安全的 API 構建整個agent架構似乎相當冒險,而 Oracle 正致力於進一步鎖定這些 API。

第二種方法是在子類載入器中載入所有 advice 類和輔助類。這種方法無需依賴不安全的 API,因為類載入器是由agent開發人員實現的,而且類載入器可以訪問父類載入器定義的所有型別。

在專用類載入器中載入輔助類,而不是將其注入到插樁類的類載入器中的另一個好處是,可以解除安裝這些類。這樣就可以將agent從應用程式中完全分離出來,並附加新版本的agent,而不會留下前一版本的任何痕跡,這也被稱為實時更新agent。Byte Buddy 已經允許透過重新轉換來恢復所有已應用的插樁類(Byte Buddy already allows reverting all the instrumentations it has applied via re-transformation)。當agent輔助類載入器的其他引用沒有洩露時,這將使其所有物件、類甚至整個類載入器都符合垃圾回收的條件。

這種方法的一個複雜問題是,插樁類看不到 Advice 類。上例中的插樁方法 HttpServlet::service 透過靜態方法呼叫 AdviceClass。這會在執行時導致 NoClassDefFoundError,因為 AdviceClassHttpServlet::service 方法的上下文中不可見。這是因為 AdviceClass 是由插樁類(HttpServlet)的子類載入器載入的。雖然 AdviceClass 可以訪問插樁類可見的類,如 HttpServletRequest 引數,但反之則不行。

引入基於 invokedynamic 的 advice 分派方法

除了透過靜態方法呼叫來排程 advice 外,還有另一種鮮為人知的方法。透過 net.bytebuddy.asm.Advice.WithCustomMapping::bootstrap,您可以指示 Byte Buddy 將 invokedynamic 位元組碼指令插入到插樁方法中。該指令是在 Java 7 中新增的,目的是更好地支援 JVM 中的動態語言,如 Groovy 和 JRuby。
簡而言之,invokedynamic 呼叫包括兩個階段:查詢 CallSite,然後呼叫 CallSite 持有的方法控制代碼。如果再次執行相同的 invokedynamic 指令,將呼叫最初查詢的 CallSite。

下面的示例顯示了 invokedynamic 指令在方法位元組碼中的樣子。

// InvokeDynamic #1:exit:(Ljavax/servlet/ServletRequest;long)V</p> <p>invokedynamic #1076, 0

CallSite 的查詢發生在所謂的引導方法中。該方法接收用於查詢的幾個引數,如 advice 類名稱、方法名稱以及代表引數和返回型別的 advice 方法型別。下面的示例展示瞭如何在類的位元組碼中宣告引導方法。

BootstrapMethods:
  1: #1060 REF_invokeStatic java/lang/IndyBootstrapDispatcher.bootstrap:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite
    Method arguments:
      #1049 org.example.ServletAdvice
      #1050 1
      #12 javax/servlet/http/HttpServlet
      #1072 service
      #1075 REF_invokeVirtual javax/servlet/http/HttpServlet.service:(Ljavax/servlet/HttpServletRequest;Ljavax/servlet/HttpServletResponse;)V

包含引導方法的類(本例中為 java/lang/IndyBootstrapDispatcher.bootstrap)必須在任何插樁的類中可見。因此,需要將該類新增到引導類載入器中。為確保與過濾類載入器(如 OSGi 載入器)相容,該類被放入 java.lang 包中。

雖然這種方法並不能完全避免類注入,但只注入一個類確實會減少agent新增的永恆類的數量,並在 JDK 的未來版本不再允許此類注入時減少重構現有agent的需要。

Elastic APM Java agent中,引導方法將建立一個新的類載入器,其父類載入器就是插樁類的類載入器,並從中載入 advice 和任意數量的助手。然後,我們可以從這個新建立的類載入器中載入 advice 類, advice 類名稱作為引數提供給引導方法(方法引數:org.example.ServletAdvice)。

使用引導方法的其他引數,我們可以構建一個方法控制代碼(MethodHandle)和一個呼叫站點(CallSite),在我們建立的子類載入器中表示 advice 方法。根據我們的需要,目標方法總是相同的。因此,可以返回一個 ConstantCallSite,允許 JIT 內聯(inline) advice 方法。

現在,我們只依賴一個類(java.lang.IndyBootstrapDispatcher)來顯示插樁的方法,我們可以透過從專用類載入器載入非特定庫的類來進一步隔離agent。如上一節所述,將agent的類從常規類載入器層次結構中隱藏起來可避免相容性問題,例如與類路徑掃描器的相容性問題。它還允許agent傳送任何依賴項,如 Byte Buddy 或日誌庫,而無需將依賴項隱藏(又稱重定位)到agent的名稱空間。這使得除錯agent變得更加容易。由於使用了隔離的類載入器,因此無需擔心應用程式的類載入器層次結構中可能存在的衝突類。有關隔離類載入器實現的更多詳情,請參閱 Elastic APM Java agent的 ShadedClassLoader 原始碼。

由此產生的類載入器層次結構如下所示:

alt 類載入器層次結構

類載入器層次結構

請注意,agent輔助類載入器(用於載入 advice 和特定於庫的輔助類)有兩個父類: helper類的類載入器(如 servlet 容器為每個網路應用程式建立的類載入器)和agent類載入器。這樣, advice 類和輔助類就能訪問從helper類的類載入器和agent類載入器中可見的兩種型別。雖然內建類載入器不提供多父類,但自己實現多父類還是比較簡單的。Byte Buddy 還提供了一個名為 net.bytebuddy.dynamic.loading.MultipleParentClassLoader 的實現。

總之,本節介紹了 invokedynamic 指令如何用於呼叫從插樁類的定義類載入器的子類載入器載入 advice 方法。這樣,agent就可以將自己的類從應用程式中隱藏起來,同時提供一種方法來呼叫它所使用的應用程式類中的隔離方法。這一點非常有用,因為 advice 和該類載入器載入的所有其他類都可以訪問helper庫的類,而 advice 程式碼仍作為常規程式碼執行。這也避免了將 advice 和輔助類直接注入目標類載入器,而目前只有透過使用內部 API 才能做到這一點,Oracle 正致力於進一步鎖定這些 API。

指定返回

雖然使用內聯或委託的 advice 都是透過相同的 API 實現的,因此看起來非常相似,但兩者還是有區別的。委託 advice 不能輕易地在插樁方法的作用域中寫入值。當使用內聯 advice 時, advice 方法可以簡單地為註釋引數賦值,然後 Byte Buddy 在內聯過程中將其轉換為替換所代表的值。舉例來說,下面的內聯 advice 將用一個也實現了 Runnable 介面的封裝例項來替換插樁方法的第一個引數(這裡是一個 Runnable),該封裝例項將向agent報告任何未來的呼叫:

@Advice.OnMethodEnter
public static void enter(
        @Advice.Argument(value = 0, readOnly = false) Runnable callback) {
    callback = new TracingRunnable(callback);
}

由於上述程式碼是內聯的,因此 advice 只是替換了分配給插樁方法第一個引數的值。因此,現在執行插樁方法時,就好像呼叫者已經傳遞了 TracingRunnable(callback)一樣。

遺憾的是,在使用委託時,這種方法不起作用。使用委託時,新值只會被賦值給 advice 方法的引數,而不會影響插樁方法的賦值,因為插樁方法在執行 advice 方法後仍會攜帶原來的 runnable。

為了在使用委託 advice 時提供此類賦值,Byte Buddy 最近引入了 Advice.AssignReturned 後處理器。 advice 後處理器是在 advice 方法被派發後呼叫的處理程式,允許進行獨立於所應用 advice 的其他操作。但最重要的是,即使 advice 本身是透過委託呼叫的,後處理器生成的程式碼也總是內聯到插樁方法中。這樣,如果 advice 方法返回了值,就可以在插樁方法的作用域中寫入這些值。由於後處理器是常規 advice 實現的擴充套件,因此首先需要透過呼叫後處理器來手動註冊:

Advice.withCustomBinding()
    .with(new Advice.AssignReturned.Factory());

顧名思義,這個後處理器允許將從 advice 方法返回的值賦值給插樁方法的引數。例如,要實現上述示例,我們可以指示後處理器將返回值賦值給插樁方法的第一個引數,就像之前所做的那樣:

@Advice.OnMethodEnter(inline = false)
@Advice.AssignReturned.ToArguments(@ToArgument(0))
public static Runnable enter(@Advice.Argument(0) Runnable callback) {
    return new TracingRunnable(callback);
}

就像在內聯示例中一樣,插樁方法現在會將 TracingRunnable 作為其第一個引數,因為它已被後處理器替換。除了為引數賦值外,還可以為欄位賦值、為方法的返回值賦值、為方法丟擲的異常賦值,如果方法是非靜態的,甚至還可以為方法的 this 引用賦值。

但在某些情況下,可能需要分配多個值。對於內聯 advice ,這可以透過在 advice 方法中直接為每個註釋引數賦多個值來直接實現。而對於委託 advice ,透過返回一個陣列作為返回型別,並指定返回陣列的哪個索引包含哪個值,同樣可以輕鬆實現多個賦值。

為了擴充套件假設示例,假設插樁方法也需要執行器服務作為第二個引數,我們可以透過將其作為 advice 方法返回陣列的第二個引數來強制使用新建立的快取執行緒池。在註釋 advice 方法的賦值時,每個賦值現在只需指明哪個陣列索引代表哪個賦值。

@Advice.OnMethodEnter(inline = false)
@Advice.AssignReturned.ToArguments(
  @ToArgument(value = 0, index = 0, typing = DYNAMIC)、
  @ToArgument(value = 1, index = 1, typing = DYNAMIC))
public static Runnable enter(@Advice.Argument(0) Runnable callback) {
    return new Object[] {
        new TracingRunnable(callback)、
        Executors.newCachedThreadPool()
    };
}

最後,由於Object-typed陣列可能包含不可賦值的值,因此註解必須指定使用動態型別。這樣,Byte Buddy 就會在賦值前嘗試對值進行型別轉換。為避免潛在的 ClassCastException(類轉換異常)影響插樁應用程式,可以配置後處理器來抑制這些異常。

new Advice.AssignReturned().Factory()
    .withSuppressed(ClassCastException.class)

如果在陣列包含不可賦值的情況下沒有配置動態型別,就會在類的檢測過程中導致異常。除了失去檢測功能外,應用程式不會受到影響。

權衡利弊

這種架構的侷限之一是無法支援 Java 6 應用程式,因為它依賴於 Java 7 中新增的 invokedynamic 位元組碼指令。由於 Elastic APM Java agent從未支援過 Java 6,因此在這種情況下這並不是一個問題。許多其他agent甚至不再支援 Java 7,其市場份額僅約為 1-5%,具體取決於哪項研究。

除了要求 Java 7+ 之外,插樁類還必須達到位元組碼 51 級,這意味著它必須以 Java 7 或更高版本為目標進行編譯。這是因為舊版本的類檔案無法使用 invokedynamic 指令。有些庫,尤其是agent可能希望使用的舊版 JDBC 驅動程式,有時會使用相當舊的類檔案版本進行編譯。不過有一個相對簡單的解決方法。使用 ClassVisitor,我們可以讓 ASM 將位元組碼重寫為類檔案版本 51(Java 7)。自從 Elastic APM Java agent引入這種方法以來,事實證明這是一種穩定可靠的方法。這確實會帶來一些效能損失,但我們只需要在插樁類的類檔案版本低於 51 的相對罕見情況下這樣做。

另一個需要注意的問題是,早期版本的 Java 7(更新 60 之前,2014 年 5 月釋出)和 Java 8(更新 40 之前,2015 年 3 月釋出)在 invokedynamicMethodHandl 支援方面存在bugs。因此,如果檢測到 Elastic APM Java agent在這些 JVM 版本上執行,它就會禁用自己。

參考資料

原文:https://www.elastic.co/cn/blog/embracing-invokedynamic-to-tame-class-loaders-in-java-agents/
專案:https://github.com/raphw/byte-buddy
專案:https://github.com/elastic/apm-agent-java

相關文章