5.2.4 外部命令导致系统缓慢

一个数字校园应用系统,运行在一台四路处理器的Solaris 10操作系统上,中间件为GlassFish服务器。系统在做大并发压力测试的时候,发现请求响应时间比较慢,通过操作系统的mpstat工具发现处理器使用率很高,但是系统中占用绝大多数处理器资源的程序并不是该应用本身。这是个不正常的现象,通常情况下用户应用的处理器占用率应该占主要地位,才能说明系统是在正常工作。

通过Solaris 10的dtrace脚本可以查看当前情况下哪些系统调用花费了最多的处理器资源,dtrace运行后发现最消耗处理器资源的竟然是“fork”系统调用。众所周知,“fork”系统调用是Linux用来产生新进程的,在Java虚拟机中,用户编写的Java代码通常最多只会创建新的线程,不应当有进程的产生,这又是个相当不正常的现象。

通过联系该系统的开发人员,最终找到了答案:每个用户请求的处理都需要执行一个外部Shell脚本来获得系统的一些信息。执行这个Shell脚本是通过Java的Runtime.getRuntime().exec()方法来调用的。 这种调用方式可以达到执行Shell脚本的目的,但是它在Java虚拟机中是非常消耗资源的操作,即使外部命令本身能很快执行完毕,频繁调用时创建进程的开销也会非常可观。Java虚拟机执行这个命令的过程是首先复制一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程。如果频繁执行这个操作,系统的消耗必然会很大,而且不仅是处理器消耗,内存负担也很重。

用户根据建议去掉这个Shell脚本执行的语句,改为使用Java的API去获取这些信息后,系统很快恢复了正常。

5.2.5 服务器虚拟机进程崩溃

一个基于B/S的MIS系统,硬件为两台双路处理器、8GB内存的HP系统,服务器是WebLogic 9.2(与第二个案例中那套是同一个系统)。正常运行一段时间后,最近发现在运行期间频繁出现集群节点的虚拟机进程自动关闭的现象,留下了一个hs_err_pid###.log文件后,虚拟机进程就消失了,两台物理机器里的每个节点都出现过进程崩溃的现象。从系统日志中注意到,每个节点的虚拟机进程在崩溃之前,都发生过大量相同的异常,见代码清单5-2。

代码清单5-2 异常堆栈2

1
2
3
4
5
6
7
java.net.SocketException: Connection reset 
at java.net.SocketInputStream.read(SocketInputStream.java:168)
at java.io.BufferedInputStream.fill(BufferedInputStream.java:218)
at java.io.BufferedInputStream.read(BufferedInputStream.java:235)
at org.apache.axis.transport.http.HTTPSender.readHeadersFromSocket(HTTPSender.java:583)
at org.apache.axis.transport.http.HTTPSender.invoke(HTTPSender.java:143)
... 99 more

这是一个远端断开连接的异常,通过系统管理员了解到系统最近与一个OA门户做了集成,在MIS 系统工作流的待办事项变化时,要通过Web服务通知OA门户系统,把待办事项的变化同步到OA门户之中。通过SoapUI测试了一下同步待办事项的几个Web服务,发现调用后竟然需要长达3分钟才能返回,并且返回结果都是超时导致的连接中断。

由于MIS系统的用户多,待办事项变化很快,为了不被OA系统速度拖累,使用了异步的方式调用Web服务,但由于两边服务速度的完全不对等,时间越长就累积了越多Web服务没有调用完成,导致在等待的线程和Socket连接越来越多,最终超过虚拟机的承受能力后导致虚拟机进程崩溃。通知OA门户方修复无法使用的集成接口,并将异步调用改为生产者/消费者模式的消息队列实现后,系统恢复正常。

5.2.7 由Windows虚拟内存导致的长时间停顿 [^1]

有一个带心跳检测功能的GUI桌面程序,每15秒会发送一次心跳检测信号,如果对方30秒以内都没有信号返回,那就认为和对方程序的连接已经断开。程序上线后发现心跳检测有误报的可能,查询日志发现误报的原因是程序会偶尔出现间隔约一分钟的时间完全无日志输出,处于停顿状态。

