Java虚拟机会比硬件快吗?
【IT168 专稿】我决定从所进行的bug确定追踪后休息一会儿,我从这些东西之中很难找到乐趣。过一会我将恢复bug确定的讨论。今天,我想要阐明一个通用的误解,那就是java的处理机比动态适应编译器/及时编译器(例如,JIT)要快。我将会通过一些分析来向你们证明我的观点。这些分析将会基于CDC的phoneME的增强虚拟机为例子,但是这个论证当然也可以延伸到其余的虚拟机。让我们开始进入正题吧。
一、硬件加速
硬件加速是一项技术,它根据速度获得了更好的软件速度。这个方法在图形、声音和数字信号处理方面都取得了很大的成功。在这些情况下,硬件加速将图形、声音和数字信息处理影响了核心的处理器和将主要的一些CPU闲置出来做另外的操作。这个并行化是我们因为硬件提速而提高性能的一个原因。
硬件加速能提供了特殊的使用说明,这些使用说明能像传统的软件那样起作用,这是第二个原因。当然,特殊的使用说明对算法类型(例如:图形、声音和数字信号处理)来说是如此的特别。因此,如果你的应用程序不能做很多的图形、声音和/或数字信号处理,那么这种硬件加速不会使得你的应用程序运行得更快。
鉴于硬件加速在它们各自应用程序中的显著的的成功,我们开始归纳这些成功,认为所有的硬件加速将会打败软件的解决方案。在Java硬件处理器与JIT比较的情况下,这个归纳显然是不正确的。
二、Java处理器
Java虚拟机规范有它自己的一套说明集合。这些说明中的某一些看起来很像那些将会找到一个典型的CPU指令转码的Java虚拟机指令集合,它将会Java代码执行的性能,这个观察是有根有据的。但是,这种误解是硬件加速也会与虚拟机JIT匹配带来更好的性能。在JIT编译情况下,一个Java处理器将会打败JIT的性能。注意:在接下来的讨论中,我将会简化硬件Java处理器为JPU。
声明:我不会对市场上的任何的硬件和Java处理器实现发表任何评论,而仅仅是从理论的观点来看待问题。
好了,让我们来看一个简单而又特别的例子。
三、一个简单的例子
三、一个简单的例子
这里有一段用Java编程语言写的一小段程序段:
a = a + b; // 将两个整数相加.
当编译成字节码的时候,程序段将会变成如下这样:
iload_0 //蒋a放入堆栈中.
iload_1 // 将b放入堆栈中.
iadd //弹出这两个值,并将两者相加,
//将得出的结果推入堆栈
istore_0 //蒋结果保存到a中.
虚拟机的解释器
这儿说明了虚拟机解释器是如何执行上述的代码的:
// opc_iload_0:
ldr r4, [fp, #-140] //获得本地变量起始的地址locals
ldrb r3, [r8, #1]! //计算第一段
ldr r2, [r4, #0] //加载本地的0
ldr r1, [r9, r3, asl #2] //计算第二段
str r2, [rJSP], #4 //将值放入堆栈中
mov pc, r1 //分出下一个段
...
// opc_iadd:
sub r4, rJSP, #8 //计算变量的堆栈位置
ldmia r4, {r2, r4} //加载变量,例如.从内存中加载两个字的空间from memory
add r2, r2, r4 //执行加操作
ldr r1, [r9, r3, asl #2] //计算下一个段
str r2, [rJSP, #-8] //存储结果到堆栈中、
sub rJSP, rJSP, #4 //调整堆栈指针
mov pc, r1 //分出下一个段
...
// opc_istore_0:
ldrb r3, [r8, #1]! //计算下一个段
ldr r2, [rJSP, #-4]! //从堆栈中弹出值
ldr r4, [fp, #-140] //获得本地起始的地址
ldr r1, [r9, r3, asl #2] //计算一个个部分
str r2, [r4, #0] //将值放入本地的0
mov pc, r1 //分出下一个段
...
如上所述通过编译CVM的解释器循环运用gcc进入ARM。我挑选ARM仅仅作为指令集的一个例子。我的目的是获得一个真实世界的例子,这个例子能够做一些耗时的工作。但是,我把一些注册名用符号等价物来表示,使得集合的代码更加容易理解。JSP是包含Java堆栈指针的一个注册器。
在如上情况中,我并没有说明iload_1字节码的解释器段,因为它本质上与iload_0字节码的解释器段是相同的,除了一个不同的对段指针的偏移量外。
在如上代码中,我也指出了一些指令是用来计算下一个段的。我的意思是这个解释器段的指令地址用来执行下一个字节码是有效的。因此,注释“分出下一个段”实际上的意思是“跳到下一个字节码”。
因此,为了执行上面的a = a + b语句,使用了虚拟机解释器。
指令的数量:
6 (iload_0) + 6 (iload_1) + 7 (iadd) + 6 (istore_0) = 25 条指令
内存访问数量:
5 (iload_0) + 5 (iload_1) + 4 (iadd) + 5 (istore_0) = 19 次内存访问量
5 (iload_0) + 5 (iload_1) + 4 (iadd) + 5 (istore_0) = 19 次内存访问量
对于内存访问,我仅仅计算了加载和存储的指令,而没有计算转向语句。
基于加强的ARM 1110处理器,在非常好的的条件下(所有的内存访问都是在缓存中准备),每一条指令将会至少执行一个机器循环(如果我没记错的话)。如果有一个错过了缓存,那么它的内存访问将会占据4个机器周期。另外,它将占用更多的时间。注意:Idmia指令占用两个周期的原因是因为它要载入两次。因此,在理想的情况下:
机器周期个数
1 + 5*1 (iload_0) + 1 + 5*1 (iload_1) + 4 + 4*1 (iadd) + 1 + 5*1 (istore_0) = 26 个机器周期
作为对比,让我们看看所有的内存访问会占用4个机器周期来完成的情况。这是最不理想的情况:
机器周期个数(有缓存丢失的情况):
1 + 5*4 (iload_0) + 1 + 5*4 (iload_1) + 4 + 4*4 (iadd) + 1 + 5*4 (istore_0) = 83个机器周期
Java硬件处理器
作为硬件处理器,指令作为它们自己的字节码执行。但是,执行它们的机器周期个数能基于解释器来估计。
对于一个JPU,它没有任何解释器。因此,这里没有任何的“下一段”,“下一段”变成了下一个字节码指令,它被JPU的程序计数器注册器所指向。
我也假定它是一个假定的注册器,它指向了当前的Java栈的起点。有了它,获得本地变量的地址的步骤能够被排除。
留下来的必要操作是JPU仍然必须执行(即使它在场景后面)。在如上的解释器段中,这个必要的操作是表示asm代码的行数。JPU的指令数如下:
“ASM指令”的数量:
2 (iload_0) + 2 (iload_1) + 3 (iadd) + 2 (istore_0) = 9 条指令
2 (iload_0) + 2 (iload_1) + 3 (iadd) + 2 (istore_0) = 9 条指令
内存访问数量:
2 (iload_0) + 2 (iload_1) + 3 (iadd) + 2 (istore_0) = 9次内存访问
2 (iload_0) + 2 (iload_1) + 3 (iadd) + 2 (istore_0) = 9次内存访问
机器周期个数 (没有内存丢失的情况):
2*1 (iload_0) + 2*1 (iload_1) + 1 + 3*1 (iadd) + 2*1 (istore_0) = 10个机器周期
2*1 (iload_0) + 2*1 (iload_1) + 1 + 3*1 (iadd) + 2*1 (istore_0) = 10个机器周期
机器周期个数(有内存丢失的情况):
2*4 (iload_0) + 2*4 (iload_1) + 1 + 3*4 (iadd) + 2*4 (istore_0) = 37个机器周期
2*4 (iload_0) + 2*4 (iload_1) + 1 + 3*4 (iadd) + 2*4 (istore_0) = 37个机器周期
注意:JPU不能在执行字节码之前将其翻译成等价的ARM指令。但是,从内部来看,JPU仍然需要做一些等量的工作。我将会使用ARM指令作为一个被JPU硬件工作的一个近似值。
另外还要注意,Idmia指令与两个load指令等价。如果你想要核对如上的数据要记住这一点。
JIT
对于JIT,a = a + b语句将会编译成如下的ARM指令:
ldr r7, [rJFP, #-140] //加载本地变量0
ldr r8, [rJFP, #-136] //加载本地变量1
add r7, r7, r8 //将两者相加
str r7, [rJFP, #-140] //将计算结果存储到本地变量0中
rJFP是一个注册器,它被JIT用来包含Java框架指针。
实际点讲,最后的存储指令可能需要排除,这取决于字节码是否在它之后。因此,在某些时候,本地变量0和1的值可能已经在注册器中存在了,这取决于字节码是如何执行这些代码段的。在这种情况下,这2个载入指令可能也需要除去。因此,在理想的JIT条件下,a = a + b被JIT编译成一个单独的ARM相加指令。但是在JPU情况下做一个保守的估计,我将会包括我们比较的载入和存储指令。因此,JIT对应的数量如下所示:
指令数量: 4条指令
内存访问的数量: 3次内存访问
机器周期数量 (无内存丢失的情况): 1 + 3*1 = 4个机器周期
机器周期数量(有内存丢失的情况): 1 + 3*4 = 13 个机器周期
内存访问的数量: 3次内存访问
机器周期数量 (无内存丢失的情况): 1 + 3*1 = 4个机器周期
机器周期数量(有内存丢失的情况): 1 + 3*4 = 13 个机器周期
在JIT下的理想情况下,我们仅仅将其编译成一条ARM相加指令。
指令数量: 1条指令
内存访问的数量: 0次内存访问
机器周期数量 (无内存丢失的情况): 1个机器周期
机器周期数量(有内存丢失的情况): 1个机器周期
内存访问的数量: 0次内存访问
机器周期数量 (无内存丢失的情况): 1个机器周期
机器周期数量(有内存丢失的情况): 1个机器周期
数量的比较
下面是两者耗时度的比较:
下面是两者耗时度的比较:
|
机器周期数
|
解释器
|
JPU
|
JIT
|
理想的JIT
|
|
无内存丢失的情况
|
26
|
10 (2.60x)
|
4 (6.50x)
|
1 (26.0x)
|
|
有内存丢失的情况
|
83
|
37 (2.24x)
|
13 (6.38x)
|
1 (83.0x)
|
表格中的x指明对解释器的提速率。基于这个试验例子,JIT在表现上明显强于JPU。原因在于JIT能够除去某些或全部内存访问(通过保存工作数据在注册器中)处理时间。对比来说,JPU受虚拟机说明的堆栈机器架构(它招致了很多的内存访问)的约束。
但是在你为这些数字变得疯狂之前,让我们给你降降温。在真实世界中,你可能看不到理论上的JIT在如上环境中的26x或83x的加速。这是因为JIT并不总是能够保持所有的工作数据都存储在寄存器中,因为这里只有有限的寄存器集合。这个限制被称为寄存器压力。这在某些时候确实是存在的,但是并不是在所有的情况下,因此,JIT时间的真实数据更接近于JIT在非理想情况下的数据,而不是理想情况下的数据。
为了对JPU公平一点,围绕堆栈架构问题,一个JPU将可能实现一个堆栈缓存,这在一些注册器将会映像在一些高层的N值的Java操作栈上。它也存在一些本地的注册器映像。这点有效的允许JPU像JIT的理想情况一样避免内存访问,因此理论上允许它获得对JIT理想情况下的内存访问速度。但是,注册器压力也像JIT一样限制了JPU。
另外,JPU仅仅能够映像栈中的顶部的大部分操作数,并且可能是最先的少数本地变量。在注册器中的堆栈操作数和/或本地变量获得映像,这通常在运行过程中是不可配置的。但是JIT能够动态的处理这种情况,并且能够任意的选择哪一个参数和/或本地变量能够保存在注册器中。因此,JIT相对JPU来说,具有降低内存访问率的更高的可能性。
四、复杂的字节码
请等一下,这并不是整个故事。我说过我上面选择的例子是如此的简单,因此它并没有显示JPU的最好的性能。注意:即使当JPU在这场游戏中是最好的,JIT仍然将趋向于打败它的性能。现在在虚拟机指令集中考虑字节码的复杂性(点击这儿来获得虚拟机的字节码的相关术语)。
虚拟机指令集也包括例如new(为了分配对象)、newarray(为了分配数组)、monitorenter(为了锁定一个对象)、monitorexit(用于解锁某个对象)和属性进入器,例如getfield,getstatic,putfield,putstatic和一些复杂的字节码。
我说字节码“复杂”是因为它的执行不能在硬件中做。例如,“new”需要与虚拟机的垃圾收集器在一起才能正确工作,“moitorenter”需要与虚拟机的同步和线程机制协作才能正确工作,等等。在属性进入情况下,字节码必须接触类的常量表,这些常量表能够具有不同的实现,这取决于软件的虚拟机实现。常量库的进入将也会需要在属性进入之前被解决(在其余的事情之间)。
JPU支持这些字节码的唯一方式是去调出软件处理例行测试。注意:在Java程序中,这些字节码的使用实际上是很常见的。这就意味着当真实世界的程序执行的时候,JPU将不会意识到它的非常好的速度是可能的。真实世界的程序并不仅仅做循环操作,它还做简单的算法和逻辑操作(这些操作JPU能获得最好的性能)。
有一些JPU将会支持加速的字节码,一旦所有不同的填充物都在软件上实现,这有可能加速属性访问。但是,硬件加速的效率到底有多高,这一直是惹争议的问题。
但是让我们假定一下JPU具有所有理想的组件,这些组件能够使得它接近JIT的性能。毕竟,JIT也同样需要在软件中分配和同步,是不是呢?从技术上讲,是这样的。但是,当JIT能够对特定的虚拟机组件如何工作的时候,JIT可能有一个微小的优势,那就是它能够对特别的知识进行使用。这允许JIT在这些组件条件下发挥非常好的性能。而JPU是不能够做到的。这不是一个可供选择的选项。通常,JPU必须在某种程度上保持一般化,因为它需要不同的垃圾收集器和同步算法。如果虚拟机改变了垃圾收集器算法,使JIT在它上上面工作变得更简单。但是,如果它没有指定特定的算法,JPU将会要求新的方法,这是为什么它必须保持一般化的原因。
五、为什么使用硬件实现?
不管怎么样,JPU有它存在的价值。在性能上它不能与JIT竞争,但是它将比软件解释器的性能要好。使用JPU的另一个原因是JPU在没有足够的钱花费在内存上得到时候非常受内存环境限制。JIT在k字节的内存消耗中需要10到100条指令。如果这不是能够承受的消费,JPU可能是性能的最好的解决方案。注意:JPU执行Java字节码是非常紧凑的,这就意味着代码脚本更少。
最后一个你想要使用JPU的原因是:如果你想要避免JIT将导致的编译时间,这是一个有根有据的关心。但是,在实际情况下,人们趋向于指责JIT启动时的速度慢,通常,这有很多原因引起。JIT的编译时间通常对启动时间起的作用并不大。我冒昧的说出我所猜测的那个值,那就是少于5%到10%的启动时间。如果在这点上你无法接受,那么JPU将会是更好的选择。
六、更深层的思考
通常很有可能的是某些人已经用了一些我无法想象的高级技术模拟了JPU。我当然不不能对这些情况发表言论。但是,上述所说在我所懂得的现今的CPU技术和架构上是现实可行的。除了JPU,其它一些Java虚拟机的硬件加速可能包括内存访问的提速和查找的提速。这些可能将产生比提升字节码执行的速度产生更好的回报。
这个只是一个入口,由此可以进行展开,希望大家具有美好的一天。
在上面我已经确立了某些类型并且提供了一些附加的阐述。下面是一些额外的想法,我认为这些是非常值得关注的。
“JPU仅仅能够在堆栈中映像高级的操作数”。--我认为这是对的,
当然JIT也能够组另外的一些奇特的优化。
考虑到JPU在堆栈的最高层的操作数上映射,为了保持有效,JPU将需要阻止热心于加载和溢出(在镜像注册器和内存中)。热心于有效地加载/溢出获得了相同的性能,像没有使用任何注册器镜像一样。代替地,加载/溢出必须懒加载(例如,在需要的时候请求)。在最坏的情况下,这产生了没有镜像时的相同性能。在最好的情况下,内存访问在假定操作堆栈使用保持在镜像存储器容量下使用的情况下能够被避免。当然,所有的这些需要通过JPU硬件自动的处理。再者,在这里JIT有一个优点,在这里它能够在内存内容在注册起中镜像的时候进行更加明智的选择。
考虑到JIT其它的非常好的情况,我忽略去提到它们了,我前面所提及的“插入”确定无疑即使不是最大的一点,也是最大的一点之一。JPU不具有如下的选项:循环空转、不变量的挂起、通用的子表达式的排除、内在方法的使用等等。我并不是说所有的非常好的化都在JIT中实现。但是,JIT确实具有实现它们的选项,但是JPU是不具有这些选项的。
0
相关文章