我们在 github 上收到社区用户的问题反馈:
用户原先应用已经接入 skywalking,需要再接入数列的 LinkAgent 时启动会抛 java.lang.UnsupportedOperationException,导致应用启动失败。
也就是说在不修改代码的情况下如果需要启动应用,skywalking 和 LinkAgent 只能存在一个,两者不能同时存在。skywalking 与 LinkAgent 不兼容该如何解决?本文将围绕这个问题的详细展开。 skywalking 是分布式系统的应用程序性能监视工具,大家相对熟悉,可有的人并不了解 Agent,这里稍微科普一下:
agent 是什么
介绍 javaagent 之前也要介绍另一个概念 JVMTI。 JVMTI 是 JDK 提供的一套用于开发 JVM 监控, 问题定位与性能调优工具的通用编程接口(API)。 通过 JVM TI,我们可以开发各式各样的 JVMTI Agent。这个 Agent 的表现形式是一个以 C/C++语言编写的动态共享库。 javaagent 可以帮助我们快速使用 JVMTI 的功能,又不需要重写编写 C/C++的底层库。
javaagent 是依赖 java 底层提供的一个叫 instrument 的 JVMTI Agent。这个 agent 又叫 JPLISAgent(Java Programming Language Instrumentation Services Agent)
简单来说,javaagent 是一个 JVM 的“插件”。 在 java 运行命令中 javaagent 是一个参数,用来指定 agent。
agent 能干什么
可以在加载 class 文件之前进行拦截并把字节码做修改。
可以在运行期对已加载类的字节码做变更,但是这种情况下会有很多的限制。
还有其他一些小众的功能获取所有已经加载过的类获取所有已经初始化过的类(执行过 clinit 方法,是上面的一个子集)获取某个对象的大小将某个 jar 加入到 bootstrap classpath 里作为高优先级被 bootstrapClassloader 加载将某个 jar 加入到 classpath 里供 AppClassloard 去加载设置某些 native 方法的前缀,主要在查找 native 方法的时候做规则匹配
总的来说可以让 JVM 按照我们的预期逻辑去执行。 最主要的也是使用最广的功能就是对字节码的修改。通过对字节码的修改我们就可以实现对 JAVA 底层源码的重写,也正好可以满足我之前的需求。 我们还可以做:
完全非侵入式的进行代码埋点,进行系统监控
修改 JAVA 底层源码,进行 JVM 自定义
实现 AOP 动态代理
agent 的两种使用方式
在 JVM 启动的时候加载,通过 javaagent 启动参数 java -javaagent:myagent.jar MyMain
,这种方式在程序 main 方法执行之前执行 agent 中的 premain 方法
public static void premain(String agentArgument, Instrumentation instrumentation) throws Exception
复制代码
在 JVM 启动后 Attach,通过 Attach API 进行加载,这种方式会在 agent 加载以后执行 agentmain 方法
public static void agentmain(String agentArgument, Instrumentation instrumentation) throws Exception
复制代码
这两个方法都有两个参数
第一个 agentArgument 是 agent 的启动参数,可以在 JVM 启动命令行中设置,比如java -javaagent:<jarfile>=appId:agent-demo,agentType:singleJar test.jar
的情况下 agentArgument 的值为 “appId:agent-demo,agentType:singleJar”。
第二个 instrumentation 是 java.lang.instrument.Instrumentation
的实例,可以通过 addTransformer 方法设置一个 ClassFileTransformer。
第一步:问题分析
异常信息是说在重新定义某个类的时候,原先的父类或者接口类发生了改变,导致重新定义失败。可是在没有使用 skywalking 的时候,数列 LinkAgent 与其他的一些 agent 并没有出现过类似的兼容性问题。 在 github 上搜索发现发现有人提过 skywalking 和 arthas 的兼容性问题。链接🔗
问题原因 skywalking 官方也给出了答复: 当 Java 应用程序启动时,SkyWalking 代理使用 ByteBuddy 转换类。 ByteBuddy 每次都会生成具有不同随机名称的辅助类。 当另一个 Java 代理重新转换同一个类时,它会触发 SkyWalking 代理再次增强该类。 由于 ByteBuddy 重新生成了字节码,修改了字段和导入的类名,JVM 对类字节码的验证失败,因此重新转换类将不成功。
所以问题还是由 ByteBuddy 产生的,而数列 agent 底层使用的是 ASM 不会产生对应的问题。
第二步:本地复现
从前面的分析已经得知 skywalking 与 LinkAgent 的不兼容问题背后的原因,可要想有效解决就得先本地复现这个问题,编写 DemoApplication 手动的去触发 retransform,并且在 retransform 前后打印 jvm 中的所有类名。代码地址🔗
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) throws InterruptedException, UnmodifiableClassException {
SpringApplication.run(DemoApplication.class, args);
test();
}
public static void test() throws InterruptedException, UnmodifiableClassException {
Instrumentation instrumentation = ByteBuddyAgent.install();
System.err.println("before =============");
printAllTestControllerClasses(instrumentation);
reTransform(instrumentation);
reTransform(instrumentation);
reTransform(instrumentation);
System.err.println("after =============");
printAllTestControllerClasses(instrumentation);
}
public static void reTransform(Instrumentation instrumentation) throws UnmodifiableClassException {
ClassFileTransformer transformer = new ClassFileTransformer() {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
return null;
}
};
try {
instrumentation.addTransformer(transformer, true);
try {
instrumentation.retransformClasses(TestController.class);
} catch (Throwable e) {
e.printStackTrace();
}
} finally {
instrumentation.removeTransformer(transformer);
}
}
public static void printAllTestControllerClasses(Instrumentation instrumentation) {
Class<?>[] classes = instrumentation.getAllLoadedClasses();
for (Class<?> clazz : classes) {
if (clazz.getName().startsWith(TestController.class.getName())) {
System.out.println(clazz.getName());
}
}
}
复制代码
1.不加 skywalking 直接启动
结果如下:在 retransform 前后都是有 com.example.demo.TestController
2.指定 skywalking 启动
通过 -javaagent:${path}/apache-skywalking-apm-6.4.0-bin/agent/skywalking-agent.jar
启动参数来运行项目。 发现在 retransform 之前多生成了 com.example.demo.TestController$auxiliary$tTwQs5Cs
和com.example.demo.TestController$auxiliary$rZrClpy4
两个类。 在 retransform 的时候抛出了java.lang.UnsupportedOperationException
retransform 之后又多生成了三个匿名内部类。
第三步:给出合理的解决方案
1.添加 jvm 的启动参数
skywalking 官方 8.1.0 以后的版本可以通过添加 jvm 的启动参数来解决这个问题。
-Dskywalking.agent.is_cache_enhanced_class=true
-Dskywalking.agent.class_cache_mode=MEMORY
复制代码
也可以通过 -Dskywalking.agent.class_cache_mode=MEMORY
或 -Dskywalking.agent.class_cache_mode=FILE
命令来指定是通过内存缓存还是文件缓存。 注意:但是这些参数在 8.1.0 以后的 skywalking 中才有,低于 8.1.0 版本的 skywalking 还是无法解决上述问题。
2.新写额外的 agent 来实现 skywalking cache 功能
低于 8.1.0 版本的 skywalking 可以新写一个额外的 agent 来实现 skywalking cache 的功能。 问题原因是 skywalking 重新 retransform 的时候重新生成了匿名内部类导致的问题,所以只需要在 skywalking 对应的 transformer 进行 retransform 的时候使其走缓存即可解决这个问题。
通过 debug 发现 skywalking 是由 org.apache.skywalking.apm.dependencies.net
.bytebuddy.agent.builder.AgentBuilder$Default$ExecutingTransformer
的 transform 方法进行 retransform 的。
public byte[] transform(ClassLoader classLoader,
String internalTypeName,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] binaryRepresentation) {
// ... 忽略实现
}
复制代码
public static void premain(final String agentArgs, final Instrumentation instrumentation) throws Exception {
System.err.println("====== skywalking-byte-buddy-agent ======");
// 预处理启动参数
AgentConfig.instance().initConfig();
if (AgentConfig.enable) {
System.err.println("=== begin start skywalking-byte-buddy-agent ===");
System.out.println("=== cacheMode is " + AgentConfig.cacheMode + " ===");
AgentBuilder.Transformer transformer = (builder, typeDescription, classLoader, javaModule) -> builder
// 拦截transform方法
.method(ElementMatchers.hasMethodName("transform")
.and(ElementMatchers.takesArguments(5))
)
// 委托
.intercept(MethodDelegation.to(CacheInterceptor.class));
new AgentBuilder
.Default()
// 指定需要拦截的类
.type(ElementMatchers.named("org.apache.skywalking.apm.dependencies.net.bytebuddy.agent.builder.AgentBuilder$Default$ExecutingTransformer"))
.transform(transformer)
.installOn(instrumentation);
System.err.println("=== end start skywalking-byte-buddy-agent ===");
} else {
System.err.println("=== enable is false, not start skywalking-byte-buddy-agent ===");
}
}
复制代码
/**
* @Description 缓存拦截器
* @Author ocean_wll
* @Date 2021/8/5 11:53 上午
*/
public class CacheInterceptor {
@RuntimeType
public static Object intercept(@Origin Method method, @AllArguments Object[] args,
@SuperCall Callable<?> callable) {
Object returnObj = null;
try {
// 校验参数
if (checkArgs(args)) {
ClassLoader classLoader = (ClassLoader) args[0];
String className = (String) args[1];
// 获取缓存中的value
byte[] bytes = Cache.getClassCache(classLoader, className);
if (bytes != null) {
return bytes;
}
// 调用原有方法
returnObj = callable.call();
if (returnObj != null) {
// 如果缓存中没有,并且原方法执行结果不为null,则放入缓存中
Cache.putClassCache(classLoader, className, (byte[]) returnObj);
}
} else {
// 会出现classloader为null的情况,但还是需要去执行transform
returnObj = callable.call();
}
return returnObj;
} catch (Exception e) {
e.printStackTrace();
}
return returnObj;
}
/**
* 因为拦截的方法是五个参数,jvm中类的唯一性是根据classloader和className来确定的,所以进行增强前对方法参数进行一次校验避免方法增强错误
* <p>
* 需要增强的方法
* public byte[] transform(ClassLoader classLoader,
* String internalTypeName,
* Class<?> classBeingRedefined,
* ProtectionDomain protectionDomain,
* byte[] binaryRepresentation) {
* if (circularityLock.acquire()) {
* try {
* return AccessController.doPrivileged(new AgentBuilder.Default.ExecutingTransformer.LegacyVmDispatcher(classLoader,
* internalTypeName,
* classBeingRedefined,
* protectionDomain,
* binaryRepresentation), accessControlContext);
* } finally {
* circularityLock.release();
* }
* } else {
* return NO_TRANSFORMATION;
* }
* }
*
* @param args 方法入参
* @return true校验通过,false校验失败
*/
private static boolean checkArgs(Object[] args) {
// 先校验参数个数
if (args.length == 5) {
// 校验第一个参数,第一个参数类型是classLoader
boolean arg0IsTrue = args[0] != null && args[0] instanceof ClassLoader;
// 校验第二个参数,第二个参数表示的是类名,类型为String
boolean agr1IsTrue = args[1] != null && args[1] instanceof String;
return arg0IsTrue && agr1IsTrue;
}
return false;
}
}
复制代码
通过不同的 ClassCacheResolver 来采用不同的缓存策略
/**
* @Description cacheResolver接口
* @Author ocean_wll
* @Date 2021/8/5 4:02 下午
*/
public interface ClassCacheResolver {
/**
* 获取class缓存
*
* @param loader ClassLoader
* @param className 类名
* @return byte数组
*/
byte[] getClassCache(ClassLoader loader, String className);
/**
* 存放class缓存
*
* @param loader ClassLoader
* @param className 类名
* @param classfileBuffer 字节码数据
*/
void putClassCache(ClassLoader loader, String className, byte[] classfileBuffer);
}
复制代码
/**
* @Description 内存缓存解析器
* @Author ocean_wll
* @Date 2021/8/5 4:03 下午
*/
public class MemoryCacheResolver implements ClassCacheResolver {
/**
* key为 classloader+className,value为 字节码
*/
private final Map<String, byte[]> classCacheMap = new ConcurrentHashMap<>();
@Override
public byte[] getClassCache(ClassLoader loader, String className) {
String cacheKey = getCacheKey(loader, className);
return classCacheMap.get(cacheKey);
}
@Override
public void putClassCache(ClassLoader loader, String className, byte[] classfileBuffer) {
String cacheKey = getCacheKey(loader, className);
classCacheMap.put(cacheKey, classfileBuffer);
}
/**
* 获取缓存key ClassLoaderHash(loader) + "@" + className
*
* @param loader ClassLoader
* @param className 类名
* @return 缓存key
*/
private String getCacheKey(ClassLoader loader, String className) {
return Cache.getClassLoaderHash(loader) + "@" + className;
}
}
复制代码
在 skywalking javaagent 参数前 加上 -javaagent:${jarPath}/skywalking-byte-buddy-cache-agent-1.0.0.jar
确保在 skywalking agent 启动之前已经对 skywalking 的类进行增强了。
可以看到加了自定义的 agent 以后多次 retransform 并不会抛出 java.lang.UnsupportedOperationException,并且 retransform 前后也没有产生新的匿名内部类了。
完整代码🔗
一点个人的思考
1、可插拔、不侵入代码
这个问题其实 skywalking 官方已经给出了解决方案,但是官方的解决方案只对 8.1.0 及以上版本才会生效。对于无法升级 skywalking 版本还在使用低版本的用户来说就需要另辟蹊径了。 第一种方法:修改低版本的 skywalking 的源码,重新打包。但是必须十分了解 skywalking 源码的人才能去干,否则免不齐又会引入什么新的问题。实现难度非常高。 第二种方法:自己写一个 agent,修改字节码。这种方式灵活方便,即不干预原来的代码,又可以根据自己想要的进行调整。实现难度一般。 所以以后在类似的问题上,能不修改原有代码就尽量不修改原有代码,除非你非常了解原来的业务逻辑,不然在不清楚的情况下随意修改风险太大了。
2、最小改动
在这个问题里我可以对所有的 transformer 的 transform 方法进行切入,但这样就会导致缓存数据过多,有些根本不会出现问题的数据也被缓存起来了导致资源浪费。 所以还是得找到最核心的问题点进行修改,确保这次改动的影响面是最小的。
评论