大力的长颈鹿 · PHP时间戳与时间相互转换(精确到毫秒)-阿 ...· 11 月前 · |
爱搭讪的开水瓶 · HstsBuilderExtensions. ...· 1 年前 · |
稳重的红豆 · 【WebService】WebService ...· 1 年前 · |
冷静的消炎药 · JAVA使用SnakeYAML解析与序列化Y ...· 1 年前 · |
道上混的鸡蛋 · 如何在 CentOS 6 上设置和配置 ...· 1 年前 · |
随着微服务架构的兴起,应用行为的复杂性显著提高,为了提高服务的可观察性,分布式监控系统变得十分重要。
基于 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`
用 16 进制打开 Demo.class 文件,解析后字段也是有很多 struct 字段组成:比如常量池、父类信息、方法信息等。
JDK 自带的解析工具 javap ,可以以人类可读的方式打印 class 文件,其结果也和上述一致
JVM(Java Virtual Machine),一种能够运行 Java bytecode 的虚拟机,是 Java 体系的一部分。JVM 有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统。JVM 屏蔽了与具体操作系统平台相关的信息,使得 Java 程序只需生成在 JVM 上运行的目标代码(字节码),就可以在多种平台上不加修改地运行, 这便是 "一次编译,到处运行" 的真正含义 。
作为一种编程语言的虚拟机,实际上不只是专用于 Java 语言,只要生成的编译文件符合 JVM 对加载编译文件格式要求,任何语言都可以由 JVM 编译运行。
同时 JVM 技术规范未定义使用的垃圾回收算法及优化 Java 虚拟机指令的内部算法等,仅仅是描述了应该具备的功能,这主要是为了不给实现者带来过多困扰与限制。正是由于恰到好处的描述,这给各厂商留下了施展的空间。
维基百科:已有的 JVM 比较
其中 HotSpot(Orcale) 与性能更好的 OpenJ9(IBM) 被广大开发者喜爱。
JVM 部署之后,每一个 Java 应用的启动,都会调用 JVM 的 lib 库去申请资源创建一个 JVM 实例。JVM 将内存分做了不同区域,如下是 JVM 运行时的内存模型:
Java 应用程序在启动和运行时,一个重要的动作是:加载类的定义,并创建实例。这依赖于 JVM 自身的 ClassLoader 机制。
双亲委派
一个类必须由一个 ClassLoader 负责加载,对应的 ClassLoader 还有父 ClassLoader ,寻找一个类的定义会自下而上的查找,这就是双亲委派模型。
为了节省内存,JVM 并不是将所有的类定义都放入内存,而是
这样的设计让我们联想到:如果能在加载时或者直接替换已经加载的类定义,就可以完成神奇的增强。
默默无闻的 JVM 屏蔽了底层的复杂,让开发者专注于业务逻辑。除了启动时通过 java -jar 带内存参数之外,其实有一套专门接口提供给开发者,那就是 JVM tool Interface 。
JVM TI 是一个双向接口。JVM TI Client 也叫 agent ,基于 event 事件机制。它接受事件,并执行对 JVM 的控制,也能对事件进行回应。
它有一个重要的特性 - Callback (回调函数 )机制:JVM 可以产生各种事件,面对各种事件,它提供了一个 Callback 数组。每个事件执行时,都会调用 Callback 函数, 所以编写 JVM TI Client 的核心就是放置 Callback 函数。
正是有了这个机制能让我们向 JVM 发送指令,加载新的类定义。
现在我们试着思考下:如何去魔改应用程序中的方法的定义呢?
这有点像大象放入冰箱需要几步:
替换后,系统将使用我们增强过的方法。
这并不容易,但幸运的是,jdk 已经为我们准备好了这样的上层接口 instructment 包。它使用起来也是十分容易,我们下面通过一个 agent 简单示例,来讲解 instructment 包的关键设计。
javaagent 有两种使用 方式:
使用第一种方式的 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.0Can-Redefine-Classes: trueCan-Retransform-Classes: truePremain-Class: PreMainTraceAgent |
---|
然后在 resources 目录下新建目录:META-INF,在该目录下新建文件:MANIFREST.MF:
最后打包成 agent.jar 包
到了这里就会发现,增强字节码也是如此的简单。
通过前面的了解,有种修改字节码也不过如此的感觉 ^_^ !!!但是我们不得不重视另一个问题,字节的如何生成的?
下面会介绍几个常见的字节码生成工具
ASM 是一个纯粹的字节码生成和分析框架。它有完整的语法分析,语义分析,可以被用来动态生成 class 字节码。但是这个工具还是过于专业,使用者必须十分了解 JVM 规范,必须清楚替换一个函数究竟要在 class 文件做哪些改动。ASM 提供了两套 API:
初步掌握字节码 与 JVM 内存模型的知识,可以照着官方文档进行简单地类生成。
ASM 十分强大,被应用于
ByteBuddy 是一款出众的运行时字节码生成工具,基于 ASM 实现,提供更易用的 API。被众多分布式监控项目比如 Skywalking、Datadog 等使用 作为 Java 应用程序的探针来采集监控信息。
以下是与其他工具的性能比较。
在我们实际的使用中,ByteBuddy 的 API 确实比较友好,基本满足了所有字节码增强需求:接口、类、方法、静态方法、构造器方法、注解等的修改。除此之外内置的 Matcher 接口,支持模糊匹配,可以根据名称匹配修改符合条件的类型。
但也有缺点,官方文档比较旧,中文文档少。很多重要的特性,比如切面,并未详细介绍,往往需要看代码注释,和测试用例才弄懂真正的含义。如果对 ByteBuddy 这个工具有兴趣的同学,可以关注我们的公众号,后面的文章会就 ByteBuddy 做专门的分享。
通过字节码增强,我们可以做到无侵入的埋点,那么和 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
手动生成 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;
植入 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.Greeting
大力的长颈鹿 · PHP时间戳与时间相互转换(精确到毫秒)-阿里云开发者社区 11 月前 |