4.3 JDK的可视化工具

JDK中除了提供大量的命令行工具外,还有两个功能强大的可视化工具:JConsole和VisualVM,这两个工具是JDK的正式成员,没有被贴上“unsupported and experimental”的标签。

其中JConsole是在JDK 1.5时期就已经提供的虚拟机监控工具,而VisualVM在JDK 1.6 Update7中才首次发布,现在已经成为Sun(Oracle)主力推动的多合一故障处理工具[1],并且已经从JDK中分离出来成为可以独立发展的开源项目。

为了避免本节的讲解成为对软件说明文档的简单翻译,笔者准备了一些代码样例,都是笔者特意编写的“反面教材”。后面将会使用这两款工具去监控、分析这几段代码存在的问题,算是本节简单的实战分析。读者可以把在可视化工具观察到的数据、现象,与前面两章中讲解的理论知识互相印证。

4.3.1 JConsole:Java监视与管理控制台

JConsole(Java Monitoring and Management Console)是一种基于JMX的可视化监视、管理工具。它管理部分的功能是针对JMX MBean进行管理,由于MBean可以使用代码、中间件服务器的管理控制台或者所有符合JMX规范的软件进行访问,所以本节将会着重介绍JConsole监视部分的功能。

1.启动JConsole

通过JDK/bin目录下的“jconsole.exe”启动JConsole后,将自动搜索出本机运行的所有虚拟机进程,不需要用户自己再使用jps来查询了,如图4-4所示。双击选择其中一个进程即可开始监控,也可以使用下面的“远程进程”功能来连接远程服务器,对远程虚拟机进行监控。

figure_0134_0046

图 4-4 JConsole连接页面

从图4-4可以看出,笔者的机器现在运行了Eclipse、JConsole和MonitoringTest三个本地虚拟机进程,其中MonitoringTest就是笔者准备的“反面教材”代码之一。双击它进入JConsole主界面,可以看到主界面里共包括“概述”、“内存”、“线程”、“类”、“VM摘要”、“MBean”6个页签,如图4-5所示。

figure_0135_0047

图 4-5 JConsole主界面

“概述”页签显示的是整个虚拟机主要运行数据的概览,其中包括“堆内存使用情况”、“线程”、“类”、“CPU使用情况”4种信息的曲线图,这些曲线图是后面“内存”、“线程”、“类”页签的信息汇总,具体内容将在后面介绍。

2.内存监控

“内存”页签相当于可视化的jstat命令,用于监视受收集器管理的虚拟机内存(Java堆和永久代)的变化趋势。我们通过运行代码清单4-8中的代码来体验一下它的监视功能。运行时设置的虚拟机参数为:-Xms100m-Xmx100m-XX:+UseSerialGC,这段代码的作用是以64KB/50毫秒的速度往Java堆中填充数据,一共填充1000次,使用JConsole的“内存”页签进行监视,观察曲线和柱状指示图的变化。

代码清单4-8 JConsole监视代码


/**

*内存占位符对象,一个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);

}


程序运行后,在“内存”页签中可以看到内存池Eden区的运行趋势呈现折线状,如图4-6所示。而监视范围扩大至整个堆后,会发现曲线是一条向上增长的平滑曲线。并且从柱状图可以看出,在1000次循环执行结束,运行了System.gc()后,虽然整个新生代Eden和Survivor区都基本被清空了,但是代表老年代的柱状图仍然保持峰值状态,说明被填充进堆中的数据在System.gc()方法执行之后仍然存活。笔者的分析到此为止,现提两个小问题供读者思考一下,答案稍后给出。

1)虚拟机启动参数只限制了Java堆为100MB,没有指定-Xmn参数,能否从监控图中估计出新生代有多大?

2)为何执行了System.gc()之后,图4-6中代表老年代的柱状图仍然显示峰值状态,代码需要如何调整才能让System.gc()回收掉填充到堆中的对象?

figure_0137_0048

图 4-6 Eden区内存变化状况

问题1答案:图4-6显示Eden空间为27 328KB,因为没有设置-XX:SurvivorRadio参数,所以Eden与Survivor空间比例为默认值8:1,整个新生代空间大约为27 328KB×125%=34 160KB。

问题2答案:执行完System.gc()之后,空间未能回收是因为List<OOMObject>list对象仍然存活,fillHeap()方法仍然没有退出,因此list对象在System.gc()执行时仍然处于作用域之内[2]。如果把System.gc()移动到fillHeap()方法外调用就可以回收掉全部内存。

3.线程监控

如果上面的“内存”页签相当于可视化的jstat命令的话,“线程”页签的功能相当于可视化的jstack命令,遇到线程停顿时可以使用这个页签进行监控分析。前面讲解jstack命令的时候提到过线程长时间停顿的主要原因主要有:等待外部资源(数据库连接、网络资源、设备资源等)、死循环、锁等待(活锁和死锁)。通过代码清单4-9分别演示一下这几种情况。

代码清单4-9 线程等待演示代码


/**

*线程死循环演示

*/

