4.4 HotSpot虚拟机插件及工具

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。