前言

        随着微服务架构的兴起,应用行为的复杂性显著提高,为了提高服务的可观察性,分布式监控系统变得十分重要。

        基于 Google 的 Dapper 论文,发展出了很多有名的监控系统:Zipkin、Jaeger、Skywalking 以及想一统江湖的 OpenTelemetry 等。一众厂家和开源爱好者围绕着监控数据的采集、收集、存储以及展示做出了不少出色的设计。 

        时至今日即使是个人开发者也能依赖开源产品,轻松的搭建一套完备的监控系统。但作为监控服务的提供者,必须要做好与业务的解绑,来降低用户接入、版本更新、问题修复、业务止损的成本。所以一个可插拔、无侵入的采集器成为一众厂家必备的杀手锏。

        为了获取服务之间调用链信息,采集器通常需要在方法的前后做埋点。在 Java 生态中,常见的埋点方式有两种:依赖 SDK 手动埋点;利用 Javaagent 技术来做无侵入埋点。下面围绕着 无侵入埋点的技术与原理为大家做一个全面的介绍。


无侵入的采集器(探针)

分布式监控系统中,模块可以分为:采集器(Instrument)、发送器(TransPort)、收集器(Collector)、存储(Srotage)、展示(API&UI)。

 zipkin 的架构图示例

采集器将收集的监控信息,从应用端发送给收集器,收集器进行存储,最终提供给前端查询。

采集器收集的信息,我们称之为 Trace (调用链)。一条 Trace 拥有唯一的标识 traceId,由自上而下的树状 span 组成。每个 span 除了spanId 外,还拥有 traceId 、父 spanId,这样就可以还原出一条完整的调用链关系。

为了生成一条 span , 我们需要在方法调用的前后放入埋点。比如一次 http 调用,我们在 execute() 方法的前后加入埋点,就可以得到完整的调用方法信息,生成一个 span 单元。

     在 Java 生态中,常见的埋点方式有两种:依赖 SDK 手动埋点;利用 Javaagent 技术来做无侵入埋点。不少开发者接触分布式监控系统,是从 Zipkin 开始的,最经典的是搞懂 X-B3 trace协议,使用 Brave SDK,手动埋点生成 trace。但是 SDK 埋点的方式,无疑和业务逻辑做了深深的依赖,当升级埋点时,必须要做代码的变更。 

       那么如何和业务逻辑解绑呢?

      Java 还提供了另外一种方式:依赖  Javaagent 技术,修改目标方法的字节码,做到无侵入的埋点。这种利用 Javaagent 的方式的采集器,也叫做探针。在应用程序启动时使用 -javaagent ,或者运行时使用 attach( pid) 方式,就可以将探针包导入应用程序,完成埋点的植入。无侵入的方式,可以做到无感的热升级。用户不需要理解深层的原理,就可以使用完整的监控服务。目前众多开源监控产品已经提供了丰富的 java 探针库,作为监控服务的提供者,进一步降低了开发成本。

       想要开发一个无侵入的探针,可以分为三个部分:Javaagent ,字节码增强工具,trace 生成逻辑。下面会为大家介绍这些内容。


基础概念

使用 JavaAgent 之前 让我们先了解一下 Java 相关的知识。

什么是字节码?

      类 c 语言 Java 从1994年被 sun 公司发明以来,依赖着 "一次编译、到处运行" 特性,迅速的风靡全球。与 C++ 不同的是,Java 将所有的源码首先编译成  class (字节码)文件,再依赖各种不同平台上的 JVM(虚拟机)来解释执行字节码,从而与硬件解绑。class 文件的结构是一个 table 表,由众多 struct 对象拼接而成。

类型

名称

说明

长度

u4 magic 魔数,识别Class文件格式 4个字节
u2 minor_version 副版本号 2个字节
u2 major_version 主版本号 2个字节
u2 constant_pool_count 常量池计算器 2个字节
cp_info constant_pool 常量池 n个字节
u2 access_flags 访问标志 2个字节
u2 this_class 类索引 2个字节
u2 super_class 父类索引 2个字节
u2 interfaces_count 接口计数器 2个字节
u2 interfaces 接口索引集合 2个字节
u2 fields_count 字段个数 2个字节
field_info fields 字段集合 n个字节
u2 methods_count 方法计数器 2个字节
method_info methods 方法集合 n个字节
u2 attributes_count 附加属性计数器 2个字节
attribute_info attributes 附加属性集合 n个字节

                                                  字节码的字段属性
让我们编译一个简单的类`Demo.java`

package com.httpserver;public class Demo {    private int num = 1;        public int add() {        num = num + 2;        return num;    }}

用16进制打开 Demo.class 文件,解析后字段也是有很多 struct 字段组成:比如常量池、父类信息、方法信息等。

