JVM CPU Profiler技术原理及源码深度解析

JVM CPU Profiler技术原理及源码深度解析

研发人员在遇到线上报警或需要优化系统性能时,常常需要分析程序运行行为和性能瓶颈。Profiling技术是一种在应用运行时收集程序相关信息的动态分析手段,常用的JVM Profiler可以从多个方面对程序进行动态分析,如CPU、Memory、Thread、Classes、GC等,其中CPU Profiling的应用最为广泛。CPU Profiling经常被用于分析代码的执行热点,如“哪个方法占用CPU的执行时间最长”、“每个方法占用CPU的比例是多少”等等,通过CPU Profiling得到上述相关信息后,研发人员就可以轻松针对热点瓶颈进行分析和性能优化,进而突破性能瓶颈,大幅提升系统的吞吐量。

本文介绍了JVM平台上CPU Profiler的实现原理,希望能帮助读者在使用类似工具的同时也能清楚其内部的技术实现。

CPU Profiler简介

社区实现的JVM Profiler很多,比如已经商用且功能强大的JProfiler,也有免费开源的产品,如JVM-Profiler,功能各有所长。我们日常使用的Intellij IDEA最新版内部也集成了一个简单好用的Profiler,详细的介绍参见官方Blog

在用IDEA打开需要诊断的Java项目后,在“Preferences -> Build, Execution, Deployment -> Java Profiler”界面添加一个“CPU Profiler”,然后回到项目,单击右上角的“Run with Profiler”启动项目并开始CPU Profiling过程。一定时间后(推荐5min),在Profiler界面点击“Stop Profiling and Show Results”,即可看到Profiling的结果,包含火焰图和调用树,如下图所示:

Intellij IDEA - 性能火焰图

Intellij IDEA - 性能火焰图

Intellij IDEA - 调用堆栈树

Intellij IDEA - 调用堆栈树

火焰图是根据调用栈的样本集生成的可视化性能分析图,《如何读懂火焰图?》一文对火焰图进行了不错的讲解,大家可以参考一下。简而言之,看火焰图时我们需要关注“平顶”,因为那里就是我们程序的CPU热点。调用树是另一种可视化分析的手段,与火焰图一样,也是根据同一份样本集而生成,按需选择即可。

这里要说明一下,因为我们没有在项目中引入任何依赖,仅仅是“Run with Profiler”,Profiler就能获取我们程序运行时的信息。这个功能其实是通过JVM Agent实现的,为了更好地帮助大家系统性的了解它,我们在这里先对JVM Agent做个简单的介绍。

JVM Agent简介

JVM Agent是一个按一定规则编写的特殊程序库,可以在启动阶段通过命令行参数传递给JVM,作为一个伴生库与目标JVM运行在同一个进程中。在Agent中可以通过固定的接口获取JVM进程内的相关信息。Agent既可以是用C/C++/Rust编写的JVMTI Agent,也可以是用Java编写的Java Agent。

执行Java命令,我们可以看到Agent相关的命令行参数:

Plain Text
    -agentlib:<库名>[=<选项>]
                  加载本机代理库 <库名>, 例如 -agentlib:jdwp
                  另请参阅 -agentlib:jdwp=help
    -agentpath:<路径名>[=<选项>]
                  按完整路径名加载本机代理库
    -javaagent:<jar 路径>[=<选项>]
                  加载 Java 编程语言代理, 请参阅 java.lang.instrument

JVMTI Agent

JVMTI(JVM Tool Interface)是JVM提供的一套标准的C/C++编程接口,是实现Debugger、Profiler、Monitor、Thread Analyser等工具的统一基础,在主流Java虚拟机中都有实现。

当我们要基于JVMTI实现一个Agent时,需要实现如下入口函数:

// $JAVA_HOME/include/jvmti.h

JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved);

