java类热加载原理及应用
01 引言
1.1背景
性能优化是一个
Java
程序员的必备技能,但是定位性能瓶颈或者是问题点是一个费时又费力的事情。在我们的实际项目中就碰到了这样的烦恼,某业务的一个接口,平均耗时很短,但是总有约
1%
的流量,波动较大。多次
review
代码,并没有发现明显的问题,查看业务日志和三方调用日志也没有明显的问题。所以定位小概率的耗时长成为了的关键。
1.2性能优化
定位问题首先想到的就是添加日志,但是在业务复杂的系统中,要想通过添加日志来定位问题,那就需要添加大量的日志,会陷入到加日志-重启服务-加日志的循环中。不仅导致代码混乱,服务不断重启更是不可接受的。
Skywalking
配合
Arthas
是一个比较好的定位问题的手段。使用
Sk
ywalking
定位少数耗时长的接口,时间大概消耗在哪块,然后通过
Arthas
动态监控、添加日志和修改部分业务代码,来实时验证自己的优化结果。
02 原理分析 为了更好 地 理解问题定位过程,我们深入分析一下 Skywalking 和 Arthas 的原理。 Skywalking 和 Arthas 共同使用的是 Instrumentation JavaAgent 机制、以及基于 ASM 为基础的字节码框架。其中 Arthas 还使用了 Attach API 、 OGNL 等技术。下面我们从类加载机制、 javaAgent 机制、字节码技术、 Arthas 的实现四个方面详细阐述下。
2.1类加载机制
要理解类的动态修改和加载,我们先简单介绍下类的加载机制,这部分可能大家都比较熟悉,下图是一个完整的
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 文件被加载到内存后的形式。
2.2Java Agent
2.2.1agent机制
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. 可以修改方法体,但不能修改方法名称。
2.2.2Attach模式
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动态修改类的流程:
2.3字节码及相关技术
通过
javaAgent
机制可以修改类的字节码,不过上面章节介绍的都是通过一个已有的
class
文件来替换原来的字节码,是否可以做到实时按需修改字节码呢?通过字节码相关技术框架加上
javaAgent
机制就可以做到。
字节码文件结构是一组以
8
位为最小基础的十六进制数据流, 各个数据项按顺序紧密的从前向后排列, 相邻的项之间没有间隙, 这样可以使得
class
文件非常紧凑, 体积轻巧, 可以被
JVM
快速的加载至内存。
在一些特殊的场景,我们需要修改类的字节码来达到一些业务需求。比如在项目优化的时候,需要查看具体方法的执行时间,通过字节码操作就可以达到要求,不需要每个方法都加日志。目前有多种操作类字节码的工具,具体工具依赖图如下:
其中 skywalking 使用的字节码框架是 ByteBuddy , Arthas 使用的框架是 ByteKit 。 具体如何使用字节码工具修改字节码,我们在这里就不详细描述了。
2.4Arthas原理分析
Arthas
主要基于上面的描写的基础技术实现的。在不停止应用服务的情况下,将
Arthas
的
jar
包代码动态加载到应用的
JVM
中,再配合
Instrumentation
类,动态修改应用
JVM
中运行的字节码,实现对目标应用增强,如获取某方法的参数、返回值、耗时等信息、调用
JVM
相关类获取
JVM
运行时信息,最后再通过
OGNL
过滤、存取对象属性。
2.4.1Attach到JVM
Arthas
使用的是
attach
方式连接到
JVM
,核心代码如下:
VirtualMachine.loadAgent () ,加载的 jar 包有 arthas-agent.jar 、 arthas-core.jar 。
2.4.2类隔离和增强
因为
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 实例实现方法增强。
2.4.3Arthas服务端实现
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
做了封装:
2.4.4Arthas支持OGNL
Arthas
是支持
OGNL
表达式的,所以使用
Arthas
的时候非常灵活。
Arthas
会将增强执行的结果全部放在
Advice
实例中,主要包括增强的方法名、参数、执行结果、耗时等数据,在返回是先判断是否有
OGNL
表达式,如果有
OGNL
表达式,会执行
OGNL
表达式,针对
OGNL
设置的条件进行过滤或者数值筛选。主要依赖了
OGNL
对应的
jar
包。
2.4.5Arthas命令及分类
2.4.6Arthas应用
当你遇到以下类似问题而束手无策时,
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.jar 70049, 执行该程序的用户需要和目标进程具有相同的权限,所以执行前,一般需要运维授权此命令才可以执行成功。 启动成功后的界面如下:
然后在此页面输入相关命令即可。当然也可以通过浏览器来连接 arthas , Arthas 目前支持 Web Console ,用户在 attach 成功之后,可以直接访问: http://127.0.0.1:8563/ 。可以填入 IP ,远程连接其它机器上的 arthas 。 也可以通过 http api 的方式使用 arthas ,用 p ost 方式提交数据:
2. 常用命令 在我本次优化过程中,使用过的命令有: 1 ) Watch 可以监测一个方法的入参和返回值,这个主要是原来定位 bug 。有些问题线上会出现,本地重现不了,这时这个命令就有用了:
2 ) Trace 输出方法内部调用路径,和路径上每个节点的耗时,这个命令在性能优化中非常重要。 可以通过这个命令,查看哪些方法耗性能,从而找出导致性能缺陷的代码,这个耗时还包含了 arthas 执行的时间:
3 ) Monitor 方法监控,可以监控方法执行次数、成功次数、失败次数、平均 rt ,失败率:
4 ) Job 执行后台异步任务 线上有些问题是偶然发生的,这时就需要使用异步任务,把信息写入文件。 使用 & 指定命令去后台运行,使用 > 将结果重写到日志文件,以 trace 为例:
5 ) Dashboar d 查看当前系统的实时数据面板 这个命令可以全局的查看 jvm 运行状态,比如内存和 cpu 占用情况:
ID : Java 级别的线程 ID ,注意这个 ID 不能跟 jstack 中的 nativeID 一一对应 我们可以通过 thread id 查看线程的堆栈信息,通过这两个命令就可以查看线程死锁情况,在实际项目中,我们通过这两个命令定位了 dubbo3 的一个因 kill 命令导致的线程死锁问题:
6 ) Retransform 加载外部的. class 文件, retransform jvm 已加载的类,我们在优化项目的时候,会修改代码,然后直接通过该命令,让代码实时生效,查看优化效果,不用重启整个项目:
如果对某个类执行 retransform 之后,想消除影响,则需要:
- 删除这个类对应的 retransform entry
- 重新触发 retransform
7 )退出 arthas 如果只是退出当前的连接,可以用 quit 或者 exit 命令。 Attach 到目标进程上的 arthas 还会继续运行,端口会保持开放,下次连接时可以直接连接上。 如果想完全退出 arthas ,可以执行 stop 命令。
03 总结
通过上面基础知识的总结介绍,还有常用工具的实现原理的总结,可以让我们对项目优化依赖的一些工具和方法做到知其然还知其所以然。不至于乱用工具,导致线上运行的项目在优化的过程中出现问题。其实还有很多其他项目优化的工具,但不管有多少工具,其基本原理都是上面我们描述的哪些知识。