因为是桌面程序,所需的内存并不大(-Xmx256m),所以开始并没有想到是垃圾收集导致的程序停顿,但是加入参数-XX:+PrintGCApplicationStoppedTime-XX:+PrintGCDate-Stamps-Xloggc: gclog.log后,从收集器日志文件中确认了停顿确实是由垃圾收集导致的,大部分收集时间都控制在100 毫秒以内,但偶尔就出现一次接近1分钟的长时间收集过程。

1
2
3
4
5
6
7
8
Total time for which application threads were stopped: 0.0112389 seconds 
Total time for which application threads were stopped: 0.0001335 seconds
Total time for which application threads were stopped: 0.0003246 seconds
Total time for which application threads were stopped: 41.4731411 seconds
Total time for which application threads were stopped: 0.0489481 seconds
Total time for which application threads were stopped: 0.1110761 seconds
Total time for which application threads were stopped: 0.0007286 seconds
Total time for which application threads were stopped: 0.0001268 seconds

从收集器日志中找到长时间停顿的具体日志信息(再添加了-XX:+PrintReferenceGC参数),找到的日志片段如下所示。从日志中看到,真正执行垃圾收集动作的时间不是很长,但从准备开始收集,到真正开始收集之间所消耗的时间却占了绝大部分。

1
2012-08-29T19:14:30.968+0800: 10069.800: [GC10099.225: [SoftReference, 0 refs, 0.0000109 secs]10099.226: [WeakReference, 4072 refs, 0.0012099 secs]10099.227: [FinalReference, 984 refs, 1.5822450 secs]10100.809: [PhantomReference, 251 refs, 0.0001394 secs]10100.809: [JNI Weak Reference, 0.0994015 secs] [PSYoungGen: 175672K->8528K(167360K)] 251523K->100182K(353152K), 31.1580402 secs] [Times: user=0.61 sys=0.52, real=31.16 secs]

除收集器日志之外,还观察到这个GUI程序内存变化的一个特点,当它最小化的时候,资源管理中显示的占用内存大幅度减小,但是虚拟内存则没有变化,因此怀疑程序在最小化时它的工作内存被自动交换到磁盘的页面文件之中了,这样发生垃圾收集时就有可能因为恢复页面文件的操作导致不正常的垃圾收集停顿。

在MSDN上查证[^2]确认了这种猜想,在Java的GUI程序中要避免这种现象,可以加入参数“- Dsun.awt.keepWorkingSetOnMinimize=true”来解决。这个参数在许多AWT的程序上都有应用,例如JDK(曾经)自带的VisualVM,启动配置文件中就有这个参数,保证程序在恢复最小化时能够立即响应。在这个案例中加入该参数,问题马上得到解决。

[^1]: 本案例来源于ITEye HLLVM群组的讨论:http://hllvm.group.iteye.com/group/topic/28745。
[^2]: http://support.microsoft.com/default.aspx?scid=kb;en-us;293215。

5.2.8 由安全点导致长时间停顿

^1
有一个比较大的承担公共计算任务的离线HBase集群,运行在JDK 8上,使用G1收集器。每天都有大量的MapReduce或Spark离线分析任务对其进行访问,同时有很多其他在线集群Replication过来的数据写入,因为集群读写压力较大,而离线分析任务对延迟又不会特别敏感,所以将-XX: MaxGCPauseMillis参数设置到了500毫秒。不过运行一段时间后发现垃圾收集的停顿经常达到3秒以上,而且实际垃圾收集器进行回收的动作就只占其中的几百毫秒,现象如以下日志所示。

1
2
[Times: user=1.51 sys=0.67, real=0.14 secs] 
2019-06-25T 12:12:43.376+0800: 3448319.277: Total time for which application threads were stopped: 2.2645818 sec

