写点什么

JVM 执行篇:使用 HSDIS 插件分析 JVM 代码执行细节

  • 2011 年 10 月 31 日
  • 本文字数:6994 字

    阅读完需:约 23 分钟

在《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

代码并不多,一句一句来看:

  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:方法返回。

从汇编代码中可见,访问 Java 堆、栈和方法区中的数据,都是直接访问某个内存地址或者寄存器,之间并没有看见有什么隔阂。HotSpot 虚拟机本身是一个运行在物理机器上的程序,Java 堆、栈、方法区都在 Java 虚拟机进程的内存中分配。在 JIT 编译之后,Native Code 面向的是 HotSpot 这个进程的内存,说变量 a 还在 Java Heap 中,应当理解为 a 的位置还在原来的那个内存位置上,但是 Native Code 是不理会 Java Heap 之类的概念的,因为那并不是同一个层次的概念。

案例二:循环语句的写法以及 Client 和 Server 的性能差异

如果第一个案例还有点抽象的话,那这个案例就更具体实际一些:有位朋友给了笔者下面这段代码(如代码清单 4 所示),并提出了 2 个问题:

  1. 在写循环语句时,“for (int i = 0, n = list.size(); i < n; i++)”的写法是否会比“for (int i = 0; i < list.size(); i++)”更快?
  2. 为何这段代码在 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 中文站用户讨论组中与我们的编辑和其他读者朋友交流。

2011 年 10 月 31 日 01:4515228

评论

发布
暂无评论
发现更多内容

初识动态规划,java程序设计教程第三版机械工业出版社

Java 程序员 后端

前端同事老是说swagger不好用,我用了knife4j后,同事爽得不行

Java 程序员 后端

初来乍到,IT职场人,有些黑话要先了解(1),springboot定时任务注解原理

Java 程序员 后端

制作Docker镜像,用来下载OpenJDK11源码,分享一点面试小经验

Java 程序员 后端

加班2个月,肛出最强干货之“Spring全家桶,rabbitmq消息队列原理

Java 程序员 后端

千万别踩坑,一面就凉透!Java,java语言程序设计基础篇第十一版答案

Java 程序员 后端

单机下如何让Java程序支持百万长连接,你知道吗?,云计算架构师认证

Java 程序员 后端

又是一些小细节!3面成功入职字节跳动:算法,数据库mysql教程视频教程

Java 程序员 后端

删了HDFS又能怎样?记一次删库不跑路事件,kafka的架构图

Java 程序员 后端

卧槽,牛皮了!某程序员苦刷这两份算法PDF47天,java大神需要掌握的技术

Java 程序员 后端

初来乍到,IT职场人,有些黑话要先了解,太牛了

Java 程序员 后端

分布式系统改造方案——数据篇,Java架构师视频

Java 程序员 后端

十个超酷的java谋生方式,你喜欢吗?,linux网络编程书籍

Java 程序员 后端

十月一奉上九大核心专题,630页内容,祝你收割大厂offer

Java 程序员 后端

千万级电商项目从0到1到100全过程 涵盖Java程序员不同成长阶段的问题及优选解决方案!

Java 程序员 后端

分布式、微服务必须配个日志管理系统才优秀,Exceptionless走起

Java 程序员 后端

创业神器-JAVA开源网盘系统推荐,springaop实现原理面试题

Java 程序员 后端

分享我的2021京东4面面经,送给备战金三银四的你,Java小程序开发实例

Java 程序员 后端

前端必备 Nginx 配置,kafka原理解析

Java 程序员 后端

又是一年金九银十,不明白JVM虚拟机还怎么面试-,kafka部署架构

Java 程序员 后端

分布式系统的一致性级别划分及Zookeeper一致性级别分析

Java 程序员 后端

历时半个多月,支付宝3面+美团4面,基础+进阶+高级

Java 程序员 后端

压箱底的干货!干货!,多线程面试题目

Java 程序员 后端

制作JavaCV应用依赖的基础Docker镜像(CentOS7+JDK8+OpenCV4)

Java 程序员 后端

刚从蚂蚁金服面试回来,分享我拿到社招Java研发岗offer的过程

Java 程序员 后端

创建和销毁对象-考虑用静态工厂方法代替构造器,熬夜整理出Java后端学习路线

Java 程序员 后端

前端向后端进发之----Springboot JPA增删改查,外包Java后端开发三年

Java 程序员 后端

力荐:提高千倍效率的一些 Java 代码小技巧,java语言程序设计教程朱晓龙课后答案

Java 程序员 后端

华为架构师亲手操刀,世界五百强都在用的kafka也就那么回事

Java 程序员 后端

双非本科怎么了,照样拿到阿里 offer! 分享阿里技术四面 + 交叉面

Java 程序员 后端

第15份敏捷年度状态报告

Bruce Talk

敏捷 Agile

JVM执行篇:使用HSDIS插件分析JVM代码执行细节_Java_周志明_InfoQ精选文章