深入理解Java虚拟机之工具篇


虚拟机性能监控、故障处理工具

给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里说的数据包括但不限于异常堆栈、虚拟机运行日志、垃圾收集器日志、线程快照(threaddump/javacore文件)、堆转储快照(heapdump/hprof文件)等。恰当地使用虚拟机故障处理、分析的工具可以提升我们分析数据、定位并解决问题的效率

基础故障处理工具

JDK的bin目录下提供了许多小工具

image-20220112115227650

jps:虚拟机进程状况工具

可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(LVMID,Local Virtual Machine Identifier)

jps [选项] [hostid]

image-20220112191015040

jps主要选项:

选项 作用
-q 只输出LVMID,省略主类的名称
-m 输出虚拟机进程启动时传递给主类main()函数的参数
-l 输出主类的全名,若进程执行的是JAR包,输出JAR路径
-v 输出虚拟机进程启动时的JVM参数

jstat:虚拟机统计信息监视工具

jstat(JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类加载、内存、垃圾收集、即时编译等运行时等数据

jstat [选项 vmid(虚拟机进程ID)] [间隔时间] [执行次数]

image-20220112193733480

jinfo:Java配置信息工具

jinfo(Configuration Info for Java)的作用是实时查看和调整虚拟机各项参数

jinfo [选项] pid(操作系统进程ID)

jmap:Java内存映像工具

jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为heapdump或dump文件)

jmap [选项] vmid

image-20220112194503396

jhat:虚拟机堆转储快照分析工具

JDK提供jhat(JVM Heap Analysis Tool)命令与jmap搭配使用,来分析jmap生成的堆转储快照

分析功能比较简陋,少用

jstack:Java堆栈跟踪工具

jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的目的通常是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间挂起等,都是导致线程长时间停顿的常见原因。线程出现停顿时通过jstack来查看各个线程的调用堆栈,就可以获知没有响应的线程到底在后台做些什么事情,或者等待着什么资源

jmap [选项] vmid

image-20220112194938785

可视化故障处理工具

JHSDB:基于服务性代理的调试工具

JHSDB是一款基于服务性代理(Serviceability Agent,SA)实现的进程外调试工具。服务性代理是HotSpot虚拟机中一组用于映射Java虚拟机运行信息的、主要基于Java语言(含少量JNI代码)实现的API集合

在JDK8的bin目录下我并没有找到这个工具,为此特地下载了个JDK17进行演示

image-20220113112827414

package org.example;

/**
 * 使用Debug 启动,利用JHSDB工具获取staticObj,instanceObj,localObj三个变量的存储位置
 * VM参数:-Xmx10m -XX:+UseSerialGC -XX:-UseCompressedOops
 */
public class Hello {
    static class Test {
        static ObjectHolder staticObj = new ObjectHolder();
        ObjectHolder instanceObj = new ObjectHolder();
        void foo() {
            ObjectHolder localObj = new ObjectHolder();
            System.out.println("done"); // 这里设一个断点
        }
    }
    private static class ObjectHolder {}
    public static void main(String[] args) {
        Test test = new Hello.Test();
        test.foo();
    }
}
  1. Debug启动上述代码,确保内存已分配好,打开命令行,去到JDK的bin目录下,使用命令jps -l查看虚拟机进程

    image-20220113140952909

  2. 执行命令.\jhsdb.exe hsdb,打开jhsdb界面,菜单栏选择File->Attach To HotSpot Process,输入上面拿到的pid

    image-20220113141336858

  3. 菜单栏选择Tools->Heap Parameters查看堆中信息,由于VM参数指定使用的是SerialGC(不指定默认为G1收集器),所以可以看到典型的分代布局:Eden,From,To,Old;以及它们各自的虚拟内存地址起止范围

    image-20220113145845426

  4. 菜单栏选择Windows->Console,使用scanoops命令在Java堆的新生代(从Eden起始地址到To Survivor结束地址)范围内查找ObjectHolder的实例,可以看到三个实例的地址都在Eden区地址范围内,也印证了新对象在Eden区创建的规则

    image-20220113150744366

  5. 菜单栏选择Tools->Inspector,输入上述任一实例的地址,可查看对象信息(对象头和指向对象元数据的指针,里面包括了Java类型的名字、继承关系、实现接口关系,字段信息、方法信息、运行时常量池的指针、内嵌的虚方法表(vtable)以及接口方法表(itable)等)

    image-20220113151514197

  6. 根据堆中对象实例地址找出引用它们的指针,继续回到上述的Console界面,执行命令revptrs 对象实例地址,得出引用指针,可以看到这是一个Class类型对象实例,里面有一个staticObj实例字段,注意一下它后面的地址,可以发现,就是这里引用了第一个对象

    JDK 7及其以后版本的HotSpot虚拟机选择把静态变量与类型在Java语言一端的映射Class对象存放在一起,存储于Java堆之中

    image-20220113155108239

  7. 同理第二,三个对象实例查找也是如此操作。可以看到第二次找到的是Hello$Test的对象实例,第二个ObjectHolder的指针是在Java堆中Hello$Test对象的instanceObj字段上。接着查找第三个对象时发现返回的是null,这是因为第三个对象在方法中创建,是要进栈的,而revptrs并不支持查找栈上的指针引用,所以需要换一个方法查找

    image-20220113161118298

    image-20220113161124069

  8. 在Java Threads窗口选择main,然后点击打开Stack Memory窗口,就可以看到main线程中的栈内存内容,图中红框内就是第三个对象了,可以看到是引用了新生代中的Hello$ObjectHolder对象

    image-20220113165252846