考虑到不是所有读者都了解计算机体系和操作系统原理,笔者先解释一下user、sys、real这三个时间的概念:

  • user:进程执行用户态代码所耗费的处理器时间。
  • sys:进程执行核心态代码所耗费的处理器时间。
  • real:执行动作从开始到结束耗费的时钟时间。

请注意,前面两个是处理器时间,而最后一个是时钟时间,它们的区别是处理器时间代表的是线程占用处理器一个核心的耗时计数,而时钟时间就是现实世界中的时间计数。如果是单核单线程的场景下,这两者可以认为是等价的,但如果是多核环境下,同一个时钟时间内有多少处理器核心正在工作,就会有多少倍的处理器时间被消耗和记录下来。

在垃圾收集调优时,我们主要依据real时间为目标来优化程序,因为最终用户只关心发出请求到得到响应所花费的时间,也就是响应速度,而不太关心程序到底使用了多少个线程或者处理器来完成任务。

日志显示这次垃圾收集一共花费了0.14秒,但其中用户线程却足足停顿了有2.26秒,两者差距已经远远超出了正常的TTSP(Time To Safepoint)耗时的范畴。所以先加入参数-XX: +PrintSafepointStatistics和-XX:PrintSafepointStatisticsCount=1去查看安全点日志,具体如下所示:

1
2
3
4
vmop [threads: total initially_running wait_to_block] 
65968.203: ForceAsyncSafepoint [931 1 2]
[time: spin block sync cleanup vmop] page_trap_count
[2255 0 2255 11 0] 1

日志显示当前虚拟机的操作(VM Operation,VMOP)是等待所有用户线程进入到安全点,但是有两个线程特别慢,导致发生了很长时间的自旋等待。日志中的2255毫秒自旋(Spin)时间就是指由于部分线程已经走到了安全点,但还有一些特别慢的线程并没有到,所以垃圾收集线程无法开始工作,只能空转(自旋)等待。

解决问题的第一步是把这两个特别慢的线程给找出来,这个倒不困难,添加-XX: +SafepointTimeout和-XX:SafepointTimeoutDelay=2000两个参数,让虚拟机在等到线程进入安全点的时间超过2000毫秒时就认定为超时,这样就会输出导致问题的线程名称,得到的日志如下所示:

1
2
3
4
5
6
7
# SafepointSynchronize::begin: Timeout detected: 
# SafepointSynchronize::begin: Timed out while spinning to reach a safepoint.
# SafepointSynchronize::begin: Threads which did not reach the safepoint:
# "RpcServer.listener,port=24600"
#32 daemon prio=5 os_prio=0 tid=0x00007f4c14b22840 nid=0xa621 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
# SafepointSynchronize::begin: (End of list)

从错误日志中顺利得到了导致问题的线程名称为“RpcServer.listener,port=24600”。但是为什么它们会出问题呢?有什么因素可以阻止线程进入安全点?在第3章关于安全点的介绍中,我们已经知道安全点是以“是否具有让程序长时间执行的特征”为原则进行选定的,所以方法调用、循环跳转、异常跳转这些位置都可能会设置有安全点,但是HotSpot虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用int类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(Counted Loop),相对应地,使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环 (Uncounted Loop),将会被放置安全点。通常情况下这个优化措施是可行的,但循环执行的时间不单单是由其次数决定,如果循环体单次执行就特别慢,那即使是可数循环也可能会耗费很多的时间。

HotSpot原本提供了-XX:+UseCountedLoopSafepoints参数去强制在可数循环中也放置安全点,不过这个参数在JDK 8下有Bug^2,有导致虚拟机崩溃的风险,所以就不得不找到RpcServer线程里面的缓慢代码来进行修改。最终查明导致这个问题是HBase中一个连接超时清理的函数,由于集群会有多个MapReduce或Spark任务进行访问,而每个任务又会同时起多个Mapper/Reducer/Executer,其每一个都会作为一个HBase的客户端,这就导致了同时连接的数量会非常多。更为关键的是,清理连接的索引值就是int类型,所以这是一个可数循环,HotSpot不会在循环中插入安全点。当垃圾收集发生时, 如果RpcServer的Listener线程刚好执行到该函数里的可数循环时,则必须等待循环全部跑完才能进入安全点,此时其他线程也必须一起等着,所以从现象上看就是长时间的停顿。找到了问题,解决起来就非常简单了,把循环索引的数据类型从int改为long即可,但如果不具备安全点和垃圾收集的知识,这种问题是很难处理的。

