您还未登录! 登录 | 注册 | 帮助  

您的位置: 首页 > 软件开发专栏 > 开发技术 > 正文

性能优化那些事儿(三)

发表于:2022-04-14 作者:张锦程 来源: Thoughtworks洞见
接上文:

在讨论完性能优化的方面和策略之后,这次我们的文章更偏向技术层面,来分享下如何开发一个自己的性能分析工具(基于JVM)。

『新』知识

考虑到咱们大多数还是开发业务为主,所以Java里面一些『鲜为人知』的API可能很多人都不知道,这里就简单介绍一番,如果想深究的,就自己谷歌一下吧。

  • JVMTI(JVM Tool Interface)是 Java 虚拟机所提供的 native 编程接口,即底层的相关调试接口调用,我们熟知的Java调试其实也是基于它。
  • Instrumentation,虽然Java提供了JVMTI,但是对应的agent需要用C/C++开发,对Java开发者而言并不是非常友好。因此在Java SE 5的新特性中加入了Instrumentation机制。有了Instrumentation,开发者可以构建一个基于Java编写的Agent来监控或者操作JVM了,比如替换或者修改某些类的定义等。

有了上面两个知识,其实我们就可以开发一个简单的Agent了,Instrumentation可以理解为JVM层面的AOP(Aspect Oriented Programming),通过应用启动时挂载Agent,我们可以对每一个class字节码进行查看和修改。

  • ASM ASM是一种通用Java字节码操作和分析框架,它可以用于修改现有的class文件或动态生成class文件,结合Instrumentation我们可以做到挂载Agent的时候,对字节码进行修改,加上我们需要的性能监控手段。ASM的学习是有难度的,需要对字节码有所了解,但由于其性能优秀,被各种工具作为修改字节码的首选,比如大家熟悉的Cglib。
  • Javassist 依旧是一个字节码的修改工具,但对初学者更加友好,不需要过多了解字节码层面,可以书写Java语法片段对已有class字节进行修改,缺点是过于模板化,难以优化,并且功能有限。我们做性能分析工具,本身是要尽可能减少插入字节码对现有代码的影响,并且注入的速度也要尽可能快,所以一般都会选择ASM作为首选项。

好了,介绍完Instrumentation和ASM,我们是不是就可以满足制作性能分析工具的前提条件了呢?你看我们通过Instrumentation进行JVM层面的AOP,再通过ASM对JAVA的字节码进行修改,就可以着手完成性能分析最重要的埋点环节了。

看起来没有错,但是谁也不希望我们增强修改过的代码一直存在内存中,分析一次就对环境造成不可逆的破坏吧。Instrumentation可以通过addTransformer添加字节码转换器,也可以将字节码恢复原样(只需要removeTransformer再retransformClasses就可以恢复了),但javaAgent毕竟是个单独的jar包,它也会有一些依赖,将其加载进来必然会引发新的Class加载甚至是Class的冲突。那么新的问题就出来了,javaAgent如何不对现有的类有影响呢?

ClassLoader 类加载器,我们可以采用一个新的类加载器,专门加载javaAgent里面的类库,这样就可以解决agent的类引发冲突的问题,在旧版本JDK中我们很难对ClassLoader做卸载,并且类的卸载是很麻烦的事情,限制很多,好在我们现在多数用的都是jdk1.8,只要遵循类卸载的规则,对ClassLoader进行清理还是很轻松的。

额外的类加载器实现了业务代码和Agent代码类的隔离,使它们可以安全引用包,并且可以对Agent的类进行卸载,但这样同时引入了一个新的问题。类是隔离的,我在对业务代码进行增强时,如何向agent代码传递信息?增强的代码一定是被加载在AppClassLoader里,如何与AgentClassLoader进行通讯呢?

BootStrapClassLoader 启动类加载器,该ClassLoader是JVM在启动时创建的,理解这一部分知识,就一定要理解ClassLoader的双亲委派机制。我们可以创建一个非常简单的Spy类和一个SpyHandler接口,Spy类定义好一些静态方法用于代码增强时调用,而SpyHandler则是定义一些用于通讯传参的接口。我们将这两个类打成jar包,并通过Instrumentation的appendToBootstrapClassLoaderSearch接口,在agent加载时引入BootStrapClassLoader类中,这样我们在各个ClassLoader中都能访问Spy类和SpyHandler接口了。

