本文字数:5232字
预计阅读时间:25分钟
01
引言
性能优化是一个Java程序员的必备技能,但是定位性能瓶颈或者是问题点是一个费时又费力的事情。在我们的实际项目中就碰到了这样的烦恼,某业务的一个接口,平均耗时很短,但是总有约1%的流量,波动较大。多次review代码,并没有发现明显的问题,查看业务日志和三方调用日志也没有明显的问题。所以定位小概率的耗时长成为了的关键。
定位问题首先想到的就是添加日志,但是在业务复杂的系统中,要想通过添加日志来定位问题,那就需要添加大量的日志,会陷入到加日志-重启服务-加日志的循环中。不仅导致代码混乱,服务不断重启更是不可接受的。Skywalking配合Arthas是一个比较好的定位问题的手段。使用Skywalking定位少数耗时长的接口,时间大概消耗在哪块,然后通过Arthas动态监控、添加日志和修改部分业务代码,来实时验证自己的优化结果。
02
原理分析
为了更好地理解问题定位过程,我们深入分析一下Skywalking和Arthas的原理。Skywalking和Arthas共同使用的是Instrumentation JavaAgent机制、以及基于ASM为基础的字节码框架。其中Arthas还使用了Attach API 、OGNL等技术。下面我们从类加载机制、javaAgent机制、字节码技术、Arthas的实现四个方面详细阐述下。
要理解类的动态修改和加载,我们先简单介绍下类的加载机制,这部分可能大家都比较熟悉,下图是一个完整的JVM系统。一个java类,通过编译生成class文件,然后class文件被类加载系统加载到java虚拟机中:
当jvm要加载某个类时,jvm会先指定一个类加载器,负责加载此类。而此指定的类加载器在尝试自己去根据某个类的二进制名字查找其相应的字节码文件并加载之前,会首先委托给其父亲(getParent方法返回的类加载器)尝试加载,如果加载失败,就会由自己来尝试加载此类。一般情况下,这个由jvm指定的类加载器就是AppClassLoader,jvm会自动调用其loadClass(String name)方法来开启类的加载过程(真正完成类加载工作的是通过调用defineClass方法来实现的),具体细节如下图:
不过无论是什么ClassLoader,它的根父类都是 java.lang.ClassLoader。loadClass()方法最终会调用到 ClassLoader.defineClass1()中,这是一个Native方法:
在openjdk中查看源码,definClass1()对应的方法在ClassLoader.c文件中的Java_java_lang_ClassLoader_defineClass1():
Java_java_lang_ClassLoader_defineClass1 主要是调用了JVM_DefineClassWithSource()加载类,继续跟踪,会发现最终调用的是jvm.cpp中的jvm_define_class_common()方法。该逻辑主要就是利用ClassFileStream将要加载的class文件转成文件流,然后调用SystemDictionary::resolve_from_stream(),生成Class在JVM中的代表:Klass。对于Klass,就是JVM用来定义一个Java Class的数据结构。不过Klass只是一个基类,Java Class真正的数据结构定义在InstanceKlass中。
InstanceKlass中记录了一个Java类的所有属性,包括注解、方法、字段、内部类、常量池等信息。这些信息本来被记录在Class文件中,所以说,InstanceKlass就是一个Java Class文件被加载到内存后的形式。
JDK1.5版本开始,Java增加了Instrumentation(Java Agent API)和JVMTI(JVM Tool Interface)功能,该功能可以实现JVM再加载某个class文件对其字节码进行修改,也可以对已经加载的字节码进行一个重新的加载。
Java Agent有两种运行模式:
1.启动Java程序时添加-javaagent(Instrumentation API实现方式)或-agentpath/-agentlib(JVMTI的实现方式)参数,如java -javaagent:/data/XXX.jar Test。Skywalking就是使用的该种方式启动;
2.JDK1.6新增了attach(附加方式)方式,可以对运行中的Java进程附加Agent。Arthas主要使用该方式启动;
3.第一种方式只能在程序启动时指定Agent文件,而attach方式可以在Java程序运行后根据进程ID动态注入Agent到JVM;
4.Java Agent和普通的Java类并没有任何区别,普通的Java程序中规定了main方法为程序入口,而Java Agent则将premain(Agent模式)和agentmain(Attach模式)作为了Agent程序的入口,如下:
Java.lang.instrument这个包提供了Java运行时,动态修改系统中的Class类型的功能。这里面有2个重要的接口:Instrumentation和ClassFileTransformer,java.lang.instrument.Instrumentation是监测运行在JVM程序的Java API:
Java.lang.instrument.ClassFileTransformer是一个转换类文件的代理接口,我们可以在获取到Instrumentation对象后通过addTransformer方法添加自定义类文件转换器。我们可以使用addTransformer注册一个我们自定义的Transformer(实现接口ClassFileTransformer)到Java Agent,当有新的类被JVM加载时JVM会自动回调用我们自定义的Transformer类的transform方法,传入该类的transform信息(类名、类加载器、类字节码等),我们可以根据传入的类信息决定是否需要修改类字节码。修改完字节码后我们将新的类字节码返回给JVM,JVM会验证类和相应的修改是否合法,如果符合类加载要求JVM会加载我们修改后的类字节码。
大多数情况下,我们使用Instrumentation都是使用其字节码插桩的功能,或者笼统说就是类重定义(Class Redefine)的功能,但是有以下的局限性:premain和agentmain两种方式修改字节码的时机都是类文件加载之后,也就是说必须要带有Class类型的参数,不能通过字节码文件和自定义的类名重新定义一个本来不存在的类。类的字节码修改称为类转换(Class Transform),类转换其实最终都回归到类重定义Instrumentation#redefineClasses()方法,此方法有以下限制:
1.新类和老类的父类必须相同;
2.新类和老类实现的接口数也要相同,并且是相同的接口;
3.新类和老类访问符必须一致;
4.新类和老类字段数和字段名要一致;
5.新类和老类新增或删除的方法必须是private static/final修饰的;
6.可以修改方法体,但不能修改方法名称。
Attach能做些什么,总结起来说,比如内存dump,线程dump,类信息统计(比如加载的类及大小以及实例个数等),动态加载agent,动态设置vm flag(但是并不是所有的flag都可以设置的,因为有些flag是在jvm启动过程中使用的,是一次性的),打印vm flag,获取系统属性等。
Attach机制的关键是Attach Listener和Signal Dispatcher两个线程。Signal Dispatcher是JVM启动的时候就有的,Attach Listener是在初始化socket,启动监听的时候创建的,就是从队列里不断取AttachOperation,然后找到请求命令对应的方法进行执行,比如我们一开始说的jstack命令,找到{ “threaddump”, thread_dump }的映射关系,然后执行thread_dump方法,我们通常使用的增强类是load命令。如果没有请求的话,会一直accept在那里,当来了请求,然后就会创建一个套接字,并读取数据,执行相关命令:
AttachOperation操作命令如下:
下面我们看一下通过Agent动态修改类的流程:
通过javaAgent机制可以修改类的字节码,不过上面章节介绍的都是通过一个已有的class文件来替换原来的字节码,是否可以做到实时按需修改字节码呢?通过字节码相关技术框架加上javaAgent机制就可以做到。
字节码文件结构是一组以8位为最小基础的十六进制数据流, 各个数据项按顺序紧密的从前向后排列, 相邻的项之间没有间隙, 这样可以使得class文件非常紧凑, 体积轻巧, 可以被JVM快速的加载至内存。
在一些特殊的场景,我们需要修改类的字节码来达到一些业务需求。比如在项目优化的时候,需要查看具体方法的执行时间,通过字节码操作就可以达到要求,不需要每个方法都加日志。目前有多种操作类字节码的工具,具体工具依赖图如下:
其中skywalking使用的字节码框架是ByteBuddy,Arthas使用的框架是ByteKit。
具体如何使用字节码工具修改字节码,我们在这里就不详细描述了。
Arthas主要基于上面的描写的基础技术实现的。在不停止应用服务的情况下,将Arthas的jar包代码动态加载到应用的JVM中,再配合Instrumentation类,动态修改应用JVM中运行的字节码,实现对目标应用增强,如获取某方法的参数、返回值、耗时等信息、调用JVM相关类获取JVM运行时信息,最后再通过OGNL过滤、存取对象属性。
Arthas使用的是attach方式连接到JVM,核心代码如下:
VirtualMachine.loadAgent(),加载的jar包有arthas-agent.jar、arthas-core.jar。
因为ArthasBootstrap是由ArthasClassLoader加载器加载的,ArthasBootstrap负责初始化arthas-core,按照加载当前类的加载器,也会用于加载其所依赖的类原则,ArthasBootstrap依赖的类也会由ArthasClassLoader加载,所以就是实现了Arthas与应用的隔离。
Arthas类加载结构如下:
根据类加载器的双亲委派原则,父类加载器加载的类对子类加载器是可见的,而子类加载器加载的类对父类加载器是不可见的,兄弟类加载器加载的类互相是不可见的。也就是说Arthas和应用JVM之间共享了JMX等底层API(由BootstrapClassLoader和ExtClassLoader加载的类),所以Arthas可以通过调用JDK的一些API获取应用JVM相应的运行时数据,比如dashboard/thread/mbean等命令。但是对于增强型命令如watch/trace/tt,Arthas会对应用代码注入一些代码,当被增强的应用代码执行时,会执行到Arthas注入的代码,从而实现功能的增强。但是由于ApplicationClassLoader和ArthasClassLoader加载器的类之间是不可见的,也就是说应用代码是不能直接调用Arthas代码的,会有ClassNotFoundException或者ClassCastException。
Arthas采用了一种很巧的方案,引入了一个arthas-spy模块,相当于在应用和Arthas之间架起了一座桥梁。arthas-spy模块中只有一个SpyAPI类文件。ApyAPI类由BoostrapClassLoader加载,所以SpyAPI对于ApplicationClassLoader和ArthasClassLoader都是可见的,应用通过SpyAPI实现对Arthas的调用,从而实现功能的增强:
因为AbstractSpy是SpyAPI的内部静态类,并且在SpyAPI中定义了一个静态属性,所以AbstractApy也会由BoostrapClassLoader加载。而SpyImpl在arthas-core模块中实现,所以会被ArthasClassLoader加载。所以Arthas可以通过调用SpyAPI的setSpy方法设置增强代码的具体执行逻辑。因为AbstractApy由BoostrapClassLoader加载,SpyImpl由ArthasClassLoader加载,所以SpyImpl实例可以向上类型转换成AbstractApy实例,完成赋值操作。应用代码在调用Arthas的增强代码时,是通过调用SpyAPI的静态方法,然后调用AbstractSpy实例实现方法增强。
Arthas设计架构,主要分为4个大的模块,
1.服务器的抽象
这个领域抽象的主要是arthas命令的执行环境,arthas采用的是传统的client-server的架构,但是server层的环境也是比较复杂的,为了兼容不同的网络传输协议,以及统一整个命令的执行流程,对这一层进行了抽象,这一层的作用是,在一个client-server架构下,大家敲击的命令如何到server中,然后如何返回显示执行结果。
2.命令的抽象
Arthas所支持的命令有两类:
一类是纯观察JVM信息或者操作arthas的命令如:VersionCommand,JvmCommand, QuitCommand等
一类是要对目标方法进行插装,如watchCommand,TraceCommand等,这些命令是arthas的核心。
3.命令执行模块
Arthas支持这么多的命令,如果不用一个好的设计组织起来,任由if-else横行是万万不行的,为此,arthas在接受到命令后会生成一个Job,具体的命令执行交由Job来做,也因此形成了命令执行的这个子模块。命令执行这个子领域是依赖于命令触发的。
4.代码插装模块
对于一些需要插装才能获取到的信息,比如方法的执行时长,方法的trace等,Arthas使用了bytekit框架,bytekit是基于ASM框架的,可以方便的做字节码操作,主要通过注解的方式,对ASM做了封装:
Arthas是支持OGNL表达式的,所以使用Arthas的时候非常灵活。Arthas会将增强执行的结果全部放在Advice实例中,主要包括增强的方法名、参数、执行结果、耗时等数据,在返回是先判断是否有OGNL表达式,如果有OGNL表达式,会执行OGNL表达式,针对OGNL设置的条件进行过滤或者数值筛选。主要依赖了OGNL对应的jar包。
当你遇到以下类似问题而束手无策时,Arthas可以帮助你解决:
这个类从哪个jar包加载的?为什么会报各种类相关的Exception?
我改的代码为什么没有执行到?难道是我没commit?分支搞错了?
遇到问题无法在线上debug,难道只能通过加日志再重新发布吗?
线上遇到某个用户的数据处理有问题,但线上同样无法debug,线下无法重现!
有什么办法可以监控到JVM的实时运行状态?
怎样直接从JVM内查找某个类的实例?
1.启动arthas
先下载arthas包curl -O https://arthas.aliyun.com/arthas-boot.jar
启动arthas:sudo /opt/jdk18/bin/java -jar arthas-boot.jar70049,执行该程序的用户需要和目标进程具有相同的权限,所以执行前,一般需要运维授权此命令才可以执行成功。
启动成功后的界面如下:
然后在此页面输入相关命令即可。当然也可以通过浏览器来连接arthas,Arthas目前支持Web Console,用户在attach成功之后,可以直接访问:http://127.0.0.1:8563/。可以填入IP,远程连接其它机器上的arthas。
也可以通过http api的方式使用arthas,用post方式提交数据:
2.常用命令
在我本次优化过程中,使用过的命令有:
1)Watch
可以监测一个方法的入参和返回值,这个主要是原来定位bug。有些问题线上会出现,本地重现不了,这时这个命令就有用了:
2)Trace
输出方法内部调用路径,和路径上每个节点的耗时,这个命令在性能优化中非常重要。
可以通过这个命令,查看哪些方法耗性能,从而找出导致性能缺陷的代码,这个耗时还包含了arthas执行的时间:
3)Monitor
方法监控,可以监控方法执行次数、成功次数、失败次数、平均rt,失败率:
4)Job
执行后台异步任务
线上有些问题是偶然发生的,这时就需要使用异步任务,把信息写入文件。
使用&指定命令去后台运行,使用>将结果重写到日志文件,以trace为例:
5)Dashboard
查看当前系统的实时数据面板 这个命令可以全局的查看jvm运行状态,比如内存和cpu占用情况:
ID:Java级别的线程ID,注意这个ID不能跟jstack中的nativeID一一对应 我们可以通过thread id查看线程的堆栈信息,通过这两个命令就可以查看线程死锁情况,在实际项目中,我们通过这两个命令定位了dubbo3的一个因kill命令导致的线程死锁问题:
6)Retransform
加载外部的.class文件,retransform jvm已加载的类,我们在优化项目的时候,会修改代码,然后直接通过该命令,让代码实时生效,查看优化效果,不用重启整个项目:
如果对某个类执行retransform之后,想消除影响,则需要:
删除这个类对应的retransform entry
重新触发retransform
如果不清除掉所有的retransform entry,并重新触发retransform ,则arthas stop时,retransform过的类仍然生效。
7)退出arthas
如果只是退出当前的连接,可以用quit或者exit命令。Attach到目标进程上的arthas还会继续运行,端口会保持开放,下次连接时可以直接连接上。
如果想完全退出arthas,可以执行stop命令。
03
总结
通过上面基础知识的总结介绍,还有常用工具的实现原理的总结,可以让我们对项目优化依赖的一些工具和方法做到知其然还知其所以然。不至于乱用工具,导致线上运行的项目在优化的过程中出现问题。其实还有很多其他项目优化的工具,但不管有多少工具,其基本原理都是上面我们描述的哪些知识。