5.2.6 不恰当数据结构导致内存占用过大

一个后台RPC服务器,使用64位Java虚拟机,内存配置为-Xms4g-Xmx8g-Xmn1g,使用ParNew加CMS的收集器组合。平时对外服务的Minor GC时间约在30毫秒以内,完全可以接受。但业务上需要每10分钟加载一个约80MB的数据文件到内存进行数据分析,这些数据会在内存中形成超过100万个HashMap<Long,Long>Entry,在这段时间里面Minor GC就会造成超过500毫秒的停顿,对于这种长度的停顿时间就接受不了了,具体情况如下面的收集器日志所示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{Heap before GC invocations=95 (full 4): 
par new generation total 903168K, used 803142K [0x00002aaaae770000, 0x00002aaaebb70000, 0x00002aaaebb70000)
eden space 802816K, 100% used [0x00002aaaae770000, 0x00002aaadf770000, 0x00002aaadf770000)
from space 100352K, 0% used [0x00002aaae5970000, 0x00002aaae59c1910, 0x00002aaaebb70000)
to space 100352K, 0% used [0x00002aaadf770000, 0x00002aaadf770000, 0x00002aaae5970000)
concurrent mark-sweep generation total 5845540K, used 3898978K [0x00002aaaebb70000, 0x00002aac507f9000, 0x00002aacae770000)
concurrent-mark-sweep perm gen total 65536K, used 40333K [0x00002aacae770000, 0x00002aacb2770000, 0x00002aacb2770000)
2011-10-28T11:40:45.162+0800: 226.504: [GC 226.504: [ParNew: 803142K-> 100352K(903168K), 0.5995670 secs] 4702120K->4056332K(6748708K), 0.5997560 secs] [Times: user=1.46 sys=0.04, real=0.60 secs]
Heap after GC invocations=96 (full 4):
par new generation total 903168K, used 100352K [0x00002aaaae770000, 0x00002-aaaebb70000, 0x00002aaaebb70000)
eden space 802816K, 0% used [0x00002aaaae770000, 0x00002aaaae770000, 0x00002aaadf770000)
from space 100352K, 100% used [0x00002aaadf770000, 0x00002aaae5970000, 0x00002aaae5970000)
to space 100352K, 0% used [0x00002aaae5970000, 0x00002aaae5970000, 0x00002aaaebb70000)
concurrent mark-sweep generation total 5845540K, used 3955980K [0x00002aaaebb70000, 0x00002aac507f9000, 0x00002aacae770000)
concurrent-mark-sweep perm gen total 65536K, used 40333K [0x00002aacae770000, 0x00002aacb2770000, 0x00002aacb2770000) }
Total time for which application threads were stopped: 0.6070570 seconds

观察这个案例的日志,平时Minor GC时间很短,原因是新生代的绝大部分对象都是可清除的,在Minor GC之后Eden和Survivor基本上处于完全空闲的状态。但是在分析数据文件期间,800MB的Eden 空间很快被填满引发垃圾收集,但Minor GC之后,新生代中绝大部分对象依然是存活的。我们知道ParNew收集器使用的是复制算法,这个算法的高效是建立在大部分对象都“朝生夕灭”的特性上的,如果存活对象过多,把这些对象复制到Survivor并维持这些对象引用的正确性就成为一个沉重的负担,因此导致垃圾收集的暂停时间明显变长。