使用C/C++实现该函数,并将代码编译为动态连接库(Linux上是.so),通过-agentpath参数将库的完整路径传递给Java进程,JVM就会在启动阶段的合适时机执行该函数。在函数内部,我们可以通过JavaVM指针参数拿到JNI和JVMTI的函数指针表,这样我们就拥有了与JVM进行各种复杂交互的能力。

更多JVMTI相关的细节可以参考官方文档

Java Agent

在很多场景下,我们没有必要必须使用C/C++来开发JVMTI Agent,因为成本高且不易维护。JVM自身基于JVMTI封装了一套Java的Instrument API接口,允许使用Java语言开发Java Agent(只是一个jar包),大大降低了Agent的开发成本。社区开源的产品如GreysArthasJVM-SandboxJVM-Profiler等都是纯Java编写的,也是以Java Agent形式来运行。

在Java Agent中,我们需要在jar包的MANIFEST.MF中将Premain-Class指定为一个入口类,并在该入口类中实现如下方法:

public static void premain(String args, Instrumentation ins) {
   
    // implement
}

这样打包出来的jar就是一个Java Agent,可以通过-javaagent参数将jar传递给Java进程伴随启动,JVM同样会在启动阶段的合适时机执行该方法。

在该方法内部,参数Instrumentation接口提供了Retransform Classes的能力,我们利用该接口就可以对宿主进程的Class进行修改,实现方法耗时统计、故障注入、Trace等功能。Instrumentation接口提供的能力较为单一,仅与Class字节码操作相关,但由于我们现在已经处于宿主进程环境内,就可以利用JMX直接获取宿主进程的内存、线程、锁等信息。无论是Instrument API还是JMX,它们内部仍是统一基于JVMTI来实现。

更多Instrument API相关的细节可以参考官方文档

CPU Profiler原理解析

在了解完Profiler如何以Agent的形式执行后,我们可以开始尝试构造一个简单的CPU Profiler。但在此之前,还有必要了解下CPU Profiling技术的两种实现方式及其区别。

Sampling vs Instrumentation

使用过JProfiler的同学应该都知道,JProfiler的CPU Profiling功能提供了两种方式选项: Sampling和Instrumentation,它们也是实现CPU Profiler的两种手段。

Sampling方式顾名思义,基于对StackTrace的“采样”进行实现,核心原理如下:

  1. 引入Profiler依赖,或直接利用Agent技术注入目标JVM进程并启动Profiler。
  2. 启动一个采样定时器,以固定的采样频率每隔一段时间(毫秒级)对所有线程的调用栈进行Dump。
  3. 汇总并统计每次调用栈的Dump结果,在一定时间内采到足够的样本后,导出统计结果,内容是每个方法被采样到的次数及方法的调用关系。

Instrumentation则是利用Instrument API,对所有必要的Class进行字节码增强,在进入每个方法前进行埋点,方法执行结束后统计本次方法执行耗时,最终进行汇总。二者都能得到想要的结果,那么它们有什么区别呢?或者说,孰优孰劣?

Instrumentation方式对几乎所有方法添加了额外的AOP逻辑,这会导致对线上服务造成巨额的性能影响,但其优势是:绝对精准的方法调用次数、调用时间统计。

Sampling方式基于无侵入的额外线程对所有线程的调用栈快照进行固定频率抽样,相对前者来说它的性能开销很低。但由于它基于“采样”的模式,以及JVM固有的只能在安全点(Safe Point)进行采样的“缺陷”,会导致统计结果存在一定的偏差。譬如说:某些方法执行时间极短,但执行频率很高,真实占用了大量的CPU Time,但Sampling Profiler的采样周期不能无限调小,这会导致性能开销骤增,所以会导致大量的样本调用栈中并不存在刚才提到的”高频小方法“,进而导致最终结果无法反映真实的CPU热点。更多Sampling相关的问题可以参考《Why (Most) Sampling Java Profilers Are Fucking Terrible》。

