前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >实现一个分布式调用链路追踪Java探针你可能会遇到的问题

实现一个分布式调用链路追踪Java探针你可能会遇到的问题

作者头像
吴就业
发布2020-09-08 15:47:15
1.6K0
发布2020-09-08 15:47:15
举报
文章被收录于专栏:Java艺术Java艺术

Java探针可以在Java应用运行时毫无感知的切入应用代码,是一种用于监听代码行为或改变代码行为的工具。

分布式调用链路追踪的实现无非两种方式,代码侵入式和非代码侵入式,基于Java探针实现的属于非代码侵入式。

运行在Java虚拟机上的编程语言所编写的代码,都有一种统一的中间格式:class文件格式。实现动态修改class字节码插入额外行为的代码,可实现非代码侵入式的应用调用行为收集。

得益于Java SE 6提供的Instrumentation接口。基于Instrumentation可开发运行时修改class字节码的Java Agent应用(Java探针),可在类加载之前替换类的字节码、或在类加载之后通过重新加载类方式修改类的字节码。

只是实现运行时修改class字节码还不足以称为“探针”。基于Instrumentation开发的Java Agent,只需要在Java应用启动命令上加上虚拟机参数“-javaagent”指定Java Agent应用jar包的位置,而不需要在工程项目中引入其jar包,即可将探针插入应用代码的各个角落。通过与应用使用不同的类加载实现环境隔离,让人有种Java Agent是吸附在应用上运行的错觉。

Instrumentation之所以难驾驭,在于需要了解Java类加载机制以及字节码,一不小心就能遇到各种陌生的Exception。笔者在实现Java探针时就踩过不少坑,其中一类就是类加载相关的问题,也是本篇所要跟大家分享的。

父类加载器加载的类不能引用子类加载器加载的类

由父类加载器加载的类,不能引用子类加载器加载的类,否则会抛出NoClassDefFoundError。

怎么理解这句话呢?这其实也是道面试题。

JDK提供的java.*类都由启动类加载器加载。如果我们在java agent中修改java包下的类,插入调用logback打印日记的代码,结果会怎样?由于java agent包下的logback由AppClassLoader(应用类加载器,也称为系统类加载器)加载,而加载java包下的类的启动类加载器是AppClassLoader的父类加载器,在java包下的类中插入调用logback打印日记的代码,首先在加载java包下的类时,jvm会查看启动类加载器有没有加载过这个类,如果没有加载过尝试加载,但启动类加载器加载不了logback包的类,而启动类加载器不会向子类加载器去询问,任何类加载器都不会向子类加载器询问子类加载器是否能加载,即使子类加载器加载了这个类。所以就会出现NoClassDefFoundError。

如果非要修改java包下的类,且非要在java包下的类中访问项目中我们编写的类或者第三方jar包提供的类、或者我们编写的javaagent包下的类,如何避免NoClassDefFoundError呢?

笔者遇到这个问题网上找过很多资源,遗憾的是并未找到。于是笔者想起自己电脑上下载有Arthas的源码,不如学习下Arthas是如何解决的。

Arthas是Alibaba开源的一款Java诊断工具,非常适合用于线上问题排查。

参考Alibaba开源的Arthas的解决方案:

用于接收埋点代码上报事件的类(Spy):

代码语言:javascript
复制
public final class Spy {
    public static void before(String className, String methodName, String descriptor, Object[] params) {
    }

    public static void complete(Object returnValueOrThrowable, String className, String methodName, String descriptor) {
    }
}
  • before:方法执行之前上报;
  • complete:方法return之前或者抛出异常之前上报,当方法抛出异常时,第一个参数为异常,否则第一个参数为返回值;

将Spy放在一个独立的jar包下,在premain、agentmain方法中调用Instrumentation的appendToBootstrapClassLoaderSearch方法,将Spy类所在的jar包交由启动类加载器扫描加载,如下代码所示。

代码语言:javascript
复制
// agent-spy.jar
String agentSpyJar = jarPath[1];
File spyJarFile = new File(agentSpyJar);
instrumentation.appendToBootstrapClassLoaderSearch(new JarFile(spyJarFile));

在Spy类中打印类加载器,如果打印的结果为null,则说明Spy类是由启动类加载器加载的。

