記一次多個Java Agent同時使用的類增強衝突問題及分析

2022-11-10 12:00:13
摘要:Java Agent技術常被用於載入class檔案之前進行攔截並修改位元組碼,以實現對Java應用的無侵入式增強。

本文分享自華為雲社群《記一次多個JavaAgent同時使用的類增強衝突問題及分析》,作者:Vansittart。

問題背景

Java Agent技術常被用於載入class檔案之前進行攔截並修改位元組碼,以實現對Java應用的無侵入式增強。Sermant是致力於服務治理領域的開源Java Agent框架專案。某客戶在整合Sermant之前已整合了兩套Java Agent:用於業務能力增強的自研Java Agent和用於鏈路採集的SkyWalking。該客戶單獨掛載自研Java Agent外掛包時,位元組碼增強可以按照預期生效。後期引入開源SkyWalking並同時將自研Java Agent外掛包和SkyWalking通過-javaagent啟動引數掛載至業務應用中。使用過程中發現,兩者的載入順序會對預期的攔截點增強生效與否有直接影響。為什麼會產生這種現象?該客戶求助Sermant社群尋求解決多個JavaAgent的增強衝突問題,以避免類似典型問題再次出現以及順利整合Sermant用於業務的服務治理。

筆者嘗試從位元組碼增強的底層邏輯的角度來分析該問題的癥結。

掛載多個JavaAgent的增強衝突問題

引入SkyWalking的初衷,是希望自研JavaAgent對業務的增強和SkyWalking的鏈路追蹤能力都能正常在業務應用上生效。-javaagent引數是支援多次執行的,所以因此在啟動應用時在JAVA_TOOL_OPTIONS中加上了-javaagent:/xxx/my-agent.jar和-javaagent:/xxx/skywalking-agent.jar引數。

先載入自研JavaAgent後載入SkyWalking

在測試時首先把自研JavaAgent放在前面,SkyWalking放在後面, 即-javaagent:/xxx/my-agent.jar -javaagent:/xxx/SkyWalking-agent.jar。應用啟動前執行的邏輯如下圖所示。按照引數的設定順序,應該是自研JavaAgent先對業務應用的jar包中位元組碼進行增強,然後再由SkyWalking進行增強,最後再執行業務應用的main()方法啟動應用。

然而啟動後發現紀錄檔中SkyWalking丟擲java.lang.UnsupportedOperationException異常,該異常對應的目標類是com.google.common.eventbus.Dispatcher$LegacyAsyncDispatcher。自研JavaAgent無異常丟擲。

ERROR 2022-09-27 15:32:09:546 main SkyWalkingAgent : index=0, batch=[class com.google.common.eventbus.Dispatcher$LegacyAsyncDispatcher], types=[class com.google.common.eventbus.Dispatcher$LegacyAsyncDispatcher] 
Caused by: java.lang.UnsupportedOperationException: class redefinition failed: attempted to change superclass or interfaces
at sun.instrument.InstrumentationImpl.retransformClasses0(Native Method)
at sun.instrument.InstrumentationImpl.retransformClasses(InstrumentationImpl.java:144)
at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
at java.lang.reflect.Method.invoke(Method.java:498)
at org.apache.SkyWalking.apm.dependencies.net.bytebuddy.agent.builder.AgentBuilder$RedefinitionStrategy$Dispatcher$ForJava6CapableVm.retransformClasses(AgentBuilder.java:6910)
... 12 more

經過確認自研JavaAgent並沒有對這個類有過攔截和增強,而SkyWalking中的apm-guava-eventbus-plugin外掛對該類進行了攔截和增強。兩個JavaAgent並沒有同時增強同一個類,但是SkyWalking卻增強失敗了,有點令人費解。初步猜測可能JavaAgent的載入順序有關,筆者調整了順序,再次進行了測試。

先載入SkyWalking後載入自研JavaAgent

調整後JAVA_TOOL_OPTIONS設定為-javaagent:/xxx/SkyWalking-agent.jar -javaagent:/xxx/my-agent.jar,應用啟動前執行的邏輯如下圖所示

經過調整後,發現兩個JavaAgent都沒有錯誤紀錄檔,而且各攔截點的增強也能正常生效,沒有遇到類增強的衝突問題。

問題表象給人的直覺是JavaAgent的載入順序確實對位元組碼增強有關係。但是為什麼會出現這種現象呢?

衝突根因分析

增強失敗的類在兩個JavaAgent中的角色

上面提到,先載入自研JavaAgent後載入SkyWalking的場景中遇到SkyWalking對com.google.common.eventbus.Dispatcher$LegacyAsyncDispatcher增強失敗。Dispatcher$LegacyAsyncDispatcher這個類在SkyWalking的外掛中定義為被攔截增強的類。