JDK 自带的解析工具 javap  ,可以以人类可读的方式打印 class 文件,其结果也和上述一致

什么是JVM? 

     JVM(Java Virtual Machine),一种能够运行 Java bytecode 的虚拟机,是Java 体系的一部分。JVM 有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。JVM 屏蔽了与具体操作系统平台相关的信息,使得Java 程序只需生成在 JVM 上运行的目标代码(字节码),就可以在多种平台上不加修改地运行, 这便是 "一次编译,到处运行" 的真正含义 。

     作为一种编程语言的虚拟机,实际上不只是专用于 Java 语言,只要生成的编译文件符合 JVM 对加载编译文件格式要求,任何语言都可以由JVM编译运行。

     同时 JVM 技术规范未定义使用的垃圾回收算法及优化 Java 虚拟机指令的内部算法等,仅仅是描述了应该具备的功能,这主要是为了不给实现者带来过多困扰与限制。正是由于恰到好处的描述,这给各厂商留下了施展的空间。

 维基百科:已有的 JVM 比较 

其中 HotSpot(Orcale) 与性能更好的 OpenJ9(IBM) 被广大开发者喜爱。

JVM 的内存模型

JVM 部署之后,每一个 Java 应用的启动,都会调用 JVM 的 lib 库去申请资源创建一个 JVM 实例。JVM 将内存分做了不同区域,如下是 JVM 运行时的内存模型:

  • 方法区:用于存放的类信息、常量、静态变量、即时编译器编译后的代码等数据

  • :所有线程共享,放置 object 对象与数组,也是 GC (垃圾收集器的主要区域)

  • 虚机栈&程序计数器:线程私有的,每一个新的线程都会分配对应的内存对象。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

双亲委派加载机制

Java 应用程序在启动和运行时,一个重要的动作是:加载类的定义,并创建实例。这依赖于 JVM 自身的 ClassLoader 机制。

双亲委派

 
一个类必须由一个 ClassLoader 负责加载,对应的 ClassLoader 还有父 ClassLoader ,寻找一个类的定义会自下而上的查找,这就是双亲委派模型。

为了节省内存,JVM 并不是将所有的类定义都放入内存,而是

  • 启动时:将必要的类通过 ClassLoader 加载到内存

  • 运行时:创建一个新实例时,优先从内存中寻找,否则加载进内存

  • 执行方法:寻找方法的定义,将局部变量和方法的字节码放入虚机栈中,最终返回计算结果。当然静态方法会有所区别。

这样的设计让我们联想到:如果能在加载时或者直接替换已经加载的类定义,就可以完成神奇的增强。

JVM tool Interface

       默默无闻的 JVM 屏蔽了底层的复杂,让开发者专注于业务逻辑。除了启动时通过 java -jar 带内存参数之外,其实有一套专门接口提供给开发者,那就是 JVM tool Interface 。

      JVM TI 是一个双向接口。JVM TI Client 也叫 agent ,基于 event 事件机制。它接受事件,并执行对 JVM 的控制,也能对事件进行回应。

      它有一个重要的特性 - Callback (回调函数 )机制:JVM 可以产生各种事件,面对各种事件,它提供了一个 Callback 数组。每个事件执行时,都会调用 Callback 函数, 所以编写 JVM TI Client 的核心就是放置 Callback 函数。

      正是有了这个机制能让我们向 JVM 发送指令,加载新的类定义。 

JavaAgent 

现在我们试着思考下:如何去魔改应用程序中的方法的定义呢?

这有点像大象放入冰箱需要几步:

  1. 按照字节码的规范生成新的类

  2. 使用 JVM TI ,命令 JVM 将类加载到对应的内存去。

替换后,系统将使用我们增强过的方法。

       这并不容易,但幸运的是,jdk已经为我们准备好了这样的上层接口 instructment 包。它使用起来也是十分容易,我们下面通过一个 agent 简单示例,来讲解 instructment 的关键设计。

Javaagent 简单示例

javaagent 有两种使用 方式:

  • 启动时加入参数配置 agent 包路径 : -javaagent:/${path}/agent.jar;

  • 运行时attach 到JVM 实例的pid ,将 jar 包附着上去 :VirtualMachine.attach(pid);VirtualMachine.loadAgent("/<path>/agent.jar");

使用第一种方式的 demo

public class PreMainTraceAgent {    public static void premain(String agentArgs, Instrumentation inst) {        inst.addTransformer(new DefineTransformer(), true);    }    static class DefineTransformer implements ClassFileTransformer{        @Override        public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {            System.out.println("premain load Class:" + className);            return classfileBuffer;        }    }}

Manifest-Version: 1.0

Can-Redefine-Classes: true

Can-Retransform-Classes: true

Premain-Class: PreMainTraceAgent

