在遥远的希艾斯星球爪哇国塞沃城中,两名年轻的程序员正在为一件事情苦恼,程序出问题了,一时看不出问题出在哪里,因而有了如下对话:java
“Debug一下吧。”git
“线上机器,没开Debug端口。”程序员
“看日志,看看请求值和返回值分别是什么?”github
“那段代码没打印日志。”express
“改代码,加日志,从新发布一次。”后端
“怀疑是线程池的问题,重启会破坏现场。”数组
长达几十秒的沉默以后:“听说,排查问题的最高境界,就是只经过Review代码来发现问题。”浏览器
比几十秒长几十倍的沉默以后:“我轮询了那段代码一十七遍以后,终于得出一个结论。”安全
“结论是?”数据结构
“我还没到达只经过Review代码就能发现问题的至高境界。”
对于大多数Java程序员来讲,早期的时候,都会接触到一个叫作JSP(Java Server Pages)的技术。虽然这种技术,在先后端代码分离、先后端逻辑分离、先后端组织架构分离的今天来看,已通过时了,可是其中仍是有一些有意思的东西,值得拿出来讲一说。
当时刚刚处于Java入门时期的咱们,大多数精力彷佛都放在了JSP的页面展现效果上了:
“这个表格显示的行数不对”
“原来是for循环写的有问题,改一下,刷新页面再试一遍”
“嗯,好了,表格显示没问题了,可是,登陆人的姓名没取到啊,是否是Sesstion获取有问题?”
“有可能,我再改一下,一下子再刷新试试”
......
在一遍一遍修改代码刷新浏览器页面重试的时候,咱们本身也许并无注意到一件很酷的事情:咱们修改完代码,竟然只是简单地刷新一遍浏览器页面,修改就生效了,整个过程并无重启JVM。按照咱们的常识,Java程序通常都是在启动时加载类文件,若是都像JSP这样修改完代码,不用重启就生效的话,那文章开头的问题就能够解决了啊:Java文件中加一段日志打印的代码,不重启就生效,既不破坏现场,又能够定位问题。忍不住试一试:修改、编译、替换class文件。额,不行,新改的代码并无生效。那为何恰恰JSP能够呢?让咱们先来看看JSP的运行原理。
当咱们打开浏览器,请求访问一个JSP文件的时候,整个过程是这样的:
JSP文件修改事后,之因此能及时生效,是由于Web容器(Tomcat)会检查请求的JSP文件是否被更改过。若是发生过更改,那么就将JSP文件从新解析翻译成一个新的Sevlet类,并加载到JVM中。以后的请求,都会由这个新的Servet来处理。这里有个问题,根据Java的类加载机制,在同一个ClassLoader中,类是不容许重复的。为了绕开这个限制,Web容器每次都会建立一个新的ClassLoader实例,来加载新编译的Servlet类。以后的请求都会由这个新的Servlet来处理,这样就实现了新旧JSP的切换。
HTTP服务是无状态的,因此JSP的场景基本上都是一次性消费,这种经过建立新的ClassLoader来“替换”class的作法行得通,可是对于其余应用,好比Spring框架,即使这样作了,对象多数是单例,对于内存中已经建立好的对象,咱们没法经过这种建立新的ClassLoader实例的方法来修改对象行为。
我就是想不重启应用加个日志打印,就这么难吗?
既然JSP的办法行不通,那咱们来看看还有没有其余的办法。仔细想一想,咱们会发现,文章开头的问题本质上是动态改变内存中已存在对象的行为的问题。因此,咱们得先弄清楚JVM中和对象行为有关的地方在哪里,有没有更改的可能性。
咱们都知道,对象使用两种东西来描述事物:行为和属性。举个例子:
public class Person{ private int age; private String name; public void speak(String str) { System.out.println(str); } public Person(int age, String name) { this.age = age; this.name = name; } }
上面Person类中age和name是属性,speak是行为。对象是类的事例,每一个对象的属性都属于对象自己,可是每一个对象的行为倒是公共的。举个例子,好比咱们如今基于Person类建立了两个对象,personA和personB:
Person personA = new Person(43, "lixunhuan"); personA.speak("我是李寻欢"); Person personB = new Person(23, "afei"); personB.speak("我是阿飞");
personA和personB有各自的姓名和年龄,可是有共同的行为:speak。想象一下,若是咱们是Java语言的设计者,咱们会怎么存储对象的行为和属性呢?
“很简单,属性跟着对象走,每一个对象都存一份。行为是公共的东西,抽离出来,单独放到一个地方。”
“咦?抽离出公共的部分,跟代码复用好像啊。”
“大道至简,不少东西原本都是异曲同工。”
也就是说,第一步咱们首先得找到存储对象行为的这个公共的地方。一番搜索以后,咱们发现这样一段描述:
Method area is created on virtual machine startup, shared among all Java virtual machine threads and it is logically part of heap area. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors.
Java的对象行为(方法、函数)是存储在方法区的。
“方法区中的数据从哪来?”
“方法区中的数据是类加载时从class文件中提取出来的。”
“class文件从哪来?”
“从Java或者其余符合JVM规范的源代码中编译而来。”
“源代码从哪来?”
“废话,固然是手写!”
“倒着推,手写没问题,编译没问题,至于加载......有没有办法加载一个已经加载过的类呢?若是有的话,咱们就能修改字节码中目标方法所在的区域,而后从新加载这个类,这样方法区中的对象行为(方法)就被改变了,并且不改变对象的属性,也不影响已经存在对象的状态,那么就能够搞定这个问题了。但是,这岂不是违背了JVM的类加载原理?毕竟咱们不想改变ClassLoader。”
“少年,能够去看看java.lang.instrument.Instrumentation
。”
看完文档以后,咱们发现这么两个接口:redefineClasses和retransformClasses。一个是从新定义class,一个是修改class。这两个大同小异,看reDefineClasses的说明:
This method is used to replace the definition of a class without reference to the existing class file bytes, as one might do when recompiling from source for fix-and-continue debugging. Where the existing class file bytes are to be transformed (for example in bytecode instrumentation) retransformClasses should be used.
都是替换已经存在的class文件,redefineClasses是本身提供字节码文件替换掉已存在的class文件,retransformClasses是在已存在的字节码文件上修改后再替换之。
固然,运行时直接替换类很不安全。好比新的class文件引用了一个不存在的类,或者把某个类的一个field给删除了等等,这些状况都会引起异常。因此如文档中所言,instrument存在诸多的限制:
The redefinition may change method bodies, the constant pool and attributes. The redefinition must not add, remove or rename fields or methods, change the signatures of methods, or change inheritance. These restrictions maybe be lifted in future versions. The class file bytes are not checked, verified and installed until after the transformations have been applied, if the resultant bytes are in error this method will throw an exception.
咱们能作的基本上也就是简单修改方法内的一些行为,这对于咱们开头的问题,打印一段日志来讲,已经足够了。固然,咱们除了经过reTransform来打印日志,还能作不少其余很是有用的事情,这个下文会进行介绍。
那怎么获得咱们须要的class文件呢?一个最简单的方法,是把修改后的Java文件从新编译一遍获得class文件,而后调用redefineClasses替换。可是对于没有(或者拿不到,或者不方便修改)源码的文件咱们应该怎么办呢?其实对于JVM来讲,无论是Java也好,Scala也好,任何一种符合JVM规范的语言的源代码,均可以编译成class文件。JVM的操做对象是class文件,而不是源码。因此,从这种意义上来说,咱们能够说“JVM跟语言无关”。既然如此,无论有没有源码,其实咱们只须要修改class文件就好了。
Java是软件开发人员能读懂的语言,class字节码是JVM能读懂的语言,class字节码最终会被JVM解释成机器能读懂的语言。不管哪一种语言,都是人创造的。因此,理论上(实际上也确实如此)人能读懂上述任何一种语言,既然能读懂,天然能修改。只要咱们愿意,咱们彻底能够跳过Java编译器,直接写字节码文件,只不过这并不符合时代的发展罢了,毕竟高级语言设计之始就是为咱们人类所服务,其开发效率也比机器语言高不少。
对于人类来讲,字节码文件的可读性远远没有Java代码高。尽管如此,仍是有一些杰出的程序员们创造出了能够用来直接编辑字节码的框架,提供接口可让咱们方便地操做字节码文件,进行注入修改类的方法,动态创造一个新的类等等操做。其中最著名的框架应该就是ASM了,cglib、Spring等框架中对于字节码的操做就创建在ASM之上。
咱们都知道,Spring的AOP是基于动态代理实现的,Spring会在运行时动态建立代理类,代理类中引用被代理类,在被代理的方法执行先后进行一些神秘的操做。那么,Spring是怎么在运行时建立代理类的呢?动态代理的美妙之处,就在于咱们没必要手动为每一个须要被代理的类写代理类代码,Spring在运行时会根据须要动态地创造出一个类,这里创造的过程并不是经过字符串写Java文件,而后编译成class文件,而后加载。Spring会直接“创造”一个class文件,而后加载,创造class文件的工具,就是ASM了。
到这里,咱们知道了用ASM框架直接操做class文件,在类中加一段打印日志的代码,而后调用retransformClasses就能够了。
截止到目前,咱们都是停留在理论描述的层面。那么如何进行实现呢?先来看几个问题:
幸运的是,由于有BTrace的存在,咱们没必要本身写一套这样的工具了。什么是BTrace呢?BTrace已经开源,项目描述极其简短:
A safe, dynamic tracing tool for the Java platform.
BTrace是基于Java语言的一个安全的、可提供动态追踪服务的工具。BTrace基于ASM、Java Attach Api、Instruments开发,为用户提供了不少注解。依靠这些注解,咱们能够编写BTrace脚本(简单的Java代码)达到咱们想要的效果,而没必要深陷于ASM对字节码的操做中不可自拔。
看BTrace官方提供的一个简单例子:拦截全部java.io包中全部类中以read开头的方法,打印类名、方法名和参数名。当程序IO负载比较高的时候,就能够从输出的信息中看到是哪些类所引发,是否是很方便?
package com.sun.btrace.samples; import com.sun.btrace.annotations.*; import com.sun.btrace.AnyType; import static com.sun.btrace.BTraceUtils.*; /** * This sample demonstrates regular expression * probe matching and getting input arguments * as an array - so that any overload variant * can be traced in "one place". This example * traces any "readXX" method on any class in * java.io package. Probed class, method and arg * array is printed in the action. */ @BTrace public class ArgArray { @OnMethod( clazz="/java\\.io\\..*/", method="/read.*/" ) public static void anyRead(@ProbeClassName String pcn, @ProbeMethodName String pmn, AnyType[] args) { println(pcn); println(pmn); printArray(args); } }
再来看另外一个例子:每隔2秒打印截止到当前建立过的线程数。
package com.sun.btrace.samples; import com.sun.btrace.annotations.*; import static com.sun.btrace.BTraceUtils.*; import com.sun.btrace.annotations.Export; /** * This sample creates a jvmstat counter and * increments it everytime Thread.start() is * called. This thread count may be accessed * from outside the process. The @Export annotated * fields are mapped to jvmstat counters. The counter * name is "btrace." + <className> + "." + <fieldName> */ @BTrace public class ThreadCounter { // create a jvmstat counter using @Export @Export private static long count; @OnMethod( clazz="java.lang.Thread", method="start" ) public static void onnewThread(@Self Thread t) { // updating counter is easy. Just assign to // the static field! count++; } @OnTimer(2000) public static void ontimer() { // we can access counter as "count" as well // as from jvmstat counter directly. println(count); // or equivalently ... println(Counters.perfLong("btrace.com.sun.btrace.samples.ThreadCounter.count")); } }
看了上面的用法是否是有所启发?忍不住冒出来许多想法。好比查看HashMap何时会触发rehash,以及此时容器中有多少元素等等。
有了BTrace,文章开头的问题能够获得完美的解决。至于BTrace具体有哪些功能,脚本怎么写,这些Git上BTrace工程中有大量的说明和举例,网上介绍BTrace用法的文章更是恒河沙数,这里就再也不赘述了。
咱们明白了原理,又有好用的工具支持,剩下的就是发挥咱们的创造力了,只需在合适的场景下合理地进行使用便可。
既然BTrace能解决上面咱们提到的全部问题,那么BTrace的架构是怎样的呢?
BTrace主要有下面几个模块:
整个BTrace的架构大体以下:
BTrace最终借Instruments实现class的替换。如上文所说,出于安全考虑,Instruments在使用上存在诸多的限制,BTrace也不例外。BTrace对JVM来讲是“只读的”,所以BTrace脚本的限制以下:
如此多的限制,其实能够理解。BTrace要作的是,虽然修改了字节码,可是除了输出须要的信息外,对整个程序的正常运行并无影响。
BTrace脚本在使用上有必定的学习成本,若是能把一些经常使用的功能封装起来,对外直接提供简单的命令便可操做的话,那就再好不过了。阿里的工程师们早已想到这一点,就在去年(2018年9月份),阿里巴巴开源了本身的Java诊断工具——Arthas。Arthas提供简单的命令行操做,功能强大。究其背后的技术原理,和本文中提到的大体无二。Arthas的文档很全面,想详细了解的话能够戳这里。
本文旨在说明Java动态追踪技术的前因后果,掌握技术背后的原理以后,只要愿意,各位读者也能够开发出本身的“冰封王座”出来。
如今,让咱们试着站在更高的地方“俯瞰”这些问题。
Java的Instruments给运行时的动态追踪留下了但愿,Attach API则给运行时动态追踪提供了“出入口”,ASM则大大方便了“人类”操做Java字节码的操做。
基于Instruments和Attach API前辈们创造出了诸如JProfiler、Jvisualvm、BTrace、Arthas这样的工具。以ASM为基础发展出了cglib、动态代理,继而是应用普遍的Spring AOP。
Java是静态语言,运行时不容许改变数据结构。然而,Java 5引入Instruments,Java 6引入Attach API以后,事情开始变得不同了。虽然存在诸多限制,然而,在前辈们的努力下,仅仅是利用预留的近似于“只读”的这一点点狭小的空间,仍然创造出了各类大放异彩的技术,极大地提升了软件开发人员定位问题的效率。
计算机应该是人类有史以来最伟大的发明之一,从电磁感应磁生电,到高低电压模拟0和1的比特,再到二进制表示出几种基本类型,再到基本类型表示出无穷的对象,最后无穷的对象组合交互模拟现实生活乃至整个宇宙。
两千五百年前,《道德经》有言:“道生一,一辈子二,二生三,三生万物。”
两千五百年后,计算机的发展过程也大抵如此吧。