如果不修改程序,仅从GC调优的角度去解决这个问题,可以考虑直接将Survivor空间去掉(加入参数-XX:SurvivorRatio=65536、-XX:MaxTenuringThreshold=0或者-XX:+Always-Tenure),让新生代中存活的对象在第一次Minor GC后立即进入老年代,等到Major GC的时候再去清理它们。这种措施可以治标,但也有很大副作用;治本的方案必须要修改程序,因为这里产生问题的根本原因是用HashMap<Long,Long>结构来存储数据文件空间效率太低了。

我们具体分析一下HashMap空间效率,在HashMap<Long,Long>结构中,只有Key和Value所存放的两个长整型数据是有效数据,共16字节(2×8字节)。这两个长整型数据包装成java.lang.Long对象之后,就分别具有8字节的Mark Word、8字节的Klass指针,再加8字节存储数据的long值。然后这2个Long对象组成Map.Entry之后,又多了16字节的对象头,然后一个8字节的next字段和4字节的int型的hash字段,为了对齐,还必须添加4字节的空白填充,最后还有HashMap中对这个Entry的8字节的引用,这样增加两个长整型数字,实际耗费的内存为(Long(24byte)×2)+Entry(32byte)+HashMap Ref(8byte)=88byte,空间效率为有效数据除以全部内存空间,即16字节/88字节=18%,这确实太低了。

5.4 本章小结

Java虚拟机的内存管理与垃圾收集是虚拟机结构体系中最重要的组成部分,对程序的性能和稳定有着非常大的影响。在本书的第2~5章里,笔者从理论知识、异常现象、代码、工具、案例、实战等几个方面对其进行讲解,希望读者能有所收获。

本书关于虚拟机内存管理部分到此就结束了,下一章我们将开始学习Class文件与虚拟机执行子系统方面的知识。

5.2 案例分析

本章中的案例一部分来源于笔者处理过的实际问题,还有另一部分来源于网上有特色和代表性的案例总结。出于对客户商业信息保护的原因,在不影响前后逻辑的前提下,笔者对实际环境和用户业务做了一些屏蔽和精简。

本章内容将着重考虑如何在应用部署层面去解决问题,有不少案例中的问题的确可以在设计和开发阶段就先行避免,但这并不是本书要讨论的话题。也有一些问题可以直接通过升级硬件或者使用最新JDK版本里的新技术去解决,但我们同时也会探讨如何在不改变已有软硬件版本和规格的前提下, 调整部署和配置策略去解决或者缓解问题。

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里 面的人却想出来。

5.1 概述

在前面3章笔者系统性地介绍了处理Java虚拟机内存问题的知识与工具,在处理应用中的实际问题时,除了知识与工具外,经验同样是一个很重要的因素。在本章,将会与读者分享若干较有代表性的实际案例。

考虑到虚拟机的故障处理与调优主要面向各类服务端应用,而大多数Java程序员较少有机会直接接触生产环境的服务器,因此本章还准备了一个所有开发人员都能够进行“亲身实战”的练习,希望大家通过实践能获得故障处理、调优的经验。

本章介绍了随JDK发布的6个命令行工具与4个可视化的故障处理工具,灵活使用这些工具,可以 为处理问题带来很大的便利。除了本章涉及的OpenJDK中自带的工具之外,还有很多其他监控和故障 处理工具,如何进行监控和故障诊断,这并不是《Java虚拟机规范》中定义的内容,而是取决于虚拟 机实现自身的设计,因此每种处理工具都有针对的目标范围,如果读者使用的是非HotSpot系的虚拟 机,就更需要使用对应的工具进行分析,如:

  • IBM的Support Assistant^1、Heap Analyzer^2、Javacore Analyzer^3、Garbage Collector Analyzer^4适用于IBM J9/OpenJ9 VM。
  • HP的HPjmeter、HPjtune适用于HP-UX、SAP、HotSpot VM。
  • Eclipse的Memory Analyzer Tool^5(MAT)适用于HP-UX、SAP、HotSpot VM,安装IBM DTFJ^6插件后可支持IBM J9虚拟机。

4.4 HotSpot虚拟机插件及工具

