记一次多个Java Agent同时使用的类增强冲突问题及分析

一次,多个,java,agent,同时,使用,增强,冲突,问题,分析 · 浏览次数 : 843

小编点评

# JavaAgent类增强冲突根因分析 **类增强冲突问题原因分析:** 1. **接口设计理念:** reTransformClasses()增强类时不能新增、删除或重命名字段和方法,不能更改类的继承关系。 2. **字节码增强限制:** 在使用中,不能改变类的原始继承关系或类方法的签名等。 3. **链路追踪问题:** Sermant计划将premain方法中对第三方依赖的使用进行懒加载,将其放置在所有JavaAgent的premain方法执行完成后,main方法执行的初始阶段进行加载。 **避免类增强冲突的方法:** 1. **顺序安排:** 在自身字节码增强生效的问题上,Sermant严格遵守了上述的字节码增强的官方限制,未改变类的原始继承关系或类方法的签名等。 2. **链路追踪控制:** Sermant计划将premain方法中对第三方依赖的使用进行懒加载,将其放置在所有JavaAgent的premain方法执行完成后,main方法执行的初始阶段进行加载。 3. **优先加载:** Sermant在多个JavaAgent场景中加载顺序优先。 **其他建议:** * 使用Sermant替代其他JavaAgent框架,以保持代码的清晰和可维护性。 * 在使用Sermant时,严格遵守其官方的限制。 * 优先考虑使用单一JavaAgent框架,以减少类增强冲突的风险。

正文

摘要: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按部就班的工作。

 

点击关注,第一时间了解华为云新鲜技术~

与记一次多个Java Agent同时使用的类增强冲突问题及分析相似的内容:

记一次多个Java Agent同时使用的类增强冲突问题及分析

摘要:Java Agent技术常被用于加载class文件之前进行拦截并修改字节码,以实现对Java应用的无侵入式增强。 本文分享自华为云社区《记一次多个JavaAgent同时使用的类增强冲突问题及分析》,作者:Vansittart。 问题背景 Java Agent技术常被用于加载class文件之前进

记一次 .NET 某外贸ERP 内存暴涨分析

一:背景 1. 讲故事 上周有位朋友找到我,说他的 API 被多次调用后出现了内存暴涨,让我帮忙看下是怎么回事?看样子是有些担心,但也不是特别担心,那既然找到我,就给他分析一下吧。 二:WinDbg 分析 1. 到底是哪里的泄露 这也是我一直在训练营灌输的理念,一定要知道是哪一边的暴涨,否则很可能就

记一次 .NET某工控视觉自动化系统 卡死分析

一:背景 1. 讲故事 今天分享的dump是训练营里一位学员的,从一个啥也不会到现在分析的有模有样,真的是看他成长起来的,调试技术学会了就是真真实实自己的,话不多说,上windbg说话。 二:WinDbg 分析 1. 为什么会卡死 这位学员是从事工控大类下的视觉自动化,也是目前.NET的主战场,这个

记一次线上Redis内存占用过高、大Key问题的排查

问题背景 在一个风和日丽的下午,公司某项目现场运维同学反馈,生产环境3个Redis的Sentinel集群节点内存占用都很高,达到了17GB的内存占用量。 稍加思索,应该是某些Key的Value数据体量过大,占用了过多的内存空间,我们在使用Redis的过程中,单个Value或者单个集合中的元素应该保证

记一次 .NET 某仪器测量系统 CPU爆高分析

一:背景 1. 讲故事 最近也挺奇怪,看到了两起 CPU 爆高的案例,且诱因也是一致的,觉得有一些代表性,合并分享出来帮助大家来避坑吧,闲话不多说,直接上 windbg 分析。 二:WinDbg 分析 1. CPU 真的爆高吗 这里要提醒一下,别人说爆高不一定真的就是爆高,我们一定要拿数据说话,可以

记一次 .NET某工控WPF程序被人恶搞的 卡死分析

一:背景 1. 讲故事 这一期程序故障除了做原理分析,还顺带吐槽一下,熟悉我的朋友都知道我分析dump是免费的,但免费不代表可以滥用我的宝贵时间,我不知道有些人故意恶搞卡死是想干嘛,不得而知,希望后面类似的事情越来越少吧!废话不多说,我们来看看是如何被恶搞的。 二:WinDbg 分析 1. 程序是如

记一次nginx配置不当引发的499与failover 机制失效

背景 nginx 499在服务端推送流量高峰期长期以来都是存在的,间或还能达到告警阈值触发一小波告警,但主观上一直认为499是客户端主动断开,可能和推送高峰期的用户打开推送后很快杀死app有关,没有进一步探究问题根源。 然而近期在非高峰期也存在499超过告警阈值的偶发情况,多的时候一天几次,少的时候

记一次 .NET某账本软件 非托管泄露分析

一:背景 1. 讲故事 中秋国庆长假结束,哈哈,在老家拍了很多的短视频,有兴趣的可以上B站观看:https://space.bilibili.com/409524162 ,今天继续给大家分享各种奇奇怪怪的.NET生产事故,希望能帮助大家在未来的编程之路上少踩坑。 话不多说,这篇看一个.NET程序集泄

记一次 .NET 某企业OA后端服务 卡死分析

一:背景 1.讲故事 前段时间有位朋友微信找到我,说他生产机器上的 Console 服务看起来像是卡死了,也不生成日志,对方也收不到我的httpclient请求,不知道程序出现什么情况了,特来寻求帮助。 哈哈,一般来说卡死的情况在窗体程序(WinForm,WPF) 上特别多,在 Console,We

记一次 .NET 某医疗器械 程序崩溃分析

一:背景 1.讲故事 前段时间有位朋友在微信上找到我,说他的程序偶发性崩溃,让我帮忙看下怎么回事,上面给的压力比较大,对于这种偶发性崩溃,比较好的办法就是利用 AEDebug 在程序崩溃的时候自动抽一管血出来,看看崩溃点是什么,其实我的系列文章中,关于崩溃类的dump比较少,刚好补一篇上来,话不多说