JProfiler 初步使用

3,199 阅读13分钟

JProfiler

1.背景

平时生产上会时不时地遇到JVM的一些性能问题,比如CPU飙升、内存溢出、线程死锁、IO异常等,那么我们就需要一些性能诊断工具去排查,JAVA自带的性能诊断工具有如下几种:

  • jinfo - 用于实时查看和调整目标 JVM 的各项参数。
  • jstack - 用于获取目标 Java 进程内的线程堆栈信息,可用来检测死锁、定位死循环等。
  • jmap - 用于获取目标 Java 进程的内存相关信息,包括 Java 堆各区域的使用情况、堆中对象的统计信息、类加载信息等。
  • jstat - 一款轻量级多功能监控工具,可用于获取目标 Java 进程的类加载、JIT 编译、垃圾收集、内存使用等信息。
  • jcmd - 相比 jstat 功能更为全面的工具,可用于获取目标 Java 进程的性能统计、JFR、内存使用、垃圾收集、线程堆栈、JVM 运行时间等信息。

上面基本上都是我们平时比较常见的工具,现在再介绍一款比较好用、颜值比较高的工具-JProfiler

2.介绍

Profiler 是一个专业工具,用于分析正在运行的 JVM 内部发生的事情。当您的生产系统遇到问题时,您可以在开发、质量保证和消防任务中使用它。

JProfiler 主要处理四个主题:

  • 方法调用

    这通常称为“CPU 分析”。方法调用可以通过不同的方式进行测量和可视化。方法调用分析可帮助您了解应用程序正在执行的操作并找到提高其性能的方法。

  • 分配

    分析堆上的对象的分配、引用链和垃圾收集属于“内存分析”的范畴。此功能使您能够修复内存泄漏,通常使用更少的内存并分配更少的临时对象。

  • 线程和锁

    线程可以持有锁,例如通过同步对象。当多个线程协作时,可能会发生死锁,JProfiler 可以为您可视化它们。此外,锁可以被争用,这意味着线程在获取它们之前必须等待。JProfiler 提供对线程及其各种锁定情况的洞察。

  • 更高级别的子系统

    许多性能问题发生在更高的语义级别。例如,对于 JDBC 调用,您可能想找出最慢的 SQL 语句。对于这样的子系统,JProfiler 提供了将特定有效负载附加到调用树的“探针”。

Profiler 的 UI 作为桌面应用程序提供。您可以在不使用 UI 的情况下以交互方式自动分析实时 JVM 或配置文件。分析数据保存在可以使用 JProfiler UI 打开的快照中。此外,命令行工具和构建工具集成可帮助您实现分析会话的自动化。

JProfiler 是一个商业授权的 Java剖析工具,用于分析Java EE和Java SE应用程序。

2.1 Profiler内部模型

image-20211211124308381

2.2 数据采集原理

image-20211212204515943

Profiled JVM:要分析的应用程序 JProfiler GUI:分析工具

JVMTI:JVM Tool Interface,是基于事件消息的系统,JProfiler agent可以注册不同的处理函数到不同的消息事件上;(线程的生命周期/类的加载/对象的分配/堆内存的实时信息/垃圾回收等等)

JProfiler agent:数据采集代理器,将采集好的信息保留在内存中进行,并统计好;

Socket 8849:传输数据或者指令,走的是tcp,意味着可以远程进行性能分析监控或操控(比如内存回收GC);

GUI Render:界面工具;

  1. 用户在JProfiler GUI中下达监控的指令(一般就是点击某个按钮)
  2. JProfiler GUI JVM 通过socket(默认端口8849),发送指令给被分析的jvm中的JProfile Agent。
  3. JProfiler Agent(如果不清楚Agent请看文章第三部分"启动模式") 收到指令后,将该指令转换成相关需要监听的事件或者指令,来注册到JVMTI上或者直接让JVMTI去执行某功能(例如dump jvm内存)
  4. JVMTI 根据注册的事件,来收集当前jvm的相关信息。 例如: 线程的生命周期; jvm的生命周期;classes的生命周期;对象实例的生命周期;堆内存的实时信息等等
  5. JProfiler Agent将采集好的信息保存到内存中,按照一定规则统计好(如果发送所有数据JProfiler GUI,会对被分析的应用网络产生比较大的影响)
  6. 返回给JProfiler GUI Socket.
  7. JProfiler GUI Socket 将收到的信息返回 JProfiler GUI Render
  8. JProfiler GUI Render 渲染成最终的展示效果

