在《Java 虚拟机规范》之中,详细描述了虚拟机指令集中每条指令的执行过程、执行前后对操作数栈、对局部变量表的影响等细节。这些细节描述与 Sun 的早期虚拟机(Sun Classic VM)高度吻合,但随着技术的发展,高性能虚拟机真正的细节实现方式已经渐渐与虚拟机规范所描述产生越来越大的差距,虚拟机规范中的描述逐渐成了虚拟机实现的“概念模型”——即实现只能保证规范描述等效。
基于上面的原因,我们分析程序的执行语义问题(虚拟机做了什么)时,在字节码层面上分析完全可行,但分析程序的执行行为问题(虚拟机是怎样做的、性能如何)时,在字节码层面上分析就没有什么意义了,需要通过其他方式解决。
准备工作
分析程序如何执行,通过软件调试工具(GDB、Windbg 等)来断点调试是最常见的手段,但是这样的调试方式在 JVM 中会遇到很大困难,因为大量执行代码是通过 JIT 编译器动态生成到 CodeBuffer 中的,没有很简单的手段来处理这种混合模式的调试(不过相信虚拟机开发团队内部肯定是有内部工具的)。因此我们要通过一些曲线手段来解决问题,基于这种背景下,本文的主角——HSDIS 插件就正式登场了。
HSDIS 是由 Project Kenai( http://kenai.com/projects/base-hsdis)提供并得到 Sun 官方推荐的 HotSpot VM JIT 编译代码的反汇编插件,作用是让 HotSpot 的 -XX:+PrintAssembly 指令调用它来把动态生成的本地代码还原为汇编代码输出,同时还生成了大量非常有价值的注释,这样我们就可以通过输出的代码来分析问题。读者可以根据自己的操作系统和 CPU 类型从 Kenai 的网站上下载编译好的插件,直接放到 JDK_HOME/jre/bin/client 和 JDK_HOME/jre/bin/server 目录中即可。如果没有找到所需操作系统(譬如 Windows 的就没有)的成品,那就得自己拿源码编译一下,或者去 HLLVM 圈子( http://hllvm.group.iteye.com/)中下载也可以。
当然,既然是通过 -XX:+PrintAssembly 指令使用的插件,那自然还要求一份 FastDebug 版的 JDK,在 OpenJDK 网站上各个 JDK 版本发布时一般都伴随有 FastDebug 版的可以下载,不过听说 JDK 7u02 之后不再提供了,要用最新的 JDK 版本就可能需要自己编译。笔者所使用的虚拟机是 HotSpot B127 FastDebug(JDK 7 EA 时的 VM),默认为 Client VM,后面的案例都基于这个运行环境之下:
代码清单 1:
>java -version java version "1.7.0-ea-fastdebug" Java(TM) SE Runtime Environment (build 1.7.0-ea-fastdebug-b127) Java HotSpot(TM) Client VM (build 20.0-b06-fastdebug, mixed mode)
案例一:Java 堆、栈在本地代码中的存在形式
环境准备好后,本篇的话题正式开始。三个案例都是笔者给朋友的回信,第一个案例的问题是“在 Java 虚拟机规范中把虚拟机内存划分为 Java Heap、Java VM Stack、Method Area 等多个运行时区域,那当 ByteCode 编译为 Native Code 后,Java 堆、栈、方法区还是原来那个吗?在 Java 堆、栈、方法区中的数据是如何访问的?”
我们通过下面这段简单代码的实验来回答这个问题:
代码清单 2
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); } }
代码很简单,sum() 方法使用到 3 个变量 a、b、c,按照概念模型中的划分,其中 a 是实例变量,来自 Java Heap,b 是类变量,来自 Method Area,c 是参数,来自 VM Stack。那我们来看看 JIT 之后,它们是怎么访问的。使用下面命令来执行上述代码:
>java -XX:+PrintAssembly -Xcomp -XX:CompileCommand=dontinline,*Bar.sum -XX:CompileCommand=compileonly,*Bar.sum test.Bar
其中,参数 -Xcomp 是让虚拟机以编译模式执行代码,这样代码可以偷懒,不需要执行足够次数来预热都能触发 JIT 编译。两个 -XX:CompileCommand 意思是让编译器不要内联 sum() 并且只编译 sum(),-XX:+PrintAssembly 就是输出反汇编内容。如果一切顺利的话,屏幕上出现类似下面代码清单 3 所示的内容:
代码清单 3
[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
代码并不多,一句一句来看:
- mov %eax,-0x8000(%esp):检查栈溢。
- push %ebp:保存上一栈帧基址。
- sub $0x18,%esp:给新帧分配空间。
- mov 0x8(%ecx),%eax:取实例变量 a,这里 0x8(%ecx) 就是 ecx+0x8 的意思,前面“[Constants]”节中提示了“this:ecx = ‘test/Bar’”,即 ecx 寄存器中放的就是 this 对象的地址。偏移 0x8 是越过 this 对象的对象头,之后就是实例变量 a 的内存位置。这次是访问“Java 堆”中的数据。
- mov $0x3d2fad8,%esi:取 test.Bar 在方法区的指针。
- mov 0x68(%esi),%esi:取类变量 b,这次是访问“方法区”中的数据。
- add %esi,%eax 、add %edx,%eax:做 2 次加法,求 a+b+c 的值,前面的代码把 a 放在 eax 中,把 b 放在 esi 中,而 c 在 [Constants] 中提示了,“parm0:edx = int”,说明 c 在 edx 中。
- add $0x18,%esp:撤销栈帧。
- pop %ebp:恢复上一栈帧。
- test %eax,0x2b0100:轮询方法返回处的 SafePoint
- ret:方法返回。
从汇编代码中可见,访问 Java 堆、栈和方法区中的数据,都是直接访问某个内存地址或者寄存器,之间并没有看见有什么隔阂。HotSpot 虚拟机本身是一个运行在物理机器上的程序,Java 堆、栈、方法区都在 Java 虚拟机进程的内存中分配。在 JIT 编译之后,Native Code 面向的是 HotSpot 这个进程的内存,说变量 a 还在 Java Heap 中,应当理解为 a 的位置还在原来的那个内存位置上,但是 Native Code 是不理会 Java Heap 之类的概念的,因为那并不是同一个层次的概念。
案例二:循环语句的写法以及 Client 和 Server 的性能差异
如果第一个案例还有点抽象的话,那这个案例就更具体实际一些:有位朋友给了笔者下面这段代码(如代码清单 4 所示),并提出了 2 个问题:
- 在写循环语句时,“for (int i = 0, n = list.size(); i < n; i++)”的写法是否会比“for (int i = 0; i < list.size(); i++)”更快?
- 为何这段代码在 Server VM 下测出来的速度比 Client VM 还慢?
代码清单 4:
public class Client1 { public static void main(String[] args) { List<Object> list = new ArrayList<Object>(); Object obj = new Object(); // 填充数据 for (int i = 0; i < 200000; i++) { list.add(obj); } long start; start = System.nanoTime(); // 初始化时已经计算好条件 for (int i = 0, n = list.size(); i < n; i++) { } System.out.println(" 判断条件中计算:" + (System.nanoTime() - start) + " ns"); start = System.nanoTime(); // 在判断条件中计算 for (int i = 0; i < list.size(); i++) { } System.out.println(" 判断条件中计算:" + (System.nanoTime() - start) + " ns"); } }
首先来看,代码最终执行时,for (int i = 0, n = list.size(); i < n; i++) 的写法所生成的代码与 for (int i = 0; i < list.size(); i++) 有何差别。它们反汇编的结果如下(提取循环部分的代码):
代码清单 5:for (int i = 0, n = list.size(); i < n; i++) 的循环体
0x01fcd554: inc %edx ; OopMap{[60]=Oop off=245} ;*if_icmplt ; - Client1::main@63 (line 17) 0x01fcd555: test %eax,0x1b0100 ; {poll} 0x01fcd55b: cmp %eax,%edx ;; 124 branch [LT] [B5] 0x01fcd55d: jl 0x01fcd554 ;*if_icmplt ; - Client1::main@63 (line 17)
变量 i 放在 edx 中,变量 n 放在 eax 中,inc 指令对应 i++(被优化成 ++i 了),test 指令是在回边处进行轮询 SafePoint,cmp 是比较 n 和 i 的值,jl 就是当 i<n 的时候进行跳转,跳转的地址是回到 inc 指令。
代码清单 6:for (int i = 0; i < list.size(); i++) 的循环体
0x01b6d610: inc %esi ;; block B7 [110, 118] 0x01b6d611: mov %esi,0x50(%esp) 0x01b6d615: mov 0x3c(%esp),%esi 0x01b6d619: mov %esi,%ecx ;*invokeinterface size ; - Client1::main@113 (line 23) 0x01b6d61b: mov %esi,0x3c(%esp) 0x01b6d61f: nop 0x01b6d620: nop 0x01b6d621: nop 0x01b6d622: mov $0xffffffff,%eax ; {oop(NULL)} 0x01b6d627: call 0x01b2b210 ; OopMap{[60]=Oop off=460} ;*invokeinterface size ; - Client1::main@113 (line 23) ; {virtual_call} 0x01b6d62c: nop ; OopMap{[60]=Oop off=461} ;*if_icmplt ; - Client1::main@118 (line 23) 0x01b6d62d: test %eax,0x160100 ; {poll} 0x01b6d633: mov 0x50(%esp),%esi 0x01b6d637: cmp %eax,%esi ;; 224 branch [LT] [B8] 0x01b6d639: jl 0x01b6d610 ;*if_icmplt ; - Client1::main@118 (line 23)
可以看到,除了上面原有的几条指令外,确实还多了一次 invokeinterface 方法调用,执行的方法是 size(),方法接收者是 list 对象,除此之外,其他指令都和上面的循环体一致。所以至少在 HotSpot Client VM 中,第一种循环的写法是能提高性能的,因为实实在在地减少了一次方法调用。
但是这个结论并不是所有情况都能成立,譬如这里把 list 对象从 ArrayList 换成一个普通数组,把 list.size() 换成 list.length。那将可以观察到两种写法输出的循环体是完全一样的(都和前面第一段汇编的循环一样),因为虚拟机不能保证 ArrayList 的 size() 方法调用一次和调用 N 次是否会产生不同的影响,但是对数组的 length 属性则可以保证这一点。也就是 for (int i = 0, n = list.length; i < n; i++) 和 for (int i = 0; i < list.length; i++) 的性能是没有什么差别的。
再来继续看看为何这段代码在 Server VM 下测出来的速度比 Client VM 还慢,这个问题不好直接比较 Server VM 和 Client VM 所生成的汇编代码,因为 Server VM 经过重排序后,代码结构完全混乱了,很难再和前面代码的比较,不过我们还是可以注意到两者编译过程的不同,加入 -XX:+ PrintCompilation 参数后,它们的编译过程输出如下:
代码清单 7:Server VM 和 Client VM 的编译过程
// 下面是 Client VM 的编译过程 VM option '+PrintCompilation' 169 1 java.lang.String::hashCode (67 bytes) 172 2 java.lang.String::charAt (33 bytes) 174 3 java.lang.String::indexOf (87 bytes) 179 4 java.lang.Object::<init> (1 bytes) 185 5 java.util.ArrayList::add (29 bytes) 185 6 java.util.ArrayList::ensureCapacityInternal (26 bytes) 186 1% Client1::main @ 21 (79 bytes) // 下面是 Server VM 的编译过程 VM option '+PrintCompilation' 203 1 java.lang.String::charAt (33 bytes) 218 2 java.util.ArrayList::add (29 bytes) 218 3 java.util.ArrayList::ensureCapacityInternal (26 bytes) 221 1% Client1::main @ 21 (79 bytes) 230 1% made not entrant Client1::main @ -2 (79 bytes) 231 2% Client1::main @ 51 (79 bytes) 233 2% made not entrant Client1::main @ -2 (79 bytes) 233 3% Client1::main @ 65 (79 bytes)
可以看到,ServerVM 中 OSR 编译发生了 3 次,丢弃了其中 2 次(made not entrant 的输出),换句话说,在这个 TestCase 里面,main() 方法的每个循环 JIT 编译器都要折腾一下子。当然这并不是 ServerVM 看起来比 ClientVM 看起来慢的唯一原因。ServerVM 的优化目的是为了长期执行生成尽可能高度优化的执行代码,为此它会进行各种努力:譬如丢弃以前的编译成果、在解释器或者低级编译器(如果开启多层编译的话)收集性能信息等等,这些手段在代码实际执行时是必要和有效的,但是在 Microbenchmark 中就会显得很多余并且有副作用。因此写 Microbenchmark 来测试 Java 代码的性能,经常会出现结果失真。
案例三:volatile 变量与指令重排序
在 JMM 模型(特指 JDK 5 修复后的 JMM 模型)中,对 volatile 关键字赋予的其中一个语义是禁止指令重排序优化,这个语义可以保证在并发访问 volatile 变量时保障一致性,在外部线程观察 volatile 变量确保不会得到脏数据。这也是为何在 JDK 5 后,将变量声明为 volatile 就可以使用 DCL(Double Checked Locking)来实现单例模式的原因。那进一步的问题就是 volatile 变量访问时与普通变量有何不同?它如何实现禁止重排序的呢?
首先,我们编写一段标准的 DCL 单例代码,如代码清单 8 所示。观察加入 volatile 和未加入 volatile 关键字时生成汇编代码的差别。
代码清单 8:
public class Singleton { private volatile static Singleton instance; public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } public static void main(String[] args) { Singleton.getInstance(); } }
编译后,这段代码对 instance 变量赋值部分代码清单 9 所示:
代码清单 9:
0x01a3de0f: mov $0x3375cdb0,%esi ;...beb0cd75 33 ; {oop('Singleton')} 0x01a3de14: mov %eax,0x150(%esi) ;...89865001 0000 0x01a3de1a: shr $0x9,%esi ;...c1ee09 0x01a3de1d: movb $0x0,0x1104800(%esi) ;...c6860048 100100 0x01a3de24: lock addl $0x0,(%esp) ;...f0830424 00 ;*putstatic instance ; - Singleton::getInstance@24
通过对比发现,关键变化在于有 volatile 修饰的变量,赋值后(前面 mov %eax,0x150(%esi) 这句便是赋值操作)多执行了一个“lock addl $0x0,(%esp)”操作,这个操作相当于一个内存屏障,只有一个 CPU 访问内存时,并不需要内存屏障;但如果有两个或更多 CPU 访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。
指令“addl $0x0,(%esp)”显然是一个空操作,关键在于 lock 前缀,查询 IA32 手册,它的作用是使得本 CPU 的 Cache 写入了内存,该写入动作也会引起别的 CPU invalidate 其 Cache。所以通过这样一个空操作,可让前面 volatile 变量的修改对其他 CPU 立即可见。
那为何说它禁止指令重排序呢?从硬件架构上讲,指令重排序是指 CPU 采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。但并不是说指令任意重排,CPU 需要能正确处理指令依赖情况保障程序能得出正确的执行结果。譬如指令 1 把地址 A 中的值加 10,指令 2 把地址 A 中的值乘以 2,指令 3 把地址 B 中的值减去 3,这时指令 1 和指令 2 是有依赖的,它们之间的顺序不能重排——(A+10)*2 与 A*2+10 显然不相等,但指令 3 可以重排到指令 1、2 之前或者中间,只要保证 CPU 执行后面依赖到 A、B 值的操作时能获取到正确的 A 和 B 值即可。所以在本内 CPU 中,重排序看起来依然是有序的。因此,lock addl $0x0,(%esp) 指令把修改同步到内存时,所有之前的操作都已经执行完成,这样便形成了“指令重排序无法越过内存屏障”的效果。
感谢张凯峰对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。
评论