HotSpot虚拟机发展了二十余年,现在已经是一套很复杂的软件系统,如果深入挖掘HotSpot的源码,可以发现在HotSpot的研发过程中,开发团队曾经编写(或者收集)过不少虚拟机的插件和辅助工具,它们存放在HotSpot源码hotspot/src/share/tools目录下,包括(含曾经有过但新版本中已被移除的):

  • Ideal Graph Visualizer:用于可视化展示C2即时编译器是如何将字节码转化为理想图,然后转化为机器码的。
  • Client Compiler Visualizer[^1]:用于查看C1即时编译器生成高级中间表示(HIR),转换成低级中间表示(LIR)和做物理寄存器分配的过程。
  • MakeDeps:帮助处理HotSpot的编译依赖的工具。
  • Project Creator:帮忙生成Visual Studio的.project文件的工具。
  • LogCompilation:将-XX:+LogCompilation输出的日志整理成更容易阅读的格式的工具。
  • HSDIS:即时编译器的反汇编插件。

关于Client Compiler Visualizer和Ideal Graph Visualizer,在本书第11章会有专门的使用介绍,而Project Creator、LogCompilation、MakeDeps这三个工具对本书的讲解和实验帮助有限,最后一个HSDIS是学习、实践本书第四部分“程序编译与代码优化”的有力辅助工具,借本章讲解虚拟机工具的机会,简要介绍其使用方法。

HSDIS:JIT生成代码反汇编

在《Java虚拟机规范》里详细定义了虚拟机指令集中每条指令的语义,尤其是执行过程前后对操作数栈、局部变量表的影响。这些细节描述与早期Java虚拟机(Sun Classic虚拟机)高度吻合,但随着技术的发展,高性能虚拟机真正的细节实现方式已经渐渐与《Java虚拟机规范》所描述的内容产生越来越大的偏差,《Java虚拟机规范》中的规定逐渐成为Java虚拟机实现的“概念模型”,即实现只保证与规范描述等效,而不一定是按照规范描述去执行。由于这个原因,我们在讨论程序的执行语义问题 (虚拟机做了什么)时,在字节码层面上分析完全可行,但讨论程序的执行行为问题(虚拟机是怎样做的、性能如何)时,在字节码层面上分析就没有什么意义了,必须通过其他途径解决。

至于分析程序如何执行,使用软件调试工具(GDB、Windbg等)来进行断点调试是一种常见的方式,但是这样的调试方式在Java虚拟机中也遇到了很大麻烦,因为大量执行代码是通过即时编译器动态生成到代码缓存中的,并没有特别简单的手段来处理这种混合模式的调试,不得不通过一些曲线的间接方法来解决问题。在这样的背景下,本节的主角——HSDIS插件就正式登场了。

HSDIS是一个被官方推荐的HotSpot虚拟机即时编译代码的反汇编插件,它包含在HotSpot虚拟机的源码当中[^2],在OpenJDK的网站[^3]也可以找到单独的源码下载,但并没有提供编译后的程序。

HSDIS插件的作用是让HotSpot的-XX:+PrintAssembly指令调用它来把即时编译器动态生成的本地代码还原为汇编代码输出,同时还会自动产生大量非常有价值的注释,这样我们就可以通过输出的汇编代码来从最本质的角度分析问题。读者可以根据自己的操作系统和处理器型号,从网上直接搜索、下载编译好的插件,直接放到JDK_HOME/jre/bin/server目录(JDK 9以下)或JDK_HOME/lib/amd64/server(JDK 9或以上)中即可使用。如果读者确实没有找到所采用操作系统的对应编译成品[^4],那就自己用源码编译一遍(网上能找到各种操作系统下的编译教程)。

另外还有一点需要注意,如果读者使用的是SlowDebug或者FastDebug版的HotSpot,那可以直接通过-XX:+PrintAssembly指令使用的插件;如果读者使用的是Product版的HotSpot,则还要额外加入一个-XX:+UnlockDiagnosticVMOptions参数才可以工作。笔者以代码清单4-12中的测试代码为例简单演示一下如何使用这个插件。