public static void createBusyThread(){

Thread thread=new Thread(new Runnable(){

@Override

public void run(){

while(true)//第41行

}

},"testBusyThread");

thread.start();

}

/**

*线程锁等待演示

*/

public static void createLockThread(final Object lock){

Thread thread=new Thread(new Runnable(){

@Override

public void run(){

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);

}


程序运行后,首先在“线程”页签中选择main线程,如图4-7所示。堆栈追踪显示BufferedReader在readBytes方法中等待System.in的键盘输入,这时线程为Runnable状态,Runnable状态的线程会被分配运行时间,但readBytes方法检查到流没有更新时会立刻归还执行令牌,这种等待只消耗很小的CPU资源。

figure_0139_0049

图 4-7 main线程

接着监控testBusyThread线程,如图4-8所示,testBusyThread线程一直在执行空循环,从堆栈追踪中看到一直在MonitoringTest.java代码的41行停留,41行为:while(true)。这时候线程为Runnable状态,而且没有归还线程执行令牌的动作,会在空循环上用尽全部执行时间直到线程切换,这种等待会消耗较多的CPU资源。

figure_0139_0050

图 4-8 testBusyThread线程

图4-9显示testLockThread线程在等待着lock对象的notify或notifyAll方法的出现,线程这时候处于WAITING状态,在被唤醒前不会被分配执行时间。

figure_0140_0051

图 4-9 testLockThread线程

testLockThread线程正在处于正常的活锁等待,只要lock对象的notify()或notifyAll()方法被调用,这个线程便能激活以继续执行。代码清单4-10演示了一个无法再被激活的死锁等待。

代码清单4-10 死锁代码样例


/**

*线程死锁等待演示

*/

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循环的版本最多运行2~3次就会遇到线程死锁,程序无法结束。造成死锁的原因是Integer.valueOf()方法基于减少对象创建次数和节省内存的考虑,[-128,127]之间的数字会被缓存[3],当valueOf()方法传入参数在这个范围之内,将直接返回缓存中的对象。也就是说,代码中调用了200次Integer.valueOf()方法一共就只返回了两个不同的对象。假如在某个线程的两个synchronized块之间发生了一次线程切换,那就会出现线程A等着被线程B持有的Integer.valueOf(1),线程B又等着被线程A持有的Integer.valueOf(2),结果出现大家都跑不下去的情景。

出现线程死锁之后,点击JConsole线程面板的“检测到死锁”按钮,将出现一个新的“死锁”页签,如图4-10所示。

figure_0141_0052

图 4-10 线程死锁

图4-10中很清晰地显示了线程Thread-43在等待一个被线程Thread-12持有Integer对象,而点击线程Thread-12则显示它也在等待一个Integer对象,被线程Thread-43持有,这样两个线程就互相卡住,都不存在等到锁释放的希望了。

[1]VisualVM官方站点:https://visualvm.dev.java.net/。

[2]准确地说,只有在虚拟机使用解释器执行的时候,“在作用域之内”才能保证它不会被回收,因为这里的回收还涉及局部变量表Slot复用、即时编译器介入时机等问题,具体读者可参考第8章中关于局部变量表内存回收的例子。

[3]默认值,实际值取决于java.lang.Integer.IntegerCache.high参数的设置。