一:概述
前面两篇文章对于虚拟机内存分配与回收技术各方面的介绍,相信可以建立一套比较完整的理论基础。这一章是从实践的角度去了解虚拟机内存管理的世界。给一个系统定位问题的时候,知识、经验是关键基础,数据是依据,工具是运用知识处理数据的手段。这里说的数据包括:运行日志、异常堆栈、GC日志、线程快照(threaddump/javacore文件)、堆转储快照(headdump/hprof文件)等。经常使用适当的虚拟机监控和分析工具可以加快我们分析数据、定位解决问题的速度,但在学习工具前,也应当意识到工具永远都是知识技能的一层包装。
二:JDK的命令行工具
在JDK的bin目录中提供了很多命令行工具,这些命令行工具为用户处理应用程序性能问题、定位故障提供了帮助。下面将介绍其中关于监视虚拟机和故障处理的工具。
这些JDK工具都非常小,基本都在27KB左右,大多数都是对jdk/lib/tools.jar类库的一层薄包装。
之所以选用Java代码实现这些监控工具,是因为当程序部署到生产环境后,无论是直接接触物理服务器还是远程Telnet到服务器都可能会受到限制。借助tools.jar类库中的接口,可以直接在应用程序中实现功能强大的监控分析工具,由于部分功能基于JMX,因此需要开启JMX管理功能,JDK 1.6之上的虚拟机默认开启。
JDK主要命令行监控工具用途如下:
jps:虚拟机进程状况工具
功能:可以列出正在运行的虚拟机进程,并显示虚拟机执行主类(Main Class,main()函数所在的类)名称以及这些进程的本地虚拟机唯一ID(Local Virtual Machine Identifier,LVMID)。(虽然功能比较单一,但它是使用频率最高的JDK命令行工具,因为其他的JDK命令行工具大多需要输入它查询到LVMID来确定要监控的是哪一个虚拟机进程。对于本地虚拟机进程来说,LVMID与操作系统的进程ID(Process Identifier,PID)是一致的,使用Windows的任务管理器或者unix的ps命令也可以查询到虚拟机进程的LVMID,但如果同时启动了多个虚拟机进程,无法根据进程名称定位时,那就只能依赖jps命令来显示主类的功能才能区分了)。
jps命令格式:jps [ option ] [ hostid ]
执行案例:
jps可以通过RMI协议查询开启了RMI服务的远程虚拟机进程状态,hostid为RMI注册表中注册的主机名。jps的其他常用选项见下表:
jstat:虚拟机统计信息监视工具
jstat(JVM Statistics Monitoring Tool)是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据,在没有GUI图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首先工具。
jstat命令格式为:jstat [ option vmid [ interval [ s | ms ] [ count ] ] ]
对于命令格式中的VMID与LVMID需要特别说明一下:如果是本地虚拟机进程,VMID与LVMID是一致的,如果是远程虚拟机进程,那VMID的格式应当是:
[ protocol :] [ // ] lvmid [ @hostname [ :port] /servername]
参数interval和count代表查询间隔和次数,如果省略这两个参数,说明只查询一次。假设需要每250毫秒查询一次进程2764垃圾收集状况,一共查询20次,那命令应当是:
jstat -gc 2764 250 20
选项option代表着用户希望查询的虚拟机信息,主要分为3类:类装载、垃圾收集、运行期编译状况,具体选项及作用请参考表中的描述:
查询结果表明:新生代Eden区(E,表示Eden)使用了6.2%的空间,两个Survivor区(S0、S1,表示Survivor0、Survivor1)里面都是空的,老年代(O,表示Old)和永久代(P,表示Permanent)则分别使用了41.42%和47.20%的空间。程序运行以来共发生Minor GC(YGC,表示Young GC)16次,总耗时0.105秒,发生Full GC(FGC,表示Full GC)3次,Full GC总耗时(FGCT,表示Full GC Time)为0.472秒,所有GC总耗时(GCT,表示GC Time)为0.577秒。
jinfo:Java配置信息工具
jinfo(Configuration Info for Java)的作用是实时地查看和调整虚拟机各项参数。
jinfo命令格式:jinfo [ option ] pid
jmap:Java内存映像工具
jmap(Memory Map for Java)命令用于生成堆转储快照(一般称为headdump或dump文件)。如果不使用jmap命令,要想获取Java堆转储快照,还有一些比较“暴力”的手段:譬如在第2章中用过的-XX:+HeapOnOutOfMemoryError参数,可以让虚拟机在OOM异常出现之后自动生成dump文件,通过-XX:+HeapDumpOnCtrlBreak参数则可以使用[ Ctrl ] + [ Break ]键让虚拟机生成dump文件,又或者在Linux系统下通过Kill -3命令发送进程退出信号“吓唬”一下虚拟机,也能拿到dump文件。
jmap的作用并不仅仅是为了获取dump文件,它可以查询finalize执行队列、Java堆和永久代的信息信息,如空间使用率、当前用的是哪种收集器等。
和jinfo命令一样,jmap有不少功能在Windows平台下都是受限的,除了生成dump文件的-dump选项和用于查看每个类的实例、空间占用统计的-histo选项在所有操作系统都提供之外,其余选项都只能在Linux/Solaris下使用。
jmap命令格式:jmap [ option ] vmid
option选项的合法值与具体含义见下表:
jhat:虚拟机堆转储快照分析工具
Sun JDK提供jhat(JVM heap Analysis Tool)命令与jmap搭配使用,来分析jmap生成的堆转储快照。jhat内置了一个微型的HTTP/HTML服务器,生成dump文件的分析结果后,可以在浏览器中查看。(一般不会直接使用jhat命令来分析dump文件,原因有两点:1,一般不会在部署应用程序的服务器上直接分析dump文件,即使这样做,一般也尽量把dump文件复制到其他机器上去分析,因为分析是一个耗时且消耗硬件资源的一个过程,既然都要在其他机器上运行了,就没有必要受到命令行工具的限制了;2,jhat的分析功能相对来说比较丑陋,有其工具能实现比jhat更强大更专业的分析功能)。
分析结果默认是以包为单位进行分组显示,分析内存泄漏问题主要会使用到其中的“Heap Histogram”(与jmap -histo功能一样)与OQL页签的功能,前者可以找到内存中总容量最大的对象,后者是标准的对象查询语言,使用类似SQL的语法对内存中的对象进行查询统计。
jstack:Java堆栈跟踪工具
jstack(Stack Trace for Java)命令用于生成虚拟机当前时刻的线程快照(一般称为threaddump或者javacore文件)。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待都是导致线程长时间停顿的常见原因。线程出现停顿的时候使用jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程在后台做些什么事情,或者等待着什么资源。
jstack命令格式: jstack [ option ] vmid
在JDK1.5中,java.lang.Thread类新增了一个getAllStackTraces()方法用于获取虚拟机中所有线程的StackTraceElement对象。使用这个方法可以通过简单的几行代码就完成了jstack的大部分功能,在实际项目中不妨调用这个方法做个管理员界面,可以随时使用浏览器来查看线程堆栈,如下面代码清单所示:
<%@ page import = "java.util.Map" %> <html> <head> <title>服务器线程信息</title> </head> <body> <pre> <% for(Map.Entry<Thread, StackTraceElement[]> stackTrace : Thread.getAllStackTraces().entrySet()){ Thread thread = (Thread)stackTrace.getKey(); StackTraceElement[] stack = (StackTraceElement[]) stackTrace.getValue(); if(thread.equals(Thread.currentThread())){ continue; } out.print("\n线程:" + thread.getName() + "\n"); for(StackTraceElement element : stack){ out.print("\t" + element + "\n"); } } %> </pre> </body> </html>
HSDIS:JIT生成代码反汇编
HSDIS是一个Sun官方推荐的HotSpot虚拟机JIT编译代码的反汇编插件,它包含在HotSpot虚拟机源码之中,但没有提供编译后的程序。在Project Kenai的网站也可以下载到单独的源码。它的作用是让HotSpot的-XX:+PrintAssembly指令调用它来把动态生成的本地代码还原为汇编代码输出,同时还生成了大量非常有价值的注释,这样我们就可以通过输出的代码来分析问题。读者可以根据自己的操作系统和CPU类型从Project Kenai的网站上下载编译好的插件,直接放到JDK_HOME/jre/bin/client和JDK_HOME/jre/bin/server目录中即可。如果没有找到所需操作系统(譬如windows的就没有)的成品,那就得自己使用源码编译一下。(值得注意的是:如果读者使用的是Debug或者FastDebug版的HotSpot,那就可以直接通过-XX:PrintAssembly指令使用插件;如果使用的是Product版的HotSpot,那还需要额外加入一个-XX:UnlockDiagnosticVMOptions参数)。JVM书的作者曾以下面简单地代码为例演示了这个插件的使用。
public class Bar{ int a = 1; static int b = 2; public int sum(int c){ return a + b + c; } public static void main(String[] args) { new Bar().sum(3); } }
其中,参数-Xcomp是让虚拟机以编译模式执行代码,这样代码就可以偷懒,不需要执行足够次数来预测就能触发JIT编译。两个-XX:CompileCommand意思是让编译期不需要内联sum()并且只编译sum(),-XX:+PrintAssembly就是输出反汇编内容。如果一切顺的话,就可以出现如下图所示的内容:
对上图代码进行解释:
1:mov %eax,-0x8000(%esp):检查栈溢;
2:push %ebp:保存上一帧栈基址;
3:sub $0x18,%esp:给新帧分配空间;
4:mov 0x8(%ecx) ,%eax:取实例变量a,这里0x8(%ecx)的意思是ecx+0x8的意思,前面“[Constants]”节中提示了“this.ecx = ‘test/Bar’”,即ecx存放的是this对象的地址。偏移0x8是越过this对象的对象头,之后就是实例变量a的内存位置。这次是访问“Java堆”中的数据;
5:mov $0x3d2fad8,%esi:取test.Bar方法区的指针;
6:mov 0x68(%esi),%esi:取类变量b,这次是访问“方法区”中的数据;
7:add %esi,%eax和add %edx,%eax:做两次加法,求a+b+c的值,前面的代码把a放在eax中,把b放在esi中,而c在[Constants]中提示了,“parm0:edx = int”,说明c在edx中;
8:add $0x18,%esp:撤销帧栈;
9:pop %ebp:恢复上一帧栈;
10:test %eax,0x2b0100:轮询方法返回处的SafePoint;
11:ret:方法返回
三:JDK可视化工具
JDK中除了提供大量的命令行工具外,还有两个功能强大的可视化工具:JConsole和VisualVM,这两个工具是JDK的正式成员,没有被贴上“unsupported and experimental”的标签。
JConsole:Java监视与管理控制台
JConsole(Java Monitoring and Management Console)是一种基于JMX的可视化监视、管理工具。它管理部分的功能是针对JMX MBean进行管理,由于MBean可以使用代码、中间服务器的管理控制台或者所有符合JMX规范的软件进行访问。
1:启动JConsole:
通过JDK/bin目录下的“jconsole.exe”启动JConsole后,将自动搜索出本机运行的所有虚拟机进程,不需要用户再使用jps来查询了。如下图所示:双击选择其中一个进程即可进行监控,也可以使用下面的“远程进程”功能来连接远程服务器,对远程虚拟机进行监控。
选择monitoring这个双击它进入JConsole主页面
2:内存监控
“内存”页签相当于可视化的jstat命令,用于监视受收集管理的虚拟机内存(Java堆和永久代)的变化趋势。
import java.util.ArrayList; import java.util.List; public class JConsoleMonitor { 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区的运行趋势呈折线,如下图所示:
但是监视范围扩大至整个堆后,会发现曲线是一条向上增长的平滑曲线。并且从柱状图可以看出,在1000次循环执行结束,运行了System.gc()后;虽然整个新生代Eden和Survivor区基本都被清空了,但是代表老年代的柱状图仍然保持巅峰值状态,说明被填充进堆中的数据在System.gc()方法执行之后仍然存活。但是有两个问题:
1:虚拟机启动参数只限制了Java堆为100MB,没有指定-Xmn参数,能否从监控图中估计出新生代有多大?
上图显示Eden空间为27328KB,因为没有设置-XX:SurvivorRadio参数,所以Eden与Survivor空间比例默认值为8:1,整个新生代空间大约为27328KB*125%=34160KB;
2:为何执行了System.gc()之后,上面的图老年代依然显示巅峰值状态,代码需要做何调整才能让System.gc()回收掉填充到堆中的对象?
执行完System.gc()之后,空间未能回收是因为List list对象仍然存活,fillHeap()方法仍然没有退出,因此list对象在System.gc()执行时仍然处于作用域之内,如果把System.gc()移动到fillHeap()方法之外调用就可以回收掉全部内存。
3:线程监控
如果上面的“内存”页签相当于可视化的jstat命令的话,“线程”页签的功能相当于可视化的jstack命令,遇到线程停顿时可以使用这个页签进行监控分析。前面讲解jstack命令的时候提到过线程长时间停顿的主要原因主要有:等待外部资源(数据库连接、网络资源、设备资源等)、死循环、锁等待(活锁和死锁)。
import java.io.BufferedReader; import java.io.InputStreamReader; public class ThreadWait { /** * 线程死循环演示 */ 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线程,如下图所示:堆栈追踪显示BufferedReader在readBytes方法中等待System.in的键盘输入,这是线程为Runnable状态,Runnable状态的线程会被分配运行时间,但readBytes方法检查到流没有更新时会立刻归还执行令牌,这种等待只消耗很小的CPU资源。
接着监控testBusyThread线程,如下图所示,testBusyThread线程一直在执行空循环,从堆栈追踪中看到一直在MonitoringTest.java代码的41行停留,41行为:while(true)。这时候线程为Runnable状态,而且没有归还线程执行令牌的动作,会在空循环上用尽全部执行时间直到线程切换,这种等待会消耗较多的CPU资源。
下图显示testLockThread线程在等待着lock对象的notify或notifyAll方法的出现,线程这时处于WAITING状态,在被唤醒前不会被分配执行时间。
testLockThread线程正在处于正常的活锁等待,只要lock对象的notify()或notifyAll()方法被调用,这个线程便能激活以继续执行。下面代码演示了一个无法再被激活的死锁等待。
public class DeadlockWait { /** * 线程死锁等待演示 */ static class SynAddRunnable implements Runnable{ int a, b; public SynAddRunnable(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++){//开启200个线程分别取计算1+2以及2+1的值,其实for循环可以省略,两个线程也可能会出现死锁,不过概率太小 new Thread(new SynAddRunnable(1,2)).start(); new Thread(new SynAddRunnable(2,1)).start(); } } }
这段代码原因是Integer.valueOf()方法基于减少对象创建次数和节省内存的考虑,[ -128, 127]之间的数字会被缓存,当valueOf()方法传入参数在这个范围之内,将直接返回缓存中的对象。也就是说,代码中调用了200次Integer.valueOf()方法一共就只返回了两个不同的对象。假如在某个线程的两个synchronized块之间发生了一次线程切换,就会出现线程A等着线程B持有的Integer.valueOf(1),线程B又等着被线程A持有的Integer.valueOf(2),结果出现大家都跑不下去的情景。
出现线程死锁之后,点击JConsole线程面板的“检测到死锁”按钮,将出现一个新的“死锁”页签,如下图所示,很清楚的看大Thread-43在等待一个被线程Thread-12持有的Integer对象,而点击线程Thread-12则显示它也在等待一个Integer对象,被线程Thread-43持有,这样两个线程就相互卡主,都不存在等到锁释放的希望了。
VisualVM:多合一故障处理工具
VisualVM(All-in-One Java Troubleshooting Tool)是到目前为止随着JDK发布的功能最强大的运行监视和故障处理程序。它的性能分析也比一些收费软件不相上下,它还有一个优点:不需要被监视的程序基于特殊Agent运行。
1:VisualVM兼容范围与插件安装
通过插件扩展支持,VisualVM可以做到:
2.生成、浏览堆转储快照
3.分析程序性能
4.BTrace动态日志跟踪
开启赞赏
赞赏开启后,读者将可以付费支持你的创作。