代码清单4-12 测试代码

1
2
3
4
5
6
7
8
9
10
11
12
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);
}
}

编译这段代码,并使用以下命令执行:

1
java -XX:+PrintAssembly -Xcomp -XX:CompileCommand=dontinline,*Bar.sum -XX:Compile-Command=compileonly,*Bar.sum test.Bar

其中,参数-Xcomp是让虚拟机以编译模式执行代码,这样不需要执行足够次数来预热就能触发即时编译。两个-XX:CompileCommand的意思是让编译器不要内联sum()并且只编译sum(),-XX: +PrintAssembly就是输出反汇编内容。如果一切顺利的话,屏幕上会出现类似代码清单4-13所示的内容。
代码清单4-13 测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[Disassembling for mach='i386'] 
[Entry Point]
[Constants]
# {method} 'sum' '(I)I' in 'test/Bar'
# this: ecx = 'test/Bar'
# parm0: edx = int
# [sp+0x20] (sp of caller)
……
0x01cac407: cmp 0x4(%ecx),%eax
0x01cac40a: jne 0x01c6b050 ; {runtime_call}
[Verified Entry Point]
0x01cac410: mov %eax,-0x8000(%esp)
0x01cac417: push %ebp
0x01cac418: sub $0x18,%esp ; *aload_0
; - test.Bar::sum@0 (line 8)
;; block B0 [0, 10]

0x01cac41b: mov 0x8(%ecx),%eax ; *getfield a
; - test.Bar::sum@1 (line 8)
0x01cac41e: mov $0x3d2fad8,%esi ; {oop(a 'java/lang/Class' = 'test/Bar')}
0x01cac423: mov
0x68(%esi),%esi ; *getstatic b ; - test.Bar::sum@4 (line 8)
0x01cac426: add %esi,%eax
0x01cac428: add %edx,%eax
0x01cac42a: add $0x18,%esp
0x01cac42d: pop %ebp
0x01cac42e: test %eax,0x2b0100 ; {poll_return}
0x01cac434: ret

虽然是汇编,但代码并不多,我们一句一句来阅读:

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:做2次加法,求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:方法返回。

在这个例子中测试代码比较简单,肉眼直接看日志中的汇编输出是可行的,但在正式环境中- XX:+PrintAssembly的日志输出量巨大,且难以和代码对应起来,这就必须使用工具来辅助了。

JITWatch[^5]是HSDIS经常搭配使用的可视化的编译日志分析工具,为便于在JITWatch中读取,读 者可使用以下参数把日志输出到logfile文件:

1
2
3
4
5
6
-XX:+UnlockDiagnosticVMOptions 
-XX:+TraceClassLoading
-XX:+LogCompilation
-XX:LogFile=/tmp/logfile.log
-XX:+PrintAssembly
-XX:+TraceClassLoading

在JITWatch中加载日志后,就可以看到执行期间使用过的各种对象类型和对应调用过的方法了, 界面如图4-28所示。

image-20210919104618372

图4-28 JITWatch主界面

选择想要查看的类和方法,即可查看对应的Java源代码、字节码和即时编译器生成的汇编代码, 如图4-29所示。

image-20210919104646747

图4-29 查看方法代码

[^1]: 不同于Ideal Graph Visualizer,Client Compiler Visualizer的源码其实从未进入过HotSpot的代码仓库, 不过为了C1、C2配对,还是把它列在这里。
[^2]: OpenJDK中的源码位置:hotspot/src/share/tools/hsdis/。
[^3]: 地址:http://hg.openjdk.java.net/jdk7u/jdk7u/hotspot/file/tip/src/share/tools/hsdis/。也可以在GitHub上 搜索HSDIS得到。
[^4]: HLLVM圈子中有已编译好的,地址:http://hllvm.group.iteye.com/。
[^5]: 下载地址:https://github.com/AdoptOpenJDK/jitwatch。