具体到“孰优孰劣”的问题层面,这两种实现技术并没有非常明显的高下之判,只有在分场景讨论下才有意义。Sampling由于低开销的特性,更适合用在CPU密集型的应用中,以及不可接受大量性能开销的线上服务中。而Instrumentation则更适合用在I/O密集的应用中、对性能开销不敏感以及确实需要精确统计的场景中。社区的Profiler更多的是基于Sampling来实现,本文也是基于Sampling来进行讲解。

基于Java Agent + JMX实现

一个最简单的Sampling CPU Profiler可以用Java Agent + JMX方式来实现。以Java Agent为入口,进入目标JVM进程后开启一个ScheduledExecutorService,定时利用JMX的threadMXBean.dumpAllThreads()来导出所有线程的StackTrace,最终汇总并导出即可。

Uber的JVM-Profiler实现原理也是如此,关键部分代码如下:

// com/uber/profiling/profilers/StacktraceCollectorProfiler.java

/*
 * StacktraceCollectorProfiler等同于文中所述CpuProfiler,仅命名偏好不同而已
 * jvm-profiler的CpuProfiler指代的是CpuLoad指标的Profiler
 */

// 实现了Profiler接口,外部由统一的ScheduledExecutorService对所有Profiler定时执行
@Override
public void profile() {
   
    ThreadInfo[] threadInfos = threadMXBean.dumpAllThreads(false, false);
    // ...
    for (ThreadInfo threadInfo : threadInfos) {
   
        String threadName = threadInfo.getThreadName();
        // ...
        StackTraceElement[] stackTraceElements = threadInfo.getStackTrace();
        // ...
        for (int i = stackTraceElements.length - 1; i >= 0; i--) {
   
            StackTraceElement stackTraceElement = stackTraceElements[i];
            // ...
        }
        // ...
    }
}

Uber提供的定时器默认Interval是100ms,对于CPU Profiler来说,这略显粗糙。但由于dumpAllThreads()的执行开销不容小觑,Interval不宜设置的过小,所以该方法的CPU Profiling结果会存在不小的误差。

JVM-Profiler的优点在于支持多种指标的Profiling(StackTrace、CPUBusy、Memory、I/O、Method),且支持将Profiling结果通过Kafka上报回中心Server进行分析,也即支持集群诊断。

基于JVMTI + GetStackTrace实现

使用Java实现Profiler相对较简单,但也存在一些问题,譬如说Java Agent代码与业务代码共享AppClassLoader,被JVM直接加载的agent.jar如果引入了第三方依赖,可能会对业务Class造成污染。截止发稿时,JVM-Profiler都存在这个问题,它引入了Kafka-Client、http-Client、Jackson等组件,如果与业务代码中的组件版本发生冲突,可能会引发未知错误。Greys/Arthas/JVM-Sandbox的解决方式是分离入口与核心代码,使用定制的ClassLoader加载核心代码,避免影响业务代码。

在更底层的C/C++层面,我们可以直接对接JVMTI接口,使用原生C API对JVM进行操作,功能更丰富更强大,但开发效率偏低。基于上节同样的原理开发CPU Profiler,使用JVMTI需要进行如下这些步骤:

1.编写Agent_OnLoad(),在入口通过JNI的JavaVM*指针的GetEnv()函数拿到JVMTI的jvmtiEnv指针:

// agent.c

JNIEXPORT jint JNICALL Agent_OnLoad(JavaVM *vm, char *options, void *reserved) {
   
    jvmtiEnv *jvmti;
    (*vm)->GetEnv((void **)&jvmti, JVMTI_VERSION_1_0);
    // ...
    return JNI_OK;
}

2.开启一个线程定时循环,定时使用jvmtiEnv指针配合调用如下几个JVMTI函数:

// 获取所有线程的jthread
jvmtiError GetAllThreads(jvmtiEnv *env, jint *threads_count_ptr, jthread **threads_ptr
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值