前言:如标题所示,接下来我要介绍的是一个用于监控 JVM 性能的工具(JVM Agent),JVM 是每一个 Java 开发工程师或多或少都有所接触了解的,不管是在求职面试的过程中,日常的 Coding 中,还是在处理 Memory Leak 或者 GC 等问题时。目前公司随着业务发展,一个对外的服务可能运行于众多台 JVM 实例之上,如何及时发现处理集群中的JVM出现的问题成了棘手的问题,这就是 JVM 监控工具出现的现实意义。
本文将分为两部分
第一部分: 将简单介绍 JVM 性能监控工具以及所涉及的技术和API,为之后的设计实现部分准备基础
第二部分: 将探讨性能监控工具的设计实现以及 ClassNotFoundException 等问题的处理
简单认识 JVM 监控
可能部分读者对 JVM 监控还没有基本的概念和认识,所以本节我先简单聊聊 JVM 性能监控工具(JVM Agent)的开发背景,对监控工具的要求以及借助这个工具能干些什么。
开源的 JVM Profiler 工具有哪些
市面上也有不少做得不错的开源 JVM Profiler 工具,简单列举几个我所了解的 Profiler 工具如下:
- Pinpoint: 除了提供 JVM 监控外,还支持分布式链路跟踪。可指定数据存储方式,提供了WebUI供使用者查看监控数据
- Uber JVM-Profiler: Uber 针对 Hadoop 和 Spark 集群 JVM 监控开发,同时也支持 Java 方法调用跟踪
- Honest-Profiler:JVM 监控,同时针对线程堆栈监控中的 sample bias 问题做处理
为什么要监控 JVM
目前公司以 Java 技术栈为主,主要服务都是运行于 JVM 之上,日常接触到的 JVM 问题一般都是内存,GC,锁之类。尽管 JVM 对于我们是黑箱子,但是在问题发生时,通过诸如 jstat、jhat、jmap、jstack等 JDK 工具以及 GC 日志,可以快速协助我们定位问题,就这点 JDK 工具是非常实用的:
- JDK 自带无需额外安装部署
- 简单易用,基本可以满足查看 JVM 各方面性能信息的需要
可能会有疑问,既然 JDK 工具足够好用帮助到我们排查问题,那为什么还要把 JVM 监控接入到监控平台中呢?
需要注意的是,当我们使用 JDK 工具排查问题的时候,潜台词往往就是问题已经发生并且可能对业务造成了影响,并且我们无法通过 JDK 工具了解到问题出现前发生了什么。如果我们能够将 JVM 监控接入到公司的监控平台 ,持续监控并且辅以合理的告警阈值,我们可以做到在问题发生前就将其处理掉,避免对业务的影响,对比使用 JDK 排查问题的方式,JVM 监控工具有以下优势:
- 更加简单易用。
- 直观。通过监控图表看到各方面的性能数据
- 持续实时采集数据
- 历史可回溯。协助分析问题
- 可告警通知。设置阈值告警,先于问题发生进行处理
对 JVM Agent 的要求
除了要满足 JVM 性能数据采集的功能需要,作为对 JVM 性能进行监控的工具,必须要保证避免或较少的对所监控的 JVM 造成影响。故 JVM Agent 应当关注同时也尽量满足以下几点:
- 较低的性能损耗。启动快,资源占用少,Agent 的数据采集较少的影响到 JVM 的性能
- 容错性。Agent 自身的运行时错误不应影响到 JVM 之上业务的运行
- 低侵入性。监控逻辑不应该侵入到业务逻辑当中
- 可配置化。能灵活打开关闭或调整 Agent 的监控行为
- 实时性。每一次的采集到数据应当是那一刻的真实 JVM 性能数据
- 兼容性。针对不同的 Java 应用程序以及 JDK 版本均能正常采集数据
JVM Agent 应当采集的数据
JVM Agent 主要采集 JVM 的内存使用、GC以及线程的状态信息,具体如下:
指标 | 含义 |
---|---|
heap_init | 堆内存初始字节数 |
heap_max | 堆内存最大字节数 |
heap_used | 堆内存已使用字节数 |
heap_committed | 堆内存已提交字节数 |
non_heap_init | 非堆内存初始字节数 |
non_heap_max | 非堆内存最大字节数 |
non_heap_used | 非堆内存已使用字节数 |
non_heap_committed | 非堆内存已提交字节数 |
direct_capacity | DirectBuffer总字节大小 |
direct_used | DirectBuffer已使用字节数 |
mapped_capacity | MappedBuffer总字节大小 |
mapped_used | MappedBuffer已使用字节数 |
eden_space_init | 新生代Eden初始字节数 |
eden_space_max | 新生代Eden最大字节数 |
eden_space_used | 新生代Eden已使用字节数 |
eden_space_committed | 新生代Eden已提交字节数 |
survivor_space_init | 新生代Survivor初始字节数 |
survivor_space_max | 新生代Survivor最大字节数 |
survivor_space_used | 新生代Survivor已使用字节数 |
survivor_space_committed | 新生代Survivor已提交字节数 |
old_gen_init | 老年代初始字节数 |
old_gen_max | 老年代最大字节数 |
old_gen_used | 老年代已使用字节数 |
old_gen_committed | 老年代已提交字节数 |
perm_gen_init | 永生代初始字节数 |
perm_gen_max | 永生代最大字节数 |
perm_gen_used | 永生代已使用字节数 |
perm_gen_committed | 永生代已提交字节数 |
metaspace_init | Metaspace初始字节数(JDK8+) |
metaspace_max | Metaspace最大字节数(JDK8+) |
metaspace_used | Metaspace已使用字节数(JDK8+) |
metaspace_committed | Metaspace已提交字节数(JDK8+) |
minor_gc_time | MinorGC耗时 |
minor_gc_cnt | MinorGC次数 |
major_gc_time | MajorGC耗时 |
major_gc_cnt | MajorGC次数 |
live_thread_cnt | 当前线程总数 |
peak_thread_cnt | 最大线程数 |
total_thread_cnt | 累计已启动线程数 |
daemon_thread_cnt | 守护线程数 |
new_thread_cnt | New状态线程数 |
runnable_thread_cnt | Runnable状态线程数 |
blocked_thread_cnt | Blocked状态线程数 |
waiting_thread_cnt | Waiting状态线程数 |
timedwaiting_thread_cnt | TimedWaiting状态线程数 |
terminated_thread_cnt | Terminated状态线程数 |
deadlock_thread_cnt | 死锁线程数 |
java_spec_version | Java版本 |
java_vm_name | JVM名称 |
java_vm_version | JVM版本 |
Java Agent 及相关 API
JVM 性能监控 Agent 的实现主要使用了 Java Agent 技术,以及Management API。本节将对 java.lang.instument
和 java.lang.management
这两个用到的主要 API 作简单的介绍,接着对 Java Agent 的特点,并结合 demo 对 Java Agent 的使用和开发进行介绍。
java.lang.instrument API 简介
java.lang.instrument
从 JDK5 开始引入,它是基于 JVMTI 工具提供的一套 Java API, 利用 JVMTI 提供的丰富编程接口,可以完成很多跟 JVM 有关的功能。它为开发者提供了 API 用于检测 Java 应用程序,例如监控应用程序或者收集性能信息;同时开发者也可以通过 java.lang.instrument
API 来修改 class 的字节码(字节码增强),从而达到动态改变和操作类定义的目的,其最常见的用途是在无侵入的情况下为 Java 方法提供 AOP 服务。
Java Agent 开发指南
Java Agent 与 java.lang.instrument
API 有莫大的关系,它是 java.lang.instrument
API 提供的的编程入口,在 Java Agent 中可以很方便的使用到 Instrument API,以及完成其他一些操作。
Java Agent 的使用有以下几个重点:
- Java Agent 必须打包成 jar 包部署,同时在 jar 包的 manifest 文件中指定需要启动的 Java Agent 类
- Java Agent 支持两种启动方式。两种启动方式的区别在于 Java Agent 被加载和启动的时机不一样,第一种方式是命令行接口, Agent 会伴随 JVM 的启动被加载,且早于
main
方法被调用;第二种方式是在 JVM 启动后,通过 Attach API 启动 Agent - Java Agent 的 jar 包会被加入到 SystemClassLoader 的 ClassPath 中,并且由 SystemClassLoader 加载
Java Agent 的两种启动方式介绍如下:
命令行接口
在命令行接口中,Agent 的启动必须在 JVM 启动参数中加入以下:
1 | -javaagent:jarpath[=options] |
jarpath
指的是 Agent jar 包的路径,options
为需要传入到 Agent 的参数,当有多个 Agent 的时候可以多次使用 javaagent
参数。
Agent jar 包的 manifest 文件必须包含参数 Premain-Class
, 其值为 Agent 类名。Agent 类提供了两个重载的premain()
方法,premain()
方法的作用类似于main()
方法作为调用入口,当 JVM 完成初始化后,每一个 Agent 的 premain()
方法会先被调用,然后才调用真正的应用程序的 main()
方法。两个重载的方法如下:
JVM 会先尝试调用 Agent 类中带两个参数的
premain
重载方法1
public static void premain(String agentArgs, Instrumentation inst);
如果 Agent 类中没有实现上面的方法,那么 JVM 会尝试调用带一个参数的
premain
重载方法1
public static void premain(String agentArgs);
需要注意的是,如果 Agent 类解析失败(例如 Agent 的 class 加载失败或者 Agent 类中没有实现 premain
方法),那么 JVM 将退出;又或者 premain
方法抛出了未捕获异常,那么 JVM 也会退出。
JVM 运行时加载 Agent
通过 Attach API 提供了在运行时加载 Agent 的机制,至于 Agent 是什么时候被加载视乎具体 attach 的时机而定,而一般 attach 的时候,应用程序已经启动并且 main
方法已经被调用。
Agent jar 包的 manifest 文件必须包含参数 Agent-Class
,其值为 Agent 类名。同样的 Agent 提供了两个重载的 agentmain
方法作为调用入口,并且传入 Agent 参数以及 Instrumentation 对象。两个重载的方法如下:
JVM 会首先尝试调用 Agent 类中带两个参数的
agentmain
重载方法1
public static void agentmain(String agentArgs, Instrumentation inst);
如果 Agent 类中没有实现上面的方法,那么 JVM 会尝试调用带一个参数的
agentmain
方法1
public static void agentmain(String agentArgs);
与前面一种启动方式不同,如果 Agent 不能成功启动(例如:Agent 类加载失败或者没有实现agentmain
方法),那么 JVM 将不会退出;另外,如果agentmain
方法抛出了未捕捉异常,那么这个异常将被忽略,而不会导致 JVM 的退出
Java Agent 示例
Agent 的更多详细内容可以参考官方文档,如果对 JVMTI 以及 Attach API 有兴趣的童鞋也可去了解一下。下面通过一个简单 demo 演示了一下使用第一种启动方式开发的 Agent。
Main方法:
1 | public class AgentTest { |
Agent 类:
1 | import java.lang.instrument.Instrumentation; |
manifest:
1 | Manifest-Version: 1.0 |
使用 jar 命令打包:
1 | [ken@vm1 src]$ /usr/local/jdk1.8/bin/jar -cvfm JavaAgent.jar JavaAgent_manifest JavaAgent.class |
JVM 参数:
1 | // 启动时,通过 -javaagent 参数指定 Agent jar 包的路径 |
输出:
1 | JavaAgent premain invoke!!! |
java.lang.manament API 简介
java.lang.management
包提供管理接口用于监控以及管理 JVM 以及 Java 运行时的其他组件。我们开发的 JVM Agent 就是通过这个包提供的接口,收集到 JVM 中包括内存、GC、线程在内的信息。java.lang.management
包提供了以下的接口:
- BufferPoolMXBean:bufferPool 管理接口,例如:直接缓冲池、映射缓冲池
- ClassLoadingMXBean:JVM 的类加载系统管理接口
- CompilationMXBean:JVM 编译系统管理接口
- GarbageCollectionMXBean:JVM GC 管理接口,提供不同垃圾回收器的回收次数和耗时信息
- MemoryManagerMXBean:内存管理器接口,内存管理器负责管理各个分区的内存,包括了我们常说的垃圾回收器
- MemoryMXBean:JVM 内存系统管理接口,提供获取堆内存以及非堆内存信息
- MemoryPoolMXBean:内存池管理接口,内存池也就是我们常说的 Java 内存分代分区,如:新生代、老年代、永生代等等
- OperatingSystemMXBean:提供 JVM 所运行的操作系统信息
- RuntimeMXBean:提供 JVM 运行时系统信息
- ThreadMXBean:JVM 线程管理接口,提供 JVM 线程的相关信息
各个 MXBean 提供的具体数据获取接口,可以查看官方文档进一步了解。
下面以 MemoryMXBean 为例,其提供了主要接口获取内存信息:
MemoryUsage getHeapMemoryUsage()
MemoryUsage getNonHeapMemoryUsage()
上面两个接口将返回当前堆或非堆的内存使用信息,这些信息封装在 MemoryUsage 对象中。MemoryUsage 对象提供了内存的四个属性,分别是:init、used、committed、max,表示当前堆或非堆内存的:初始化内存大小,已使用内存大小,已提交内存大小,最大内存大小,单位为字节数。
通过以上的属性,我们就可以清晰的了解到内存的详细使用情况,对于 JVM 的其他监控指标同理。