代码语言:javascript
复制
public final class Spy {
    static {
        System.out.println("Spy class loader is " + Spy.class.getClassLoader());
    }
    //.......
}

最后,给Spy注入上报方法,在Spy中通过反射调用上报方法,完整的Spy类的代码如下。

代码语言:javascript
复制
public final class Spy {

    public static Method beforMethod;
    public static Method completeMethod;

    public static void before(String className, String methodName, String descriptor, Object[] params) {
        if (beforMethod != null) {
            try {
                beforMethod.invoke(null, className, methodName, descriptor, params);
            } catch (IllegalAccessException | InvocationTargetException e) {
            }
        }
    }

    public static void complete(Object returnValueOrThrowable, String className, String methodName, String descriptor) {
        if (completeMethod != null) {
            try {
                completeMethod.invoke(null, returnValueOrThrowable, className, methodName, descriptor);
            } catch (IllegalAccessException | InvocationTargetException e) {
            }
        }
    }
}

通过反射调用对性能会有所影响,特别是调用链路上每个方法都需要反射调用两个上报方法。

可能不完全理解正确,但笔者试过这个方案确实可行。

实现Agent与应用环境隔离

为什么要实现隔离?

隔离是避免Agent污染应用自身,使开发Java Agent无需考虑引入的jar包是否与目标应用引入的jar包冲突。

Java Agent与Spring Boot应用相遇时会发生什么?

Spring Boot应用打包后,将Agent附着到应用启动可能会抛出醒目的NoClassDefFoundError异常,这在IDEA中测试是不会发生的,而背后的原因是Agent与打包后的Spring Boot应用使用了不同的类加载器。

我们可能会在Agent中调用被监控的SpringBoot应用的代码,也可能调用Agent依赖的第三方jar包的API,而这些jar包恰好在SpringBoot应用中也有导入,就可能会出现NoClassDefFoundError。

Agent的jar包由AppClassLoader类加载器(系统类加载器)所加载。

在IDEA中,项目的class文件和第三方库是通过AppClassLoader加载的,而使用-javaagent指定的jar也是通过AppClassLoader加载,所以在idea中测试不会遇到这个问题。

SpringBoot应用打包后,JVM进程启动入口不再是我们写的main方法,而是SpringBoot生成的启动类。SpringBoot使用自定义的类加载器(LaunchedClassLoader)加载jar中的类和第三方jar包中的类,该类加载器的父类加载器为AppClassLoader。

也就是说,SpringBoot应用打包后,加载javaagent包下的类使用的类加载器是SpringBoot使用的类加载器的父类加载器。

如何实现隔离?

让加载agent包不使用AppClassLoader加载器加载,而是使用自定义的类加载器加载。

参考Alibaba开源的Arthas的实现,自定义URLClassLoader加载agent包以及agent依赖的第三方jar包。

由于premain或者agentmain方法所在的类由jvm使用AppClassLoader所加载,所以必须将agent拆分为两个jar包。核心功能放在agent-core包下,premain或者agentmain方法所在的类放在agent-boot包下。在premain或者agentmain方法中使用自定义的URLClassLoader类加载器加载agent-core。

第一步:

自定义类加载器OnionClassLoader,继承URLClassLoader,如下代码所示:

代码语言:javascript
复制
public class OnionClassLoader extends URLClassLoader {

    public OnionClassLoader(URL[] urls) {
        super(urls, ClassLoader.getSystemClassLoader().getParent());
    }

    @Override
    protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        final Class<?> loadedClass = findLoadedClass(name);
        if (loadedClass != null) {
            return loadedClass;
        }
        // 优先从parent(SystemClassLoader)里加载系统类,避免抛出ClassNotFoundException
        if (name != null && (name.startsWith("sun.") || name.startsWith("java."))) {
            return super.loadClass(name, resolve);
        }
        try {
            Class<?> aClass = findClass(name);
            if (resolve) {
                resolveClass(aClass);
            }
            return aClass;
        } catch (Exception e) {
            // ignore
        }
        return super.loadClass(name, resolve);
    }

}

同时在构造方法中指定OnionClassLoader的父类加载器为AppClassLoader的父类加载器。

