写点什么

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

  • 2022-11-10
    中国香港
  • 本文字数:7602 字

    阅读完需:约 25 分钟

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

本文分享自华为云社区《记一次多个JavaAgent同时使用的类增强冲突问题及分析》,作者:Vansittart。

问题背景


JavaAgent 技术常被用于加载 class 文件之前进行拦截并修改字节码,以实现对 Java 应用的无侵入式增强。Sermant 是致力于服务治理领域的开源 JavaAgent 框架项目。某客户在集成 Sermant 之前已集成了两套 JavaAgent:用于业务能力增强的自研 JavaAgent 和用于链路采集的 SkyWalking。该客户单独挂载自研 JavaAgent 插件包时,字节码增强可以按照预期生效。后期引入开源 SkyWalking 并同时将自研 JavaAgent 插件包和 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 按部就班的工作。


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

发布于: 刚刚阅读数: 3
用户头像

提供全面深入的云计算技术干货 2020-07-14 加入

华为云开发者社区,提供全面深入的云计算前景分析、丰富的技术干货、程序样例,分享华为云前沿资讯动态,方便开发者快速成长与发展,欢迎提问、互动,多方位了解云计算! 传送门:https://bbs.huaweicloud.com/

评论

发布
暂无评论
记一次多个Java Agent同时使用的类增强冲突问题及分析_开发_华为云开发者联盟_InfoQ写作社区