在字节码增强解密(上)的章节中,给大家介绍了字节码的基本结构、主流的字节码增强构架、以及各个架构的优缺点和应用建议。在本章节中,将从字节码的重载、JavaAgent、Dynamic Attach、以及对在线Debug的高级应用展开分析,给大家详细分解下字节码增强的实际应用。让你真正体会下字节码增强技术带来的丝般顺滑体验。
一、字节码的重载
在上一章节中,我们介绍了使用ASM、CGlib和Javassit字节码增强框架来对Class字节码文件进行增强,并且在我们的示例中,使用新的Main函数来启动程序并进行功能增强。但在实际的应用当中,这似乎并不符合我们的要求,因为我们的系统一直都在生产环境运行着,一直都在不间断的提供着服务;我们不可以为了让其生效而重启我们整个系统;那么我们应该如何做呢?是的没错,字节码的重载技术,正是让已增强的字节码文件生效。那么具体的重载技术都有哪些呢?
1、自定义ClassLoader来重新加载到JVM
如果我们使用同一个ClassLoader多次加载相同名称的Class时,并不能如愿的让动态增强后的类生效,但是我们却可以使用一个新的ClassLoader来加载增强后的类到JVM,然后做好相应的状态恢复,对旧ClassLoader进行卸载等动作。Tomcat的动态部署其实就是监听war变化,然后调用StandardContext.reload(),用新的WebContextClassLoader实例来加载新的war包,最后再初始化servlet。
2、生成新类然后直接加载到JVM(就是更改类名,包括继承)
与上面的方法类似,我们可以将增强后的类进行重命名,然后使用现有的ClassLoader来加载到JVM。通常这种方式适用于继承或者接口实现的场景,便于在程序中调用。使用的方法就是加载到JVM后,直接newInstance创建对象即可。这种方式最简单直接,易于使用。
3、替换原来的Class定义后重新载入JVM
除去换ClasssLoader和更改类名外,我们也可以在JVM加载Class时进行拦截,然后返回修改后的字节码;或者是在系统运行时动态的替换掉原来的字节码;而在实际应用中,该方法则是使用最为普遍的方法,同时也是最为复杂的一种。接下来我们也将重点介绍该方案的具体实现。需要使用到的技术包括:JVMTI、JavaAgent、Attach技术
二、JVMTI
即JVM工具接口,从JDK1.5开始支持,是一套对JVM进行操作的工具接口,可以通过这些工具接口注册JVM各种事件,在JVM事件触发时被同时触发执行(钩子),这里的事件包括:类文件加载、异常产生与捕获、线程启动和结束、成员变量修改、GC开始和结束、方法调用/进入/退出、VM启动与退出等等。
有了它才真正具备给JVM“开挂”的基础。有了它才能真正发挥字节码增强技术的最大价值。
三、Java Agent
Agent其实就是JVMTI的一种实现,可以在启动JVM时使用-javaagent入参指定一个Agent的jar包。会在启动JVM的时候同时运行你的Agent应用。
命令如:java -javaagent:myAgent.jar=xxxx -jar myProgram.jar
Agent根据启动的时机可分为2种:
第一种是跟随JVM启动而启动(上面命令行则是属于这种方式)
第二种是在JVM运行时通过Attach技术载入。
看到这里,你或许已然明白,正是因为有Agent的存在,以及它的两种启动方式,可以让我们根据实际的情况灵活的选择字节码增强方式:前者可以拦截Class的加载,后者可以动态的替换原来的Class。
如何开发一个Agent应用???
Agent主要由2部分组成:MANIFEST.MF文件以及Agent的入口类。
MANIFEST文件:
是在jar包内的META-INF/MANIFEST.MF文件,可以在工程内手动创建,也可以在Maven的build插件里进行配置。
文件最终配置的内容如下:
Agent的入口类:
注:该入口类中必须至少实现以下2个main方法的其中之一,或者2者全部实现,根据实际启动时机来定,如果2者都实现了则2种启动方式同时支持。
上面实现的2个方法中,会接收一个Instrumentation类对象,而该类的最大作用则是类定义的动态改变和操作,需要重点关注的方法有:
四、Dynamic Attach
Attach:简单点说Attach就是jdk提供的一种jvm进程间通信的能力,能让一个进程传命令给另外一个进程,并让它执行一些操作,将结果返回。
根据操作的方式Attach又分2种方式:
方式一、通过sun.tools进行Attach(以arthas诊断工具为例)
方式二:直接和JVM进行通信式的Attach(由于太原生了,这种方式更直接但也更复杂,因为它够原生,简单介绍下原理即可)
JVM提供了一种特殊机制,允许使用Socket的方式与其他进程进行通信。具体的方式如下:
1、首先建立/tmp/.attach_pid的空文件
2、然后向目标JVM发送一个SIGQUIT信号,JVM会主动异步创建一个/tmp/.java_pid的Socket文件
3、当前主程序当然是等待.java_pid文件的创建,创建成功后则可连接这个地址,建立与JVM的通信。连接的方式则是socket方式。
4、连接建立成功后就可以向JVM发起执行命令了。
五、字节码增强的高级应用(在线Debug系统)
截止到目前,您已经基本掌握了字节码增强的要点,接下来我们也从实际需求出发,一起分析下字节码增强技术的强大功能实现 - 在线Debug系统。
你应该经常在IDE上进行断点调试(除非你不是一个合格的程序猿),这种非常直观的、高效的排错体验,让我产生了将Debug功能搬到线上,对生产环境系统进行在线Debug的想法,为快速排查线上问题提供有效手段。经过一个月的努力,系统终于发布,并且支持内网穿透、跨机房、跨环境、安装即用的在线诊断平台。接下来就和大家一起分析下具体的实现细节。
具备的能力/功能模块:
1、跨机房、跨环境的能力
由于公司的各业务系统均为多机房部署,同时线下还有测试环境、预发环境,为了尽量多的覆盖适用范围,我们决定支持跨机房、跨环境的能力。
2、“内网穿透”的能力
由于Debug系统部署于生产环境,如要对线下环境/预发环境(内网)的机器进行Debug,则必须具有“内网穿透”的能力,可以让诊断命令从线上机器发往公司内网环境各机器。
3、在线Debug的核心模块 - 我们称之为“诊断器”
要能够对目标机器的JVM进行Debug,就必须要有一段程序运行于目标机器,并且Aattach到目标JVM,对其类进行增强,接收来自前台的诊断指令,实现Debug能力。
4、安装即用的能力
所有业务系统都在正常的跑着,不间断的提供着服务,有的甚至已经好几年没有重启过服务。如果需要进行在线Debug,不能对业务系统有侵入性,操作简便,因此需要安装即用、超时卸载。方便使用的同时,最大化的减少目标机器的资源占用。
5、数据中心
由于诊断的目标代码被执行的时机是不确定的,所以当在目标机器安装“诊断器”,并向其发起Debug指令后,Debug的诊断结果也并非是实时的,整个系统大部分为异步交互,然后在产生诊断结果后上报到该数据中心,后输出到前端页面呈现给用户。
接下来分析下我们的核心所在-“诊断器”的实现:(注:暂不介绍其他模块,并非本篇文章重点)
毫无疑问,字节码增强技术将是本系统的核心所在。需要动态的对目标类进行增强:添加断点钩子。当程序执行到这些钩子时,实时的记录下当前所在断点行的全局变量、局部变量、静态变量、以及调用堆栈信息。然后上报给数据中心,最终呈现给用户。
1、源码反编
为了提供友好的交互界面,需要反编对应类的源码供查看,然后在该类的行号上点击并添加断点。
那如何反编源码呢?JDK有自带的javap,但都并不能输出代码块内容啊~~~,别着急,这是有现成的三方实现的,JAVA反编工具CRF,几行代码快速搞定。
2、类的行号解析
要在某类上添加断点,当然需要知道哪一行执行什么代码,以及该行号是否有可执行代码。我们不允许你添加一个没有可执行代码的断点的。
如果你看过字节码增强解密的上半部分,你应该就知道字节码是按约定的结构进行的存储,按指定的方式进行解析就能得知某一行对应的指令(代码块),你需要做的则是将它们暂时存起来,在添加断点时做为判断的依据。实现的方式则是(以ASM框架为例):继承自ClassVisitor、MethodVisitor可分别对类和方法进行操作,重写ClassVisitor内部的visitMethod方法,使用MethodVisitor类可对行号进行解析,并记录下对应行号和代码块。参见代码如下:
@Override public MethodVisitor visitMethod(final int access, final String methodName, final String desc, final String signature, final String[] exceptions) { final MethodVisitor superMV = super.visitMethod(access, methodName, desc, signature, exceptions); final String methodUniqueName = methodName + desc; return new MethodVisitor(ASM_VERSION, superMV) { private final Map<String, Integer> labelLineMapping = new HashMap<>(); @Override public void visitLineNumber(final int line, final Label start) { labelLineMapping.put(start.toString(), line); } @Override public void visitLocalVariable(final String name, final String desc, final String signature, final Label start, final Label end, final int index) { super.visitLocalVariable(name, desc, signature, start, end, index); classMetadata.addVariable(methodUniqueName, new LocalVariable(name, desc, labelLine(start), labelLine(end), index)); } private int labelLine(final Label label) { final String labelId = label.toString(); if (labelLineMapping.containsKey(labelId)) { return labelLineMapping.get(label.toString()); } return Integer.MAX_VALUE; } }; }
3、添加断点
到此准备工作已基本做好,很简单是不是?接下则是添加断点了;添加断点其实是对类进行增强,在指定行埋点一个钩子,当发现执行到当前代码行时获取当前信息并记录。
具体的实现方法:新增XXClassVisitor继承自AdviceAdpter,通过重写其中的visitLineNumber方法添加断点钩子,钩子的代码当然则是获取并记录当前全局变量、类变量、局部变量、调用堆栈的信息。参见代码如下:
4、数据上报
定期扫描各断点的执行结果,并上报到数据中心。到此核心功能已基本完成。
结语:字节码增强技术作为众多三方框架/开源框架的底层核心技术,足见其功能的强大和应用的普遍。在日常的业务系统研发中,或许你并不直接使用到字节码增强,但实际它却在系统的某个角落默默的担负着核心的作用。你不应该选择忽视它,你需要将它擅长的事情全都交给它。