JVMTI是一个基于事件的系统。分析代理库可以为不同的事件注册处理程序函数。然后,它可以启用或禁用所选事件

形象地说,JVMTI是Java虚拟机提供的一整套后门。通过这套后门可以对虚拟机方方面面进行监控,分析。甚至干预虚拟机的运行。

3.使用

3.1 启动

3.1.1 启动模式

  1. Attach Mode:依附模式 可直接加载JProfiler Agent到本机正在运行的jvm中, 优点是很方便,缺点是一些特性不能支持。 如果选择Instrumentation数据采集方式,那么需要花一些额外时间来重写需要分析的class,这时,开启会慢一些;

  2. Launch Mode:发行模式(完整模式) 在启动被分析的程序时, (1) 先启动JVM,带上相关VM参数; (2) 启动JProfiler Agent; (3) JProfiler GUI将收集到的配置信息通过socket发送给JProfiler Agent; (4) 收到这些信息后,被分析的程序才开始启动;

3.1.2 采集模式

Sampling 取样:每隔一定时间(5ms)将每个线程栈及方法栈中的信息统计出来。优点是对应用的影响较小,缺点是一些数据/特性不能提供(例如:方法的调用次数);

Instrumentation 指令:在class加载时,JProfiler会把相关的功能代码写入到需要分析的class中,对正在运行的JVM有一定影响。优点是功能完毕,但对要分析的应用影响较大,所以一般结合过滤器filter一起使用,如JRE中的class和framework的class都会过滤掉;

image-20211211131654849

Sampling推荐模式,开销很低,是一种相对安全的模式,但是一些特性不支持;

Instrumentation,指令模式下,所有特性都支持,比如方法调用次数和方法统计,但是良好的过滤器是非常必要的,否则会影响性能开销;

3.2 主要目录介绍

在概览页我们可以清晰的看到内存使用量、垃圾收集活动、类加载数量、线程个数和状态、CPU 使用率等指标随时间变化的趋势。

image-20211211132312195

通过上面,我们可以大致了解:

  1. 程序在运行过程中会产生大量对象,但这些对象生命周期极短,大部分都能被垃圾收集器及时回收,不会造成内存无限增长。
  2. 加载类的数量在程序初始时增长较快,随后保持平稳,符合预期。
  3. 在程序运行过程中,有大量线程处于阻塞状态,需要重点关注。
  4. 在程序刚启动时,CPU 使用率较高,需要进一步探究其原因。

最后,怀疑哪个数据的趋势不正常,那么我们就可以往下,找到具体的模块进行详细分析了

3.3 CPU分析

3.3.1 代码例子

1.用户类
package com.zy.entity;

import java.util.Random;

/**
 * @Description:
 * @ClassName: User
 * @Author: zy
 * @Date: 2021/12/12 17:48
 * @Version: 1.0
 */
public class User {

    private Integer pId;
    private String pName;
    private Random random = new Random();
    // 计算器
    private static Object calculator = new Object();

    public User() {
    }

    public User(Integer pId, String pName) {
        this.pId = pId;
        this.pName = pName;
    }


    // 做某些事情
    public void doSomething() {
        int thingType = random.nextInt(3);
        int sum = 0;
        switch (thingType) {
            case 0: // 轻
                sum = this.sum0();
                break;

            case 1: // 中
                sum = this.sum1();
                break;

            case 2: // 重
                sum = this.sum2();
                break;

            default:
                break;
        }
//         System.out.println("thingType=" + thingType + " sum=" + sum);
    }

    // 轻活
    private int sum0() {
        int a = 1, b = 1;
        int sum = a + b;
        return sum;
    }

    // 中活
    private int sum1() {
        int sum = 0;
        for (int i = 0; i < 1000; i++) {
            sum += i;
            sum += i - sum;
        }
        return sum;
    }

    // 重活
    private int sum2() {
        int sum = 0;
        for (int i = 0; i < 100000; i++) {
            sum += (i % 10);
            sum += (sum % 10);
            sum += (sum % 10);
        }
        return sum;
    }


}

2.测试
    @Test
    public void test1() throws InterruptedException {

        new Thread() {
            @Override
            public void run() {
                Thread.currentThread().setName("子线程A");
                while (true){
                    int pid = 0;
                    // 不停的创建对象
                    User User = new User(pid,"User_" + pid );

                    // 不同的对象 做某些事情
                    User.doSomething();

                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    pid++;
                }
            };
        }.start();

        Thread.sleep(600000);

    }




3.3.2 调用树