然后在 resources 目录下新建目录:META-INF,在该目录下新建文件:MANIFREST.MF: 


最后打包成 agent.jar 包

  •  premain() :-javaagent  方式进入的入口。顾名思义他是在 main 函数前执行的,制作 jar 包时需要在 MF 文件中指名入口 Premain-Class: PreMainTraceAgent

  • Instrumentation:JVM 实例的句柄。无论是 -javaagent 还是 attach 上去,最终都会获得一个实例相关的 Instrumentation。inst 中比较重要的两个函数是 redefineClasses(ClassDefinition... definitions) 与 retransformClasses(Class<?>... classes) 通过这两个函数,我们都可以将增强后字节码加入到 JVM 中 

    redefineClasses() 和 retransformClasses() 的区别 ?
    redefineClasses() 适合将新加入的类做修改,而 retransformClasses() 可以将哪些已经加载到内存中的类定义做替换
  • ClassFileTransformer:这个接口里面有一个重要的方法 transform() ,使用者需要实现这个类。当这个类被加入 inst  的内的 Transformer 数组时,每一个类的加载或修改,都会调用到该方法。类的定义相关信息,比如类二进制定义 classfileBuffer 

  • addTransformer() :可以将实现了 ClassFileTransformer 的类加入 Instrumentation 中内置的数组。就像一个加工厂,上一个 ClassFileTransformer 处理过的类,会作为下一个 ClassFileTransformer 的参数。

到了这里就会发现,增强字节码也是如此的简单。


字节码生成工具

通过前面的了解,有种修改字节码也不过如此的感觉 ^_^ !!!但是我们不得不重视另一个问题,字节的如何生成的?

  1.  大佬:我熟悉 JVM 规范,明白每一个字节码的含义,我可以手动改class文件,为此我写了一个库 。

  2.  高手:我知道客户的框架,我修改源码,重新编译,将二进制替换进去。

  3.  小白:字节码我是看不懂啦,大佬写的库我会用就行了。

下面会介绍几个常见的字节码生成工具

ASM 

      ASM 是一个纯粹的字节码生成和分析框架。它有完整的语法分析,语义分析,可以被用来动态生成 class 字节码。但是这个工具还是过于专业,使用者必须十分了解 JVM 规范,必须清楚替换一个函数究竟要在 class 文件做哪些改动。ASM 提供了两套API:

  • CoreAPI 基于事件的形式表现类;

  • TreeAPI 基于对象的方式来表现类

初步掌握字节码 与JVM 内存模型的知识,可以照着官方文档进行简单地类生成。 

ASM 十分强大,被应用于 
 1. OpenJDK的 lambda语法 
 2. Groovy  Koltin 的编译器 
 3. 测试覆盖率统计工具 Cobertura  Jacoco 
 4. 单测 mock 工具,比如 Mockito  EasyMock 
 5. CGLIB ,ByteBuddy 这些动态类生成工具。

BYTEBUDDY

      ByteBuddy 是一款出众的运行时字节码生成工具,基于 ASM 实现,提供更易用的 API。被众多分布式监控项目比如 Skywalking、Datadog 等使用 作为 Java 应用程序的探针来采集监控信息。

以下是与其他工具的性能比较。

  • Java Proxy:JDK 自带的代理机制,可以做到托管用户的类,以便于扩展。但是必须给定一个接口,作用有限

  • Cglib:很有名气,但是开发的太早了,并没有随着 JDK 的特性一起更新。虽然它的库依旧很有用,但是也慢慢被被使用者从项目中移除

  • Javassit: 这个库企图模仿 javac 编译器,做到运行时转化源代码。这非常有雄心,然而这个难度很有挑战,目前为止和 javac 还有相当大的差距。

      在我们实际的使用中,ByteBuddy 的 API 确实比较友好,基本满足了所有字节码增强需求:接口、类、方法、静态方法、构造器方法、注解等的修改。除此之外内置的 Matcher 接口,支持模糊匹配,可以根据名称匹配修改符合条件的类型。

       但也有缺点,官方文档比较旧,中文文档少。很多重要的特性,比如切面,并未详细介绍,往往需要看代码注释,和测试用例才弄懂真正的含义。如果对 ByteBuddy 这个工具有兴趣的同学,可以关注我们的公众号,后面的文章会就 ByteBuddy 做专门的分享。 


Trace 数据的生成

通过字节码增强,我们可以做到无侵入的埋点,那么和 trace 的生成逻辑的关联才算是注入灵魂。下面我们通过一个简单例子,来展示这样的结合是如何做到的。

Tracer API

这是一个简单的 API,用来生成 trace 消息。