image-20220113165453736

总结:

image-20220114165011506

JConsole:Java监视与管理控制台

JConsole(Java Monitoring and Management Console)是一款基于JMX(Java Manage-mentExtensions)的可视化监视、管理工具。它的主要功能是通过JMX的MBean(Managed Bean)对系统进行信息收集和参数动态调整

内存监控:

// VM参数:-Xms100m -Xmx100m -XX:+UseSerialGC
public class Hello {
    /**
     * 内存占位符对象,一个OOMObject大约占64KB
     */
    static class OOMObject {
        public byte[] placeholder = new byte[64 * 1024];
    }

    public static void fillHeap(int num) throws InterruptedException {
        List<OOMObject> list = new ArrayList<OOMObject>();
        for (int i = 0; i < num; i++) {
            // 稍作延时,令监视曲线的变化更加明显
            Thread.sleep(50);
            list.add(new OOMObject());
        }
        System.gc();
    }

    public static void main(String[] args) throws Exception {
        fillHeap(1000);
    }
}
  1. run上述代码,然后在控制台执行命令:jconsole,选择对应的本地进程建立连接

    image-20220117144242388

  2. 程序执行结束后,可以选择内存页签看到堆内存的变化。可以看到,Eden和Survivor区都差不多被清空,只有Old区还处于峰值状态,这是因为List<OOMObject> list = new ArrayList<OOMObject>();仍然存活未被回收,list对象在System.gc()执行时仍然处于作用域之内。如果把System.gc()移动到fillHeap()方法外调用就可以回收掉全部内存

    image-20220118143243613

线程监控:

  • 线程资源等待演示:
public class Hello {
    /**
     * 线程死循环演示
     */
    public static void createBusyThread() {
        Thread thread = new Thread(() -> {
            while (true)
                ;
        }, "testBusyThread");
        thread.start();
    }

    /**
     * 线程锁等待演示
     */
    public static void createLockThread(final Object lock) {
        Thread thread = new Thread(() -> {
            synchronized (lock) {
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "testLockThread");
        thread.start();
    }

    public static void main(String[] args) throws Exception {
        BufferedReader br = new BufferedReader(new InputStreamReader(System.in));
        br.readLine();
        createBusyThread();
        br.readLine();
        Object obj = new Object();
        createLockThread(obj);
    }
}
  1. 运行代码,选择线程页签查看main线程。可以看到BufferedReader br = new BufferedReader(new InputStreamReader(System.in));一直在等待键盘输入,所以此时还在Runnable状态

    image-20220118171017627

  2. 在控制台中输入任意内容,再次观察发现testBusyThread线程出现。根据堆栈跟踪可以看出,在代码13行处产生了资源等待

    image-20220118171239777

  3. createBusyThread();注释掉,重新启动代码,和上述操作一致,观察testLockThread线程。可以看到线程进入Waiting状态,在等待lock对象的notify()或notifyAll()方法的出现

    image-20220118171522758

  • 线程死锁演示:
public class Hello {
    /**
     * 线程死锁等待演示
     */
    static class SynAddRunalbe implements Runnable {
        int a, b;
        public SynAddRunalbe(int a, int b) {
            this.a = a;
            this.b = b;
        }
        @Override
        public void run() {
            synchronized (Integer.valueOf(a)) {
                synchronized (Integer.valueOf(b)) {
                    System.out.println(a + b);
                }
            }
        }
    }
    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(new SynAddRunalbe(1, 2)).start();
            new Thread(new SynAddRunalbe(2, 1)).start();
        }
    }
}

这段代码开了200个线程去分别计算1+2以及2+1的值,理论上for循环都是可省略的,两个线程也可能会导致死锁,不过那样概率太小,需要尝试运行很多次才能看到死锁的效果。如果运气不是特别差的话,上面带for循环的版本最多运行两三次就会遇到线程死锁,程序无法结束。造成死锁的根本原因是Integer.valueOf()方法出于减少对象创建次数和节省内存的考虑,会对数值为-128~127之间的Integer对象进行缓存,如果valueOf()方法传入的参数在这个范围之内,就直接返回缓存中的对象。也就是说代码中尽管调用了200次Integer.valueOf()方法,但一共只返回了两个不同的Integer对象。假如某个线程的两个synchronized块之间发生了一次线程切换,那就会出现线程A在等待被线程B持有的Integer.valueOf(1),线程B又在等待被线程A持有的Integer.valueOf(2),结果大家都跑不下去的情况

  1. 启动代码,点击线程页签内的检测死锁,会发现新的页签。可以看到线程处于Blocked状态,Thread48Thread49互相持有了对方想要的资源无法释放

总结:


评论
  目录