调用树是"CPU视图"部分的第一个视图,当你开始进行CPU分析时,它是一个很好的起点, 因为遵循方法调用从起点到最细化的终点的自上而下视图,最容易理解。 JProfiler按照子节点的总时间进行排序,所以你可以深度优先打开树,分析对性能影响最大的部分。

image-20211212180643622

从上图可以看出,doSomething这个方法消耗CPU资源比较多的,再细看,发现sum2这个方法比起其他方法花费更多的CPU资源

上图还可以根据过滤器来过滤,选出需要的关心的类或方法,也可以根据线程状态进行分类查询

3.3.3 热点

如果你的应用程序运行得太慢,你要找到那些占用大部分时间的方法。通过调用树,有时可以直接找到这些方法, 但通常这样做是行不通的,因为调用树可能很大而且有大量叶节点

在这种情况下,你需要反转调用树:一个所有方法的列表,按其总的自身时间排序,从所有不同的调用堆栈中累计出来, 并通过回溯跟踪显示这些方法是如何被调用的。 在热点树中,叶节点是入口点, 就像应用程序的main 方法或线程的run 方法。 从热点树中最深的节点开始,调用向上传递到顶层节点。

image-20211212181059928

从上图中可知,除sleep是休眠的时间外,sum2业务消耗时间是比较多的

3.3.4 调用图

无论是调用树中,还是在热点视图中,每个节点都可能出现多次,尤其是递归调用的情况。 在某些情况下,你对以方法为中心的统计感兴趣,在这种情况下,每个方法只出现一次,所有的传入和传出调用都是可见的。 这样的视图最好以图的形式显示,在JProfiler中,它被称为调用图。

image-20211212181744773

可以通过上图可知,sum2的耗费时间比较多

当然 了,后面还有异常检测、复杂度分析、调用跟踪器等等高级功能,有兴趣的可以自学

3.4 内存分析

3.4.1 代码示例



    @Test
    public void testMem() throws InterruptedException {

        int pid = 0;
        while(pid < 1000000){
            Person person = new Person(pid,"person_" + pid );
            pid++;
            Thread.sleep(100);
            System.out.println("person = " + person);
        }

    }


3.4.2 所有对象

为了了解堆上有哪些对象,"所有对象"视图向你显示了所有类及其实例计数的直方图。 该视图中显示的数据不是通过分配记录收集的,而是通过执行一个迷你堆快照,只计算实例计数。 堆越大,执行该操作所需的时间就越长,因此视图的更新频率会根据测量的开销自动降低。 当视图不活动时,不收集数据,视图不会产生任何开销。与大多数动态更新的视图一样, 一个 冻结工具栏按钮可以停止更新显示的数据。

image-20211212182542476

image-20211212183208244

3.4.3 分配调用树

在分配调用树中,往往有很多节点完全没有执行分配,特别是当你为一个选定类显示分配时。 这些节点存在只是为了向你展示到实际执行分配节点的完整调用堆栈路径。这样的节点在JProfiler中被称为"桥接"节点, 并以灰色图标显示,正如你在上面的截图中看到的那样。在某些情况下,分配的累积可能会妨碍你,因为你只想看到实际的分配点。 为此,分配树的视图设置提供了一个选项以显示非累计数。如果激活,桥接节点将始终显示零分配,并且没有百分比条。

image-20211212183658733

上图中,testMem这个方法分配占比比较多

3.4.4 分配热点

分配热点视图与分配调用树一起,允许你直接关注负责创建所选类的方法。就像记录的对象视图,分配热点视图也支持标记当前状态和 观察一段时间内的差值。视图中会添加一个差值列,它显示了热点自当标记当前值 操作被调用后的变化。因为默认情况下,分配视图不会定期更新,所以你必须单击计算工具栏按钮以获得一个新数据集然后与基线值比较。 在选项对话框中可以有一个自动更新更新选项可用,但对于大堆,不建议使用。

image-20211212183756983

从上图可知,testMem这个热点比较多

3.4.5 类跟踪器

对于所选类,你还可以通过使用上下文菜单中的将所选内容添加到类跟踪器操作显示一个时间解析图。

image-20211212184215268

image-20211212184416054

可以分析一下这个类产生的时间解析

3.5 线程分析

3.5.1 代码示例


@Getter
@Setter
public class User2 {

    private Integer uId;
    private String uName;

    private Lock lock=new ReentrantLock();

    public User2(Integer uId, String uName) {
        this.uId = uId;
        this.uName = uName;
    }

    public User2() {
    }

    // 做某些事情
    public void doSomething() {
        lock.lock();
        try{
            this.sum2();
        }finally {
            lock.unlock();
        }
    }

