JVM性能监控Agent设计实现(一)

前言:如标题所示,接下来我要介绍的是一个用于监控 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.instumentjava.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 的使用有以下几个重点:

  1. Java Agent 必须打包成 jar 包部署,同时在 jar 包的 manifest 文件中指定需要启动的 Java Agent 类
  2. Java Agent 支持两种启动方式。两种启动方式的区别在于 Java Agent 被加载和启动的时机不一样,第一种方式是命令行接口, Agent 会伴随 JVM 的启动被加载,且早于main方法被调用;第二种方式是在 JVM 启动后,通过 Attach API 启动 Agent
  3. 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()方法。两个重载的方法如下:

  1. JVM 会先尝试调用 Agent 类中带两个参数的premain重载方法

    1
    public static void premain(String agentArgs, Instrumentation inst);
  2. 如果 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 对象。两个重载的方法如下:

  1. JVM 会首先尝试调用 Agent 类中带两个参数的 agentmain 重载方法

    1
    public static void agentmain(String agentArgs, Instrumentation inst);
  2. 如果 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
2
3
4
5
6
public class AgentTest {
public static void main(String[] args) {
// main方法调用时打印
System.out.println("AgentTest main function invoked!!!");
}
}

Agent 类:

1
2
3
4
5
6
7
8
import java.lang.instrument.Instrumentation;

public class JavaAgent {
public static void premain(String agentArgs, Instrumentation instrumentation) {
// 采用伴随 JVM 启动加载 Agent 的方式,premain 方法应当先于 main 方法被调用
System.out.println("JavaAgent premain invoke!!!");
}
}

manifest:

1
2
3
Manifest-Version: 1.0
Premain-Class: JavaAgent
Created-By: 1.8.0_161 (Oracle Corporation)

使用 jar 命令打包:

1
[ken@vm1 src]$ /usr/local/jdk1.8/bin/jar -cvfm JavaAgent.jar JavaAgent_manifest JavaAgent.class

JVM 参数:

1
2
// 启动时,通过 -javaagent 参数指定 Agent jar 包的路径
[ken@vm1 src]$ /usr/local/jdk1.8/bin/java -javaagent:/home/ken/tmp/javaAgent/src/JavaAgent.jar AgentTest

输出:

1
2
JavaAgent premain invoke!!!
AgentTest main function invoked!!!

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 的其他监控指标同理。

Ref

如果您觉得我的文章对您有帮助,不介意您请我喝杯咖啡