經過排查發現Dispatcher$LegacyAsyncDispatcher也被自研JavaAgent中在增強過程中作為第三方依賴引入,但並未對其增強。

Debug分析

鑑於自研JavaAgent沒有報錯,但SkyWalking出現異常,所以對SkyWalking進行debug分析。

在premain方法中,可以看到進入到SkyWalkingAgent時``com.google.common.eventbus.Dispatcher`已經被載入了。觀察它的類載入器,可以知道該類是在自研JavaAgent啟動過程中被載入的。是不是被載入過後的類再進行增強就會衝突呢?接著往下看。

分析原始碼可知SkyWalking使用的是Byte Buddy位元組碼增強工具,AgentBuilder作為其提供位元組碼增強的介面,SkyWalking中使用到的是如下的預設的AgentBuilder$Default,其中的RedefinitionStrategy規定了已載入的類如何被構建的JavaAgent修改位元組碼,RedefinitionStrategy.DiscoveryStrategy則規定了發現哪些類來進行位元組碼的重定義,該預設策略使用的是RedefinitionStrategy.DiscoveryStrategy.SinglePass

/**
 * Creates a new agent builder with default settings. By default, Byte Buddy ignores any types loaded by the bootstrap class loader, any
 * type within a {@code net.bytebuddy} package and any synthetic type. Self-injection and rebasing is enabled. In order to avoid class format
 * changes, set {@link AgentBuilder#disableClassFormatChanges()}. All types are parsed without their debugging information
 * ({@link PoolStrategy.Default#FAST}).
 *
 * @param byteBuddy The Byte Buddy instance to be used.
 */ 
public Default(ByteBuddy byteBuddy) {
 this(byteBuddy,
 Listener.NoOp.INSTANCE,
                    DEFAULT_LOCK,
 PoolStrategy.Default.FAST,
 TypeStrategy.Default.REBASE,
 LocationStrategy.ForClassLoader.STRONG,
 NativeMethodStrategy.Disabled.INSTANCE,
 WarmupStrategy.NoOp.INSTANCE,
 TransformerDecorator.NoOp.INSTANCE,
 new InitializationStrategy.SelfInjection.Split(),
 RedefinitionStrategy.DISABLED,
 RedefinitionStrategy.DiscoveryStrategy.SinglePass.INSTANCE,
 RedefinitionStrategy.BatchAllocator.ForTotal.INSTANCE,
 RedefinitionStrategy.Listener.NoOp.INSTANCE,
 RedefinitionStrategy.ResubmissionStrategy.Disabled.INSTANCE,
 InjectionStrategy.UsingReflection.INSTANCE,
 LambdaInstrumentationStrategy.DISABLED,
 DescriptionStrategy.Default.HYBRID,
 FallbackStrategy.ByThrowableType.ofOptionalTypes(),
 ClassFileBufferStrategy.Default.RETAINING,
 InstallationListener.NoOp.INSTANCE,
 new RawMatcher.Disjunction(
 new RawMatcher.ForElementMatchers(any(), isBootstrapClassLoader().or(isExtensionClassLoader())),
 new RawMatcher.ForElementMatchers(nameStartsWith("net.bytebuddy.")
 .and(not(ElementMatchers.nameStartsWith(NamingStrategy.BYTE_BUDDY_RENAME_PACKAGE + ".")))
 .or(nameStartsWith("sun.reflect.").or(nameStartsWith("jdk.internal.reflect.")))
 .<TypeDescription>or(isSynthetic()))),
 Collections.<Transformation>emptyList());
 }

RedefinitionStrategy.DiscoveryStrategy.SinglePass原始碼中的resolve()方法返回的是instrumentation.getAllLoadedClasses(),也就是說,該方法將返回JVM當前載入的所有類的集合。由此可以看出,AgentBuilder$Default將會對所有在JVM中已載入的類進行篩選(也包括其內部類)。上文提到com.google.common.eventbus.Dispatcher和其內部類都在其中。RedefinitionStrategy作為位元組碼redefine的策略將作用於位元組碼增強的retransform過程。

/**
 * A strategy for discovering types to redefine.
 */
public interface DiscoveryStrategy {
 /**
     * Resolves an iterable of types to retransform. Types might be loaded during a previous retransformation which might require
     * multiple passes for a retransformation.
     *
     * @param instrumentation The instrumentation instance used for the redefinition.
     * @return An iterable of types to consider for retransformation.
     */
 Iterable<Iterable<Class<?>>> resolve(Instrumentation instrumentation);
 /**
     * A discovery strategy that considers all loaded types supplied by {@link Instrumentation#getAllLoadedClasses()}.
     */
 enum SinglePass implements DiscoveryStrategy {
 /**
         * The singleton instance.
         */
        INSTANCE;
 /**
         * {@inheritDoc}
         */
 public Iterable<Iterable<Class<?>>> resolve(Instrumentation instrumentation) {
 return Collections.<Iterable<Class<?>>>singleton(Arrays.<Class<?>>asList(instrumentation.getAllLoadedClasses()));
 }
 }

在AgentBuilder中,retransform過程如下圖進行。首先AgentBuilder在構建過程中會根據重定義策略來對JVM中當前已載入的所有類來進行篩選處理,執行到Dispatcher#retransformClasses()時已經篩選出JVM已載入的類和SkyWalking宣告要增強的類的交集,最終將通過反射呼叫到位元組碼增強的底層實現邏輯Instrumentation#retransformClasses(),通過native方法retransformClasses0()來完成最後的處理。

上文所述產生衝突的類com.google.common.eventbus.Dispatcher$LegacyAsyncDispatcher就在Instrumentation#retransformClasses()要處理的類的集合中。

根因探究

分析到這一步,可以初步看出應該是retransformClasses()方法的某些限制造成衝突的類遇到前面的的java.lang.UnsupportedOperationException異常的丟擲。因此接下來分析下Instrumentation的實現邏輯。

transform

在使用java.lang.instrument.Instrumentation介面進行位元組碼增強操作時,我們必要使用的方法便是:

void addTransformer(ClassFileTransformer transformer, boolean canRetransform)

通過此方法,我們可以為我們想要操作的類新增一個ClassFileTransFormer,顧名思義其為類檔案轉換器,其官方描述如下:

All future class definitions will be seen by the transformer, except definitions of classes upon which any registered transformer is dependent. The transformer is called when classes are loaded, when they are redefined. and if canRetransform is true, when they are retransformed.

簡單來講,在對一個類註冊了該轉換器後,未來該類的每一次redefine以及retransform,都會被該轉換器檢查到,並且執行該轉換器的操作。

由上述描述可以知道,我們想要做的位元組碼增強操作就是通過向JVM中新增轉換器並且通過轉換器將JVM中的類轉換為我們想要的結果(Transform a class by transfomer.)流程如下:

首先通過premain方法執行JavaAgent,此時在premain引數中我們可以獲取到Instrumentation,第二步通過Instrumentation介面將實現的ClassFileTransfomer註冊到JVM上,當JVM去載入類的時候,ClassFileTransfomer會獲得類的位元組陣列,並對其進行transform後再返回給JVM,此後該類在Java程式中的表現就是轉換之後的結果。

retransform

上述為類載入時Instrumentation在其中所做的工作,但是如果類以及被載入完成後,想要再次對其做轉換(適用於多個JavaAgent場景及通過agentmain方式執行JavaAgent),就需要使用到Instrumentation介面為我們提供的如下方法:

void retransformClasses(Class<?>... classes) throws UnmodifiableClassException

其官方描述如下:

This function facilitates the instrumentation of already loaded classes. When classes are initially loaded or when they are redefined, the initial class file bytes can be transformed with the ClassFileTransformer. This function reruns the transformation process (whether or not a transformation has previously occurred)

這個方法將用於對已經載入的類進行插樁,並且是從最初類載入的位元組碼開始重新應用轉換器,並且每一個被註冊到JVM的轉換器都將會被執行。

通過這個方法,我們就可以對已經被載入的類進行transform,執行該方法後的流程如下,其實就是重新觸發ClassFileTransformer中的transform方法:

值得注意的是,reTransformClasses 功能很強大,但是其也有一系列的限制,在官方檔案描述中,其限制如下:

The retransformation must not add, remove or rename fields or methods, change the signatures of methods, or change inheritance.

重轉換過程中,我們不能新增、刪除或者重新命名欄位和方法,不能更改方法的簽名,不能更改類的繼承。

位元組碼分析

上述reTransformClasses方法的限制是否是問題產生的根因呢?

在反編譯經過SkyWalking增強後的位元組碼檔案後,原因水落石出。類經過Skywalking增強之後的繼承關係上多了implements EnhancedInstance。這顯然改變了類的繼承關係,而這一點恰好是官網介面檔案中明確描述的限制行為。正是因為這個介面的實現導致了本文開頭描述的多個JavaAgent的類衝突增強失敗的問題。

該問題在SkyWalking的社群中也有一個相關issue,社群解釋為了減少鏈路追蹤過程中的反射呼叫確實打破了reTransformClasses()的限制,類增強後新增實現了一個介面。

final class Dispatcher$LegacyAsyncDispatcher extends Dispatcher implements EnhancedInstance {
 private final ConcurrentLinkedQueue<com.google.common.eventbus.Dispatcher.LegacyAsyncDispatcher.EventWithSubscriber> queue;
 private volatile Object _$EnhancedClassField_ws;
 private Dispatcher$LegacyAsyncDispatcher() {
 this.queue = Queues.newConcurrentLinkedQueue();
 }
 void dispatch(Object var1, Iterator<Subscriber> var2) {
        delegate$51c0bj0.intercept(this, new Object[]{var1, var2}, cachedValue$P524FzM0$7gcbrk1, new JKwtdbN5(this));
 }
 public void setSkyWalkingDynamicField(Object var1) {
 this._$EnhancedClassField_ws = var1;
 }
 public Object getSkyWalkingDynamicField() {
 return this._$EnhancedClassField_ws;
 }
 static {
        ClassLoader.getSystemClassLoader().loadClass("net.bytebuddy.dynamic.Nexus").getMethod("initialize", Class.class, Integer.TYPE).invoke((Object)null, Dispatcher$LegacyAsyncDispatcher.class, -1207479570);
        cachedValue$P524FzM0$7gcbrk1 = Dispatcher$LegacyAsyncDispatcher.class.getDeclaredMethod("dispatch", Object.class, Iterator.class);
 }
}

總結

避免多個JavaAgent增強衝突的建議

現在JavaAgent技術越來越受到各大廠商和開源社群的青睞,湧現出不少優秀的JavaAgent框架。開發者或廠商在使用JavaAgent的時候難免會遇到同時掛載多個JavaAgent的場景,如果JavaAgent開發方能夠對其他同類框架做到良好的相容性,將會給使用者帶來更少的麻煩,畢竟使用者未必能透徹的瞭解位元組增強的底層原理。

上文經過分析已經找到多個JavaAgent類增強衝突的根因,那麼該如何避免此類問題出現呢?這裡給出兩點較為通用的建議。

謹慎安排JavaAgent的掛載順序

前面我們提到SkyWalking和自研JavaAgent載入順序會有不同的結果。SkyWalking增強時對類的繼承關係有修改,而自研JavaAgent則沒有,那麼該場景將相容性相對較低的SkyWalking放在前面,相容性相對較高的自研JavaAgent放在後面,可以暫時規避類增強的衝突問題。

嚴格遵守位元組碼增強的使用要求和限制

但是如果我們需要使用3個甚至更多的JavaAgent,上面的方法是治標不治本的。

無論是Byte Buddy、Javassist還是ASM,底層實現都離不開JDK1.5之後引入的Instrumentation介面。既然官方介面的設計理念是reTransformClasses()增強類時不能新增、刪除或者重新命名欄位和方法,不能更改方法的簽名,也不能更改類的繼承關係,那作為JavaAgent的框架開發者,應該不要做出超越上述限制的設計,否則極易導致JavaAgent之間的相容性問題出現。不僅僅是這個介面,JavaAgent框架的開發者也需要遵循所有的位元組碼增強的底層介面的設計理念,畢竟有規則才有秩序。

Sermant避免類增強衝突的實踐

首先,在自身位元組碼增強生效的問題上,Sermant嚴格遵守了上述的位元組碼增強的官方限制,未改變類的原始繼承關係或類方法的簽名等,在使用中都未遇到因多個JavaAgent相容性導致Sermant的位元組碼增強失效的問題。只需要把Sermant放在最後掛載,基本可以杜絕上文典型的類增強的衝突問題發生。

其次,Sermant不僅要保護自身增強不受其他JavaAgent影響,也考慮到避免Sermant對其他JavaAgent的影響。Sermant計劃將premain方法中對第三方依賴的使用進行懶載入,將其放置在所有JavaAgent的premain方法執行完成後,main方法執行的初始階段進行載入。這樣,無論Sermant在多個JavaAgent場景中載入順序如何,都不會影響其他任何JavaAgent的執行,真正做到不與其他任何JavaAgent發生衝突。

目前市面上和社群的JavaAgent大都是定位於鏈路追蹤或者應用監控領域,Sermant基於服務治理的自身定位,和其他主流JavaAgent不是互相替代的關係,而是友好共存的關係。使用者掛載多個JavaAgent的場景也許並不少見,Sermant避免JavaAgent類增強衝突的做法不僅可以保證客戶的業務服務可以不受干擾地運用Sermant提供的限流降級、服務註冊、負載均衡、標籤路由、優雅上下線、動態設定這些微服務治理能力,也能不干擾客戶使用的其他JavaAgent按部就班的工作。

 

點選關注,第一時間瞭解華為雲新鮮技術~