摘要: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按部就班的作業,
點擊關注,第一時間了解華為云新鮮技術~
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/531396.html
標籤:Java
下一篇:String常用API