    // 重活
    private int sum2() {
        int sum = 0;
        for (int i = 0; i < 100000; i++) {
            sum += (i % 10);
            sum += (sum % 10);
            sum += (sum % 10);
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        return sum;
    }


}




    @Test
    public void testThread() {

        User2 user = new User2();
        for (int i = 0; i < 6; i++) {
            final int num = i;
            new Thread(()->{
                Thread.currentThread().setName("子线程_" + num);
                while (true) {
                    // 做某些事情
                    user.doSomething();
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }

        new Thread(()->{
            while (true){
                String result1= HttpUtil.get("https://www.baidu.com");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"netThread").start();



        try {
            Thread.sleep(6000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }


    }






3.5.2 线程历史

线程历史视图将每个线程在时间线中显示为彩色行,颜色表示记录的线程状态。线程按其创建时间排序,并可按名称进行过滤。 当有Monitor事件被记录后,你可以将鼠标悬停在线程处于"等待"或"阻塞"状态的部分上,并查看相关的堆栈跟踪, 并有一个链接可以进入Monitor历史视图。

image-20211212194323158

3.5.3 线程Monitor

在线程Monitor视图中可以看到所有线程的表格视图。如果在创建线程时CPU记录处于活动状态, JProfiler会保存创建线程的名称并将其显示在表中。在底部,显示创建线程的堆栈跟踪。 出于性能方面的考虑,不会向JVM请求实际的堆栈跟踪,而是使用CPU记录的当前信息。 这意味着,堆栈跟踪将只显示那些满足调用树收集的过滤设置的类

image-20211212202601732

如上图所示,可以实时展示线程的运行状态变化

3.5.4 线程转储

线程转储的堆栈跟踪是JVM提供的完整堆栈跟踪,不依赖于CPU记录。 当你选择两个线程转储并点击 显示差异按钮,可以在差异视图中比较不同的线程转储。 也可以从一个线程转储中选择两个线程,然后选择上下文菜单中的显示差异对其比较。

image-20211212202025588

image-20211212202107365

上面可以显示不同时刻的线程之间差异信息

3.5.5 死锁

代码示例:


    public static final Object LOCK_OBJ1 = new Object();
    public static final Object LOCK_OBJ2 = new Object();

    @Test
    public void testDeadLock(){


        new Thread(()->{
            synchronized (TestThread.LOCK_OBJ1){
                System.out.println("TreadA get lock1");
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (TestThread.LOCK_OBJ2){
                    System.out.println("TreadA get lock2");
                }
            }
        },"ThreadA").start();

        new Thread(()->{
            synchronized (TestThread.LOCK_OBJ2){
                System.out.println("TreadB get lock2");
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (TestThread.LOCK_OBJ1){
                    System.out.println("TreadB get lock1");
                }
            }
        },"ThreadB").start();


        try {
            Thread.sleep(6000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }



    }



每个Java对象都有一个相关联的Monitor,它可以用于两种同步操作: 一个线程可以在Monitor上等待,直到另一个线程对其发出通知,或者它可以在Monitor上获得一个锁,可能会阻塞, 直到另一个线程放弃锁的所有权。此外,Java在java.util.concurrent.locks 包中提供了实现更高级锁策略的类。该包中的锁不使用对象的Monitor,而是使用不同的本地实现。

对于上述机制的两种锁状态情况,JProfiler都可以记录。在锁状态情况下,会有一个或多个线程、 一个Monitor或一个java.util.concurrent.locks.Lock 实例,以及一个需要一定时间的等待或阻塞操作。 这些锁状态情况在Monitor历史视图中以表格的方式呈现,在锁状态历史图中以可视化的方式呈现。

当前锁状态图:

无论Monitor事件是否被记录,当前锁状态图和当前Monitor视图中的数据始终显示。 这些视图显示当前的锁状态情况和正在进行的Monitor事件。锁状态操作通常是短暂的,但在发生死锁的情况下, 这两个视图将显示该问题的永久视图。此外,当前锁状态图会以红色显示产生死锁的线程和Monitor,因此你可以立即发现此类问题。

image-20211212203559839

如上图所示,很清晰能看到两个线程互相需要对方的锁导致死锁

当前Monitors:

image-20211212203940956

可以看到相关等待线程

参考

1.官方文档: www.ej-technologies.com/resources/j…

2.www.jianshu.com/p/784c60d94…

3.developer.aliyun.com/article/276

4.zhuanlan.zhihu.com/p/54274894