ClassLoader.getSystemClassLoader():获取系统类加载器(AppClassLoader)

第二步:

在premain或者agentmain方法中使用OnionClassLoader类加载器加载agent-core。

代码语言:javascript
复制
// 1
File agentJarFile = new File(agentJar);
final ClassLoader agentLoader = new OnionClassLoader(new URL[]{agentJarFile.toURI().toURL()});
// 2
Class<?> transFormer = agentLoader.loadClass("com.msyc.agent.core.OnionClassFileTransformer");
// 3
Constructor<?> constructor = transFormer.getConstructor(String.class);
Object instance = constructor.newInstance(opsParams);
// 4
instrumentation.addTransformer((ClassFileTransformer) instance);
  • 1、根据agent-core.jar所在绝对路径构造OnionClassLoader;
  • 2、加载agent-core.jar下的ClassFileTransformer;
  • 3、使用反射创建ClassFileTransformer实例;
  • 4、将ClassFileTransformer添加到Instrumentation;

OnionClassFileTransformer类所依赖的agent-core包下的类,自然也会被使用OnionClassLoader类加载器加载,包括agent-core依赖的第三方jar包。

适配webmvc框架

生成分布式调用链日记的难点在于方法埋点和方法调用日记串连。

分布式调用链日记串连的方式有多种,笔者采用的是最简单的方式:打点id+打点时间。

对于同进程内的同线程,可用打点id将调用的方法串连起来,根据打点时间与一个累加器的值排序方法调用日记。

对于不同进程,通过传递打点id可将不同应用的打点日记串连起来,根据打点时间排序。

例如,适配webmvc框架的目的是从请求头获取调用来源传递过来的打点ID(事务ID)。对DispatcherServlet#doDispatch方法插桩,从HttpServletRequest参数获取请求头“S-Tid”。“S-Tid”是自定义的请求头参数,用于传递打点ID。

笔者在实现适配webmvc和openfeign时都遇到了同样的问题,如在适配webmvc时,修改DispatcherServlet的doDispatch方法时,asm框架抛出java.lang.TypeNotPresentException。

  • java.lang.TypeNotPresentException:当应用程序试图使用表示类型名称的字符串对类型进行访问,但无法找到带有指定名称的类型定义时,抛出该异常。

其原因是,使用asm框架改写DispatcherServlet类时,asm会使用Class.forName方法加载符号引用的类,如果加载不到目标类则抛出TypeNotPresentException。

默认asm会使用加载自身的类加载器去尝试加载当前改写类所依赖的一些类,而加载asm框架使用的类加载器与加载agent-core包使用的是同一个类加载器,DispatcherServlet则由SpringBoot的LaunchedClassLoader类加载器所加载。

好在ClassFileTransformer#transform方法传递了用于加载当前类的类加载器:

代码语言:javascript
复制
public class OnionClassFileTransformer implements ClassFileTransformer {
    @Override
    public byte[] transform(ClassLoader loader, String className,                             Class<?> classBeingRedefined,
                            ProtectionDomain protectionDomain,                             byte[] classfileBuffer) {
             // ......
    }
}
  • 如果当前需要改写的类是DispatcherServlet,则transform方法的第一个参数为即将用于加载DispatcherServlet类的类加载器;

我们只需要指定asm使用ClassFileTransformer#transform方法传递进来的类加载器加载DispatcherServlet依赖的类即可。

代码语言:javascript
复制
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES) {
       @Override
       protected ClassLoader getClassLoader() {
            return loader;
       }
};

如代码所示,我们重写asm的ClassWriter类的getClassLoader方法,返回的类加载器是ClassFileTransformer#transform方法传递进来的类加载器。

总结

自实现Java探针需要牢记一句话:由父类加载器加载的类,不能引用子类加载器加载的类;

通过自定义类加载加载agent,可实现agent与应用隔离,不让agent污染应用;

通过ClassFileTransformer#transform改写类的字节码,记得重写asm框架的ClassWriter类的getClassLoader方法,使用ClassFileTransformer#transform传递进来的类加载器。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2020-09-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 Java艺术 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 父类加载器加载的类不能引用子类加载器加载的类
  • 实现Agent与应用环境隔离
  • 适配webmvc框架
  • 总结
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档