public class Tracer {    public static Tracer newTracer() {        return new Tracer();    }     public Span newSpan() {        return new Span();    }      public static class Span {        public void start() {            System.out.println("start a span");        }         public void end() {            System.out.println("span finish");            // todo: save span in db        }    }}

仅有一个方法 sayHello(String name)目标类 Greeting

public class Greeting {    public static void sayHello(String name) {        System.out.println("Hi! " + name);    }}

 

手动生成 trace 消息,我们需要在方法的前后加入埋点手动埋点

... public static void main(String[] args) {    Tracer tracer = Tracer.newTracer();    // 生成新的span    Tracer.Span span = tracer.newSpan();         // span 的开始与结束    span.start();    Greeting.sayHello("developer");    span.end();}...

无侵入埋点

字节增强可以让我们无需修改源代码。现在我们可以定义一个简单的切面,将 span 生成逻辑放入切面中,然后利用 Bytebuddy 将埋点植入。

TraceAdvice 

将 trace 生成逻辑放入切面中去

public class TraceAdvice {    public static Tracer.Span span = null;     public static void getCurrentSpan() {        if (span == null) {            span = Tracer.newTracer().newSpan();        }    }     /**     * @param target 目标类实例     * @param clazz  目标类class     * @param method 目标方法     * @param args   目标方法参数     */    @Advice.OnMethodEnter    public static void onMethodEnter(@Advice.This(optional = true) Object target,                                     @Advice.Origin Class<?> clazz,                                     @Advice.Origin Method method,                                     @Advice.AllArguments Object[] args) {        getCurrentSpan();        span.start();     }     /**     * @param target 目标类实例     * @param clazz  目标类class     * @param method 目标方法     * @param args   目标方法参数     * @param result 返回结果     */    @Advice.OnMethodExit(onThrowable = Throwable.class)    public static void onMethodExit(@Advice.This(optional = true) Object target,                                    @Advice.Origin Class<?> clazz,                                    @Advice.Origin Method method,                                    @Advice.AllArguments Object[] args,                                    @Advice.Return(typing = Assigner.Typing.DYNAMIC) Object result) {        span.end();        span = null;     }}
  1. onMethodEnter:方法进入时调用。Bytebuddy 提供了一系列注解,带有 @Advice.OnMethodExit 的静态方法,可以被植入方法开始的节点。我们可以获取方法的详细信息,甚至修改传入参数,跳过目标方法的执行。

  2. OnMethodExit:方法结束时调用。类似 onMethodEnter,但是可以捕获方法体抛出的异常,修改返回值。

植入 Advice

将Javaagent 获取的 Instrumentation 句柄 ,传入给 AgentBuilder (Bytebuddy 的 API)

public class PreMainTraceAgent {      public static void premain(String agentArgs, Instrumentation inst) {         // Bytebuddy 的 API 用来修改        AgentBuilder agentBuilder = new AgentBuilder.Default()                .with(AgentBuilder.PoolStrategy.Default.EXTENDED)                .with(AgentBuilder.InitializationStrategy.NoOp.INSTANCE)                .with(AgentBuilder.RedefinitionStrategy.RETRANSFORMATION)                .with(new WeaveListener())                .disableClassFormatChanges();         agentBuilder = agentBuilder                // 匹配目标类的全类名                .type(ElementMatchers.named("baidu.bms.debug.Greeting"))                .transform(new AgentBuilder.Transformer() {                    @Override                    public DynamicType.Builder<?> transform(DynamicType.Builder<?> builder,                                                            TypeDescription typeDescription,                                                            ClassLoader classLoader,                                                            JavaModule module) {                         return builder.visit(                                // 织入切面                                Advice.to(TraceAdvice.class)                                        // 匹配目标类的方法                                        .on(ElementMatchers.named("sayHello"))                        );                    }                });        agentBuilder.installOn(inst);    }     // 本地启动    public static void main(String[] args) throws Exception {        ByteBuddyAgent.install();        Instrumentation inst = ByteBuddyAgent.getInstrumentation();         // 增强        premain(null, inst);        // 调用        Class greetingType = Greeting.class.                getClassLoader().loadClass(Greeting.class.getName());        Method sayHello = greetingType.getDeclaredMethod("sayHello", String.class);        sayHello.invoke(null, "developer");    }

本地调试

除了制作 agent.jar 之外,我们本地调试时可以在 main 函数中启动,如上面提示的那样。

打印结果

WeaveListener onTransformation : baidu.bms.debug.Greetingstart a spanHi! developerspan finishDisconnected from the target VM, address: '127.0.0.1:61646', transport: 'socket'可以看到,我们已经在目标方法的前后,已经加入 trace 的生成逻辑。

实际的业务中,我们往往只需要对应用程序使用的框做捕获,比如对 Spring 的 RestTemplate 方法,就可以获得准确的 Http 方法的调用信息。这种依赖这种字节码增强的方式,最大程度的做到了和业务解耦。