JVM Agent 设计实现
在上一篇文章中已经对 JVM 性能监控 Agent 所涉及的技术和 API 做了简单的介绍,接下来第二部分将探讨 JVM 性能监控 Agent 的设计实现以及一些问题的解决思路
最简单的 JVM Agent 实现
通过对 Java Agent 以及相关 API,我想大家应该想到一种 JVM Agent 的设计方案,基本思路就是利用 Java Agent 的先于 main 方法执行而且无需修改应用程序源代码的特性,实现一个 Java Agent 的 premain
方法,并且在 premain
中启动一个独立线程,该线程负责定时通过 java.lang.management
包提供的 API 收集 JVM 的性能数据并打包上报,如下图所示:
看上去似乎这种设计方案就可以满足我们的要求了,是真的如此吗?实际上,基于这种设计方案实现的监控 Agent 接入到普通的简单 Java 应用程序是可以胜任工作的,JVM 的性能数据能够被成功的采集并且上报。
但是,考虑到我们将应用到生产环境,需要监控的运行于 JVM 之上的应用程序有:Tomcat,Resin,Spark,Hadoop,ElasticSearch等等。这些不同的应用程序的运行环境各有差别,那么我们设计开发的 JVM 性能监控 Agent 必须考虑之前提到的兼容性。
下面我将以常见的 Web容器(Tomcat和Resin)为例来探讨 JVM Agent 的设计实现。
ClassNotFoundException 问题
考虑以下情况,在 Tomcat 中部署的 Web服务引用了公司的一个公共 jar包,这里简单叫做 package1.jar
,而在我按照上面思路开发的第一版 JVM Agent 中也使用到了这个公共 jar 包中的类com.xxx.Comm
。
当我将 JVM Agent 接入到 Tomcat 中进行测试的时候,并没有在简单 Java 应用程序中的那样顺利收集并上报 JVM 性能数据,而是出现了 ClassNotFoundException
异常,具体为找不到com.xxx.Comm
这个类,导致 Tomcat 启动失败。
在前后翻查思考一轮后意识到了问题与 Java 类加载机制有关,不过为了让大家更好的理解其中的具体原因,在揭晓答案前我先简单的谈谈 Java 的类加载机制以及在 Web 容器( Tomcat 和 Resin )中实现的类加载机制。
浅谈 Java 类加载机制
Java 源代码编译后生成的 class 文件需要经过 JVM 的加载,才能够在应用程序中使用。这个类加载过程分为多个阶段:加载、验证、准备、解析、初始化等等,不过这个加载的细节我们不需要过多的去关心,因为在 JVM 中,类的加载已经封装抽象成类加载器(ClassLoader)来完成,这个 ClassLoader 隐藏了底层类加载的细节,但是也保持了一定灵活性,使得开发者可以通过 ClassLoader 来控制类的加载行为。
类与类加载器
对于任意一个类,都需要同它的类加载器和这个类本身一同确定其在 Java 虚拟机中的唯一性。换言之,同样类名的一个类,由 ClassLoaderA 加载的和由 ClassLoaderB 加载的实际上是两个不一样的类 。
类加载的时机
JVM 启动时不会一次性将所有的类加载进来,而是在运行时根据应用程序的需要动态的由类加载进行加载。至于一个类具体时什么时候被加载进来,比较复杂需要视情况而定(在这里不做详细说明,有兴趣的可以自行了解以下),一个简单不怎么严谨的概括就是:在类第一次被使用的被加载。
双亲委派模型
双亲委派模型是 Java 中类加载机制的关键,它是 JDK 中的类加载机制实现。其主要目的是为了,通过双亲委派模型来组织类加载器之间的关系,使得 Java 类随它的类加载器一起形成具备优先级的层次关系,保证了 JDK 核心类的唯一性,避免类加载的混乱。
在 JDK 的双亲委派模型中,有三种不同的类加载器,负责不同类的加载,分别是:
BootstrapClassLoader
负责 JDK 核心类库的加载,如:rt.jar。一般为存放在
<JAVA_HOME>\lib
目录下的,或者是被-Xbootstrappath
参数所指定的路径中的类ExtensionClassLoader
主要负责 JDK 扩展类库的加载。一般为
<JAVA_HOME>\lib\ext
目录中,或者被java.ext.dirs
系统变量所指定的路径中的所有类库SystemClassLoader(ApplicationClassLoader)
负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载,一般情况下这个就是程序中默认的类加载器
双亲委派模型中,这三种类加载器的关系如下:
双亲委派模型中除了顶层的 BootstrapClassLoader 外,其余的的类加载都应该有自己的父类加载器。类加载的过程如下:
- 当一个类加载器收到了类加载请求后,首先会检查类是否已经被加载
- 如果类没有被加载,那么当前类加载器会委托父类加载器去完成,每一层次的类加载器都是如此;相反,如果发现类已经被加载,将会直接返回
- 在向上委托的过程中,如果父类加载器无法完成类加载,那么当前类加载器将会尝试自己加载
- 如果当前类加载器还是不能完成类的加载,那么说明类加载失败,报 ClassNotFoundException
整个过程在 JDK 源码实现中非常简单,但是用处很大,而在涉及类加载的开发,也应当了解这么一个流程。
JDK 源码如下:
1 | protected Class<?> loadClass(String name, boolean resolve) |
Web(Tomcat & Resin) 的类加载机制
常见的Web容器,如 Tomcat 以及 Resin,它们的类加载机制都在 JDK 原有的基础上进行了扩展,因此我们设计开发的 JVM Agent 也应该适应这种情况进行处理。
Tomcat 与 Resin 作为 Web 容器,其类加载机制是类似的,这里放在一起介绍,同时对其类加载器结构作了一定简化。
对于这类 Web 类加载器,它们是支持单个实例运行多个不同的 Web 应用。为了避免不同的 Web 应用依赖的类库发生冲突(如:版本不一样),Web 容器为不同的 Web 应用创建了其专属的类加载器负责相关类库的加载,将不同的 Web 应用的类加载隔离开来。另外,对于部分公共的基础类库,如容器自身的类库,将会由各 WebAppClassLoader 的父类加载器——CommonClassLoader负责,这样可以降低一部分的资源占用。
而一般的,Web 服务所依赖的 jar 包均放到 WEB-INF/lib/
目录下,由 WebAppClassLoader负责加载。
JVM Agent 中的解决方案
异常原因
通过上面对 JDK 类加载机制以及 Web 和 Svr 的类加载机制的简单介绍,大家应该大概可以猜到当 JVM Agent 接入到 Tomcat 后出现的 ClassNotFoundException
原因了:
我们知道 Java Agent 由 SystemClassLoader 类加载器负责加载,在 JVM Agent 中依赖到的 com.xxx.Comm
类位于 ....xxx/WEB-INF/lib/
目录下,由 WebAppClassLoader 类加载器负责加载。由双亲委派模型可知,类的加载是向上查找的,当 JVM Agent 在 SystemClassLoader、ExtensionClassLoader以及 BootstrapClassLoader 的 ClassPath 中均查找不到 com.xxx.Comm
类后就只能抛出 ClassNotFoundException
了。
解决思路
在讨论解决方案前,先回忆以下几个比较重要的点:
- Java Agent 由 SystemClassLoader 加载
- 一般情况下,当需要创建一个类的实例的时候(使用new),将会由当前所在类的类加载器负责加载
换言之,如果由 SystemClassLoader 加载的 JavaAgent 中需要用到的其他类也将会由 SystemClassLoader 负责加载。为了能让 com.xxx.Comm
类能够被 JVM Agent 查找得到,不外乎就是将其放到 JVM Agent 相同的类加载路径中(ClassPath)。
主要思路有两个:
- 将依赖到的类库jar包加入到 SystemClassLoader 的类加载路径中。
- 采用类似 Tomcat 的实现,JVM Agent 由以 SystemClassLoader 为父类加载器的自定义类加载器加载,自己拥有另外一份 jar 包,独立于应用程序的类加载器,将JVM Agent 的逻辑与 JavaAgent 的入口逻辑分离。
第一种方式看似简单,但实际上,由于依赖的公共 jar 包 package1.jar
内类的依赖情况较为复杂,会依赖到其他的一些 jar 包,反而导致其他类的 ClassNotFoundException
异常
而第二种方式,虽然是实现上要较为复杂,但是 JVM Agent 中使用到的公共 jar 包类 com.xxx.Comm
并没有依赖包外的其他类,故比较可取。而且另外的一个好处是,之后如果 JVM Agent 中需要依赖到一些第三方包,也可以避免与原应用程序依赖的包发生冲突。
使用自定义类加载器
注意:上面方式二提到,因为要把 JavaAgent 入口方法逻辑从实现的 JVM Agent 分离出去,故下面的提到 JVM Agent 指代的仅仅包含 JVM 监控逻辑
一般情况下直接创建对象实例,如new,会使用当前类或实例所属的类加载器完成加载,那么我们怎样才能够让 JVM Agent 由自定义类加载器来加载呢?答案是借助 ClassLoader 的 loadClass 方法和 Java 反射(reflect)来实现,步骤如下:
- JavaAgent 入口中创建自定义类加载器实例,同时将依赖的 jar 包加入到这个自定义类加载器的 ClassPath 中
- 调用这个自定义类加载器的 loadClass 方法 加载 JVM Agent 类,获取到其class对象
- 由class对象反射创建 JVM Agent 对象实例
实现代码片段:
分离 jar 包
为了将 JavaAgent 入口逻辑与 JVM Agent 监控逻辑分离,上面的使用自定义类加载器还未完全解决问题,还需要在物理上(jar包)做分离。
在原先的设计中,因为 JavaAgent 必须由 SystemClassLoader 加载,也就是我们的jar包也必须在SystemClassLoader 的 ClassPath 中,使用 CustomClassLoader 去加载 JVM Agent 类时,因为双亲委派模型,还是会先委派由 SystemClassLoader 加载,而包含在其 ClassPath 的 jar 包中有 JVM Agent 的 class,所以还是无效。因此我们需要将 JVM Agent 的监控逻辑分离出来独立成一个 jar 包,使得在 SystemClassLoader 的 ClassPath 中没有 JVM Agent 的类
而另一方面,因为 JavaAgent 逻辑负责 Agent 的启动和配置读取等等,会有改动的可能,而且今后可能会有多个类似 Agent 的开发,如果这部分的逻辑发生改动就需要将全部的 Agent 重新编译打包上线。而当 JavaAgent 入口分离出来后,只需要重新编译 JavaAgent 这部分逻辑就可以了。
因此我们将 JavaAgent 的入口与 JVM Agent 的监控逻辑相分离,JavaAgent 入口封装到 agent-boostrap.jar
包中,依然由 SystemClassLoader 负责加载;而 JVM Agent 的监控逻辑封装到 agent-jvm.jar
包中,与其他依赖到的包(package1.jar
)一起放到path/to/javaAgent/
这个独立的目录中,由自定义类加载器负责加载,这样 JVM Agent 中依赖到的类都会由这个自定义类加载器加载,与原本的 Web 应用程序的类加载机制独立。
流程:
- JVM 启动后
- 调用
agent-bootstrap.jar
包中的 JavaAgentpremain()
入口 - 创建自定义类加载器,通过这个类加载器从
agent-jvm.jar
包中加载 JVM Agent 对应的 class - 创建 JVM Agent 实例
- 启动 JVM Agent 定时采集上报线程
- 完成 JVM Agent 的启动,调用
main()
方法
更进一步:Agent 可插拔化
因为目前已经分离出来两个 jar 包,agent-bootstrap.jar
包负责引导启动,而 agent-jvm.jar
包则包含 JVM 监控逻辑,负责数据的采集上报。考虑到之后可能有其他开发类似的使用 JavaAgent 开发的需求,因此实际上可以进一步抽象成简单的开发框架:新开发的 Agent 只需要实现指定的接口并打成 jar 包,然后配置到Agent列表中就可以启用,无需考虑 JavaAgent 实现、 配置读取以及一些类加载的细节。
设计思路
在之前的实现中,JVM Agent 监控逻辑所在的类是由自定义类加载器根据提供的类名加载的,也即ClassLoader.loadClass(String name, boolean resolve)
方法。因此我们可以配置化的指定 Agent 的类名,就可以加载并创建该Agent类的实例。同时,这些Agent实现了 com.xxx.Agent
接口的接口方法 boot()
作为入口方法,AgentBootstrap 逻辑在创建目标 Agent 实例后可以通过反射调用该方法启动目标 Agent逻辑。
在实际代码实现中,为了进一步简化Agent列表的配置,不需要填写完整的Agent类名,如 JvmAgent
,参考了 Java SPI 的 ServiceLoader 实现,这里不作详述。
JVM Agent 最终设计
所以在最终的 JVM Agent 设计方案中,涉及到agent-bootstrap.jar
和agent-jvm.jar
这么两个 jar 包,以及需要配置这么一个 JVM 参数 -javaagent:/path/to/javaAgent/agent-bootstrap.jar -Dagent.bootstrap.agents=JvmAgent
就可以完成 JVM 监控的接入
JVM Agent 的启动流程如下: