相信大家都遇到过这种场景,线上出故障了,但是关键代码里面忘记打日志了,导致无法复现和准确定位问题。这时候可能需要重写加上日志,部署到服务器,但这第一耗时间,第二可能破坏现场,比如可能是线程池的问题呢?所以如果可以不重启服务器,就可以给代码加上日志,是多么棒的一件事呀。那能不能实现,of course。
当然市面上有很多工具可以实现热部署,比如btrace,jvm-sandbox等。那如果我们想要自己实现如下图的结果,思路是什么?
我们知道Java对象的行为(函数,方法)是存储在方法区的,从下图可以看到,方法区的数据是由类加载器把编译好的class文件加载到jvm方法区的。所以我们可以得出简单思路是:
1. 在对应类Java代码中新增日志代码,并重新编译得到新的class文件。
2. 让jvm重新加载这个类的class文件到方法区
第一步倒是挺好实现,但是第二步,如何让jvm加载一个已经加载过的类?
答案是“java.lang.instrument.Instrumentation”
那Instrumentation是做什么的呢,翻译了一下类的API注释
Instrumentation类提供控制Java语言程序代码的服务。Instrumentation可以实现在方法插入额外的字节码从而达到收集使用中的数据到指定工具的目的。由于插入的字节码是附加的,这些更变不会修改原来程序的状态或者行为。通过这种方式实现的良性工具包括监控代理、分析器、覆盖分析程序和事件日志记录程序等等。
Instrumentation中有两个方法都可以实现重新替换已经存在的class文件,它们是:redefineClasses 和 retransformClasses。区别是redefineClasses 是自己提供字节码文件替换 掉已存在的 class 文件,retransformClasses 是在已存在的字节码文件上修改后再替换之。
讲到这里,简单的实现思路已浮现在眼前,先得到编译好的class文件,然后调用redefineClasses进行替换。或者是使用ASM等可以直接修改字节码的工具,然后调用retransformClasses进行替换。
实现案例:自己实现字节码增强
BTrace 是基于 Java 语言的一个安全的、可提供动态追踪服务的工具。BTrace基于 ASM、Java Attach Api、Instruments,JavaCompile 开发,为用户提供了很多注解。依靠这 些注解,我们可以编写 BTrace 脚本(简单的 Java 代码)达到我们想要的效果。
使用技术
- JavaCompile
- Instrumentation
- ASM
- Javaagent
- attach api
原理
Btrace主要包括jar包有:btrace-agent.jar、btrace-client.jar和btrace-boot.jar。
btrace-agent.jar主要功能是在目标jvm中植入BtraceAgent的实现,主要是使用instrumentation和asm字节码处理技术,同时启用一个server socket与客户端进行交互,实现监控文件的发送和结果的返回。
btrace-client.jar在本地启动一个jvm,内部创建socket与BtraceAgent连接,进行字节码文件数据通讯,还有其他event事件等。同时,BtraceAgent的服务端将数据回传给BtraceClient进行打印。
小结: 其实BTrace就是使用了java attach api附加agent.jar,然后使用脚本解析引擎+asm来重写指定类的字节码,再使用instrument实现对原有类的替换。
使用案例:BTrace实现字节码增强
JVM SandBox 是阿里开源的一款 JVM 平台非侵入式运行期 AOP 解决方案。可用于故障定位、方法请求录制和结果回放、动态日志打印等场景。
可插拔:沙箱以及沙箱的模块可以随时加载和卸载,不会在目标应用留下痕迹
无侵入:目标应用无需重启也无需感知沙箱的存在
类隔离:沙箱以及沙箱的模块不会和目标应用的类相互干扰
多租户:目标应用可以同时挂载不同租户下的沙箱并独立控制
高兼容:支持JDK[6,11]
在沙箱的世界观中,任何一个Java方法的调用都可以分解为BEFORE、RETURN和THROWS三个环节,由此在三个环节上引申出对应环节的事件探测和流程控制机制。
// BEFORE
try {
/*
* do something...
*/
// RETURN
return;
} catch (Throwable cause) {
// THROWS
}
基于BEFORE、RETURN和THROWS三个环节事件分离,沙箱的模块可以完成很多类AOP的操作。
JVM SandBox 可插拔至少有两层含义:一层是 JVM 沙箱本身是可以被插拔的,可被动态地挂载到指定 JVM 进程上和可以被动态地卸载;另一层是 JVM 沙箱内部的模块是可以被插拔的,在沙箱启动期间,被加载的模块可以被动态地启用和卸载。
沙箱可拔插:
一个典型的沙箱使用流程如下:
/sandbox.sh -p 33342 #将沙箱挂载到进程号为 33342 的 JVM 进程上
/sandbox.sh -p 33342 -d 'my-sandbox-module/addLog' #运行指定模块, 模块功能生效
/sandbox.sh -p 33342 -S #卸载沙箱
模块可拔插:
模块生命周期类型有模块加载、模块卸载、模块激活、模块冻结、模块加载完成五个状态。模块可以通过实现com.alibaba.jvm.sandbox.api.ModuleLifecycle接口,对模块生命周期进行控制。
沙箱内部定义了一个 Spy 类,该类被称为“间谍类”,通过Spy类来实现修改和重定义业务类来实现对目标代码的增强。
private static void run(){
System.out.println( "working..." );
}
private void run(){
try {
//开始产生before事件
Spy.spyMethodOnBefore(new Object[]{},
"namespace",
100,
1334,
"com.bj58.sandbox.Base",
"run",
"",
this);
System.out.println( "working..." );
//执行后(省略)
} catch( Throwable throwable ){
// 异常省略
}
}
沙箱通过事件驱动的方式,让模块开发者可以监听到方法执行的某个事件并设置回调逻辑,这一切都可以通过实现AdviceListener 接口来做到,通过 AdviceListener 接口定义的行为,我们可以了解沙箱支持的监听事件如下:
JVM 沙箱有自己的工作代码类,而这些代码类在沙箱被挂在到目标 JVM 之后,其涉及到的相关功能实现类都要被加载到目标 JVM 中,沙箱代码和业务代码共享 JVM 进程,这里有两个问题:1)如何避免沙箱代码和业务代码之间产生冲突;2)如何避免不同沙箱模块之间的代码产生冲突。为解决这两个问题,JVM SandBox 定义了自己的类加载器,严格控制类的加载,沙箱的核心类加载器有两个:SandBoxClassLoader 和 ModuleJarClassLoader。SandBoxClassLoader 用于加载沙箱自身的工作类,ModuleJarClassLoader 用于加载三方自己开发的模块功能类。结构如下图所示:
SandBox与Tomcat同级加载,通过自定义的SandboxClassLoader破坏了双亲委派的约定,实现了和观察应用的类隔离。所以不用担心加载沙箱会引起应用的类污染、冲突。
破坏后的双亲委派机制变成了什么?要挂载一个类,它会先看我当前的ClassLoader是不是加载了,如果没加载,它会让当前的ClassLoader尝试着去加载,也就是它不再向它的父类去询问,除非它无法加载的时候,它才会去问它的父ClassLoader说,你是不是已经加载了,如果父ClassLoader也没有加载的话,它会让父ClassLoader尝试着去加载。这样就完成了我的目标应用之间与Sandbox之间的隔离
sandbox支持多个用户对同一个 JVM 同时使用沙箱功能且他们之间互不影响。沙箱的这种机制是通过支持创建多个 SandBoxClassLoader 的方式来实现的,每个 SandBoxClassLoader 关联唯一一个命名空间(namespace)用于标识不同的用户,示意图如下所示:
使用案例:jvm-sandbox实现字节码增强
优点:
缺点:
限制如下:
BTrace class不能新建类, 新建数组, 抛异常, 捕获异常,
不能调用实例方法以及静态方法(com.sun.btrace.BTraceUtils除外)
不能将目标程序和对象赋值给BTrace的实例和静态field
不能定义外部, 内部, 匿名, 本地类
不能有同步块和方法
不能有循环
不能实现接口, 不能扩展类
不能使用assert语句, 不能使用class字面值
优点:
缺点:
部分开发文档还未完善,但是项目开源并且注释都是中文,所以这一点问题不是很大。