1 背景
在JAVA 5中,我们可以通过java代码,即java.lang.instrument做动态Instrumentation,它把Java的instrument功能从本地代码中解放出来,使之可以用java代码的方式解决问题。使用Instrumentation,开发者可以构建一个独立于应用程序的代理程序(agent),用来监测和协助运行在JVM上的程序,甚至能够替换和修改某些类的定义。有了这样的功能,开发者就可以实现更为灵活的运行时虚拟机监控和Java类操作了,这样的特性实际上提供了一种虚拟机级别支持的AOP实现方式,使得开发者无需对JDK做任何升级和改动,就可以使用某些AOP的功能了。
在JAVA SE6中,通过Java ToolAPI中的attach方式,我们可以很方便的在运行过程中动态的设置加载代理类,以达到instrumentation的目的。而不用像在Java SE 5中,必须在运行前用命令行参数或者系统参数来设置代理类。
java.lang.instrument包的具体实现,依赖于JVMTI。JVMTI(Java VirtualMachine Tool Interface)是一套由Java虚拟机提供的,为JVM相关的工具提供的本地编程接口集合。除开Instrumentation功能外,JVMTI还在虚拟机内存管理,线程控制,方法和变量操作等等方面提供了大量有价值的函数。
简单的来看,如果需要通过Instrumentation操作或监控一个Java程序,相关的工具和流程如下:
2 原理
2.1 Java agent
Java agent以jar包的形式部署在JVM中,jar文件的manifest需要指定agent的类名。根据不同的启动时机,agent类需要实现不同的方法(二选一)。
/** * 以vm参数的形式载入,在程序main方法执行之前执行 * 其jar包的manifest需要配置属性Premain-Class */ public static void premain(String agentArgs, Instrumentation inst); /** * 以Attach的方式载入,在Java程序启动后执行 * 其jar包的manifest需要配置属性Agent-Class */ public static void agentmain(String agentArgs, Instrumentation inst);
一个Java agent既可以在VM启动时加载,也可以在VM启动后加载:
1) 启动时加载:
java –javaagent:jar 文件的位置 [=传入premain的参数]
2)启动后加载:
在vm启动后的任何时间点,通过attach api,动态的启动agent,参加后面的attach api节
agent会被所在java程序的classloader加载,从而在agent中可以很容易获取到想要的class。对于启动时加载的javaagent,其premain方法会在程序main方法执行之前被调用,如果premain方法执行失败或者抛出异常,那么JVM启动会被终止。对于启动后加载的java agent,其agentmain方法会在加载之时立即执行。如果agentmain执行失败或抛出异常,JVM会忽略掉错误,不会影响正在running的java程序。
2.2 Java Instrumentation 在上述两个方法中传入的参数类型Instrumentation接口定义如下: public interface Instrumentation { /** * 注册一个Transformer,从此之后的类加载都会被Transformer拦截 * Transformer可以直接对类的字节码byte[]进行修改 */ void addTransformer(ClassFileTransformer transformer); /** * 对JVM已经加载的类重新出发类加载。使用的就是上面注册的Transformer。 * retransformation可以修改方法体,但是不能变更方法签名、增加和删除方法/类的成员属性 */ void retransformClasses(Class<?>… classes) throws UnmodifiableClassException; /** * 获取一个对象的大小 */ long getObjectSize(Object objectToSize); /** * 将一个jar加入到bootstrap classloader的classpath里 */ void appendToBootstrapClassLoaderSearch(JarFile jarfile); /** * 获取当前被JVM加载的所有类对象 */ Class[] getAllLoadedClasses(); }
其中最常用的方法是addTransformer(ClassFileTransformertransformer),这个方法可以在类加载时做拦截,对输入的类的字节码进行修改,其参数是一个ClassFileTransformer接口,定义如下:
** * 传入参数表示一个即将被加载的类,包括了classloader, classname和字节码byte[] * 返回值为需要被修改后的字节码byte[] */ byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer )throws IllegalClassFormatException;
addTransformer方法配置之后,后续的类加载都会被Transformer拦截。对于已经加载的类,可以执行retransformClasses来重新触发这个Transformer的拦截。类加载的字节码被修改后,除非再次被retransform,否则不会恢复。
一个简单的java agent程序如下,该程序通过-javaagent参数附着在目标程序上启动,实现了在类加载时做拦截,修改字节码的功能。
public class InstrumentationExample { //java agent指定的premain方法,会在main方法之前被调用 public static void premain(String args, Instrumentation inst) { //Instrumentation提供的addTransformer方法,在类加载时会回调ClassFileTransformer接口 inst.addTransformer(new ClassFileTransformer() { @Override public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException { //根据className判断是否是需要修改的类 //开发者在此自定义做字节码操作,将传入的字节码修改后返回 //通常这里需要字节码操作框架如javassit,asm等 //…… return transformResult; } }); } }
执行流程如下图所示:
2.3 Attach API
Java agent在启动后再加载是通过AttachAPI实现的。Attach API是跨JVM进程通讯的工具,能够将某种指令从一个JVM进程发送给另一个JVM进程。加载agent之时Attach API发送的各种指令中的一种,诸如jstack打印进程栈、jps列出java进程、jmap做内存dump等功能,都属于Attach API可以发送的指令。下面的代码表示了向进程pid为1234的JVM发起通讯,加载一个名为agent.jar的java agent。
// VirtualMachine等相关class位于JDK的tools.jar VirtualMachine vm = VirtualMachine.attach(“1234”); // 1234表示目标JVM进程pid try { vm.loadAgent(“…/agent.jar”); // 指定agent的jar包路径,发送给目标进程 } finally { vm.detach(); }
vm.loadAgent之后,相应的agent就会被目标JVM进程加载,并执行agentmain方法。
3. 常见应用 3.1热部署(JRebel) JRebel是目前最常用的热部署工具,节省了大量重启时间,提高了个人开发效率。当程序员在开发环境中对任何一个类或者资源做出修改的时候,这个变化会直接反应在部署好的应用程序上,从而跳过构建和部署的过程,每年可以省去部署用的时间花费高达5.25个星期。 其原理是通过javaagent,使用Instrumentation API来修改已加载的类。由于Instrumentation只能修改方法体,JRebel创造了一个间接层,在应用类被载入的时候,所有的方法体被改写为通过一个运行时重定向服务进行重定向。每次修改类,会重新生成一个新版本的类和方法,而不是直接retransformClasses。这个服务会管理和加载这些类和方法的版本。比如我们看下面这个例子:
C是一个有两个方法的类:
public class C extends X { int y = 5; int method1(int x){ return x + y; } void method2(String s) { System.out.println(s); } }
当C第一次被加载的时候,JRebel修改了这个类的方法体,加入重定向的逻辑,但是方法签名不变:
JRebel会生成一个特定版本的类,比如叫C0,所有方法调用重定向到这个最新版本的类:
public abstract class C0 { public static int method1(C c, int x) { int tmp1 =Runtime.getFieldValue(c, "C", "y", "I"); return x + tmp1; } public static void method2(C c, String s) { PrintStream tmp1= Runtime.getFieldValue( null,"java/lang/System", "out","Ljava/io/PrintStream;"); Object[] o = newObject[1]; o[0] = s; Runtime.redirect(tmp1, o, "java/io/PrintStream;","println","(Ljava/lang/String;)V"); } }
一旦我们要更改C比如加入一个新方法z()并在method1里面去调用,如下:
public class C { int y = 5; int z() { return 10; } int method1(int x){ return x + y +z(); } ... }
那么下次使用这个类的时候,JRebel就会检测到类发生了变化并加载一个新版本的类,比如C1,这个新版本类包含额外的方法z并更新了method1的实现:
public class C1 { public static intz(C c) { return 10; } public static int method1(C c, int x) { int tmp1 =Runtime.getFieldValue(c, "C", "y", "I"); int tmp2 =Runtime.redirect(c, null, "C", "z", "(V)I"); return x + tmp1+ tmp2; } ... }
运行时重定向服务会重定向所有的method1请求到这个最新版本的类的方法,所以调用new C().method1(10)会返回25而不是修改前的15。
3.2 链路分析(pinpoint)
微服务时代,由于一次请求可能要经过若干服务,这样定位问题,查找性能瓶颈都会非常困难、耗时。为此,出现了很多链路分析系统,能够把整个调用链路串起来,直观的展示调用栈,分析每个阶段耗时。Pinpoint通过字节码增强技术(有的叫动态探针技术)来实现无侵入式的调用链采集。其核心实现还是基于JVM的javaagent机制来实现。Pinpoint在启动时通过设置
-javaagent:\$AGENT_PATH/pinpoint-bootstrap-\$VERSION.jar
来指定pinpoint agent加载路径,在启动的时候agent将在加载应用class文件之前做拦截并修改字节码,在class方法调用的前后加上链路采集逻辑,从而实现链路采集功能。
Pinpoint相对于CAT/zipkin等工具的好处是对应用无侵入,也即应用无需进行任何代码层面的改造即可接入,这样推广起来特别容易。
用一幅图来展示pinpoint的字节码转换过程:
1. JVM初始化并通过SystemClassLoader加载Pinpoint Agent类;创建Instrumentation接口实例并调用Pinpoint Agent的Premain方法,并自动传入Instrumentation实例;
2. Pinpoint Agent加载plugins插件,将其中的transformer类注册到Instrumentation实例中;
3. System ClassLoader加载其他的应用Java类,此时将调用注册的transformer方法对要加载的java类进行字节码转换;
4. JVM将转换后的class放入方法区。
3.3 在线诊断(btrace,greys/arthas)
很多时候我们无法在线下重现问题,需要查看线上日志,如果此时恰好没有打印相关的日志怎么办呢?如果加上日志再重新编译、打包、部署,会非常耗时,而且可能加的日志还没有找出问题的所在,这样的过程可能重复很多次,甚至会影响到线上服务。为此出现了很多线上诊断工具。其原理都是通过Attach API动态加载java agent,然后修改对应类的字节码,然后打印日志或者显示其他信息。
一个较早的工具是btrace,之后阿里推出了greys/arthas以及一个更通用的JVM-sandbox框架。其对比如下:
项目
功能
btrace
动态attach到进程,织入代码到指定类的指定方法,可动态打印线上日志,便于排查问题
需要编写代码,容易出错,使用麻烦,不能修改请求参数、返回值等
greys/arthas
Java在线问题诊断工具,可以查看入参,出参,实例属性,调用堆栈等,功能强大
不需要编码,采用命令方式,类加载隔离,通常不会影响线上系统,但是不够灵活,不能修改请求参数、返回值等
JVM-sandbox
JVM沙箱容器,一种JVM的非侵入式运行期AOP解决方案
可以动态修改请求参数、返回值等,通过监听事件方法来实现AOP,灵活且功能强大,非侵入,运行时织入
JVM-sandbox严格来说不是一个在线诊断工具,其他它是一种JVM的非侵入式运行期AOP解决方案,会在稍有给予介绍。这里对最新比较流行的Arthas进行简单的介绍。
当然在介绍Arthas之前还是要给大家说一下Greys,无论是Arthas还是jvm-sandbox都是从Greys演变而来,这个是2014年阿里开源的一款Java在线问题诊断工具。而Arthas可以看做是他的升级版本,是一款更加优秀的,功能更加丰富的Java诊断工具。
在他的github的READEME中的介绍这款工具可以帮助你做下面这些事:
这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
遇到问题无法在线上debug,难道只能通过加日志再重新发布吗?
线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
是否有一个全局视角来查看系统的运行状况?
有什么办法可以监控到JVM的实时运行状态?
举一个例子,很多时候我们方法执行的情况与我们的预期不符合,但是我们又不知道到底哪里不符合,Arthas的watch命令就能帮助我们解决这个问题。
watch命令顾名思义观察,他可以观察指定方法调用情况,定义了4个观察事件点, -b 方法调用前,-e 方法异常后,-s 方法返回后,-f 方法结束后。默认是-f
比如我们想知道某个方法执行的时候,参数和返回值到底是什么。注意这里的参数是方法执行完成的时候的参数,和入参不同有可能会发生变化。
你能得到参数和返回值的情况,以及方法时间消耗的等信息。
Arthas其他常用的命令还有:
sc: 查看JVM已加载的类信息
sm: 查看已加载累的方法信息
stack: 输出当前方法被调用的调用路径
tt: 方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下调用进行观测
thread: 查看当前线程信息,查看线程的堆栈
ognl: 执行ognl表达式
dashboard: 当前系统的实时数据面板,按ctrl+c退出
jvm: 查看当前jvm信息
jad: 反编译指定已加载类的源码
dump: dump已加载类的bytecode到特定目录
mc: Memory Compiler/内存编译器,编译.java文件生成.class
redefine: 加载外部的.class文件,redefinejvm已加载的类
monitor: 方法执行监控
sysprop: 查看当前jvm的系统属性(SystemProperty)
sysenv: 查看当前JVM的环境属性(SystemEnvironment Variables)
mbean: 查看Mbean的信息
classloader: 查看classloader的继承树,urls,类加载信息
更多信息请查看官方网站:https://alibaba.github.io/arthas
4. JVM-SANDBOX原理与应用 4.1 项目简介:
JVM-SANDBOX(沙箱)实现了一种在不重启、不侵入目标JVM应用的AOP解决方案。
4.2 沙箱的特性:
无侵入:目标应用无需重启也无需感知沙箱的存在
类隔离:沙箱以及沙箱的模块不会和目标应用的类相互干扰
可插拔:沙箱以及沙箱的模块可以随时加载和卸载,不会在目标应用留下痕迹
多租户:目标应用可以同时挂载不同租户下的沙箱并独立控制
高兼容:支持JDK[6,11]
4.3 沙箱常见应用场景:
线上故障定位
线上系统流控
线上故障模拟(比如阿里开源的混沌工程ChaosBlade)
代码覆盖率分析(比如网易的QAMonitor)
方法请求录制和结果回放(比如下面要介绍的网易的JRARP)
动态日志打印
安全信息监测和脱敏
JVM-SANDBOX还能帮助你做很多很多,取决于你的脑洞有多大了。
4.4 核心原理
在沙箱的世界观中,任何一个Java方法的调用都可以分解为BEFORE、RETURN和THROWS三个环节,由此在三个环节上引申出对应环节的事件探测和流程控制机制。
基于BEFORE、RETURN和THROWS三个环节事件分离,沙箱的模块可以完成很多类AOP的操作。
可以感知和改变方法调用的入参
可以感知和改变方法调用返回值和抛出的异常
可以改变方法执行的流程
在方法体执行之前直接返回自定义结果对象,原有方法代码将不会被执行
在方法体返回之前重新构造新的结果对象,甚至可以改变为抛出异常
在方法体抛出异常之后重新抛出新的异常,甚至可以改变为正常返回
Sandbox核心事件状态图如下:
沙箱通过自定义的SandboxClassLoader破坏了双亲委派的约定,实现了和目标应用的类隔离。所以不用担心加载沙箱会引起应用的类污染、冲突。各模块之间类通过ModuleJarClassLoader实现了各自的独立,达到模块之间、模块和沙箱之间、模块和应用之间互不干扰。
5. JAVA录制回放平台(JRARP)
在网易广告系统的开发过程中,涉及很多的外部系统,比如数据,比如算法,比如各种服务,很多线上问题与数据有关,当出现此类问题时在线下很难进行复现,因为线下很难模拟如此多样而复杂的线上数据,即使使用mock,mock数据的成本也非常之高。而直接在线上进行debug调试查找问题可能对线上系统造成影响。为此我们开发了java录制回放平台。通过它,我们可以在线上录制数据,然后在线下进行回放,重现线上整个调用流程,并在线下进行debug,查找问题。
5.1 总体架构
5.2 模块设计
在线上应用通过录制模块录制数据,在线下模块通过回放模块进行回放。Agent用来与web管理控制台通信,并且执行控制台指令如开始录制、停止录制等。录制的相关配置数据存储到mysql,录制的文件存储到HDFS或者其他文件系统中。
录制数据是指将指定的方法调用的请求参数,返回值进行序列化(采用hessian)保存到录制文件中,在回放的过程中当请求参数匹配的时候直接将录制文件中的结果反序列化返回,而不是真实的请求方法。在序列化的过程中对HashMap等序列化时要注意保证每次顺序一致。
系统提供很多默认的数据处理器,如mysql数据录制,redis数据录制,通用的基于标准请求/响应参数的方法录制等,用户还可以定义自己的扩展数据处理器(实现接口RecordDataHandler)并在定义录制的时候通过界面上传,如下图
所有部署了jrarp agent的服务器都会显示在客户端下拉列表中,我们可以指定attach到具体的进程id上并配置要录制的类、方法、时长、请求数、参数要符合的条件(支持ognl表达式)等
5.3 使用方法
ndp部署agent(一次部署,终生受益)
配置录制参数,开启录制
录制完毕文件自动上传到HDFS或者其他存储系统
通过web console查看录制情况
配置回放参数,开启回放
自动从HDFS下载录制文件开始回放,回放完毕上传详细信息到HDFS
通过web console查看回放情况
5.4 优势
对业务系统无侵入
部署简单:只需要部署一次agent即可,之后不需要再做部署
无需编码,通过web界面配置需要录制的类和方法
运行期attach,不需要重启线上系统,重定义线上类,对性能影响小
录制完毕后卸载代理,线上系统即可恢复原有状态,对性能无影响
使用简单,录制、回放、停止、开始都可以通过web console操作,不需要进行文件拷贝、输入命令行等复杂操作
目前该系统已在广告系统部分应用进行了使用,效果良好,但是功能还不尽完善,还在进一步开发完善中。
6. 总结
借助java动态字节码增强技术,我们可以在不修改源码的情况下,动态的修改系统的功能,对应用无侵入,便于推广。甚至可以对很多无法获取或者无法修改源码的类如java系统类进行修改,比如统一修改java socket类我们就可以统计系统的流入流出网络流量等。从而实现了另一种功能强大的动态非侵入式AOP编程模式。
作者简介
李海武 2016年加入网易传媒资源管理部广告研发组,技术专家,目前主要负责广告业务系统,广告业务基础设施相关研发工作。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.