通过上面的介绍,我们现在可以动手做一个自己的APM工具了,通过Instrumentation+ASM,我们可以实现Class文件的修改增强,甚至可以修改JDK自带的类比如String,通过自定义的ClassLoader我们可以隔离Agent的类和业务的类,通过打入BootStrap的Spy,我们可以实现跨ClassLoader之间的通讯。

万事俱备,我们现在可以开始动手实现一个自己的APM工具了吧!

打住,其实上面这些功能不需要自己一一实现,我们不需要重复制造轮子,来自阿里开源项目JVM-SANDBOX此时华丽登场。这个项目屏蔽了ASM难以使用的缺点,也简化了Instrumentation打桩过程,并且实现了ClassLoader的隔离,也有了BootStrapClassLoader中的Spy类,我们在此框架的基础上进行开发更为简单。

原图链接:https://github.com/alibaba/jvm-sandbox/wiki/img/jvm-sandbox-classloader.png

集『大』成

我们拥有了JVM-SANDBOX这一利器,似乎节约了我们很多的时间,我们现在终于可以着手性能分析了。

那么怎么进行性能分析呢?

  • Zipkin,开源的链路追踪。
  • Jaeger,开源的链路追踪支持Zipkin协议,个人感觉更为好用。

我们可以引入Zipkin或者Jaeger作为收集者和UI展现,根据自己的喜好选择一个好用的开源工具。通过sandbox提供的功能,我们可以很方便编写埋点代码,将我们的链路追踪工具集成到Agent里面,最终实现无侵入的定制化链路追踪。

通过集成ZipkinClient或者JaegerClient我们可以进行埋点收集,我们似乎把一些功能以搭积木的方式组装起来,解决了一个颇为复杂的实现,这就是开源的魅力所在。其实在实际的过程中我们还遇到了一些困难,比如如何追踪异步调用,如何追踪跨线程的调用,如何处理线程池,如何处理ForkJoin?

其中最为复杂的是如何处理那些跨线程的派发,我们如何将链路的上下文在多个线程中传递。JDK的InheritableThreadLocal类可以完成父线程到子线程的值传递。但对于使用线程池等会池化复用线程的执行组件的情况,线程由线程池创建好,并且线程是池化起来反复使用的;这时父子线程关系的ThreadLocal值传递已经没有意义,应用需要的实际上是把 任务提交给线程池时的ThreadLocal值传递到任务执行时。

说起来可能不好理解,总得来说无论是ThreadLocal还是InheritableThreadLocal都无法处理线程池或者ForkJoin带来的线程复用的副作用,即无法有效准确安全的传递链路的上下文,不信大家可以试一试。

那么怎么解决这个问题呢?没错,就是修改JDK源码,让线程池在进行调度的时候具有安全准确传递上下文信息的能力,比如对Runnable和Callable接口进行增强处理,让其可以携带线程的上下文。如果要对JDK的代码进行增强,我们需要非常熟悉线程调度、线程池、Forkjoin的源码,还需要小心处理值的传递确保安全,听起来就很危险,也很困难。不用担心我们不是第一次遇到这种问题的人,我们再次搬来了阿里的开源产品TTL,这个库解决的就是上面描述的问题。

但是找到开源产品也并不一定能解决所有的问题,transmittable-thread-local虽然能够解决线程复用时传值的问题,但是它的实现对JDK代码进行了『过分』的修改,以至于Instrumentation不能进行动态增强,它需要在启动时未加载到ClassLoader的时候对JDK的源码进行增强,并不能对已加载的JDK源码进行动态增强,也就是说这种增强只能发生在一开始,不能发生在中间时间,且不可卸载。

这是因为Instrumentation的redefineClasses这个方法存在限制:重定义不得添加、移除、重命名字段或方法;不得更改方法签名、继承关系(不然那些商业的热重载技术怎么赚钱。。)。而TTL的增强违反了这个原则,我们需要对其修改,并集成到Agent中。这个改造比较无趣也不好解说,可以直接看改造后的JVM-SANDBOX,我们为了后续使用方便,将TTL库直接用BootStrapClassLoader加载了进去。

开源

最终开源的性能分析工具可以在这里找到:https://github.com/tmtbe/PVisualization,配合改造后的JVM-SANDBOX,可以实现360度无死角的性能链路追踪分析,开发埋点也非常便捷,也无需考虑任何线程池的问题。

原图链接:https://github.com/tmtbe/PVisualization/raw/master/source/img.png